Skip to content
Merged
99 changes: 99 additions & 0 deletions .github/workflows/ios-testflight.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Build & Upload to TestFlight

on:
push:
tags:
- 'ios-v*'

concurrency:
group: ios-testflight
cancel-in-progress: false

jobs:
build-and-upload:
runs-on: macos-15
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install XcodeGen
run: brew install xcodegen
Comment on lines +21 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider caching Homebrew packages for faster builds.

The brew install xcodegen step runs on every workflow execution. Caching can reduce build time.

📝 Optional: Add Homebrew caching
+      - name: Cache Homebrew
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/Library/Caches/Homebrew
+            /usr/local/Cellar/xcodegen
+          key: ${{ runner.os }}-brew-xcodegen-${{ hashFiles('.github/workflows/ios-testflight.yml') }}
+          restore-keys: |
+            ${{ runner.os }}-brew-xcodegen-
+
       - name: Install XcodeGen
-        run: brew install xcodegen
+        run: brew install xcodegen || true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ios-testflight.yml around lines 21 - 22, The "Install
XcodeGen" step repeatedly runs brew install xcodegen which slows CI; modify the
workflow by adding a Homebrew cache step before the "Install XcodeGen" job (or
replace the step with a cached-install approach) so cached Homebrew bottles and
Cellar/Cache paths are restored (e.g., cache paths under the runner Homebrew
cache and Cellar) and only run brew install xcodegen when the cache is missing
or stale; update the job that contains the "Install XcodeGen" step to restore
the cache, run brew install conditionally, and save the cache afterward.


- name: Generate Xcode project
working-directory: ios
run: xcodegen generate

- name: Install Apple certificate and provisioning profile
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
run: |
# Create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -hex 16)
PP_INSTALL_PATH=~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision

# Import certificate and provisioning profile from secrets
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

# Import certificate to keychain
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH $(security list-keychains -d user | tr -d '"')

# Apply provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp "$PP_PATH" "$PP_INSTALL_PATH"

- name: Build archive
working-directory: ios
run: |
xcodebuild archive \
-project StillPoint.xcodeproj \
-scheme StillPoint \
-configuration Release \
-archivePath $RUNNER_TEMP/StillPoint.xcarchive \
-destination 'generic/platform=iOS' \
-allowProvisioningUpdates

- name: Upload to App Store Connect
env:
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
APPSTORE_API_PRIVATE_KEY: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
run: |
# Write API key to file for authentication
mkdir -p ~/.private_keys
printf '%s\n' "$APPSTORE_API_PRIVATE_KEY" > ~/.private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8

# Export and upload in one step using xcodebuild
# The ExportOptions.plist has destination=upload and method=app-store-connect
# which makes xcodebuild upload directly to App Store Connect / TestFlight
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/StillPoint.xcarchive \
-exportOptionsPlist ios/ExportOptions.plist \
-exportPath $RUNNER_TEMP/export \
-allowProvisioningUpdates \
-authenticationKeyPath ~/.private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8 \
-authenticationKeyID "$APPSTORE_API_KEY_ID" \
-authenticationKeyIssuerID "$APPSTORE_API_ISSUER_ID"
Comment on lines +70 to +90
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding explicit IPA validation before upload.

The PR objectives mention "IPA path validation" but there's no explicit check that the export produced a valid IPA. While xcodebuild -exportArchive will fail if the archive is invalid, adding an explicit check improves debuggability.

📝 Optional: Add IPA existence check
          xcodebuild -exportArchive \
            -archivePath $RUNNER_TEMP/StillPoint.xcarchive \
            -exportOptionsPlist ios/ExportOptions.plist \
            -exportPath $RUNNER_TEMP/export \
            -allowProvisioningUpdates \
            -authenticationKeyPath ~/.private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8 \
            -authenticationKeyID "$APPSTORE_API_KEY_ID" \
            -authenticationKeyIssuerID "$APPSTORE_API_ISSUER_ID"
+
+          # Verify export produced an IPA
+          if ! ls $RUNNER_TEMP/export/*.ipa 1>/dev/null 2>&1; then
+            echo "Error: No IPA file found in export directory"
+            exit 1
+          fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Upload to App Store Connect
env:
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
APPSTORE_API_PRIVATE_KEY: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
run: |
# Write API key to file for authentication
mkdir -p ~/.private_keys
printf '%s\n' "$APPSTORE_API_PRIVATE_KEY" > ~/.private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8
# Export and upload in one step using xcodebuild
# The ExportOptions.plist has destination=upload and method=app-store-connect
# which makes xcodebuild upload directly to App Store Connect / TestFlight
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/StillPoint.xcarchive \
-exportOptionsPlist ios/ExportOptions.plist \
-exportPath $RUNNER_TEMP/export \
-allowProvisioningUpdates \
-authenticationKeyPath ~/.private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8 \
-authenticationKeyID "$APPSTORE_API_KEY_ID" \
-authenticationKeyIssuerID "$APPSTORE_API_ISSUER_ID"
- name: Upload to App Store Connect
env:
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
APPSTORE_API_PRIVATE_KEY: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
run: |
# Write API key to file for authentication
mkdir -p ~/.private_keys
printf '%s\n' "$APPSTORE_API_PRIVATE_KEY" > ~/.private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8
# Export and upload in one step using xcodebuild
# The ExportOptions.plist has destination=upload and method=app-store-connect
# which makes xcodebuild upload directly to App Store Connect / TestFlight
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/StillPoint.xcarchive \
-exportOptionsPlist ios/ExportOptions.plist \
-exportPath $RUNNER_TEMP/export \
-allowProvisioningUpdates \
-authenticationKeyPath ~/.private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8 \
-authenticationKeyID "$APPSTORE_API_KEY_ID" \
-authenticationKeyIssuerID "$APPSTORE_API_ISSUER_ID"
# Verify export produced an IPA
if ! ls $RUNNER_TEMP/export/*.ipa 1>/dev/null 2>&1; then
echo "Error: No IPA file found in export directory"
exit 1
fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ios-testflight.yml around lines 70 - 90, Add an explicit
IPA existence/validation step after the xcodebuild -exportArchive export (the
exportPath $RUNNER_TEMP/export) that verifies an .ipa was produced and is
non-empty before attempting upload: check for a single .ipa (e.g. glob
$RUNNER_TEMP/export/*.ipa), confirm the file exists and has size >0, log a clear
error including the expected path if the check fails, and exit non-zero to stop
the job; place this check immediately after the xcodebuild -exportArchive
invocation so subsequent upload using the same exportPath and
authenticationKeyPath only runs when the IPA validation passes.


- name: Clean up keychain, provisioning profile, and API key
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true
rm -f $RUNNER_TEMP/build_certificate.p12 2>/dev/null || true
rm -f $RUNNER_TEMP/build_pp.mobileprovision 2>/dev/null || true
rm -f ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision 2>/dev/null || true
rm -rf ~/.private_keys 2>/dev/null || true
16 changes: 16 additions & 0 deletions ios/ExportOptions.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>T5UU4BP6AV</string>
<key>uploadSymbols</key>
<true/>
<key>destination</key>
<string>upload</string>
</dict>
</plist>
61 changes: 61 additions & 0 deletions ios/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Releasing Still Point for iOS

## Prerequisites

The following GitHub repository secrets must be configured (Settings → Secrets and variables → Actions):

| Secret | Description | How to get it |
|--------|-------------|---------------|
| `BUILD_CERTIFICATE_BASE64` | Base64-encoded `.p12` distribution certificate | Export from Keychain Access, then `base64 -i cert.p12 \| pbcopy` |
| `P12_PASSWORD` | Password used when exporting the `.p12` | The password you set during `.p12` export |
| `BUILD_PROVISION_PROFILE_BASE64` | Base64-encoded `.mobileprovision` file | Download from developer.apple.com, then `base64 -i profile.mobileprovision \| pbcopy` |
| `APPSTORE_API_KEY_ID` | App Store Connect API Key ID | From appstoreconnect.apple.com → Users and Access → Integrations |
| `APPSTORE_API_ISSUER_ID` | App Store Connect API Issuer ID | Same page as above |
| `APPSTORE_API_PRIVATE_KEY` | Contents of the `.p8` API key file | Paste the full file contents including BEGIN/END lines |

## Releasing to TestFlight

1. Update the version in `ios/project.yml`:
```yaml
MARKETING_VERSION: "1.1.0"
CURRENT_PROJECT_VERSION: 2
```

2. Commit the version bump:
```bash
git add ios/project.yml
git commit -m "Bump iOS version to 1.1.0 (build 2)"
```

3. Tag and push:
```bash
git tag ios-v1.1.0
git push origin ios-v1.1.0
```
Comment on lines +18 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add blank lines around fenced code blocks.

Per markdown linting (MD031), fenced code blocks should be surrounded by blank lines for better rendering compatibility.

📝 Proposed fix
 1. Update the version in `ios/project.yml`:
+
    ```yaml
    MARKETING_VERSION: "1.1.0"
    CURRENT_PROJECT_VERSION: 2
    ```
+
 2. Commit the version bump:
+
    ```bash
    git add ios/project.yml
    git commit -m "Bump iOS version to 1.1.0 (build 2)"
    ```
+
 3. Tag and push:
+
    ```bash
    git tag ios-v1.1.0
    git push origin main --tags
    ```
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 19-19: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 25-25: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 31-31: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/RELEASING.md` around lines 18 - 34, Add a blank line before and after
each fenced code block in the RELEASING.md snippet: ensure there is an empty
line preceding and following the ```yaml block that contains MARKETING_VERSION
and CURRENT_PROJECT_VERSION, and likewise add blank lines before and after each
```bash block that contains the git add/commit and git tag/push commands so the
fenced blocks are surrounded by blank lines per MD031.


4. The GitHub Actions workflow builds and uploads to TestFlight automatically.

5. After Apple processes the build (~15 minutes), it appears in the TestFlight app on your device.

## Version numbering

- `MARKETING_VERSION` — the user-facing version (e.g., `1.0.0`, `1.1.0`)
- `CURRENT_PROJECT_VERSION` — the build number, must increment with every upload (e.g., `1`, `2`, `3`)
- Tag format: `ios-v{MARKETING_VERSION}` (e.g., `ios-v1.0.0`)

## Submitting to the App Store

The same build uploaded to TestFlight can be submitted to the App Store:

1. Go to [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → your app
2. Fill in the required metadata (screenshots, description, privacy policy URL)
3. Select the TestFlight build under the version
4. Click "Add for Review"

## Troubleshooting

**Build fails with signing error:** Verify the certificate hasn't expired and the provisioning profile includes the correct bundle ID (`com.brettonauerbach.stillpoint`).

**Upload fails with authentication error:** Regenerate the App Store Connect API key and update the GitHub secrets.

**Build number conflict:** `CURRENT_PROJECT_VERSION` must be unique per upload. Increment it even for re-uploads of the same marketing version.
6 changes: 3 additions & 3 deletions ios/project.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: StillPoint
options:
bundleIdPrefix: com.stillpoint
bundleIdPrefix: com.brettonauerbach
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Regenerate ios/StillPoint.xcodeproj/project.pbxproj to match these changes.

The relevant code snippet shows ios/StillPoint.xcodeproj/project.pbxproj still contains the old bundle ID com.stillpoint.app. While the CI workflow runs xcodegen generate before building (which will produce the correct values), the committed project.pbxproj is now out of sync with project.yml.

Run xcodegen generate locally in the ios directory and commit the updated project.pbxproj to keep the repository consistent for local development.

Also applies to: 33-33

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/project.yml` at line 3, The committed Xcode project is out of sync with
project.yml (bundleIdPrefix: com.brettonauerbach); run xcodegen in the ios
directory to regenerate ios/StillPoint.xcodeproj/project.pbxproj so its bundle
identifier and other generated settings match project.yml, then stage and commit
the updated project.pbxproj (verify the old value com.stillpoint.app is replaced
with com.brettonauerbach).

deploymentTarget:
iOS: "17.0"
xcodeVersion: "15.0"
Expand Down Expand Up @@ -30,10 +30,10 @@ targets:
- Fonts/JetBrainsMono-Variable.ttf
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.stillpoint.app
PRODUCT_BUNDLE_IDENTIFIER: com.brettonauerbach.stillpoint
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: 1
DEVELOPMENT_TEAM: ""
DEVELOPMENT_TEAM: T5UU4BP6AV
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: "5.9"
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: "YES"
Expand Down
Loading