Triggering TestFlight with GitHub Actions Tags

· GitHub Actions to TestFlight - from zero to hero

Introduction

This is it! This is the post we’ve been building up to in our “GitHub Actions to TestFlight - from zero to hero” series. We’ve set up GitHub Actions for iOS, we’ve automated our unit tests, and now we’re going to put it all together to create the ultimate iOS developer dream: tagging your branch with “testflight” and having your app automatically built, tested, and deployed to TestFlight.

No more manual builds, no more forgetting to increment version numbers, no more “works on my machine” deployments. Just tag, push, and relax while the robots do all the work. Let’s make it happen! 🚀

The workflow we’re building

Here’s what’s going to happen when you tag your branch with “testflight”:

  1. Tests run - Making sure everything still works
  2. Version numbers bump - Using our version bumping knowledge
  3. App builds - Using our Fastlane building skills
  4. App uploads to TestFlight - The magic moment
  5. Notifications sent - So you know it worked

All triggered by a simple git tag testflight && git push --tags. Beautiful, right?

Pre-requisites

Before we dive in, make sure you have:

If you’re missing any of these, don’t worry - I’ll walk you through the tricky parts.

Setting up App Store Connect API Key

First things first - we need a way for our GitHub Action to authenticate with App Store Connect. The old username/password method is deprecated, so we’re using API keys.

Creating the API key

  1. Go to App Store Connect
  2. Navigate to Users and Access → Integrations → App Store Connect API
  3. Click the + button to create a new key
  4. Give it a name like “GitHub Actions”
  5. Select the “Developer” role (or “Admin” if you need more permissions)
  6. Download the .p8 file and note the Key ID and Issuer ID

⚠️ Important: You can only download the .p8 file once! Keep it safe.

Adding secrets to GitHub

Now we need to add these credentials to GitHub as secrets:

  1. Go to your repository on GitHub
  2. Settings → Secrets and variables → Actions
  3. Add these repository secrets:
    • APP_STORE_CONNECT_API_KEY: The content of your .p8 file
    • APP_STORE_CONNECT_API_KEY_ID: Your Key ID
    • APP_STORE_CONNECT_ISSUER_ID: Your Issuer ID
    • APP_STORE_CONNECT_APP_ID: Your app’s App Store ID (found in App Store Connect)

For the API key content, you can get it with:

cat AuthKey_XXXXXXXXXX.p8 | base64

Copy the entire base64 string into the secret.

Code signing in CI

Code signing is probably the most painful part of iOS CI/CD. If you followed our GitHub Actions setup post, you should already have Fastlane Match configured. If not, here’s a quick refresher:

Fastlane Match Setup (Required)

Our build lane uses Fastlane Match to handle code signing automatically. If you haven’t set this up yet, you’ll need to:

  1. Create a private Git repository for certificates
  2. Initialize Match in your project:
    bundle exec fastlane match init
    
  3. Generate certificates:
    bundle exec fastlane match appstore
    

For a complete setup guide, check out the Match setup section in our previous post.

Required GitHub Secrets

Make sure you have these secrets configured in your repository:

For detailed setup instructions, see the SSH access setup section in our first post.

Alternative: Manual certificates

If you prefer not to use Match, you can create a separate Fastlane lane for manual certificate setup:

desc "Set up manual certificates"
lane :setup_certificates do
  import_certificate(
    certificate_path: "ios_distribution.p12",
    certificate_password: ENV["IOS_CERTIFICATE_PASSWORD"],
    keychain_name: "login.keychain"
  )

  install_provisioning_profile(
    path: "MyApp_AppStore.mobileprovision"
  )
end

Then call it as a separate step in your GitHub Actions workflow:

- name: Set up code signing
  run: bundle exec fastlane setup_certificates
  env:
    IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}

But honestly? Match is so much easier. 😉

The TestFlight deployment workflow

Now for the main event! Here’s our complete workflow:

name: Deploy to TestFlight

on:
  push:
    tags:
      - 'testflight*'

jobs:
  deploy:
    runs-on: macos-latest

    steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0  # We need full history for version bumping

    # Set up Xcode 16.2
    - name: Set up Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: "16.2.0"

    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.1
        bundler-cache: true

    - name: Cache Xcode derived data
      uses: actions/cache@v4
      with:
        path: ~/Library/Developer/Xcode/DerivedData
        key: ${{ runner.os }}-xcode-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }}
        restore-keys: |
          ${{ runner.os }}-xcode-deriveddata-

    - name: Install dependencies
      run: bundle install

    - name: Setup App Store Connect API Key
      run: |
        mkdir -p ~/.appstoreconnect/private_keys/
        echo "${{ secrets.APP_STORE_CONNECT_API_KEY }}" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8

    # Set up SSH key for Match repository access
    - name: Set up SSH key for Match
      run: |
        mkdir -p ~/.ssh
        echo "${{ secrets.MATCH_GIT_PRIVATE_KEY }}" > ~/.ssh/id_rsa
        chmod 600 ~/.ssh/id_rsa
        ssh-keyscan github.com >> ~/.ssh/known_hosts
        # Create SSH config for GitHub
        cat > ~/.ssh/config << EOF
        Host github.com
          HostName github.com
          User git
          IdentityFile ~/.ssh/id_rsa
          IdentitiesOnly yes
        EOF
        # Test SSH connection
        ssh -T git@github.com || true

    - name: Set up code signing
      run: bundle exec fastlane match appstore --readonly --app_identifier com.yourcompany.yourapp
      env:
        MATCH_REPOSITORY: ${{ secrets.MATCH_REPOSITORY }}
        MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}

    - name: Run tests
      run: bundle exec fastlane test

    - name: Build and Deploy to TestFlight
      run: bundle exec fastlane deploy_testflight
      env:
        APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
        APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
        APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }}

    - name: Clean up API Key
      if: always()
      run: rm -f ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8

The Fastlane lanes

Now we need to create the Fastlane lanes that this workflow calls. Add these to your Fastfile:

#!/usr/bin/ruby
# frozen_string_literal: true

fastlane_version '2.225.0'

default_platform :ios
platform :ios do

  desc "Build the app"
  lane :build do
    # Build the app (code signing should be done separately)
    gym(
      scheme: "YourAppScheme",
      configuration: "Release",
      export_method: "app-store",
      output_directory: "./build",
      export_options: {
        uploadBitcode: false,
        uploadSymbols: true,
        compileBitcode: false
      }
    )
  end

  desc "Run tests"
  lane :test do
    scan(
      scheme: "YourAppScheme",
      clean: true,
      devices: "iPhone 15 Pro",
      output_directory: "./fastlane/test_output",
      output_types: "html,junit"
    )
  end

  desc "Deploy to TestFlight"
  lane :deploy_testflight do
    # First, let's bump the build number
    # Get the latest build numbers from both TestFlight and App Store
    latest_tf_build = latest_testflight_build_number(
      app_identifier: "com.yourcompany.yourapp",
      api_key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
      issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"]
    )

    latest_appstore_build = app_store_build_number(
      app_identifier: "com.yourcompany.yourapp",
      api_key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
      issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"]
    )

    # Use the highest build number and increment by 1
    highest_build = [latest_tf_build, latest_appstore_build].max
    increment_build_number_in_xcodeproj(
      build_number: highest_build + 1
    )

    # Build the app (code signing was done in a previous step)
    build

    # Upload to TestFlight
    pilot(
      app_identifier: "com.yourcompany.yourapp",
      api_key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
      issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
      skip_waiting_for_build_processing: true,
      changelog: "Automated build from GitHub Actions",
      distribute_external: false,
      notify_external_testers: false
    )

    # Send a notification (optional)
    slack(
      message: "🚀 New TestFlight build is processing!",
      slack_url: ENV["SLACK_WEBHOOK_URL"]
    ) if ENV["SLACK_WEBHOOK_URL"]
  end

end

Let me break down what’s happening in the deploy_testflight lane:

Version bumping

# Get the latest build numbers from both TestFlight and App Store
latest_tf_build = latest_testflight_build_number(
  app_identifier: "com.yourcompany.yourapp",
  api_key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"]
)

latest_appstore_build = app_store_build_number(
  app_identifier: "com.yourcompany.yourapp",
  api_key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"]
)

# Use the highest build number and increment by 1
highest_build = [latest_tf_build, latest_appstore_build].max
increment_build_number_in_xcodeproj(
  build_number: highest_build + 1
)

This gets the latest build numbers from both TestFlight and the App Store, finds the highest one, and increments it by 1. This prevents version conflicts that could occur if the App Store has a higher build number than TestFlight. No more version conflicts! This builds on what we learned in our version bumping post.

Building the app

# Build the app (code signing was done in a previous step)
build

This calls our build lane from our GitHub Actions setup post. Since we’ve already set up code signing in a previous workflow step, the build lane can focus purely on building the app. Clean and efficient!

Uploading to TestFlight

pilot(
  app_identifier: "com.yourcompany.yourapp",
  api_key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
  skip_waiting_for_build_processing: true,
  changelog: "Automated build from GitHub Actions",
  distribute_external: false,
  notify_external_testers: false
)

This uploads your app to TestFlight. The skip_waiting_for_build_processing option means the workflow won’t wait for Apple to process the build (which can take 10-30 minutes). The distribute_external and notify_external_testers options are set to false to prevent automatically distributing to external testers - you’ll want to review builds manually first.

Advanced features

Smart tagging

Instead of just using “testflight”, you can make your tags more informative:

on:
  push:
    tags:
      - 'testflight*'
      - 'tf-*'
      - 'beta-*'

This allows tags like testflight-v1.2.3, tf-hotfix, or beta-release.

Version from tag

You can extract version information from your tag:

desc "Deploy to TestFlight with version from tag"
lane :deploy_testflight do
  # Extract version from git tag
  tag_name = ENV["GITHUB_REF_NAME"] || sh("git describe --tags --abbrev=0").strip

  if tag_name.include?("-v")
    version = tag_name.split("-v").last
    increment_version_number(version_number: version)
  end

  # ... rest of the lane
end

Now you can tag with testflight-v1.2.3 and it will set your app version to 1.2.3.

Conditional notifications

Only send notifications for production builds:

# Send notification only for main branch
if ENV["GITHUB_REF"] == "refs/heads/main"
  slack(
    message: "🚀 Production TestFlight build is ready!",
    slack_url: ENV["SLACK_WEBHOOK_URL"]
  )
end

Build artifacts

Save your .ipa file as a GitHub artifact:

- name: Upload build artifacts
  uses: actions/upload-artifact@v4
  with:
    name: ios-app-${{ github.sha }}
    path: build/*.ipa

Using the workflow

Now for the moment of truth! Here’s how to use your new automated TestFlight deployment:

  1. Make your changes and commit them
  2. Tag your commit: git tag testflight
  3. Push the tag: git push --tags
  4. Watch the magic happen in the GitHub Actions tab

You can also do it all in one go:

git add .
git commit -m "Ready for TestFlight"
git tag testflight-$(date +%Y%m%d-%H%M%S)
git push --tags

This creates a unique tag with a timestamp, so you can deploy multiple times per day without conflicts.

Troubleshooting

”Invalid API Key”

Make sure your API key is properly base64 encoded and that you’ve set all three required secrets (API key, key ID, and issuer ID).

”No matching provisioning profiles found”

This usually means your provisioning profile doesn’t match your app’s bundle identifier or capabilities. Double-check your Match setup or manual provisioning profiles.

”Build already exists”

This happens when you try to upload a build with the same version and build number as an existing build. Our version bumping should prevent this, but if it happens, you might need to manually increment the build number.

”Processing takes too long”

Apple’s build processing can sometimes take a very long time. If you’re hitting timeout issues, consider using skip_waiting_for_build_processing: true and checking the status manually later.

Monitoring and notifications

Slack notifications

Add Slack notifications to know when your builds succeed or fail:

slack(
  message: "✅ TestFlight deployment successful!",
  success: true,
  payload: {
    "Build Number" => get_build_number,
    "Version" => get_version_number,
    "Git Commit" => ENV["GITHUB_SHA"]
  },
  slack_url: ENV["SLACK_WEBHOOK_URL"]
)

Email notifications

GitHub Actions can also send email notifications on failure:

- name: Send failure notification
  if: failure()
  uses: dawidd6/action-send-mail@v3
  with:
    server_address: smtp.gmail.com
    server_port: 465
    username: ${{ secrets.EMAIL_USERNAME }}
    password: ${{ secrets.EMAIL_PASSWORD }}
    subject: "TestFlight deployment failed"
    body: "The TestFlight deployment for ${{ github.repository }} failed. Check the logs at ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
    to: your-email@example.com

Best practices

Branch protection

Consider protecting your main branch and requiring pull request reviews before changes can be merged. This prevents accidental TestFlight deployments from broken code.

Environment separation

You might want different workflows for different environments:

Testing before deployment

Always run your full test suite before deploying:

desc "Deploy to TestFlight"
lane :deploy_testflight do
  # Run tests first
  scan(scheme: "YourAppScheme")

  # Only proceed if tests pass
  # ... rest of deployment
end

Security considerations

Least privilege

Your API key should have the minimum permissions needed. Usually “Developer” role is sufficient for TestFlight uploads.

Secret rotation

Rotate your API keys and certificates regularly. Consider using different keys for different environments.

Branch restrictions

Consider restricting TestFlight deployments to specific branches:

on:
  push:
    tags:
      - 'testflight*'
    branches:
      - main
      - release/*

What’s next?

Congratulations! You’ve just set up one of the most powerful iOS development workflows possible. You can now:

This is the kind of automation that makes iOS development feel magical. No more manual builds, no more forgetting steps, no more “works on my machine” problems.

But we’re not done yet! You could extend this further with:

The possibilities are endless!

Conclusion

We’ve come a long way in this series! We started with basic GitHub Actions setup, moved on to automated testing, and now we’ve created a complete TestFlight deployment pipeline.

This workflow leverages everything we’ve learned in our Fastlane series - building apps, running tests, and managing versions - and combines it with the power of GitHub Actions automation.

The result? A professional-grade iOS deployment pipeline that would make any DevOps engineer proud. And the best part? It’s all free (within GitHub’s generous limits) and runs in the cloud, so your laptop can stay in sleep mode while your app gets built and deployed.

Try it out, customize it for your needs, and let me know how it goes! And if you run into any issues, remember that both GitHub Actions and Fastlane have excellent logging - the answer is usually in the logs.

Happy deploying! 🚀

P.S. - If you found this series helpful, consider sharing it with other iOS developers. Automation like this can save hours every week, and everyone deserves to experience the joy of git tag testflight && git push --tags followed by a coffee break while the robots do all the work!