Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions .github/workflows/ios-app-store-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
name: iOS App Store release (Fastlane)

on:
push:
tags:
# Strict semver: ios-v1.2.3 (no -build suffix — those stay on TestFlight workflow).
- 'ios-v*.*.*'
Comment thread
auerbachb marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- '!ios-v*-build*'

permissions:
contents: read

concurrency:
group: ios-app-store-release
cancel-in-progress: false

jobs:
pre-flight-tests:
name: Release-config UI smoke gate
runs-on: macos-26
timeout-minutes: 30

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

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Install XcodeGen
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
shell: bash
run: |
for attempt in 1 2 3; do
if brew install xcodegen; then
exit 0
fi
if [[ "$attempt" -lt 3 ]]; then
sleep $((attempt * 10))
fi
done
echo "Failed to install xcodegen after 3 attempts."
exit 1

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

- name: Run iOS smoke lane in Release configuration
env:
E2E_ENV: ci
E2E_SECRETS_REQUIRED: "false"
IOS_TEST_CONFIGURATION: "Release"
run: npm run e2e:ios:smoke

- name: Upload simulator diagnostic reports
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-app-store-release-preflight-diagreports
path: ~/Library/Logs/DiagnosticReports/**
if-no-files-found: ignore
retention-days: 14

- name: Upload xcresult bundle
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-app-store-release-preflight-xcresult
path: artifacts/e2e/ios/**
if-no-files-found: warn
retention-days: 14

fastlane-release:
needs: pre-flight-tests
runs-on: macos-26
timeout-minutes: 45

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

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22

- name: Validate tag matches ios project marketing version
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
if [[ ! "$TAG" =~ ^ios-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Tag must be ios-vMAJOR.MINOR.PATCH (got $TAG)"
exit 1
fi
VER="${TAG#ios-v}"
MARKETING="$(grep -E '^[[:space:]]+MARKETING_VERSION:' ios/project.yml | head -1 | sed -E 's/.*MARKETING_VERSION:[[:space:]]*"?([^"#]+)"?.*/\1/' | xargs)"
if [[ "$VER" != "$MARKETING" ]]; then
echo "::error::Tag version $VER does not match ios/project.yml MARKETING_VERSION=$MARKETING"
exit 1
fi

- name: Validate release-readiness artifacts
shell: bash
run: |
set -euo pipefail
if ! test -f ios/PARITY_CHECKLIST.md; then
echo "::error::Missing ios/PARITY_CHECKLIST.md"
exit 1
fi
if ! test -f ios/QA_CHECKLIST.md; then
echo "::error::Missing ios/QA_CHECKLIST.md"
exit 1
fi
if ! grep -qE '^- \[[xX]\] Feature parity checklist between iOS and web is completed\.$' ios/PARITY_CHECKLIST.md; then
echo "::error::Missing PARITY_CHECKLIST entry: Feature parity checklist between iOS and web is completed"
exit 1
fi
if ! grep -qE '^- \[[xX]\] All critical parity gaps are fixed or explicitly deferred with owner/date\.$' ios/PARITY_CHECKLIST.md; then
echo "::error::Missing PARITY_CHECKLIST entry: All critical parity gaps are fixed or explicitly deferred with owner/date"
exit 1
fi
if ! grep -qE '^- \[[xX]\] Regression/QA pass for release candidate is completed\.$' ios/QA_CHECKLIST.md; then
echo "::error::Missing QA_CHECKLIST entry: Regression/QA pass for release candidate is completed"
exit 1
fi
if ! grep -qE '^- \[[xX]\] App Store metadata/versioning/release notes are finalized\.$' ios/QA_CHECKLIST.md; then
echo "::error::Missing QA_CHECKLIST entry: App Store metadata/versioning/release notes are finalized"
exit 1
fi

- name: Generate App Store submission dry-run evidence
run: npm run ios:app-store:dry-run -- --release-owner "${{ github.actor }}"
Comment thread
auerbachb marked this conversation as resolved.
Comment thread
auerbachb marked this conversation as resolved.

- name: Upload App Store submission dry-run evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-app-store-dry-run-release
path: artifacts/ios-app-store-dry-run/**
if-no-files-found: error
retention-days: 30

- name: Verify Xcode and iOS SDK versions
shell: bash
run: |
xcodebuild -version
SDK_VERSION="$(xcrun --sdk iphoneos --show-sdk-version)"
echo "iPhoneOS SDK: ${SDK_VERSION}"
[[ "${SDK_VERSION%%.*}" -ge 26 ]]

- name: Install XcodeGen (retry on transient Homebrew failures)
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
shell: bash
run: |
for attempt in 1 2 3; do
if brew install xcodegen; then
exit 0
fi
if [[ "$attempt" -lt 3 ]]; then
sleep $((attempt * 10))
fi
done
echo "Failed to install xcodegen after 3 attempts."
exit 1

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

- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
working-directory: ios

- 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: |
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)

echo -n "$BUILD_CERTIFICATE_BASE64" | base64 -D -o "$CERTIFICATE_PATH"
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 -D -o "$PP_PATH"

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

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-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')

mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
PP_UUID=$(/usr/libexec/PlistBuddy -c "Print UUID" /dev/stdin <<< "$(/usr/bin/security cms -D -i $PP_PATH)")
cp "$PP_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/${PP_UUID}.mobileprovision

- name: Fastlane release (preflight, gym, deliver)
working-directory: ios
env:
# Map existing GitHub Actions secrets → Fastlane standard env (spaceship / deliver).
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
# Same secrets (explicit refs keep ios:app-store:dry-run B2 checks satisfied).
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 }}
RELEASE_OWNER: ${{ github.actor }}
SKIP_PREFLIGHT: "1"
# Harness default: upload binary + metadata only — omit SUBMIT_FOR_REVIEW (deliver uses submit_for_review: false).
# Issue #296+: set SUBMIT_FOR_REVIEW=1 (and optionally AUTOMATIC_RELEASE=1) when a release owner approves submission.
run: bundle exec fastlane release

- name: Clean up keychain and provisioning profile
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
4 changes: 3 additions & 1 deletion .github/workflows/ios-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ name: Build & Upload to TestFlight
on:
push:
tags:
- 'ios-v*'
# TestFlight binary uploads use extended tags, e.g. ios-v1.0.3-build10.
# Semver-only tags (ios-v1.0.0) trigger App Store release — see ios-app-store-release.yml.
- 'ios-v*-build*'

permissions:
contents: read
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
/artifacts/e2e/
/artifacts/ios-app-store-dry-run/

# Bundler (ios/Gemfile — Fastlane)
/ios/vendor/bundle/

# next.js
/.next/
/out/
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,15 +216,15 @@ All iOS release and App Store submission work must reference:
- [iOS release / TestFlight runbook](ios/RELEASING.md)
- [iOS App Store submission and delegation runbook](docs/operations/ios-app-store-submission.md)

Use these files as the source of truth when creating GitHub issues, delegating release tasks, or asking AI coding agents to prepare release work. The TestFlight upload path is automated through GitHub Actions; the App Store submission runbook identifies which App Store Connect steps are automatable through Apple's API and which still require an Account Holder/Admin or human release decision.
Use these files as the source of truth when creating GitHub issues, delegating release tasks, or asking AI coding agents to prepare release work. **TestFlight** uploads run on `ios-v*-build*` tags via [`.github/workflows/ios-testflight.yml`](.github/workflows/ios-testflight.yml). **App Store** metadata + binary automation runs on strict `ios-vMAJOR.MINOR.PATCH` tags (matching `MARKETING_VERSION` in `ios/project.yml`) via [`.github/workflows/ios-app-store-release.yml`](.github/workflows/ios-app-store-release.yml) and Fastlane under `ios/fastlane/`. The App Store submission runbook identifies which App Store Connect steps are automatable through Apple's API and which still require an Account Holder/Admin or human release decision. For automated vs manual steps, see [docs/operations/automation-evidence.md](docs/operations/automation-evidence.md).

Before the next live App Store submission, run the AI-assisted dry run and save its generated evidence:

```bash
npm run ios:app-store:dry-run
```

The dry run reads the canonical runbooks, validates the current iOS version/build/tag plan, checks the TestFlight workflow's required checklist and secret references, maps every issue #242 checklist item, and writes `artifacts/ios-app-store-dry-run/summary.json`, `summary.md`, and `automation.log`. Set `APPSTORE_APP_ID`, `APPSTORE_API_KEY_ID`, `APPSTORE_API_ISSUER_ID`, and `APPSTORE_API_PRIVATE_KEY` to include live App Store Connect read checks; add `-- --require-live` when a release owner wants missing live ASC access to fail the dry run.
The dry run reads the canonical runbooks, validates the current iOS version/build/tag plan, checks both iOS workflows' required checklist and secret references, maps every issue #242 checklist item, and writes `artifacts/ios-app-store-dry-run/summary.json`, `summary.md`, and `automation.log`. Set `APPSTORE_APP_ID`, `APPSTORE_API_KEY_ID`, `APPSTORE_API_ISSUER_ID`, and `APPSTORE_API_PRIVATE_KEY` to include live App Store Connect read checks; add `-- --require-live` when a release owner wants missing live ASC access to fail the dry run.

### Neon (database)

Expand Down Expand Up @@ -274,7 +274,7 @@ What should be green before merge:

Notes:

- The GitHub Actions workflow `Build & Upload to TestFlight` is **release-only** (tag trigger `ios-v*`) and is not a pull-request merge gate.
- The GitHub Actions workflows **Build & Upload to TestFlight** (`ios-v*-build*` tags) and **iOS App Store release (Fastlane)** (`ios-vMAJOR.MINOR.PATCH` tags) are **release-only** and are not pull-request merge gates.
- For strict enforcement, add these check names under GitHub branch protection required checks for `main`.

## Project structure
Expand Down
53 changes: 53 additions & 0 deletions docs/operations/automation-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# iOS App Store automation — automated vs manual (issue #242)

This log complements the machine-readable output from `npm run ios:app-store:dry-run` (`artifacts/ios-app-store-dry-run/summary.json`). It records what the repository automates today versus steps that remain human-owned.

## Preconditions (human, one-time or rare)

| Step | Owner | Notes |
|------|--------|------|
| Create App Store Connect API key (`.p8`), **never commit the key** | Account Holder / Admin | One-time setup; this repo already stores `APPSTORE_API_*` in GitHub Actions secrets (rotate in ASC + GitHub if the key is revoked). |
| Confirm `gh secret list` shows required names (values are never visible) | Release owner | Cannot be automated inside CI without elevated token scope. |
| Distribution certificate + provisioning profile secrets | Engineering | `BUILD_CERTIFICATE_BASE64`, `P12_PASSWORD`, `BUILD_PROVISION_PROFILE_BASE64`. |
| Apple Agreements, Tax, Banking | Account Holder / Admin | Not reliably exposed via public API; uploads can fail until clear. |

## Automated in CI (after secrets exist)

| Capability | Mechanism | Workflow |
|------------|-----------|----------|
| Release-config UI smoke | `npm run e2e:ios:smoke` | TestFlight + App Store release |
| Release-readiness checklist gate | `grep` on `PARITY_CHECKLIST.md` / `QA_CHECKLIST.md` | Both |
| Machine-readable preflight | `npm run ios:app-store:dry-run` | Both |
| Archive + upload binary to App Store Connect / TestFlight | `xcodebuild -exportArchive` with API key | TestFlight (`ios-testflight.yml`) |
| Preflight + archive + metadata + binary via Fastlane | `bundle exec fastlane release` (`gym`, `deliver`) | App Store release (`ios-app-store-release.yml`); `submit_for_review` is false unless `SUBMIT_FOR_REVIEW=1` |

## Automated locally or on a Mac runner (Fastlane)

| Lane (`ios/` directory) | Purpose |
|-------------------------|---------|
| `bundle exec fastlane preflight` | Runs the Node dry-run (same as `npm run ios:app-store:dry-run` from repo root). |
| `bundle exec fastlane build` | `gym` → IPA (no upload). |
| `bundle exec fastlane beta` | `gym` + `pilot` (TestFlight upload). |
| `bundle exec fastlane metadata_only` | `deliver` metadata only (no binary). |
| `bundle exec fastlane release` | Preflight (unless `SKIP_PREFLIGHT=1`) + `gym` + `deliver` (binary + metadata). |

**Submit for review:** CI does **not** set `SUBMIT_FOR_REVIEW`, so `deliver` runs with `submit_for_review: false` (upload-only; issue #242 default). For a future live submission (#296+), set `SUBMIT_FOR_REVIEW=1` in the workflow when a release owner approves. Optional: `AUTOMATIC_RELEASE=1` (only applied when submitting).

## Explicit human gates (not fully automatable)

| Gate | Reason |
|------|--------|
| Release timing (manual vs automatic vs phased) after approval | Business / product decision. |
| Age rating questionnaire changes | Compliance accountability. |
| Screenshot / app preview assets | Require approved creative; automation hooks in #325. |
| Demo credentials in private review fields | Human-provided secrets. |
| Physical-device account-deletion screen recording for review | Asset capture + verification. |
| Rejection triage and engineering issue breakdown | Human judgment before filing work. |
| Closing the ops tracking issue | After final outcome is known. |

## Evidence for a given run

- **Dry run / preflight:** `artifacts/ios-app-store-dry-run/` (from `npm run ios:app-store:dry-run` or the `preflight` lane).
- **CI:** GitHub Actions run logs and uploaded artifacts for `ios-testflight.yml` and `ios-app-store-release.yml`.

Last updated: 2026-05-01 (issue #242 harness).
6 changes: 3 additions & 3 deletions docs/operations/ios-app-store-submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Use this runbook after an iOS build has been uploaded to TestFlight and before c
- **Product / App Store** owns screenshots, copy, age rating, review notes, the account-deletion recording, demo credentials, and release timing.
- **Account Holder / Admin** owns Apple Developer and App Store Connect agreements, tax, and banking.

Record submission status, reviewer notes, build strings, and final outcome in ops tracker #103. Use `ios/RELEASING.md` for tag format, build bumps, and CI upload details.
Record submission status, reviewer notes, build strings, and final outcome in ops tracker #103. Use `ios/RELEASING.md` for tag format, build bumps, and CI upload details. Binary uploads use [`.github/workflows/ios-testflight.yml`](../../.github/workflows/ios-testflight.yml) (`ios-v*-build*` tags). App Store metadata + binary automation uses [`.github/workflows/ios-app-store-release.yml`](../../.github/workflows/ios-app-store-release.yml) (strict `ios-vMAJOR.MINOR.PATCH` tags) and Fastlane under `ios/fastlane/`.

## AI-assisted dry run

Expand All @@ -18,7 +18,7 @@ Before a live submission, generate the issue #242 evidence package:
npm run ios:app-store:dry-run
```

The command reads `README.md`, `ios/RELEASING.md`, this runbook, `.github/workflows/ios-testflight.yml`, `ios/PARITY_CHECKLIST.md`, `ios/QA_CHECKLIST.md`, and `ios/project.yml`. It writes `artifacts/ios-app-store-dry-run/summary.json`, `summary.md`, and `automation.log` with:
The command reads `README.md`, `ios/RELEASING.md`, this runbook, `.github/workflows/ios-testflight.yml`, `.github/workflows/ios-app-store-release.yml`, `ios/PARITY_CHECKLIST.md`, `ios/QA_CHECKLIST.md`, and `ios/project.yml`. It writes `artifacts/ios-app-store-dry-run/summary.json`, `summary.md`, and `automation.log` with:

- the intended `ios-v*` tag, marketing version, build number, bundle ID, workflow trigger, and release artifact placeholders;
- a 38-item issue #242 checklist map across phases A-F, App Store Connect API readiness, submission execution, review follow-up, proof, and definition-of-done items;
Expand Down Expand Up @@ -119,6 +119,6 @@ Account deletion path for Guideline 5.1.1(v):

- Ops / submission log: #103
- Submit / archive checklist (historical): #37
- CI workflow: `.github/workflows/ios-testflight.yml`
- CI workflows: `.github/workflows/ios-testflight.yml`, `.github/workflows/ios-app-store-release.yml`
- Release / tag / build docs: `ios/RELEASING.md`
- App Store Connect submission URL pattern: `https://appstoreconnect.apple.com/apps/<app-id>/distribution/reviewsubmissions/details/<submission-id>`
23 changes: 23 additions & 0 deletions ios/ExportOptionsFastlane.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?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>manual</string>
<key>teamID</key>
<string>T5UU4BP6AV</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>com.brettonauerbach.stillpoint</key>
<string>StillPoint App Store</string>
</dict>
<key>uploadSymbols</key>
<true/>
<key>destination</key>
<string>export</string>
</dict>
</plist>
2 changes: 2 additions & 0 deletions ios/Gemfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

source "https://rubygems.org"

gem "fastlane", "~> 2.227"
Loading
Loading