Automate App Store releases via self-hosted Mac runner
Mirror the existing Android publishing flow for iOS. The pipeline
gains two jobs: build-ipa runs on a self-hosted Mac runner and
produces a signed App Store IPA; publish-app-store runs on a shared
Linux runner and submits the prebuilt IPA to App Store Connect.
Build pipeline (.gitlab-ci.yml):
- build-ipa (Mac, stage build, parallel with build-apk): decodes the
ASC API key, runs match (with api_key, so cert validity is verified
against Apple before xcodebuild starts), builds web assets, syncs
Capacitor, stamps MARKETING_VERSION. Uploads Ditto-${CI_COMMIT_TAG}
.ipa to GitLab's Generic Packages registry.
- publish-app-store (Linux ruby:3.3, needs: [build-ipa]): gem
install fastlane, decode the ASC API key, extract the changelog
section into release_notes.txt, fastlane submit_release with
IPA_PATH pointing at the inherited artifact. No Xcode, no signing,
no keychain \u2014 pure Apple API call.
- release job now needs both build-apk and build-ipa, and links three
assets (APK / AAB / IPA).
fastlane (ios/fastlane/Fastfile, Matchfile, Appfile, metadata/):
- Four lanes: build_ipa (CI build), submit_release (CI publish, reads
IPA_PATH from env), release (single-step convenience for local
dev), submit_only (debug lane to re-submit an already-uploaded
build).
- Match config points at the private gitlab.com/soapbox-pub
/certificates repo. App Store Connect API key is built inline in
the Fastfile to avoid a collision with match's APP_STORE_CONNECT
_API_KEY_PATH env var (match wants a JSON descriptor, the action
writes a raw .p8). CI overrides CODE_SIGN_STYLE=Manual via xcargs
so the Xcode project can stay on Automatic for local development.
Vite config (vite.config.ts):
- Renames the build-time config override env var from CONFIG_FILE to
DITTO_CONFIG_FILE. GitLab Runner sets CONFIG_FILE to its own TOML
config in job env, which broke vite's loader.
App-side changes:
- ios/App/App.xcodeproj/project.pbxproj: team GZLTTH5DLM stamped in;
MARKETING_VERSION gets stamped from the tag at build time.
- public/CHANGELOG.md, package.json: v2.14.3.
Skills + AGENTS.md updated to reflect the six-job pipeline (test /
deploy unchanged, build now has two jobs, release / publish updated)
and to document Mac-runner operations, fastlane match cert rotation,
and local debugging workflows.
This commit is contained in:
@@ -13,9 +13,9 @@ Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the
|
||||
|-----------|---------------------------|-----------------------------------------|
|
||||
| `test` | every commit (not tags) | `npm run test` |
|
||||
| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) |
|
||||
| `build` | tags only | `build-apk` (signed release APK + AAB) |
|
||||
| `release` | tags only | GitLab Release with APK artifact |
|
||||
| `publish` | tags only | `publish-zapstore` + `publish-google-play` |
|
||||
| `build` | tags only | `build-apk` (signed APK + AAB) + `build-ipa` (signed IPA on the Mac runner) |
|
||||
| `release` | tags only | GitLab Release with APK / AAB / IPA links |
|
||||
| `publish` | tags only | `publish-zapstore` + `publish-google-play` + `publish-app-store` |
|
||||
|
||||
## Creating a Release
|
||||
|
||||
@@ -155,3 +155,168 @@ The `publish-google-play` CI job uploads Android AABs to [Google Play](https://p
|
||||
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
|
||||
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.).
|
||||
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
|
||||
|
||||
## App Store Publishing
|
||||
|
||||
Ditto's iOS pipeline is split across two jobs:
|
||||
|
||||
- **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs.
|
||||
- **`publish-app-store`** (stage `publish`, `image: ruby:3.3` on a shared Linux runner) consumes the IPA artifact via `needs: [build-ipa]`. Installs fastlane via `gem install`, decodes the API key, extracts the changelog section for the tag into `release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + select the prebuilt build + auto-submit for App Store review. No Xcode required, no signing in this job — it's just an Apple API call.
|
||||
|
||||
The Mac runner is therefore only used for `build-ipa`. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill.
|
||||
|
||||
**Configuration files:**
|
||||
|
||||
- `ios/fastlane/Fastfile` — exposes four lanes:
|
||||
- `build_ipa` — setup_ci → match (readonly, with API key) → increment_build_number → build_app. Used by CI's `build-ipa`.
|
||||
- `submit_release` — reads `IPA_PATH` env var, calls deliver against the prebuilt IPA. Used by CI's `publish-app-store`.
|
||||
- `release` — combines build_ipa + submit_release; convenience for local one-shot runs.
|
||||
- `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill.
|
||||
- `ios/fastlane/Appfile` — bundle identifier and team ID
|
||||
- `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo
|
||||
- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it from `CHANGELOG.md` per release
|
||||
- `.gitlab-ci.yml` — `build-ipa` (Mac runner, `tags: [macos]`) + `publish-app-store` (Linux runner)
|
||||
|
||||
**Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`.
|
||||
|
||||
**App Store Connect auth**: a long-lived [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) (`.p8` file + key ID + issuer ID) authenticates `match`, `deliver`, and `pilot`. Avoids 2FA prompts that would interrupt CI.
|
||||
|
||||
**Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`.
|
||||
|
||||
**Release notes**: extracted from `CHANGELOG.md` per tag using the same `awk` extraction as the GitLab `release` job, written to `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text.
|
||||
|
||||
**IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile).
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `MATCH_PASSWORD` | Symmetric passphrase used by match to encrypt/decrypt certs and profiles. The single most important secret — losing it makes the cert repo unreadable. | Yes | Yes | Yes |
|
||||
| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64 of `username:deploy-token` for HTTPS clone of the certificates repo. Generated from a `read_repository`-scoped deploy token on `soapbox-pub/certificates`. | Yes | Yes | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect API key ID (10 chars). | Yes | No | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect issuer ID (UUID). | Yes | No | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Base64-encoded contents of the `.p8` private key file. CI decodes with `base64 -d` into `~/.private_keys/AuthKey_<KEY_ID>.p8` and removes it in `after_script`. | Yes | Yes | Yes |
|
||||
| `FASTLANE_KEYCHAIN_PASSWORD` | Password for the ephemeral keychain `setup_ci` creates per build. Random per setup; keep stable across runs. | Yes | Yes | Yes |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. **Provision the Mac runner.** See the **`mac-runner`** skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and `gitlab-runner` registration.
|
||||
|
||||
2. **Create the App Store Connect API key.** Log in to [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API → Generate. Use the **App Manager** role (sufficient for `deliver`'s upload + submit-for-review). Download the `.p8` file (one-time download — Apple won't show it again). Note the **Key ID** (10-char string next to the key) and the **Issuer ID** (UUID at the top of the API page).
|
||||
|
||||
Set the three GitLab CI variables:
|
||||
```bash
|
||||
# Replace <ISSUER_ID>, <KEY_ID>, and the path to your .p8
|
||||
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \
|
||||
--data-urlencode "key=APP_STORE_CONNECT_API_KEY_ISSUER_ID" \
|
||||
--data-urlencode "value=<ISSUER_ID>" \
|
||||
--data-urlencode "protected=true" --data-urlencode "raw=true"
|
||||
# repeat for APP_STORE_CONNECT_API_KEY_ID
|
||||
# for the .p8, base64 first:
|
||||
base64 -i AuthKey_<KEY_ID>.p8 | tr -d '\n' # paste this as APP_STORE_CONNECT_API_KEY_P8_BASE64 (masked)
|
||||
```
|
||||
|
||||
3. **Create the certificates repo.** A private GitLab repo at `soapbox-pub/certificates` holds match-encrypted certs/profiles. Create a project deploy token on it (Settings → Repository → Deploy tokens) with `read_repository` scope. Encode `username:token` as base64 → set as `MATCH_GIT_BASIC_AUTHORIZATION` (protected, masked, raw).
|
||||
|
||||
4. **Generate `MATCH_PASSWORD` and `FASTLANE_KEYCHAIN_PASSWORD`.** Both are arbitrary strong random strings — `openssl rand -base64 32 | tr -d '=+/' | head -c 32` works. Store them as protected, masked GitLab variables.
|
||||
|
||||
5. **Bootstrap match certs via a one-shot CI job** (preferred over running match locally — avoids the macOS keychain UI permission dialogs that fastlane bug [#15185](https://github.com/fastlane/fastlane/issues/15185) trips on newer macOS):
|
||||
|
||||
a. Create a temporary write-scoped GitLab variable. The deploy token is `read_repository`; for the initial cert creation match needs to push. Encode `username:write-pat` as base64 and set it as `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` (Protected, Masked, Raw).
|
||||
|
||||
b. Add a temporary `setup-match` job to `.gitlab-ci.yml` that runs on the macos runner with `setup_ci` (which creates an ephemeral keychain — bypasses the GUI permission issue):
|
||||
|
||||
```yaml
|
||||
setup-match:
|
||||
stage: publish
|
||||
tags: [macos]
|
||||
rules:
|
||||
- if: $SETUP_MATCH == "1"
|
||||
when: manual
|
||||
script:
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- mkdir -p "$HOME/.private_keys" && chmod 700 "$HOME/.private_keys"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
- cd ios
|
||||
- export MATCH_GIT_BASIC_AUTHORIZATION="$MATCH_GIT_BASIC_AUTHORIZATION_WRITE"
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
- |
|
||||
cat > Fastfile.setup <<'RUBY'
|
||||
default_platform(:ios)
|
||||
platform :ios do
|
||||
lane :setup do
|
||||
setup_ci
|
||||
api_key = {
|
||||
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
|
||||
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
|
||||
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
|
||||
duration: 1200,
|
||||
in_house: false,
|
||||
}
|
||||
match(type: "appstore", readonly: false, api_key: api_key, force_for_new_devices: true)
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
- mv fastlane/Fastfile fastlane/Fastfile.bak
|
||||
- mv Fastfile.setup fastlane/Fastfile
|
||||
- fastlane setup
|
||||
- mv fastlane/Fastfile.bak fastlane/Fastfile
|
||||
after_script:
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
```
|
||||
|
||||
c. Trigger the pipeline manually with `SETUP_MATCH=1`:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline" \
|
||||
--data-urlencode "ref=main" \
|
||||
--data-urlencode "variables[][key]=SETUP_MATCH" \
|
||||
--data-urlencode "variables[][value]=1"
|
||||
# Then play the manual setup-match job
|
||||
```
|
||||
|
||||
d. Once the job succeeds (cert + profile pushed to the certificates repo), **delete the `setup-match` job from `.gitlab-ci.yml` and the `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` variable**. They're only needed for bootstrap.
|
||||
|
||||
### Yearly cert renewal
|
||||
|
||||
Apple distribution certs expire annually. Renewal is one command per year, run on any Mac:
|
||||
|
||||
```bash
|
||||
cd ~/Projects/ditto/ios
|
||||
fastlane match nuke distribution # revokes old cert in Apple's portal, removes from match repo
|
||||
fastlane match appstore # creates new cert + profile, encrypts, commits, pushes
|
||||
```
|
||||
|
||||
CI's next tag run picks up the new files automatically (`match(... readonly: true)`).
|
||||
|
||||
### Disaster recovery (Mac dies / new developer joins)
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.com/soapbox-pub/ditto.git
|
||||
cd ditto/ios
|
||||
fastlane match appstore --readonly # decrypts existing certs/profiles using MATCH_PASSWORD
|
||||
```
|
||||
|
||||
No re-issuance of certs needed — the cert repo is the source of truth.
|
||||
|
||||
### App Store Connect API key rotation
|
||||
|
||||
App Store Connect API keys can be revoked anytime. To rotate:
|
||||
|
||||
1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key
|
||||
2. Download the new `.p8`, note the new key ID
|
||||
3. Update `APP_STORE_CONNECT_API_KEY_ID` and `APP_STORE_CONNECT_API_KEY_P8_BASE64` in GitLab variables
|
||||
4. (Issuer ID stays the same — it's per-team, not per-key)
|
||||
5. Revoke the old key in App Store Connect
|
||||
|
||||
### Key points
|
||||
|
||||
- `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (Linux) submits it to Apple via `deliver`.
|
||||
- Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI.
|
||||
- Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`.
|
||||
- Release notes ("What's New in This Version") are auto-extracted from `CHANGELOG.md` and uploaded by `deliver`.
|
||||
- `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in.
|
||||
- `publish-app-store` doesn't sign anything, so it doesn't need macOS or a keychain — pure Apple API call.
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: mac-runner
|
||||
description: Operate the self-hosted GitLab Runner on the Mac that builds Ditto's iOS IPA. Covers SSH access, restarting the runner, viewing logs, updating Xcode, debugging fastlane locally, and rotating match certificates.
|
||||
---
|
||||
|
||||
# Mac Runner Operations
|
||||
|
||||
Ditto's iOS pipeline splits into two CI jobs: `build-ipa` runs on a self-hosted GitLab Runner on a MacBook in the rack (Xcode + signing only run on macOS), and `publish-app-store` runs on a shared Linux runner (no signing — just an Apple API call). This skill covers operating the Mac.
|
||||
|
||||
This skill covers operating the runner: SSH access, restarting after crashes or Xcode updates, watching logs, debugging fastlane locally, and rotating the match certificates. For initial provisioning, App Store Connect API key creation, and GitLab CI variable setup, load the **`ci-cd-publishing`** skill.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Command |
|
||||
|---|---|
|
||||
| SSH in | `ssh alex@alexs-air.lan` |
|
||||
| Runner status | `gitlab-runner status` |
|
||||
| Restart runner | `gitlab-runner restart` (after `eval "$(/opt/homebrew/bin/brew shellenv)"`) |
|
||||
| Stdout log | `tail -f ~/gitlab-runner.out.log` |
|
||||
| Stderr log | `tail -f ~/gitlab-runner.err.log` |
|
||||
| Runner config | `~/.gitlab-runner/config.toml` |
|
||||
| LaunchAgent plist | `~/Library/LaunchAgents/gitlab-runner.plist` |
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Host**: `alexs-air.lan` (Apple Silicon MacBook, macOS 26+, Xcode 26+)
|
||||
- **User**: `alex` (the runner runs in user-mode so it can access keychain and Xcode UI tooling)
|
||||
- **Tooling**: Homebrew (`/opt/homebrew`), `gitlab-runner`, `node@22`, `ruby@3.3`, fastlane installed as a user gem under `~/.gem/ruby/3.3.0/`
|
||||
- **Service**: launchd LaunchAgent at `~/Library/LaunchAgents/gitlab-runner.plist`. `KeepAlive=true` (auto-restart on crash) and `RunAtLoad=true` (starts on login). The agent loads when `alex` logs in via auto-login at boot.
|
||||
- **Tags**: `macos`, `ios`, `xcode` — only the `build-ipa` job in `.gitlab-ci.yml` targets this runner. The `publish-app-store` job runs on a shared Linux runner (no signing or Xcode needed there — it's a pure Apple API call).
|
||||
- **Shell setup**: `~/.bash_profile` sources brew shellenv and prepends `~/.gem/ruby/3.3.0/bin` and `/opt/homebrew/opt/ruby@3.3/bin` to `PATH` so `bash --login` (the runner's executor) finds fastlane + ruby 3.3.
|
||||
|
||||
### Why Ruby 3.3, not the brewed 4.0
|
||||
|
||||
Brewed `fastlane` (current version) ships running on Ruby 4.0 from `brew install ruby`. Ruby 4.0's OpenSSL bindings hit fastlane bug [#20553](https://github.com/fastlane/fastlane/issues/20553) — `OpenSSL::PKey::EC.new(pem)` raises "invalid curve name" for `prime256v1` keys, which breaks every App Store Connect API key signing operation. Ruby 3.3.x doesn't have this bug. So we install fastlane via `gem install fastlane --user-install` on `ruby@3.3` instead of `brew install fastlane`.
|
||||
|
||||
### Why IPv6 is disabled on Wi-Fi
|
||||
|
||||
`networksetup -setv6off Wi-Fi` is set because Ruby's net/http on this machine attempted IPv6 to `rubygems.org` first and timed out (~30 s per request). Disabling IPv6 on the Wi-Fi interface forces IPv4 immediately. To re-enable: `sudo networksetup -setv6automatic Wi-Fi`.
|
||||
|
||||
## Verifying the runner is healthy
|
||||
|
||||
From any machine:
|
||||
|
||||
```bash
|
||||
curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/runners/53111580" \
|
||||
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['status'], d['online'])"
|
||||
```
|
||||
|
||||
Expected: `online True`. If `offline` or `not_connected`, SSH in and check:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
gitlab-runner status
|
||||
ps aux | grep gitlab-runner
|
||||
tail -50 ~/gitlab-runner.err.log
|
||||
```
|
||||
|
||||
## Restarting the runner
|
||||
|
||||
After a Mac reboot, the runner should start automatically via the LaunchAgent. To restart manually:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
gitlab-runner restart
|
||||
```
|
||||
|
||||
If `gitlab-runner restart` reports "service not installed", reinstall:
|
||||
|
||||
```bash
|
||||
gitlab-runner install
|
||||
gitlab-runner start
|
||||
```
|
||||
|
||||
This rewrites the LaunchAgent plist.
|
||||
|
||||
## Watching a CI job run live
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan 'tail -f ~/gitlab-runner.out.log'
|
||||
```
|
||||
|
||||
The runner streams build output to stdout. The same output appears in the GitLab job UI.
|
||||
|
||||
## Updating Xcode
|
||||
|
||||
After a major Xcode update:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
sudo xcodebuild -license accept # accept the new license non-interactively
|
||||
xcode-select --install # ensure command-line tools are present
|
||||
xcodebuild -version # confirm version
|
||||
```
|
||||
|
||||
Then trigger a no-op tag rebuild (e.g. cut a patch release) to verify the runner still works.
|
||||
|
||||
## Debugging fastlane locally
|
||||
|
||||
If `build-ipa` fails in CI, reproduce on the Mac. The env vars below mirror what CI sets up:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
cd ~/Projects/ditto
|
||||
git pull origin main
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
|
||||
# Match what CI provides
|
||||
export CI_COMMIT_TAG=v2.x.y
|
||||
export CI_PIPELINE_IID=99999
|
||||
export MATCH_PASSWORD='<from GitLab CI variables>'
|
||||
export MATCH_GIT_BASIC_AUTHORIZATION='<base64 of ci-readonly:gldt-...>'
|
||||
export APP_STORE_CONNECT_API_KEY_ID=<key-id>
|
||||
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<issuer-id>
|
||||
export ASC_KEY_PATH=~/.private_keys/AuthKey_<key-id>.p8
|
||||
|
||||
# Build web assets and sync to Capacitor iOS project (CI does this in before_script)
|
||||
npm ci
|
||||
npx vite build -l error
|
||||
cp dist/index.html dist/404.html
|
||||
npx cap sync ios
|
||||
node scripts/patch-cap-config.mjs
|
||||
|
||||
# Stamp marketing version (CI does this in script)
|
||||
VERSION="${CI_COMMIT_TAG#v}"
|
||||
sed -i '' "s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g" ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run the build lane
|
||||
cd ios
|
||||
fastlane build_ipa
|
||||
```
|
||||
|
||||
This produces the IPA at `../artifacts/Ditto.ipa` exactly like CI. Add `--verbose` for detailed output.
|
||||
|
||||
To also test the submission step end-to-end (this calls Apple, so be ready to "Remove from Review" in App Store Connect afterward):
|
||||
|
||||
```bash
|
||||
export IPA_PATH="$HOME/Projects/ditto/artifacts/Ditto.ipa"
|
||||
fastlane submit_release
|
||||
```
|
||||
|
||||
Or, to debug *just* the submission against an already-uploaded build without rebuilding, use the `submit_only` lane (see "Debugging App Store submission with the `submit_only` lane" below).
|
||||
|
||||
## Rotating match certificates (yearly)
|
||||
|
||||
Apple distribution certs expire one year after issuance. To renew:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
cd ~/Projects/ditto/ios
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
|
||||
# Set Apple credentials (API key path)
|
||||
export MATCH_PASSWORD='<from GitLab CI variables>'
|
||||
|
||||
# Revoke the expiring cert in Apple's portal and remove from the match repo
|
||||
fastlane match nuke distribution
|
||||
|
||||
# Issue a new cert, generate a new App Store profile, encrypt, commit, push
|
||||
fastlane match appstore \
|
||||
--api_key_path ~/.private_keys/AuthKey_<KEY_ID>.p8 \
|
||||
--api_key_id <KEY_ID> \
|
||||
--api_issuer_id <ISSUER_ID>
|
||||
```
|
||||
|
||||
CI's next tag run picks up the new files via `match(... readonly: true)`. No GitLab variables to update.
|
||||
|
||||
## Debugging App Store submission with the `submit_only` lane
|
||||
|
||||
The `Fastfile` exposes a second lane, `submit_only`, that skips build/archive/upload and just runs `deliver` against an already-uploaded build. Useful when the binary is fine but the metadata/submission step is failing — iterate in ~30 seconds instead of waiting for a full ~6-minute CI build.
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
export PATH="$HOME/.gem/ruby/3.3.0/bin:/opt/homebrew/opt/ruby@3.3/bin:$PATH"
|
||||
cd ~/Projects/ditto/ios
|
||||
|
||||
# Make sure the .p8 is on disk; CI's after_script wipes it after each job
|
||||
scp $LAPTOP:/path/to/AuthKey_<KEY_ID>.p8 ~/.private_keys/
|
||||
|
||||
export ASC_KEY_PATH=$HOME/.private_keys/AuthKey_<KEY_ID>.p8
|
||||
export APP_STORE_CONNECT_API_KEY_ID=<KEY_ID>
|
||||
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<ISSUER_ID>
|
||||
export BUILD_NUMBER=<existing-build-number-on-ASC>
|
||||
export VERSION=<marketing-version, e.g. 2.14.3>
|
||||
|
||||
fastlane submit_only
|
||||
```
|
||||
|
||||
The lane expects the version to exist in App Store Connect with a `VALID` build attached. It uploads metadata (`./fastlane/metadata/en-US/release_notes.txt`) and calls `submit_for_review`. If Apple rejects, fix the Fastfile, re-run — no rebuild needed.
|
||||
|
||||
If Apple has already accepted the submission for that version, you'll need to "Remove from Review" in App Store Connect (only available while state is `WAITING_FOR_REVIEW`, not `IN_REVIEW`) before re-running, or bump the build number.
|
||||
|
||||
## Inspecting App Store Connect state directly
|
||||
|
||||
When fastlane's error messages aren't enough, query Apple's API directly. There's no installed CLI — use the JWT signing recipe Apple documents. A working Ruby snippet lives in this skill's troubleshooting history; the short version:
|
||||
|
||||
```ruby
|
||||
require "json"; require "openssl"; require "net/http"; require "base64"
|
||||
key_pem = File.read(ENV["ASC_KEY_PATH"])
|
||||
ec = OpenSSL::PKey::EC.new(key_pem)
|
||||
header = { alg: "ES256", kid: ENV["APP_STORE_CONNECT_API_KEY_ID"], typ: "JWT" }
|
||||
payload = { iss: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], iat: Time.now.to_i, exp: Time.now.to_i + 1200, aud: "appstoreconnect-v1" }
|
||||
def b64(s); Base64.urlsafe_encode64(s, padding: false); end
|
||||
si = b64(JSON.generate(header)) + "." + b64(JSON.generate(payload))
|
||||
sig_der = ec.sign(OpenSSL::Digest::SHA256.new, si)
|
||||
asn = OpenSSL::ASN1.decode(sig_der)
|
||||
r = asn.value[0].value.to_s(2); s = asn.value[1].value.to_s(2)
|
||||
r = ("\x00".b * (32 - r.bytesize)) + r if r.bytesize < 32
|
||||
s = ("\x00".b * (32 - s.bytesize)) + s if s.bytesize < 32
|
||||
jwt = si + "." + b64(r + s)
|
||||
# Now: GET https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=pub.ditto.app
|
||||
# with header Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
Useful endpoints:
|
||||
- `GET /v1/apps?filter[bundleId]=pub.ditto.app` → app id
|
||||
- `GET /v1/apps/<id>/appStoreVersions` → version list with `appStoreState`
|
||||
- `GET /v1/apps/<id>/builds?sort=-uploadedDate` → recent builds and processing state
|
||||
- `GET /v1/appStoreVersions/<id>/appStoreVersionLocalizations` → release notes (`whatsNew`)
|
||||
|
||||
## What can go wrong
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Runner shows offline in GitLab | Mac rebooted, auto-login disabled, or LaunchAgent unloaded | SSH in, `gitlab-runner status`, `gitlab-runner restart` |
|
||||
| Build fails: "unable to find Xcode" | Xcode auto-updated and changed path, or command-line tools missing | `xcode-select --install`, `sudo xcodebuild -license accept` |
|
||||
| Build fails: "no signing certificate found" | match cert expired, was revoked manually, or `MATCH_PASSWORD` mismatched | Run yearly rotation procedure above |
|
||||
| Build fails: keychain locked / "User interaction is not allowed" | `setup_ci` failed to create the temporary keychain | Verify `FASTLANE_KEYCHAIN_PASSWORD` is set in GitLab CI variables |
|
||||
| Build fails: ASC API key invalid | Key was revoked or rotated | Generate a new key and update `APP_STORE_CONNECT_API_KEY_*` variables |
|
||||
| "Build already exists" from `deliver` | Previous tag's IPA had the same `CFBundleVersion`; fastlane's `increment_build_number` didn't bump because the value already matched `CI_PIPELINE_IID` | Push a new tag (each new tag has a new pipeline ID) |
|
||||
| Apple precheck rejects metadata | Encryption export compliance, IDFA, content rights flags don't match `Fastfile` | Update `submission_information` in `ios/fastlane/Fastfile` |
|
||||
| `OpenSSL::PKey::PKeyError: invalid curve name` | fastlane is running on brewed Ruby 4.0, which has a broken OpenSSL EC parser ([fastlane#20553](https://github.com/fastlane/fastlane/issues/20553)) | Use `ruby@3.3` from brew and install fastlane as a user gem (`gem install fastlane --user-install`); ensure `~/.bash_profile` puts `~/.gem/ruby/3.3.0/bin` on PATH ahead of `/opt/homebrew/bin` |
|
||||
| `gem install` / `bundle install` hangs for >30s per request | Ruby's net/http tries IPv6 to rubygems.org and times out on this network | `sudo networksetup -setv6off Wi-Fi` (per-interface, persistent until reboot) |
|
||||
| `Unresolved conflict between options: 'api_key_path' and 'api_key'` | `app_store_connect_api_key` action sets `APP_STORE_CONNECT_API_KEY_PATH` env var (path to `.p8`), match's same-named env var expects a JSON descriptor | Build the API key hash inline in the Fastfile (don't call `app_store_connect_api_key`); read `.p8` from a non-conflicting var like `ASC_KEY_PATH` |
|
||||
| `[match] Could not find the newly generated certificate installed` when running match interactively on macOS 26+ | [fastlane#15185](https://github.com/fastlane/fastlane/issues/15185) — the new-cert verification step trips on partition list and keychain trust | Run cert generation **in CI** via the bootstrap procedure in the `ci-cd-publishing` skill (uses `setup_ci`'s ephemeral keychain). Don't run `fastlane match appstore` interactively. |
|
||||
| iOS build fails: `No "iOS Development" signing certificate matching team ID` | The Xcode project uses `CODE_SIGN_STYLE=Automatic`; xcodebuild tries to find a Development cert even for Release builds | Override via `xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore <bundle-id>' DEVELOPMENT_TEAM=<team>"` in the Fastfile (already configured) |
|
||||
| `vite.config.ts: Unexpected token 'c', "concurrent"... is not valid JSON` | GitLab Runner sets `CONFIG_FILE=/Users/alex/.gitlab-runner/config.toml` in the job environment, which collides with vite's `process.env.CONFIG_FILE ?? "./ditto.json"` lookup | Already fixed: use `DITTO_CONFIG_FILE` for the override env var |
|
||||
| `whatsNew is missing` from `submit_for_review` | `metadata_path: "./metadata"` resolves relative to fastlane's cwd (`ios/`), not its config dir (`ios/fastlane/`); fastlane silently uploads zero locales | Use `metadata_path: "./fastlane/metadata"` (already configured) |
|
||||
| `appStoreVersions ... is not in valid state` | Apple won't accept submission because the version is past `PREPARE_FOR_SUBMISSION` (already submitted, in review, or shipped) | "Remove from Review" in App Store Connect if `WAITING_FOR_REVIEW`, or cut a new version |
|
||||
| `An attribute value is not acceptable for the current resource state. - contentRightsDeclaration` | Apple rejects PATCH on locked App-level fields when `submission_information` includes `content_rights_*` | Drop `content_rights_*` from `submission_information` in the Fastfile (already configured) |
|
||||
|
||||
## When the Mac dies
|
||||
|
||||
1. Get a replacement Mac. Install Xcode from the App Store.
|
||||
2. Run the **`ci-cd-publishing`** skill's "Initial setup" — but skip the App Store Connect API key step (you already have it). Re-register the runner with the same `macos` tag.
|
||||
3. Restore signing identity: `cd ditto/ios && fastlane match appstore --readonly` decrypts the existing certs/profiles using `MATCH_PASSWORD`.
|
||||
4. No reissuance, no revocation, no GitLab variable updates needed. The certificates repo is the source of truth.
|
||||
@@ -265,8 +265,11 @@ git push origin main vX.Y.Z
|
||||
|
||||
This triggers the GitLab CI pipeline which will:
|
||||
1. Build a signed Android APK and AAB
|
||||
2. Create a GitLab Release with download links
|
||||
3. Publish the APK to Zapstore
|
||||
2. Build a signed iOS IPA on the self-hosted Mac runner
|
||||
3. Create a GitLab Release with APK / AAB / IPA download links
|
||||
4. Publish the APK to Zapstore
|
||||
5. Publish the AAB to Google Play (production track)
|
||||
6. Submit the iOS IPA to App Store Connect for review
|
||||
|
||||
### Step 12: Confirm
|
||||
|
||||
@@ -287,11 +290,14 @@ After pushing, inform the user:
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs:
|
||||
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs six jobs:
|
||||
|
||||
1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build
|
||||
2. **release**: Creates a GitLab Release with the changelog content and download links
|
||||
3. **publish-zapstore**: Publishes the APK to Zapstore
|
||||
2. **build-ipa**: Builds the signed App Store IPA on the self-hosted Mac runner (`tags: [macos]`); stamps `MARKETING_VERSION` and `CFBundleVersion` into the Xcode project. The IPA is uploaded to GitLab's Generic Packages registry and exposed as a CI artifact for downstream jobs
|
||||
3. **release**: Creates a GitLab Release with the changelog content and APK / AAB / IPA download links
|
||||
4. **publish-zapstore**: Publishes the APK to Zapstore
|
||||
5. **publish-google-play**: Uploads the AAB to Google Play production track
|
||||
6. **publish-app-store**: Submits the prebuilt IPA to App Store Connect for review (runs on a shared Linux runner; no Xcode needed since the IPA is already built). The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
Reference in New Issue
Block a user