Introduction
Are you still manually doing Archive → Distribute... from local Xcode every time you release an iOS app?
This process is time-consuming, and it tends to create environment dependencies — leading to the dreaded "it builds on my Mac but not on yours" problem.
In this article, we'll walk through building a complete pipeline to build, sign, and automatically upload an iOS app to TestFlight using GitHub Actions.
While many tutorials use fastlane, this article takes a different approach: we'll use only Apple's native xcodebuild and xcrun altool, with no fastlane involved. This eliminates the need to bring in a Ruby environment, simplifies the workflow, and frees you from fastlane compatibility issues when Xcode updates.
For authentication, instead of Apple ID + password, we'll use the more secure App Store Connect API key that isn't affected by 2FA.
Prerequisites
- Enrolled in Apple Developer Program (paid)
- App record created in App Store Connect
- iOS project pushed to a GitHub repository
- Confirmed that local builds succeed on macOS / Xcode
Overall Flow
The overview of the pipeline we're building is as follows:
- Workflow triggered by push to
mainbranch or manual trigger - Prepare Xcode on GitHub Actions macOS runner
- Temporarily import certificate and provisioning profile into keychain
- Update build number
- Generate
.xcarchivewithxcodebuild archive - Export to
.ipawithxcodebuild -exportArchive - Upload to TestFlight with
xcrun altool - Delete temporary keychain and clean up
push → Actions triggered → Install certificate → archive → export → altool → TestFlight
Step 1: Prepare Certificates and Provisioning Profile
To perform code signing in CI, encode the following two files in Base64 and store them in GitHub Secrets.
1-1. Export Distribution Certificate (.p12)
Open "Keychain Access.app" on your local Mac, right-click the Apple Distribution or iPhone Distribution certificate, and export it in .p12 format. Keep note of the password you set during export — you'll need it later.
If you don't have a certificate, create a new one from Certificates in the Apple Developer site.
1-2. Download Provisioning Profile (.mobileprovision)
Download the App Store distribution provisioning profile from Profiles in the Apple Developer site. Make sure to also note the profile name (e.g., YourApp AppStore). You'll need to write this in ExportOptions.plist later.
1-3. Encode to Base64
Run the following commands in your terminal to convert the files to Base64 strings.
# Certificate
base64 -i Certificates.p12 | pbcopy
# Provisioning profile
base64 -i YourApp_AppStore.mobileprovision | pbcopy
The result is copied to the clipboard via pbcopy, so you can paste it directly into GitHub Secrets.
Step 2: Generate an App Store Connect API Key
To avoid dealing with two-factor authentication, we'll use an API key.
- Log in to App Store Connect
- Select "Users and Access" → "Integrations" → "App Store Connect API"
- Generate a key using the "+" button (role must be App Manager or higher)
- Save the downloaded
AuthKey_XXXXXXXXXX.p8file (cannot be re-downloaded, so be careful) - Note the Key ID and Issuer ID displayed at generation time
Also encode the .p8 file in Base64:
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
Step 3: Register in GitHub Secrets
From the repository's Settings → Secrets and variables → Actions, register the following Secrets:
| Secret Name | Contents |
|---|---|
BUILD_CERTIFICATE_BASE64 |
Base64 string of the .p12 certificate |
P12_PASSWORD |
Password set when exporting the .p12 |
BUILD_PROVISION_PROFILE_BASE64 |
Base64 string of the .mobileprovision |
KEYCHAIN_PASSWORD |
Any password for the temporary keychain |
APP_STORE_CONNECT_API_KEY_ID |
Key ID of the API key |
APP_STORE_CONNECT_API_ISSUER_ID |
Issuer ID |
APP_STORE_CONNECT_API_KEY_BASE64 |
Base64 string of the .p8 |
Step 4: Create ExportOptions.plist
This is the configuration file needed to generate an .ipa from a .xcarchive. Commit it to your repository as ios/ExportOptions.plist (contains no sensitive information).
<?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</string>
<key>teamID</key>
<string>ABCD123456</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>com.example.yourapp</key>
<string>YourApp AppStore</string>
</dict>
<key>uploadSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
</dict>
</plist>
Step 5: Create the GitHub Actions Workflow
Now for the main event. Create .github/workflows/ios-testflight.yml.
name: iOS TestFlight Deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
SCHEME: YourApp
WORKSPACE: YourApp.xcworkspace # Use PROJECT for .xcodeproj
CONFIGURATION: Release
EXPORT_OPTIONS_PLIST: ios/ExportOptions.plist
jobs:
deploy:
runs-on: macos-14
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Install the 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 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Decode Base64
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Place provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
- name: Set build number
run: |
# Use GitHub Actions run number as build number (guaranteed uniqueness)
BUILD_NUMBER=${{ github.run_number }}
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" \
YourApp/Info.plist
echo "Build number set to $BUILD_NUMBER"
- name: Build archive
run: |
xcodebuild archive \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
-destination "generic/platform=iOS" \
-allowProvisioningUpdates \
CODE_SIGNING_ALLOWED=YES \
| xcbeautify
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-exportPath "$RUNNER_TEMP/export" \
| xcbeautify
- name: Prepare API key for altool
env:
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
run: |
# altool reads from ~/.appstoreconnect/private_keys/ or ./private_keys/
mkdir -p ~/.appstoreconnect/private_keys
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode \
-o ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
run: |
xcrun altool --upload-app \
--type ios \
--file "$RUNNER_TEMP/export/YourApp.ipa" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID"
- name: Clean up keychain and profiles
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision
rm -rf ~/.appstoreconnect/private_keys
Key Points
Using github.run_number for Build Number
Instead of fastlane's increment_build_number, we use the run number automatically assigned by GitHub Actions. This is guaranteed to be monotonically increasing and satisfies TestFlight's "no duplicate build numbers" requirement. Date-based numbering ($(date +%Y%m%d%H%M)) also works.
Formatting Logs with xcbeautify
Raw xcodebuild logs are very hard to read, so we pipe through xcbeautify. It comes pre-installed on macos-14 runners. If it's not available, add brew install xcbeautify as an extra step.
API Key File Location
xcrun altool auto-detects the API key from any of the following paths. There's no flag to specify the path explicitly, so you must place it in one of the designated directories:
./private_keys/AuthKey_<KEY_ID>.p8~/private_keys/AuthKey_<KEY_ID>.p8~/.private_keys/AuthKey_<KEY_ID>.p8~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8
Why Use a Temporary Keychain
Importing certificates into the CI environment's default keychain is messy to clean up and poses security concerns. Creating and discarding a keychain per job is best practice.
Cleanup with if: always()
The temporary keychain and key files are always deleted even if the build fails. This is especially important when using self-hosted runners.
Step 6: Verify
Push to the main branch or run manually from the Actions tab with Run workflow.
It will complete in about 5–10 minutes, and a new build will appear in the TestFlight tab in App Store Connect. It may take an additional 10–30 minutes to move from "Processing" status to complete.
Certificate Management Options and Recommended Configurations
We've built the pipeline using the method of encoding .p12 in Base64 and registering it in GitHub Secrets, but GitHub Actions offers other certificate management options. Choose the best one based on your team size and requirements.
Option 1: Store as Base64 in Secrets (This Article's Approach)
How it works: Encode .p12 and .mobileprovision in Base64 and save as strings in GitHub Secrets.
Pros
- No external dependencies; everything runs within GitHub
- Easiest initial setup
.p12files are usually a few KB to tens of KB, comfortably within Secrets' 64KB limit
Cons
- Must manually update Secrets when rotating certificates
- Managing multiple apps increases the number of Secrets, becoming unwieldy
- Secrets can't be treated as files like Azure DevOps Secure Files
Best for: Individual developers, small teams of 1–3 people, single-app setups
Option 2: Commit Encrypted Files to Repository
How it works: Encrypt .p12 with GPG or OpenSSL and commit it to the repository, storing only the decryption passphrase in Secrets.
# Encrypt locally
gpg --symmetric --cipher-algo AES256 Certificates.p12
# → Commit Certificates.p12.gpg to repository
In the workflow:
- name: Decrypt certificate
run: |
gpg --quiet --batch --yes --decrypt \
--passphrase="$CERT_PASSPHRASE" \
--output $RUNNER_TEMP/cert.p12 \
secrets/Certificates.p12.gpg
env:
CERT_PASSPHRASE: ${{ secrets.CERT_PASSPHRASE }}
Pros
- Can be managed as files; rotation history is tracked in Git
- Free from Secrets size limits
- Closest to the feeling of Azure DevOps Secure Files
Cons
- If the repository is compromised, the passphrase strength is the last line of defense
.gpgfiles in the repository may be aesthetically displeasing to some
Best for: Migrating from Azure DevOps, wanting file-based management, private repository operation
Option 3: fastlane match
How it works: Encrypted certificates are stored in a dedicated private repository (or S3/GCS), and fastlane automatically fetches, installs, and decrypts them.
Pros
- Can manage certificates for multiple apps and environments (dev/adhoc/appstore) together
- New team members only need to run
fastlane matchonce to set up their local environment - Supports automatic certificate regeneration
Cons
- Requires Ruby and fastlane
- Possible fastlane compatibility issues with Xcode updates
- Goes against this article's no-fastlane approach
Best for: Managing multiple apps, iOS teams of 5+, frequent certificate rotation
Option 4: AWS Parameter Store + OIDC Integration
How it works: Store .p12 Base64 strings and passwords as SecureString in AWS Systems Manager Parameter Store, and retrieve them from GitHub Actions via OIDC. No long-lived credentials needed — a major advantage.
Parameter Store vs. Secrets Manager
AWS has two similar services, so let's clarify:
| Item | Parameter Store | Secrets Manager |
|---|---|---|
| Cost (Standard) | Free | $0.40/secret/month + API fees |
| Cost (Advanced) | $0.05/10,000 API calls | Same as above |
| Value Size Limit | Standard 4KB / Advanced 8KB | 64KB |
| Auto Rotation | None | Available |
| KMS Encryption | Supported via SecureString type | Built-in |
Base64-encoded .p12 files tend to be tens of KB to 30KB, which is the key decision point. If it fits in 8KB, Parameter Store is significantly cheaper; if it exceeds that, Secrets Manager or the S3 hybrid approach described below is appropriate.
Handling Files Larger Than 8KB
If the size is too large, a hybrid configuration that stores the encrypted .p12 in S3 and only stores the S3 key and decryption password in Parameter Store is practically clean. It combines Parameter Store's simplicity with S3's size freedom.
AWS Setup
First, create an OIDC Identity Provider for GitHub Actions in IAM (skip if already created).
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
Then store parameters in Parameter Store (AWS CLI example):
# Certificate body
aws ssm put-parameter \
--name /ios/yourapp/dist-cert-base64 \
--type SecureString \
--value "$(base64 -i Certificates.p12)"
# .p12 password
aws ssm put-parameter \
--name /ios/yourapp/p12-password \
--type SecureString \
--value "your-p12-password"
# Provisioning profile
aws ssm put-parameter \
--name /ios/yourapp/provisioning-profile-base64 \
--type SecureString \
--value "$(base64 -i YourApp_AppStore.mobileprovision)"
# App Store Connect API key (.p8)
aws ssm put-parameter \
--name /ios/yourapp/asc-api-key-base64 \
--type SecureString \
--value "$(base64 -i AuthKey_XXXXXXXXXX.p8)"
Create an IAM role for GitHub Actions and restrict Assume Role to specific repositories and specific branches in the Trust Policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
Grant minimum permissions — only GetParameter for the target parameter path:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ssm:GetParameter", "ssm:GetParameters"],
"Resource": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/ios/yourapp/*"
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
}
]
}
GitHub Actions Workflow
Don't forget to set id-token: write in the permissions block. Without this, you can't get the OIDC token.
permissions:
id-token: write # Required for OIDC token acquisition
contents: read
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
aws-region: ap-northeast-1
- name: Fetch secrets from Parameter Store
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Retrieve from Parameter Store (SecureString requires --with-decryption)
aws ssm get-parameter \
--name /ios/yourapp/dist-cert-base64 \
--with-decryption \
--query Parameter.Value --output text | base64 --decode > $CERTIFICATE_PATH
aws ssm get-parameter \
--name /ios/yourapp/provisioning-profile-base64 \
--with-decryption \
--query Parameter.Value --output text | base64 --decode > $PP_PATH
P12_PASSWORD=$(aws ssm get-parameter \
--name /ios/yourapp/p12-password \
--with-decryption \
--query Parameter.Value --output text)
# Create keychain and import certificate
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-keychain -d user -s $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
# Remaining archive / export / altool steps are the same as Step 5
Pros
- Near-zero cost: Standard parameters are completely free; Advanced is extremely cheap
- No long-lived credentials with OIDC: No need to store access keys in GitHub (biggest advantage)
- Audit logs with CloudTrail: Records of who accessed the certificate and when
- Fine-grained permission control with IAM/KMS: Flexible operation like separate roles for dev and prod
- Sharable across multiple CI/CD systems: Same certificates usable from GitHub Actions, CodeBuild, Jenkins, and local dev
- Version history: Can roll back to previous versions
Cons
- AWS account required (heavy initial setup cost for teams not already using it)
- Workaround needed if
.p12exceeds 8KB (use S3 alongside or Secrets Manager) - Some learning curve for OIDC setup (only once)
Best for: Teams already using AWS, audit log requirements, referencing the same certificate from multiple CI/CD tools, enterprise-quality management at low cost
Option 5: AWS Secrets Manager + OIDC Integration
How it works: Store .p12 directly as binary (SecretBinary) in AWS Secrets Manager, and retrieve it from GitHub Actions via OIDC. Unlike Parameter Store, the key feature is being able to handle it as a file without worrying about Base64 conversion.
Differences from Parameter Store
| Item | Parameter Store | Secrets Manager |
|---|---|---|
| Storage format | Strings only (binary requires Base64) | Binary directly OK (SecretBinary type) |
| Size limit | 4KB (Standard) / 8KB (Advanced) | 64KB |
| Cost | Free to very cheap | $0.40/month/secret + API fees |
| Auto rotation | None | Available (Lambda integration) |
| Use case direction | Configuration values, small secrets | Larger secrets, production operations |
If .p12 exceeds 8KB, or if you prioritize ease of handling as a file, Secrets Manager is more straightforward. For 1–2 certificates, the monthly cost is under a dollar, so cost isn't a major concern.
AWS Setup
OIDC Identity Provider setup is shared with Option 4, so we'll skip it here.
Register in Secrets Manager as binary using the fileb:// prefix, which tells the AWS CLI to send the file as-is:
# Certificate body (can be registered as binary)
aws secretsmanager create-secret \
--name ios/yourapp/dist-cert \
--secret-binary fileb://Certificates.p12
# Provisioning profile as binary
aws secretsmanager create-secret \
--name ios/yourapp/provisioning-profile \
--secret-binary fileb://YourApp_AppStore.mobileprovision
# App Store Connect API key (.p8) as binary
aws secretsmanager create-secret \
--name ios/yourapp/asc-api-key \
--secret-binary fileb://AuthKey_XXXXXXXXXX.p8
# Passwords as strings
aws secretsmanager create-secret \
--name ios/yourapp/p12-password \
--secret-string "your-p12-password"
For updates, use update-secret:
aws secretsmanager update-secret \
--secret-id ios/yourapp/dist-cert \
--secret-binary fileb://Certificates_new.p12
The IAM role permission policy allows Secrets Manager instead of Parameter Store:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:ios/yourapp/*"
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
}
]
}
GitHub Actions Workflow
Values retrieved via SecretBinary are returned Base64-encoded in the API response, so decode with base64 --decode and write to file:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
aws-region: ap-northeast-1
- name: Fetch certificates from Secrets Manager
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# SecretBinary is returned Base64-encoded; decode and write to file
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/dist-cert \
--query SecretBinary --output text | base64 --decode > $CERTIFICATE_PATH
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/provisioning-profile \
--query SecretBinary --output text | base64 --decode > $PP_PATH
# SecretString can be retrieved directly
P12_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id ios/yourapp/p12-password \
--query SecretString --output text)
# Place API key (.p8) at the path altool expects
API_KEY_ID="${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}"
mkdir -p ~/.appstoreconnect/private_keys
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/asc-api-key \
--query SecretBinary --output text | base64 --decode \
> ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
# Create keychain and import certificate
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-keychain -d user -s $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
# Remaining archive / export / altool steps are the same as Step 5
Pros
- Can register and retrieve
.p12directly as a file (closest to Azure DevOps Secure Files) - Supports up to 64KB: No need to worry about size limits
- No long-lived credentials with OIDC: Same security benefits as Option 4
- Audit logs with CloudTrail as standard
- Auto-rotation: Can also automatically update certificates via Lambda (though building Apple API integration has high initial cost)
- Fine-grained permission control with IAM
Cons
- Costs money: ~$0.40/secret/month + API fees; around a few dollars per month for 3–5 certificates
- AWS account required
- Learning curve for OIDC setup (shared with Option 4)
Best for: Prioritizing file-based ease of use, large .p12 files, managing many certificate files across multiple apps, considering auto-rotation in the future, wanting Azure DevOps Secure Files-equivalent operation experience
Option 6: Other Cloud Secret Managers
For teams not using AWS, the following options use the same concepts. All support OIDC integration:
| Service | Cost | Size Limit | Binary Support |
|---|---|---|---|
| Azure Key Vault | $0.03/10,000 ops | 25KB | ○ (Certificate type) |
| Google Secret Manager | $0.06/month/secret | 64KB | ○ |
| HashiCorp Vault (OSS) | Server costs only | No limit | ○ |
Azure Key Vault in particular has a Certificate type with official support for direct import/export in .p12 format. It's a natural fit if you're already using Azure.
Option 7: Additional Protection with GitHub Environments
This option can be combined with any of the above. By configuring GitHub Environments, you can restrict certificate access to deployments from specific branches only or require manual approval steps:
jobs:
deploy:
runs-on: macos-14
environment: production # ← Secrets tied to this Environment can be used
Since you can separate Secrets per Environment, you can prevent accidentally using production distribution certificates for development builds.
Recommended Configurations by Scale
| Scale | Recommended Configuration |
|---|---|
| Individual / hobby app | Option 1 (Base64 + Secrets) — simplicity wins |
| Startup / small team (1–3 people) | Option 1 or Option 2 (encrypted files) |
| Medium team (multiple apps or 5+ people) | Option 3 (fastlane match) or Option 5 (Secrets Manager) |
| AWS users, cost-conscious | Option 4 (Parameter Store + OIDC) |
| AWS users, operations-focused | Option 5 (Secrets Manager + OIDC) — file-friendly |
| Enterprise / audit requirements | Option 4 or 5 + Option 7 (Environments) combined |
| Migrating from Azure DevOps | Option 2 (encrypted files) or Option 5 (Secrets Manager) — closest to Secure Files |
Common Troubleshooting
No signing certificate "iOS Distribution" found
This commonly occurs when certificate import into the keychain failed or set-key-partition-list is missing. Add security find-identity -v -p codesigning $KEYCHAIN_PATH as a step to verify the certificate is visible, which makes it easier to isolate the issue.
error: exportArchive: "YourApp.app" requires a provisioning profile
The provisioning profile wasn't placed correctly, or the Bundle ID and profile name in provisioningProfiles in ExportOptions.plist don't match. Note that the profile name is the "name displayed on the Apple Developer site," not the filename.
altool: Invalid API key
This is often caused by line breaks or extra spaces being mixed into the Base64-encoded .p8 file. Generate it with base64 -i file.p8 | pbcopy and paste directly. Also, if the filename doesn't follow the AuthKey_<KEY_ID>.p8 naming convention, altool won't recognize it, so build it precisely via environment variables.
xcodebuild: error: The operation couldn't be completed. No such file or directory
This is the case where .xcworkspace is being used but -project is specified (or vice versa). If you're using a workspace with CocoaPods or Swift Package Manager, specify -workspace.
Parameter Store: ParameterNotFound or AccessDenied
Errors that occur during AWS integration. Check in this order:
- No typos in the parameter path (hierarchy like
/ios/yourapp/...) - Is
ssm:GetParameterallowed for the target path in the IAM role's permission policy? - If using SecureString, is the
--with-decryptionflag included? - Is
kms:Decryptpermission for the KMS key granted to the IAM role? - Does the
subcondition in the Trust Policy match the workflow execution branch?
Further Improvements
We've built the pipeline with a minimal configuration, but here are some possible extensions:
- Slack notifications: Integrate
slackapi/slack-github-actionto automatically notify when upload is complete. - Automatic release notes: Extract changes from
git logand auto-input to TestFlight's "What to Test" field (difficult with altool alone; requires calling App Store Connect API directly). - Tag push trigger: Trigger on
v*tag pushes instead ofmainbranch pushes for a more intentional release flow. - Run unit tests first: Run
xcodebuild testbefore archive and stop deployment on test failure.
Conclusion
A no-fastlane setup has the advantages of being intuitive and easy to understand, not requiring a Ruby environment, and not being affected by fastlane compatibility issues with Xcode updates. On the flip side, things like build number management need to be handled manually, and as you scale to managing multiple apps, the management overhead grows. At that point, migrating to fastlane match or AWS Secrets Manager / Parameter Store is a practical choice.
The biggest hurdle in initial setup is undoubtedly certificate management, and hopefully this article and the "Certificate Management Options" section helps you clear that hurdle.
