iOSGitHub ActionsTestFlightXcodeCI/CDCode Signing

Build, Sign, and Auto-Upload iOS Apps to TestFlight with GitHub Actions (No fastlane)

Sloth255
Sloth255
·12 min read·2,622 words

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:

  1. Workflow triggered by push to main branch or manual trigger
  2. Prepare Xcode on GitHub Actions macOS runner
  3. Temporarily import certificate and provisioning profile into keychain
  4. Update build number
  5. Generate .xcarchive with xcodebuild archive
  6. Export to .ipa with xcodebuild -exportArchive
  7. Upload to TestFlight with xcrun altool
  8. 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.

  1. Log in to App Store Connect
  2. Select "Users and Access" → "Integrations" → "App Store Connect API"
  3. Generate a key using the "+" button (role must be App Manager or higher)
  4. Save the downloaded AuthKey_XXXXXXXXXX.p8 file (cannot be re-downloaded, so be careful)
  5. 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 SettingsSecrets and variablesActions, 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).

ios/ExportOptions.plist
<?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.

.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.

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
  • .p12 files 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
  • .gpg files 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 match once 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:

trust-policy.json
{
  "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:

permission-policy.json
{
  "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.

.github/workflows/ios-testflight.yml
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 .p12 exceeds 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:

permission-policy.json
{
  "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:

.github/workflows/ios-testflight.yml
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 .p12 directly 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.

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:

  1. No typos in the parameter path (hierarchy like /ios/yourapp/...)
  2. Is ssm:GetParameter allowed for the target path in the IAM role's permission policy?
  3. If using SecureString, is the --with-decryption flag included?
  4. Is kms:Decrypt permission for the KMS key granted to the IAM role?
  5. Does the sub condition 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-action to automatically notify when upload is complete.
  • Automatic release notes: Extract changes from git log and 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 of main branch pushes for a more intentional release flow.
  • Run unit tests first: Run xcodebuild test before 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.