iOS TestFlight Release Setup
Use this skill when the user wants a new iOS app repo shipped to TestFlight, not just scaffolded. Treat repo creation, bundle/App Store Connect setup, signing, GitHub Actions, environment secrets, and first upload as one deliverable.
Operating Principles
- Prefer a hosted GitHub runner with
macos-26 and fastlane match for durable CI signing.
- Use self-hosted macOS runners when private-repo GitHub Actions minutes are exhausted or when local Apple tooling is required. Every job that must avoid minutes needs
runs-on: [self-hosted, macOS] or a more specific self-hosted label; one leftover macos-* job still consumes/bills minutes.
- Use a self-hosted local-keychain runner only as a fallback when match cannot be seeded yet.
- If working in the user's AM Software apps, inspect an already-working app first, especially
VideoMerger, and mirror its signing/CI shape where applicable.
- Do not declare success from a local upload alone. Verify the remote GitHub Actions run and App Store Connect build state.
- Keep destructive signing actions explicit. Do not revoke, nuke, or delete certificates/profiles without clear approval.
Required Tools
Check early:
command -v gh asc xcodegen fastlane jq security git xcodebuild
Use local authenticated sessions when available:
gh auth status
asc auth status
asc apps list --bundle-id "$BUNDLE_ID" --output json
Workflow
Create and inspect the repo
- Create the app repo and push an initial
main.
- If a private match repo is needed, create it separately, usually
<app>-certificates.
- Add or confirm
.gitignore, README.md, project.yml, app source, tests, fastlane/, and .github/workflows/.
Example:
gh repo create OWNER/APP_REPO --private --source . --remote origin --push
gh repo create OWNER/MATCH_REPO --private
Create the Xcode app
Prefer project.yml plus xcodegen generate when the repo uses XcodeGen.
Required release settings:
DEVELOPMENT_TEAM
PRODUCT_BUNDLE_IDENTIFIER
MARKETING_VERSION
CURRENT_PROJECT_VERSION
- Release signing configured for App Store when using match:
settings:
base:
DEVELOPMENT_TEAM: TEAMID
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "1"
targets:
App:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.example.app
CODE_SIGN_STYLE: Automatic
configs:
Release:
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: Apple Distribution
PROVISIONING_PROFILE_SPECIFIER: match AppStore com.example.app
Add export compliance when the app does not use non-exempt encryption:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
Validate:
xcodegen generate
plutil -lint App/Info.plist
xcodebuild -project App.xcodeproj -scheme App -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test
Create App Store Connect records
Resolve/create:
- Bundle ID
- App Store Connect app
- App Store Connect API key
- App Store signing certificate
- App Store provisioning profile
Useful checks:
asc apps list --bundle-id "$BUNDLE_ID" --output json
asc bundle-ids list --output json --paginate
asc certificates list --output json --paginate
asc profiles list --output json --paginate
Configure fastlane
fastlane/Matchfile:
git_url("https://github.com/OWNER/MATCH_REPO.git")
storage_mode("git")
type("appstore")
app_identifier(["com.example.app"])
username("apple-id@example.com")
Core Fastfile pattern:
def ensure_app_store_connect_api_key!
return if defined?(@app_store_api_key) && @app_store_api_key
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] || "fastlane/AuthKey.p8"
UI.user_error!("APP_STORE_CONNECT_API_KEY_ID is not set") unless key_id
UI.user_error!("APP_STORE_CONNECT_ISSUER_ID is not set") unless issuer_id
UI.user_error!("App Store Connect API key not found at #{key_path}") unless File.exist?(key_path)
@app_store_api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_filepath: key_path
)
ENV.delete("APP_STORE_CONNECT_API_KEY_PATH")
ENV.delete("DELIVER_API_KEY_PATH")
end
def match_readonly?
override = ENV["MATCH_READONLY_OVERRIDE"]
return override.downcase == "true" if override
!ENV["CI"]
end
def match_keychain_options
keychain = ENV["MATCH_KEYCHAIN_NAME"]
password = ENV["MATCH_KEYCHAIN_PASSWORD"]
return {} unless keychain && password
{ keychain_name: keychain, keychain_password: password }
end
platform :ios do
lane :certificates do
ensure_app_store_connect_api_key!
match(type: "appstore", readonly: match_readonly?, api_key: @app_store_api_key, **match_keychain_options)
end
lane :build_release do
ensure_app_store_connect_api_key!
increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"] || Time.now.utc.strftime("%Y%m%d%H%M%S"))
match(type: "appstore", readonly: match_readonly?, api_key: @app_store_api_key, **match_keychain_options)
gym(
scheme: "App",
configuration: "Release",
export_method: "app-store",
export_options: {
provisioningProfiles: {
"com.example.app" => "match AppStore com.example.app"
}
}
)
end
lane :deploy_testflight do
build_release
pilot(api_key: @app_store_api_key, skip_waiting_for_build_processing: true)
end
lane :submit_to_app_store do
build_release
ENV.delete("APP_STORE_CONNECT_API_KEY_PATH")
ENV.delete("DELIVER_API_KEY_PATH")
upload_to_app_store(
api_key: @app_store_api_key,
force: true,
skip_metadata: true,
skip_screenshots: true,
submit_for_review: false,
automatic_release: false,
precheck_include_in_app_purchases: false
)
end
end
If passing api_key: to pilot, deliver, or upload_to_app_store, clear APP_STORE_CONNECT_API_KEY_PATH and DELIVER_API_KEY_PATH after creating the API key object. Otherwise Fastlane can see both api_key and api_key_path and fail with conflicting authentication inputs.
Do not set reject_if_possible by default in an App Store upload lane. It can cancel an in-progress App Store review when all the user asked for is uploading a new build.
Seed match
Prefer importing an existing known-good .p12 and App Store provisioning profile into the match repo. Keep the P12 password separate from MATCH_PASSWORD.
Critical checks:
MATCH_PASSWORD is the encryption password for the match repo.
- The P12 password is only for importing/exporting the certificate.
- Do not copy encrypted cert files from one match repo into another unless you know both repos use the same
MATCH_PASSWORD. GitHub secrets cannot be read back, and a wrong password will fail before signing with Invalid password passed via 'MATCH_PASSWORD'.
- When a distribution certificate limit is reached, set match read-only and import/reuse a valid existing distribution cert/profile, or get explicit approval to revoke/free a slot. Do not let CI repeatedly try to create new distribution certs.
- In a match repo, certificate files should be named by the Developer Portal certificate id, not a local keychain SHA/fingerprint.
- If fastlane says
Certificate '<hash>' (stored in your storage) is not available on the Developer Portal, the match repo probably has wrongly named cert files or stale certs.
Verify certificate id by serial:
asc certificates list --output json --paginate |
jq -r '.data[] | [.id, .attributes.serialNumber, .attributes.certificateType, .attributes.displayName] | @tsv'
Verify match repo contents:
git ls-remote https://github.com/OWNER/MATCH_REPO.git
Create GitHub Actions environment secrets
Use a protected environment such as production. Required secrets for hosted match-backed deploys:
MATCH_PASSWORD
MATCH_GIT_BASIC_AUTHORIZATION
APP_STORE_CONNECT_API_KEY
APP_STORE_CONNECT_API_KEY_ID
APP_STORE_CONNECT_ISSUER_ID
Optional/common legacy names, only if the workflow reads them:
APPLE_DEVELOPER_TEAM_ID
APP_STORE_CONNECT_TEAM_ID
APP_STORE_CONNECT_KEY_ID
APP_STORE_CONNECT_KEY_CONTENT
FASTLANE_USER
MATCH_GIT_BASIC_AUTHORIZATION must be base64 for x-access-token:<github_pat>.
printf 'x-access-token:%s' "$GITHUB_PAT" | base64 | tr -d '\n'
If zsh prints a trailing %, do not paste %; it is only a no-newline marker.
Set secrets:
printf '%s' "$MATCH_PASSWORD" |
gh secret set MATCH_PASSWORD --repo OWNER/APP_REPO --env production
printf '%s' "$MATCH_GIT_BASIC_AUTHORIZATION" |
gh secret set MATCH_GIT_BASIC_AUTHORIZATION --repo OWNER/APP_REPO --env production
gh secret set APP_STORE_CONNECT_API_KEY --repo OWNER/APP_REPO --env production < AuthKey_XXXX.p8
printf '%s' "$APP_STORE_CONNECT_API_KEY_ID" |
gh secret set APP_STORE_CONNECT_API_KEY_ID --repo OWNER/APP_REPO --env production
printf '%s' "$APP_STORE_CONNECT_ISSUER_ID" |
gh secret set APP_STORE_CONNECT_ISSUER_ID --repo OWNER/APP_REPO --env production
Add hosted GitHub Actions deploy workflow
Prefer macos-26; App Store Connect may reject older SDK uploads.
name: App Store Deployment
on:
push:
branches: [ main ]
workflow_dispatch:
concurrency:
group: app-store-deployment-${{ github.ref }}
cancel-in-progress: false
env:
XCODE_PATH: ${{ vars.XCODE_PATH || '/Applications/Xcode_26.4.app/Contents/Developer' }}
FASTLANE_VERSION: '2.229.1'
jobs:
deploy:
name: Deploy to TestFlight
runs-on: macos-26
environment: production
env:
RUBY_VERSION: '3.1.4'
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
MATCH_READONLY_OVERRIDE: ${{ vars.MATCH_READONLY_OVERRIDE || 'true' }}
APP_STORE_CONNECT_API_KEY_PATH: ${{ github.workspace }}/fastlane/AuthKey.p8
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
SPACESHIP_CONNECT_API_IN_HOUSE: 'false'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Clear checkout GitHub token header
run: git config --local --unset-all http.https://github.com/.extraheader || true
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ env.RUBY_VERSION }}
- name: Install dependencies
run: gem install fastlane -v "$FASTLANE_VERSION" --no-document
- name: Configure Xcode version
shell: bash
run: |
TARGET_PATH="$XCODE_PATH"
if [ ! -d "$TARGET_PATH" ]; then
TARGET_PATH="$(xcode-select -p)"
echo "Requested Xcode path not found; falling back to $TARGET_PATH"
fi
echo "DEVELOPER_DIR=$TARGET_PATH" >> "$GITHUB_ENV"
- name: Setup temporary keychain
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/fastlane_${{ github.run_id }}.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"
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 list-keychains -d user -s "$KEYCHAIN_PATH"
{
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD"
echo "MATCH_KEYCHAIN_NAME=$KEYCHAIN_PATH"
echo "MATCH_KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD"
} >> "$GITHUB_ENV"
- name: Configure App Store Connect API key
env:
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
run: |
mkdir -p fastlane
python3 - <<'PY'
import json, os, pathlib
secret = os.environ.get('APP_STORE_CONNECT_API_KEY', '').strip()
if not secret:
raise SystemExit('APP_STORE_CONNECT_API_KEY secret is empty')
key_path = pathlib.Path(os.environ.get('APP_STORE_CONNECT_API_KEY_PATH', 'fastlane/AuthKey.p8'))
key_id = os.environ.get('APP_STORE_CONNECT_API_KEY_ID')
issuer_id = os.environ.get('APP_STORE_CONNECT_ISSUER_ID')
try:
data = json.loads(secret)
except json.JSONDecodeError:
key_material = secret
else:
key_material = data.get('key')
key_id = data.get('key_id', key_id)
issuer_id = data.get('issuer_id', issuer_id)
if not key_material:
raise SystemExit('App Store Connect key material missing from secret')
if not key_id or not issuer_id:
raise SystemExit('APP_STORE_CONNECT_API_KEY_ID or APP_STORE_CONNECT_ISSUER_ID missing')
key_path.write_text(key_material.strip() + ("\n" if not key_material.endswith('\n') else ''))
key_path.chmod(0o600)
with open(os.environ['GITHUB_ENV'], 'a') as env:
env.write(f"APP_STORE_CONNECT_API_KEY_ID={key_id}\n")
env.write(f"APP_STORE_CONNECT_ISSUER_ID={issuer_id}\n")
PY
- name: Release prerequisites preflight
run: scripts/ci-release-preflight.sh
- name: Ensure provisioning profile directory writable
run: |
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILE_DIR"
chmod u+rwX "$HOME/Library/MobileDevice" "$PROFILE_DIR"
rm -f "$PROFILE_DIR"/*.mobileprovision || true
- name: Setup Match certificates
run: fastlane _${FASTLANE_VERSION}_ certificates
- name: Deploy to TestFlight
env:
GITHUB_RUN_NUMBER: ${{ github.run_number }}
run: fastlane _${FASTLANE_VERSION}_ deploy_testflight
Add a preflight script
Keep it focused on cheap local checks; do not duplicate fragile App Store network auth checks when fastlane already validates them.
#!/usr/bin/env bash
set -euo pipefail
MATCH_REPO="${MATCH_REPO:-https://github.com/OWNER/MATCH_REPO.git}"
for var in APP_STORE_CONNECT_API_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_API_KEY_PATH MATCH_GIT_BASIC_AUTHORIZATION; do
if [ -z "${!var:-}" ]; then
echo "$var is required." >&2
exit 1
fi
done
[ -f "$APP_STORE_CONNECT_API_KEY_PATH" ] || {
echo "App Store Connect API key file not found: $APP_STORE_CONNECT_API_KEY_PATH" >&2
exit 1
}
git -c credential.helper= \
-c http.extraHeader="Authorization: Basic $MATCH_GIT_BASIC_AUTHORIZATION" \
ls-remote "$MATCH_REPO" >/dev/null
echo "Release prerequisites are present."
Self-hosted local-keychain fallback
Use this only when match cannot be seeded. The reliable pattern on this Mac was an interactive runner process with login-keychain access. A LaunchAgent/service runner can see certificates but still fail codesign with errSecInternalComponent because private-key use is blocked non-interactively.
Fallback checklist:
- Use a dedicated runner label, e.g.
app-local-keychain.
- Start runner interactively from Terminal.
- Ensure the distribution cert/private key is usable from
login.keychain-db.
- Add
security list-keychains -d user -s "$KEYCHAIN" ... and security unlock-keychain.
- Treat this as temporary until match-backed hosted CI works.
Self-hosted minute-saving runner pattern
For private repos with no Actions minutes left, run the complete deploy job on a self-hosted Mac runner. Repo-specific runners are fine; verify the runner is online before chasing workflow bugs:
gh api repos/OWNER/APP_REPO/actions/runners \
--jq '.runners[] | {name,status,busy,labels:[.labels[].name]}'
Practical workflow hardening for this Mac:
- Add a cross-repo signing lock, for example
/tmp/akmarinov-ci-signing.lock, so multiple App Store jobs do not mutate keychains/profiles concurrently.
- Use a temporary keychain per run, save original keychain search list, and restore/delete in
if: always() cleanup.
- If replacing
ruby/setup-ruby, validate Ruby with ruby -rsocket -e 'exit'; failed local Ruby builds can miss the socket extension.
- If reusing another runner's Ruby toolcache, require both
bin/ruby and the sibling .complete marker before symlinking. Without .complete, setup can race an incomplete install.
- If
actionlint is unavailable, still parse workflow YAML with Ruby or Python and run ruby -c fastlane/Fastfile.
Minimal self-hosted Ruby fallback check:
if [ -x "$RUBY_DIR/bin/ruby" ] &&
[ -f "$RUBY_DIR.complete" ] &&
"$RUBY_DIR/bin/ruby" -rsocket -e 'exit' >/dev/null 2>&1; then
echo "$RUBY_DIR/bin" >> "$GITHUB_PATH"
fi
- App Store upload on main
When the desired behavior is "upload to App Store Connect on every push to main", make that path explicit:
- Trigger on
push.branches: [main].
- Run the App Store upload lane from the push path.
- Keep TestFlight lanes available for manual dispatch if needed.
- Use
submit_for_review: false and automatic_release: false unless the user explicitly wants automatic review submission/release.
- If App Store Connect rejects an upload because a marketing version was already used, bump
MARKETING_VERSION; incrementing only the build number is not enough for a version that has already shipped.
Verification
Remote GitHub Actions verification:
GH_TOKEN="$token" gh run list \
--repo OWNER/APP_REPO \
--branch main \
--workflow app-store-deploy.yml \
--limit 5 \
--json databaseId,status,conclusion,headSha,createdAt,url,event,displayTitle
GH_TOKEN="$token" gh run watch RUN_ID --repo OWNER/APP_REPO --exit-status
GH_TOKEN="$token" gh run view RUN_ID --repo OWNER/APP_REPO --log |
rg -n "GITHUB_RUN_NUMBER|Updated CFBundleVersion|xcode_path|Ready to upload|Successfully uploaded package|Successfully uploaded the new binary"
App Store Connect verification:
asc builds list --app "$APP_ID" --platform IOS --output json --paginate |
jq -r '.data[] | [.id, (.attributes.version // ""), (.attributes.buildNumber // ""), (.attributes.processingState // ""), (.attributes.uploadedDate // "")] | @tsv' |
head -20
Success means:
- latest push-triggered GitHub run is
success,
- log contains
Successfully uploaded the new binary to App Store Connect,
- ASC shows the new build number as
VALID or processing,
- local
main is clean and matches origin/main.
Troubleshooting Map
curl/Spaceship 401 in a custom preflight: remove brittle App Store network preflight and let fastlane validate the API key.
- GitHub match repo
400: reset MATCH_GIT_BASIC_AUTHORIZATION; ensure no trailing % and use Authorization: Basic <base64>.
Invalid password passed via 'MATCH_PASSWORD': the match repo is encrypted with a different password than the app repo secret. You cannot recover the secret from GitHub; align the secret or import assets with the app repo's intended match password.
- Apple distribution certificate limit reached: do not keep retrying mutable
match; use read-only signing with existing assets, import an existing cert/profile, or ask before revoking/freeing a certificate slot.
Certificate '<hash>' is not available on the Developer Portal: match repo has stale/wrong certificate filenames; ensure cert files are named by portal cert id.
No profiles for '<bundle id>' were found and it asks for development profiles: Release target signing is still Automatic/development; set Release to manual App Store profile.
- Upload rejected for old SDK: use
macos-26 and Xcode 26+.
- Upload rejected because version already exists: bump
MARKETING_VERSION in the Xcode project or XcodeGen config, then rerun.
- Fastlane says both
api_key and api_key_path are present: clear APP_STORE_CONNECT_API_KEY_PATH and DELIVER_API_KEY_PATH before calling deliver/upload_to_app_store with api_key:.
errSecInternalComponent on self-hosted runner: private-key access is blocked; use interactive runner or switch to match.
- Compliance warning for encryption: add
ITSAppUsesNonExemptEncryption=false to the app Info.plist when true for the app.
1---2name: ios-testflight-release-setup3description: End-to-end setup for a new iOS app repo that must be created, signed, wired to App Store Connect/TestFlight, and deployed by GitHub Actions. Use when creating or hardening an iOS app release pipeline with XcodeGen/Xcode, fastlane match, GitHub repos/secrets/environments, App Store Connect API keys, hosted or self-hosted macOS runners, and first TestFlight upload verification.4---56# iOS TestFlight Release Setup78Use this skill when the user wants a new iOS app repo shipped to TestFlight, not just scaffolded. Treat repo creation, bundle/App Store Connect setup, signing, GitHub Actions, environment secrets, and first upload as one deliverable.910## Operating Principles1112- Prefer a hosted GitHub runner with `macos-26` and `fastlane match` for durable CI signing.13- Use self-hosted macOS runners when private-repo GitHub Actions minutes are exhausted or when local Apple tooling is required. Every job that must avoid minutes needs `runs-on: [self-hosted, macOS]` or a more specific self-hosted label; one leftover `macos-*` job still consumes/bills minutes.14- Use a self-hosted local-keychain runner only as a fallback when match cannot be seeded yet.15- If working in the user's AM Software apps, inspect an already-working app first, especially `VideoMerger`, and mirror its signing/CI shape where applicable.16- Do not declare success from a local upload alone. Verify the remote GitHub Actions run and App Store Connect build state.17- Keep destructive signing actions explicit. Do not revoke, nuke, or delete certificates/profiles without clear approval.1819## Required Tools2021Check early:2223```bash24command -v gh asc xcodegen fastlane jq security git xcodebuild25```2627Use local authenticated sessions when available:2829```bash30gh auth status31asc auth status32asc apps list --bundle-id "$BUNDLE_ID" --output json33```3435## Workflow36371. **Create and inspect the repo**3839 - Create the app repo and push an initial `main`.40 - If a private match repo is needed, create it separately, usually `<app>-certificates`.41 - Add or confirm `.gitignore`, `README.md`, `project.yml`, app source, tests, `fastlane/`, and `.github/workflows/`.4243 Example:4445 ```bash46 gh repo create OWNER/APP_REPO --private --source . --remote origin --push47 gh repo create OWNER/MATCH_REPO --private48 ```49502. **Create the Xcode app**5152 Prefer `project.yml` plus `xcodegen generate` when the repo uses XcodeGen.5354 Required release settings:5556 - `DEVELOPMENT_TEAM`57 - `PRODUCT_BUNDLE_IDENTIFIER`58 - `MARKETING_VERSION`59 - `CURRENT_PROJECT_VERSION`60 - Release signing configured for App Store when using match:6162 ```yaml63 settings:64 base:65 DEVELOPMENT_TEAM: TEAMID66 MARKETING_VERSION: "1.0"67 CURRENT_PROJECT_VERSION: "1"68 targets:69 App:70 settings:71 base:72 PRODUCT_BUNDLE_IDENTIFIER: com.example.app73 CODE_SIGN_STYLE: Automatic74 configs:75 Release:76 CODE_SIGN_STYLE: Manual77 CODE_SIGN_IDENTITY: Apple Distribution78 PROVISIONING_PROFILE_SPECIFIER: match AppStore com.example.app79 ```8081 Add export compliance when the app does not use non-exempt encryption:8283 ```xml84 <key>ITSAppUsesNonExemptEncryption</key>85 <false/>86 ```8788 Validate:8990 ```bash91 xcodegen generate92 plutil -lint App/Info.plist93 xcodebuild -project App.xcodeproj -scheme App -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test94 ```95963. **Create App Store Connect records**9798 Resolve/create:99100 - Bundle ID101 - App Store Connect app102 - App Store Connect API key103 - App Store signing certificate104 - App Store provisioning profile105106 Useful checks:107108 ```bash109 asc apps list --bundle-id "$BUNDLE_ID" --output json110 asc bundle-ids list --output json --paginate111 asc certificates list --output json --paginate112 asc profiles list --output json --paginate113 ```1141154. **Configure fastlane**116117 `fastlane/Matchfile`:118119 ```ruby120 git_url("https://github.com/OWNER/MATCH_REPO.git")121 storage_mode("git")122 type("appstore")123 app_identifier(["com.example.app"])124 username("apple-id@example.com")125 ```126127 Core `Fastfile` pattern:128129 ```ruby130 def ensure_app_store_connect_api_key!131 return if defined?(@app_store_api_key) && @app_store_api_key132 key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]133 issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]134 key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] || "fastlane/AuthKey.p8"135 UI.user_error!("APP_STORE_CONNECT_API_KEY_ID is not set") unless key_id136 UI.user_error!("APP_STORE_CONNECT_ISSUER_ID is not set") unless issuer_id137 UI.user_error!("App Store Connect API key not found at #{key_path}") unless File.exist?(key_path)138 @app_store_api_key = app_store_connect_api_key(139 key_id: key_id,140 issuer_id: issuer_id,141 key_filepath: key_path142 )143 ENV.delete("APP_STORE_CONNECT_API_KEY_PATH")144 ENV.delete("DELIVER_API_KEY_PATH")145 end146147 def match_readonly?148 override = ENV["MATCH_READONLY_OVERRIDE"]149 return override.downcase == "true" if override150 !ENV["CI"]151 end152153 def match_keychain_options154 keychain = ENV["MATCH_KEYCHAIN_NAME"]155 password = ENV["MATCH_KEYCHAIN_PASSWORD"]156 return {} unless keychain && password157 { keychain_name: keychain, keychain_password: password }158 end159160 platform :ios do161 lane :certificates do162 ensure_app_store_connect_api_key!163 match(type: "appstore", readonly: match_readonly?, api_key: @app_store_api_key, **match_keychain_options)164 end165166 lane :build_release do167 ensure_app_store_connect_api_key!168 increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"] || Time.now.utc.strftime("%Y%m%d%H%M%S"))169 match(type: "appstore", readonly: match_readonly?, api_key: @app_store_api_key, **match_keychain_options)170 gym(171 scheme: "App",172 configuration: "Release",173 export_method: "app-store",174 export_options: {175 provisioningProfiles: {176 "com.example.app" => "match AppStore com.example.app"177 }178 }179 )180 end181182 lane :deploy_testflight do183 build_release184 pilot(api_key: @app_store_api_key, skip_waiting_for_build_processing: true)185 end186187 lane :submit_to_app_store do188 build_release189 ENV.delete("APP_STORE_CONNECT_API_KEY_PATH")190 ENV.delete("DELIVER_API_KEY_PATH")191 upload_to_app_store(192 api_key: @app_store_api_key,193 force: true,194 skip_metadata: true,195 skip_screenshots: true,196 submit_for_review: false,197 automatic_release: false,198 precheck_include_in_app_purchases: false199 )200 end201 end202 ```203204 If passing `api_key:` to `pilot`, `deliver`, or `upload_to_app_store`, clear `APP_STORE_CONNECT_API_KEY_PATH` and `DELIVER_API_KEY_PATH` after creating the API key object. Otherwise Fastlane can see both `api_key` and `api_key_path` and fail with conflicting authentication inputs.205206 Do not set `reject_if_possible` by default in an App Store upload lane. It can cancel an in-progress App Store review when all the user asked for is uploading a new build.2072085. **Seed match**209210 Prefer importing an existing known-good `.p12` and App Store provisioning profile into the match repo. Keep the P12 password separate from `MATCH_PASSWORD`.211212 Critical checks:213214 - `MATCH_PASSWORD` is the encryption password for the match repo.215 - The P12 password is only for importing/exporting the certificate.216 - Do not copy encrypted cert files from one match repo into another unless you know both repos use the same `MATCH_PASSWORD`. GitHub secrets cannot be read back, and a wrong password will fail before signing with `Invalid password passed via 'MATCH_PASSWORD'`.217 - When a distribution certificate limit is reached, set match read-only and import/reuse a valid existing distribution cert/profile, or get explicit approval to revoke/free a slot. Do not let CI repeatedly try to create new distribution certs.218 - In a match repo, certificate files should be named by the Developer Portal certificate id, not a local keychain SHA/fingerprint.219 - If fastlane says `Certificate '<hash>' (stored in your storage) is not available on the Developer Portal`, the match repo probably has wrongly named cert files or stale certs.220221 Verify certificate id by serial:222223 ```bash224 asc certificates list --output json --paginate |225 jq -r '.data[] | [.id, .attributes.serialNumber, .attributes.certificateType, .attributes.displayName] | @tsv'226 ```227228 Verify match repo contents:229230 ```bash231 git ls-remote https://github.com/OWNER/MATCH_REPO.git232 ```2332346. **Create GitHub Actions environment secrets**235236 Use a protected environment such as `production`. Required secrets for hosted match-backed deploys:237238 ```text239 MATCH_PASSWORD240 MATCH_GIT_BASIC_AUTHORIZATION241 APP_STORE_CONNECT_API_KEY242 APP_STORE_CONNECT_API_KEY_ID243 APP_STORE_CONNECT_ISSUER_ID244 ```245246 Optional/common legacy names, only if the workflow reads them:247248 ```text249 APPLE_DEVELOPER_TEAM_ID250 APP_STORE_CONNECT_TEAM_ID251 APP_STORE_CONNECT_KEY_ID252 APP_STORE_CONNECT_KEY_CONTENT253 FASTLANE_USER254 ```255256 `MATCH_GIT_BASIC_AUTHORIZATION` must be base64 for `x-access-token:<github_pat>`.257258 ```bash259 printf 'x-access-token:%s' "$GITHUB_PAT" | base64 | tr -d '\n'260 ```261262 If zsh prints a trailing `%`, do not paste `%`; it is only a no-newline marker.263264 Set secrets:265266 ```bash267 printf '%s' "$MATCH_PASSWORD" |268 gh secret set MATCH_PASSWORD --repo OWNER/APP_REPO --env production269270 printf '%s' "$MATCH_GIT_BASIC_AUTHORIZATION" |271 gh secret set MATCH_GIT_BASIC_AUTHORIZATION --repo OWNER/APP_REPO --env production272273 gh secret set APP_STORE_CONNECT_API_KEY --repo OWNER/APP_REPO --env production < AuthKey_XXXX.p8274 printf '%s' "$APP_STORE_CONNECT_API_KEY_ID" |275 gh secret set APP_STORE_CONNECT_API_KEY_ID --repo OWNER/APP_REPO --env production276 printf '%s' "$APP_STORE_CONNECT_ISSUER_ID" |277 gh secret set APP_STORE_CONNECT_ISSUER_ID --repo OWNER/APP_REPO --env production278 ```2792807. **Add hosted GitHub Actions deploy workflow**281282 Prefer `macos-26`; App Store Connect may reject older SDK uploads.283284 ```yaml285 name: App Store Deployment286287 on:288 push:289 branches: [ main ]290 workflow_dispatch:291292 concurrency:293 group: app-store-deployment-${{ github.ref }}294 cancel-in-progress: false295296 env:297 XCODE_PATH: ${{ vars.XCODE_PATH || '/Applications/Xcode_26.4.app/Contents/Developer' }}298 FASTLANE_VERSION: '2.229.1'299300 jobs:301 deploy:302 name: Deploy to TestFlight303 runs-on: macos-26304 environment: production305 env:306 RUBY_VERSION: '3.1.4'307 MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}308 MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}309 MATCH_READONLY_OVERRIDE: ${{ vars.MATCH_READONLY_OVERRIDE || 'true' }}310 APP_STORE_CONNECT_API_KEY_PATH: ${{ github.workspace }}/fastlane/AuthKey.p8311 APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}312 APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}313 SPACESHIP_CONNECT_API_IN_HOUSE: 'false'314315 steps:316 - uses: actions/checkout@v4317 with:318 fetch-depth: 0319320 - name: Clear checkout GitHub token header321 run: git config --local --unset-all http.https://github.com/.extraheader || true322323 - uses: ruby/setup-ruby@v1324 with:325 ruby-version: ${{ env.RUBY_VERSION }}326327 - name: Install dependencies328 run: gem install fastlane -v "$FASTLANE_VERSION" --no-document329330 - name: Configure Xcode version331 shell: bash332 run: |333 TARGET_PATH="$XCODE_PATH"334 if [ ! -d "$TARGET_PATH" ]; then335 TARGET_PATH="$(xcode-select -p)"336 echo "Requested Xcode path not found; falling back to $TARGET_PATH"337 fi338 echo "DEVELOPER_DIR=$TARGET_PATH" >> "$GITHUB_ENV"339340 - name: Setup temporary keychain341 run: |342 set -euo pipefail343 KEYCHAIN_PATH="$RUNNER_TEMP/fastlane_${{ github.run_id }}.keychain-db"344 KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"345 security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"346 security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"347 security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"348 security list-keychains -d user -s "$KEYCHAIN_PATH"349 {350 echo "KEYCHAIN_PATH=$KEYCHAIN_PATH"351 echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD"352 echo "MATCH_KEYCHAIN_NAME=$KEYCHAIN_PATH"353 echo "MATCH_KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD"354 } >> "$GITHUB_ENV"355356 - name: Configure App Store Connect API key357 env:358 APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}359 run: |360 mkdir -p fastlane361 python3 - <<'PY'362 import json, os, pathlib363 secret = os.environ.get('APP_STORE_CONNECT_API_KEY', '').strip()364 if not secret:365 raise SystemExit('APP_STORE_CONNECT_API_KEY secret is empty')366 key_path = pathlib.Path(os.environ.get('APP_STORE_CONNECT_API_KEY_PATH', 'fastlane/AuthKey.p8'))367 key_id = os.environ.get('APP_STORE_CONNECT_API_KEY_ID')368 issuer_id = os.environ.get('APP_STORE_CONNECT_ISSUER_ID')369 try:370 data = json.loads(secret)371 except json.JSONDecodeError:372 key_material = secret373 else:374 key_material = data.get('key')375 key_id = data.get('key_id', key_id)376 issuer_id = data.get('issuer_id', issuer_id)377 if not key_material:378 raise SystemExit('App Store Connect key material missing from secret')379 if not key_id or not issuer_id:380 raise SystemExit('APP_STORE_CONNECT_API_KEY_ID or APP_STORE_CONNECT_ISSUER_ID missing')381 key_path.write_text(key_material.strip() + ("\n" if not key_material.endswith('\n') else ''))382 key_path.chmod(0o600)383 with open(os.environ['GITHUB_ENV'], 'a') as env:384 env.write(f"APP_STORE_CONNECT_API_KEY_ID={key_id}\n")385 env.write(f"APP_STORE_CONNECT_ISSUER_ID={issuer_id}\n")386 PY387388 - name: Release prerequisites preflight389 run: scripts/ci-release-preflight.sh390391 - name: Ensure provisioning profile directory writable392 run: |393 PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"394 mkdir -p "$PROFILE_DIR"395 chmod u+rwX "$HOME/Library/MobileDevice" "$PROFILE_DIR"396 rm -f "$PROFILE_DIR"/*.mobileprovision || true397398 - name: Setup Match certificates399 run: fastlane _${FASTLANE_VERSION}_ certificates400401 - name: Deploy to TestFlight402 env:403 GITHUB_RUN_NUMBER: ${{ github.run_number }}404 run: fastlane _${FASTLANE_VERSION}_ deploy_testflight405 ```4064078. **Add a preflight script**408409 Keep it focused on cheap local checks; do not duplicate fragile App Store network auth checks when fastlane already validates them.410411 ```bash412 #!/usr/bin/env bash413 set -euo pipefail414415 MATCH_REPO="${MATCH_REPO:-https://github.com/OWNER/MATCH_REPO.git}"416417 for var in APP_STORE_CONNECT_API_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_API_KEY_PATH MATCH_GIT_BASIC_AUTHORIZATION; do418 if [ -z "${!var:-}" ]; then419 echo "$var is required." >&2420 exit 1421 fi422 done423424 [ -f "$APP_STORE_CONNECT_API_KEY_PATH" ] || {425 echo "App Store Connect API key file not found: $APP_STORE_CONNECT_API_KEY_PATH" >&2426 exit 1427 }428429 git -c credential.helper= \430 -c http.extraHeader="Authorization: Basic $MATCH_GIT_BASIC_AUTHORIZATION" \431 ls-remote "$MATCH_REPO" >/dev/null432433 echo "Release prerequisites are present."434 ```4354369. **Self-hosted local-keychain fallback**437438 Use this only when match cannot be seeded. The reliable pattern on this Mac was an interactive runner process with login-keychain access. A LaunchAgent/service runner can see certificates but still fail `codesign` with `errSecInternalComponent` because private-key use is blocked non-interactively.439440 Fallback checklist:441442 - Use a dedicated runner label, e.g. `app-local-keychain`.443 - Start runner interactively from Terminal.444 - Ensure the distribution cert/private key is usable from `login.keychain-db`.445 - Add `security list-keychains -d user -s "$KEYCHAIN" ...` and `security unlock-keychain`.446 - Treat this as temporary until match-backed hosted CI works.44744810. **Self-hosted minute-saving runner pattern**449450 For private repos with no Actions minutes left, run the complete deploy job on a self-hosted Mac runner. Repo-specific runners are fine; verify the runner is online before chasing workflow bugs:451452 ```bash453 gh api repos/OWNER/APP_REPO/actions/runners \454 --jq '.runners[] | {name,status,busy,labels:[.labels[].name]}'455 ```456457 Practical workflow hardening for this Mac:458459 - Add a cross-repo signing lock, for example `/tmp/akmarinov-ci-signing.lock`, so multiple App Store jobs do not mutate keychains/profiles concurrently.460 - Use a temporary keychain per run, save original keychain search list, and restore/delete in `if: always()` cleanup.461 - If replacing `ruby/setup-ruby`, validate Ruby with `ruby -rsocket -e 'exit'`; failed local Ruby builds can miss the `socket` extension.462 - If reusing another runner's Ruby toolcache, require both `bin/ruby` and the sibling `.complete` marker before symlinking. Without `.complete`, setup can race an incomplete install.463 - If `actionlint` is unavailable, still parse workflow YAML with Ruby or Python and run `ruby -c fastlane/Fastfile`.464465 Minimal self-hosted Ruby fallback check:466467 ```bash468 if [ -x "$RUBY_DIR/bin/ruby" ] &&469 [ -f "$RUBY_DIR.complete" ] &&470 "$RUBY_DIR/bin/ruby" -rsocket -e 'exit' >/dev/null 2>&1; then471 echo "$RUBY_DIR/bin" >> "$GITHUB_PATH"472 fi473 ```47447511. **App Store upload on main**476477 When the desired behavior is "upload to App Store Connect on every push to `main`", make that path explicit:478479 - Trigger on `push.branches: [main]`.480 - Run the App Store upload lane from the push path.481 - Keep TestFlight lanes available for manual dispatch if needed.482 - Use `submit_for_review: false` and `automatic_release: false` unless the user explicitly wants automatic review submission/release.483 - If App Store Connect rejects an upload because a marketing version was already used, bump `MARKETING_VERSION`; incrementing only the build number is not enough for a version that has already shipped.484485## Verification486487Remote GitHub Actions verification:488489```bash490GH_TOKEN="$token" gh run list \491 --repo OWNER/APP_REPO \492 --branch main \493 --workflow app-store-deploy.yml \494 --limit 5 \495 --json databaseId,status,conclusion,headSha,createdAt,url,event,displayTitle496497GH_TOKEN="$token" gh run watch RUN_ID --repo OWNER/APP_REPO --exit-status498GH_TOKEN="$token" gh run view RUN_ID --repo OWNER/APP_REPO --log |499 rg -n "GITHUB_RUN_NUMBER|Updated CFBundleVersion|xcode_path|Ready to upload|Successfully uploaded package|Successfully uploaded the new binary"500```501502App Store Connect verification:503504```bash505asc builds list --app "$APP_ID" --platform IOS --output json --paginate |506 jq -r '.data[] | [.id, (.attributes.version // ""), (.attributes.buildNumber // ""), (.attributes.processingState // ""), (.attributes.uploadedDate // "")] | @tsv' |507 head -20508```509510Success means:511512- latest push-triggered GitHub run is `success`,513- log contains `Successfully uploaded the new binary to App Store Connect`,514- ASC shows the new build number as `VALID` or processing,515- local `main` is clean and matches `origin/main`.516517## Troubleshooting Map518519- `curl`/Spaceship `401` in a custom preflight: remove brittle App Store network preflight and let fastlane validate the API key.520- GitHub match repo `400`: reset `MATCH_GIT_BASIC_AUTHORIZATION`; ensure no trailing `%` and use `Authorization: Basic <base64>`.521- `Invalid password passed via 'MATCH_PASSWORD'`: the match repo is encrypted with a different password than the app repo secret. You cannot recover the secret from GitHub; align the secret or import assets with the app repo's intended match password.522- Apple distribution certificate limit reached: do not keep retrying mutable `match`; use read-only signing with existing assets, import an existing cert/profile, or ask before revoking/freeing a certificate slot.523- `Certificate '<hash>' is not available on the Developer Portal`: match repo has stale/wrong certificate filenames; ensure cert files are named by portal cert id.524- `No profiles for '<bundle id>' were found` and it asks for development profiles: Release target signing is still Automatic/development; set Release to manual App Store profile.525- Upload rejected for old SDK: use `macos-26` and Xcode 26+.526- Upload rejected because version already exists: bump `MARKETING_VERSION` in the Xcode project or XcodeGen config, then rerun.527- Fastlane says both `api_key` and `api_key_path` are present: clear `APP_STORE_CONNECT_API_KEY_PATH` and `DELIVER_API_KEY_PATH` before calling `deliver`/`upload_to_app_store` with `api_key:`.528- `errSecInternalComponent` on self-hosted runner: private-key access is blocked; use interactive runner or switch to match.529- Compliance warning for encryption: add `ITSAppUsesNonExemptEncryption=false` to the app `Info.plist` when true for the app.