AppForShare

Build an iOS app on GitHub Actions and send it to TestFlight

· 11 min read

Build an iOS app on GitHub Actions and send it to TestFlight

Building an iOS app on GitHub Actions sounds like one job with one command. In practice a working workflow has six parts that all have to agree: a macOS runner, the right Xcode, code signing, a build number Apple has not seen before, an export method that matches where the build is going, and an upload step. Get one of them wrong and the run fails twenty minutes in, on minutes you pay for. This guide walks through each part, shows the key workflow steps, lists the failures that come up most, and then shows how AppForShare sets the same workflow up in your repository with one pull request.

What a working iOS workflow needs

1. A macOS runner

Xcode only runs on macOS, so the job needs runs-on: macos-15, macos-26 or macos-latest. At the time of writing, macos-latest points to the macOS 26 image and macOS 14 is deprecated. GitHub keeps at most two macOS versions generally available and retires the oldest one when a new one arrives, so pin a version label instead of latest if you want builds to stay the same until you choose to move.

2. The right Xcode version

Each runner image has several Xcode versions installed under /Applications/Xcode_<version>.app, and one of them is the default. The default is not always the one you want. On the current macos-15 image, for example, the default is Xcode 16.4, while Xcode 26.x versions are installed next to it. That matters because since April 28, 2026, apps uploaded to App Store Connect must be built with Xcode 26 or later and the iOS 26 SDK. A TestFlight upload built with the default Xcode on that image is refused. Select the version explicitly as the first step after checkout:

sudo xcode-select -s /Applications/Xcode_26.3.app

The list of installed versions changes as GitHub updates the images, so check the image readme in the actions/runner-images repository when a selected version disappears.

3. Signing: automatic with an API key, or manual

Signing is where most CI setups break, because a runner is a clean Mac with no certificates, no profiles and no Apple account signed in to Xcode. There are two ways to give it what it needs.

Automatic signing with an App Store Connect API key. Create a team API key in App Store Connect (Users and Access, Integrations) and keep the .p8 file, the key ID and the issuer ID. Store all three as encrypted GitHub Actions secrets. During the build, pass -allowProvisioningUpdates together with -authenticationKeyPath, -authenticationKeyID and -authenticationKeyIssuerID to xcodebuild. Xcode then talks to Apple for you: it creates or updates the provisioning profile for the bundle ID, and for an ad hoc export it includes every device registered on your Apple account. If no distribution certificate is installed on the runner, Xcode asks Apple for a cloud managed one, and Apple only allows that for keys with wide access. To keep the key on a smaller role such as App Manager, import your own distribution certificate into a temporary keychain at the start of the job.

Manual signing with a distribution certificate and a profile. Export the Apple Distribution certificate as a .p12 with a password, download the provisioning profile, and store both as secrets (base64 encoded). The job creates a temporary keychain, imports the certificate, copies the profile into ~/Library/MobileDevice/Provisioning Profiles, and sets CODE_SIGN_STYLE=Manual, the signing identity and the profile name. Manual signing is predictable but needs care: if you set it as a command line override, it applies to every target, including CocoaPods targets, which then fail to sign. Set it on the app target in the project instead. You also have to download a new profile every time you add a device or the profile expires.

Whichever you choose, delete the key file and the keychain in a final step that runs even when the job fails.

4. A build number Apple has not seen

App Store Connect needs a new, higher build number (CFBundleVersion, set by CURRENT_PROJECT_VERSION) for every upload of the same version. If your project keeps a fixed number, the second upload fails. Common choices in CI are the workflow run number ($GITHUB_RUN_NUMBER) or a UTC timestamp such as 2611011430. Pass it to xcodebuild as a build setting so the project file in the repository does not change on every run. The marketing version (MARKETING_VERSION, like 1.4.0) can be overridden the same way.

5. Export options: ad hoc or App Store Connect

After xcodebuild archive, xcodebuild -exportArchive turns the archive into an .ipa using an export options plist. The method key decides what kind of build you get:

  • app-store-connect for TestFlight and the App Store. This build has no device list and cannot be installed from a link.
  • release-testing (the current Xcode name for Ad Hoc) for testers who install from a link. Only devices in the profile can install it.
  • debugging for a development build, and enterprise for in-house distribution with an Enterprise account.

If you are not sure which of these a build ended up as, open the .ipa in the IPA inspector, which reads the distribution type and the device list in the browser.

6. The upload

For TestFlight, xcrun altool --upload-app with --apiKey and --apiIssuer uploads the .ipa using the same API key, with no Apple ID password or app specific password. altool looks for the key as AuthKey_<KEYID>.p8 in ~/.appstoreconnect/private_keys, so copy it there first. Apple marks --upload-app as deprecated in favour of --upload-package, but it still uploads. After the upload, Apple processes the build before it shows in TestFlight, and external testers only get it after Apple's beta review. For an ad hoc build, the last step uploads the .ipa to wherever your testers install from; upload every app build from CI shows that step for AppForShare.

The key workflow steps

A shortened version of the build job with automatic signing. The team ID, workspace and scheme are placeholders:

jobs:
  build:
    runs-on: macos-15
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_26.3.app

      - name: App Store Connect key
        env:
          ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
        run: |
          mkdir -p "$RUNNER_TEMP/asc"
          printf '%s' "$ASC_KEY_P8" > "$RUNNER_TEMP/asc/AuthKey.p8"

      - name: Archive
        env:
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
        run: |
          xcodebuild archive -workspace MyApp.xcworkspace -scheme MyApp \
            -configuration Release -destination 'generic/platform=iOS' \
            -archivePath "$RUNNER_TEMP/App.xcarchive" \
            -allowProvisioningUpdates \
            -authenticationKeyPath "$RUNNER_TEMP/asc/AuthKey.p8" \
            -authenticationKeyID "$ASC_KEY_ID" \
            -authenticationKeyIssuerID "$ASC_ISSUER_ID" \
            DEVELOPMENT_TEAM=ABCDE12345 CODE_SIGN_STYLE=Automatic \
            CURRENT_PROJECT_VERSION=$GITHUB_RUN_NUMBER

The export options for a TestFlight build:

<?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-connect</string>
  <key>teamID</key><string>ABCDE12345</string>
  <key>signingStyle</key><string>automatic</string>
  <key>destination</key><string>export</string>
</dict></plist>

Then export and upload:

xcodebuild -exportArchive -archivePath "$RUNNER_TEMP/App.xcarchive" \
  -exportPath "$RUNNER_TEMP/export" -exportOptionsPlist ExportOptions.plist \
  -allowProvisioningUpdates \
  -authenticationKeyPath "$RUNNER_TEMP/asc/AuthKey.p8" \
  -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID"

mkdir -p ~/.appstoreconnect/private_keys
cp "$RUNNER_TEMP/asc/AuthKey.p8" ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8
xcrun altool --upload-app -f "$RUNNER_TEMP"/export/*.ipa -t ios \
  --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"

For an ad hoc build, change method to release-testing and replace the altool step with your tester upload. Add a final step with if: always() that removes the key and any keychain you created.

Common failures and what they mean

Signing errors

"No signing certificate found", "No profiles for the bundle ID were found" or "requires a provisioning profile" almost always mean the runner has nothing to sign with. With automatic signing, check that -allowProvisioningUpdates and all three authentication flags are on both the archive and the export command, and that the team ID matches the key's team. A message about cloud signing permission means the key's role cannot create a cloud managed certificate: add your own certificate or use a key with a wider role. With manual signing, check that the certificate in the .p12 is the one inside the profile, and that it was not revoked. Lost or revoked iOS distribution certificate covers what to do if it was.

The profile is missing devices

The build succeeds, the link opens, and a tester sees "Unable to Install". Ad hoc builds install only on devices whose UDID is in the embedded profile. Registering a device on Apple does not change builds that already exist: the profile has to be made again with the new device, and the app has to be built again. With automatic signing, register the device first and the next run picks it up. With manual signing, make and download a new profile as well. Register devices for Ad Hoc builds walks through the full process, and iOS code signing errors on test builds covers the other install errors.

The build number was already used

App Store Connect refuses an upload whose build number it already has for that version, with a message about a redundant binary or a bundle version that must be higher. Use a number that grows on every run, as described above, and allow a manual override for the occasional rerun.

The Xcode or SDK version is too old

An upload refused for the SDK version usually means the job used the image's default Xcode. Select Xcode 26 or later explicitly, and check the build log for the version actually used.

macOS minutes cost more than you expect

Standard GitHub hosted runners are free for public repositories. On private repositories, macOS jobs use your included minutes at 10 times the rate of Linux jobs, so a 15 minute iOS build takes 150 minutes from the monthly allowance. Keep builds lean: cache dependencies, set a timeout-minutes so a stuck job does not run for hours, avoid running the iOS build on every push when a build started by hand is enough, and use concurrency so two runs of the same branch do not pile up.

How AppForShare sets this up with one pull request

If you would rather not write and maintain this workflow by hand, AppForShare can set it up in your repository. The build still runs in your repository on your GitHub Actions minutes. AppForShare does not build the app on its own machines and does not read your code. See GitHub Actions builds for an overview and the setup guide for every option.

  • Install the AppForShare Builds GitHub App on the account or organization and pick which repositories it can see.
  • Set up a repository as a workspace Admin: the .xcworkspace or .xcodeproj, scheme, configuration, Team ID (filled in from the bundle ID when Apple knows it), Xcode version, runner (macos-15 by default), Node.js version for React Native and similar apps, and setup commands such as pod install. It needs an App Store Connect connection in your workspace.
  • Pick where the build goes. AppForShare: an ad hoc (release testing) export, uploaded with the AppForShare GitHub Action, with an install link and QR code for testers. TestFlight: an app-store-connect export uploaded with xcrun altool.
  • Pick how it is signed. Xcode automatic signing with your App Store Connect key and -allowProvisioningUpdates, where an ad hoc profile gets every registered device; optionally with your own distribution certificate added as two secrets, which we recommend. Or your own Apple Distribution certificate with manual signing set on the app target only, so Pods keep their settings; before each run AppForShare reads the newest matching profile of the bundle ID from Apple, or makes one, and stores it as a secret. Manual mode supports one app target without extensions for now.
  • Pick the build number: the project's own, a UTC timestamp, or the run number, with an override when you start a build. The version can be overridden too.

AppForShare then stores encrypted Actions secrets in the repository (an upload token made only for that repository and your App Store Connect key) and opens a pull request from the branch appforshare/ios-build with .github/workflows/appforshare-ios.yml. It never pushes to your other branches and never merges. You read the file, merge it yourself, and can edit it later. The workflow is started by hand, never on a push.

After that, anyone in the workspace with the Developer role or higher starts a build from AppForShare with release notes, a channel and a build number. AppForShare shows the run's progress and log link, and for builds sent to AppForShare, the new build with its install link. Removing the setup deletes the secrets and revokes the token; the workflow file stays in your repository.

Ad hoc builds need your testers' devices on your Apple account first. With iOS device registration, testers send their UDID from one link, and a workspace Admin registers the devices on Apple in one click, so the next run includes them.

Building on GitHub Actions is part of the Pro plan for up to 2 repositories and the Team plan with no limit; the iOS and Android setups of one repository count once. See pricing for the plans. The runs themselves use your GitHub Actions minutes, so the macOS minute cost above still applies.

Keep reading

App Distribution Platform · · 9 min read

AppForShare.io vs Competitors - A Comparative Analysis

For small teams and startups, selecting a mobile app testing platform is a critical crossroad. This choice is more than a mere selection: it’s a strategic decision that directly impacts the...