NewiPhone Duo, live in your browser. Try it →
Vibeview
Pricing
Store Submission · GitHub Actions

GitHub Actions to TestFlight, no Mac runner

Rokas Kašinskas — Co-founder, VibeViewPublished 2026-10-25Updated 2026-10-26

The usual way to get a build from GitHub Actions to TestFlight puts a macOS runner at the end of the pipeline. That runner needs Xcode, a signing certificate in its keychain and an App Store Connect key in the repository’s secrets. This guide uses a Linux runner instead. The runner never builds or signs anything: it asks VibeView to build, then asks VibeView to send the result.

It assumes the one-time setup from Upload to TestFlight without a Mac is done: the App Store Connect key, signing credentials, the app record and a production build profile. The same workflow works for an Apple TV app with --platform tvos.

What a CI token can and can’t do

Create the token for this repository in VibeView under Settings → API token: role Developer, access CI only, an expiry, and a label naming the repository. A CI-only token can start and follow cloud builds, submit a finished production build to the stores, follow its submissions, and give the app’s export compliance answer. It uses the store keys already saved on VibeView.

It can’t add, remove or test store keys, generate signing credentials, change TestFlight details (test information, the Beta App Review contact, groups or testers), or choose Stop following on a submission. Do those once from your own login or the dashboard. If a message names a command the token can’t run, the CLI adds These commands need a full-access API token. CI-only tokens has the complete list.

Before the first run

  1. Finish the one-time setup from your machine. Store the App Store Connect key, generate or upload signing credentials and create the app record, as in Upload to TestFlight without a Mac. Run vibeview build --cloud --production --platform ios once: it asks for the build command and the .ipa path and saves them as the production profile in vibeview.json. In CI it can’t ask, so commit that file.

  2. Turn on automatic build numbers. Set "autoIncrement": true on the production profile, so every tag gets a build number Apple hasn’t seen. Apple refuses a reused one, and a CI-only token can’t change the counter. The number reaches your build as the VIBEVIEW_BUILD_NUMBER environment variable; wire it into your project once (for Expo, ios.buildNumber in app.config.js), as Automatic build numbers shows.

  3. Name the groups in vibeview.json. The workflow below passes no --group, so the CLI reads the groups from the app’s submit block:

    {
      "platforms": {
        "ios": {
          "appId": "app_abc123",
          "build": { "...": "..." },
          "submit": { "ascKey": "team", "groups": ["QA team", "Beta testers"] }
        }
      }
    }

    Group names match the names in App Store Connect, in any case. ascKey is only needed when your organization stores more than one App Store Connect key.

  4. Answer export compliance once. For every build, Apple asks whether the app uses encryption beyond the exempt cases, such as HTTPS; testers can’t install the build until it’s answered. Set ITSAppUsesNonExemptEncryption in your Info.plist, or save the app’s answer with vibeview testflight encryption --no-uses-encryption (only if the app uses no encryption beyond the exempt cases). Without an answer, every run stops at export compliance. TestFlight “Missing Compliance” explains the choice.

  5. For external groups, fill in TestFlight details. External groups are testers you invite by email, outside your App Store Connect team. Before they get a build, Apple needs beta test information and Beta App Review contact details. Set them in Builds → Stores → your app → TestFlight details, or run vibeview testflight info to see what is missing. The CI token can read these but not change them.

  6. Add the token to GitHub. In the repository, open Settings → Secrets and variables → Actions and add it as the secret VIBEVIEW_API_TOKEN. The CLI reads it from that environment variable.

The workflow

Save this as .github/workflows/testflight.yml. A tag starting with v (for example v1.4.0) builds a signed .ipa and sends it to the groups in vibeview.json, with the tagged commit’s message as the What to Test note.

Three things you may want to edit: VIBEVIEW_CLI_VERSION (the CLI is pinned, so a new release never changes your release pipeline by surprise; this workflow needs 0.55.0 or later), --platform (tvos for an Apple TV app), and the tag pattern under tags:.

name: TestFlight

on:
  push:
    tags: ['v*']

permissions:
  contents: read

env:
  # The VibeView CLI version. Pinned: bump it deliberately.
  VIBEVIEW_CLI_VERSION: 0.58.0

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 200
    outputs:
      cloud_build_id: ${{ steps.build.outputs.cloud_build_id }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22

      - name: Install the VibeView CLI
        run: npm install --global --no-audit --no-fund "vibeview@${VIBEVIEW_CLI_VERSION}"

      # Fails fast on a missing app record, key or group name, before any build.
      - name: Check that TestFlight can take the build
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
        run: vibeview submit --check --platform ios

      - name: Build a signed release
        id: build
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
        run: |
          # "|| true" keeps the step going when the build fails, so the next
          # lines can print the end of the build log before failing the job.
          vibeview build --cloud --production --platform ios --json > build.ndjson || true
          id=$(jq -rs '[.[] | select(.event == "build_finished" and .status == "succeeded")]
                       | last | .id // empty' build.ndjson)
          if [ -z "$id" ]; then
            jq -rj 'select(.event == "build_log") | .content' build.ndjson | tail -n 100
            echo "::error::The iOS production build failed."
            exit 1
          fi
          echo "cloud_build_id=$id" >> "$GITHUB_OUTPUT"

  testflight:
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 180
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22

      - name: Install the VibeView CLI
        run: npm install --global --no-audit --no-fund "vibeview@${VIBEVIEW_CLI_VERSION}"

      - name: Send to TestFlight
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
          CLOUD_BUILD_ID: ${{ needs.build.outputs.cloud_build_id }}
        run: |
          git log -1 --format=%B > "$RUNNER_TEMP/what-to-test.txt"
          vibeview submit --platform ios --build "$CLOUD_BUILD_ID" \
            --what-to-test-file "$RUNNER_TEMP/what-to-test.txt" --wait-distribution

What the workflow’s key lines do:

  • --build "$CLOUD_BUILD_ID", not --latest. --latest means the newest successful production build for the platform. If two tags are pushed close together, each run could pick up the other’s build. Passing the id the build job printed means each run submits its own build.
  • Two jobs. The build job hands the cloud build id to the TestFlight job through a job output (outputs and needs, in GitHub’s workflow syntax).
  • Two timeouts. The build job’s 200 minutes sits above the CLI’s own wait for a cloud build. The TestFlight job’s 180 minutes covers --wait-distribution’s limit of 2 hours 15 minutes, plus a margin for the upload. So the CLI stops before GitHub does, and says why.
  • submit --check asks App Store Connect whether it knows the app and accepts your stored key, and checks the group names from vibeview.json. It exits 1 before you spend a build.
  • Secrets go through env:. The token is passed to each step as an environment variable, never written inside run: (using secrets in GitHub Actions).
  • What to Test from the commit message. External testers need a note, so the workflow always sends one. To write release notes by hand instead, commit a file and point --what-to-test-file at it, or pass --what-to-test "…". Notes are never read from vibeview.json, so an old note is never sent by accident.

To send to other groups than the ones in vibeview.json, add --group "Name" to the submit command, once per group. It replaces the file’s list.

What the job’s result means

--wait-distribution checks every 15 seconds. It prints each step as it changes: processing, export compliance, What to Test, internal groups, Beta App Review, external groups. Then it exits:

ExitWhen
0In TestFlight: the build is available to your groups
0In Beta App Review: internal groups already have the build, and Apple releases it to external testers once it approves it
0Still waiting after 7 days: no Beta App Review verdict yet. VibeView keeps checking
0Not settled after 2 h 15 min: the command prints Still going in VibeView with the build page, where VibeView keeps following it
0Not followed: VibeView isn’t following this upload, and the command prints TestFlight distribution is not running for this submission
1Distribution needs your action (the message says what and where), failed or was stopped, or the upload itself failed
1Reading the submission failed 5 times in a row, or the submission no longer exists. The command prints the build page, where VibeView keeps following it
2The API token stopped working during the wait, for example because it was revoked or expired. VibeView keeps following the build

So a green job means the build is with your testers or with Apple. A red job prints what to fix.

For many causes, VibeView looks again on its own every 30 minutes (the message says so). Otherwise, run vibeview submissions check <id> once you’ve fixed it. The CLI reference lists every exit code, and --json turns each change into one line your pipeline can read.

When the job ends early or you used --no-wait, nothing is lost: VibeView keeps following the build, and the build’s Store submissions card in the dashboard shows where it is. vibeview submissions status <id> --wait-distribution picks it up again from any job.

A shorter job: one command

If the job doesn’t need to wait for your testers, one command does both steps (CLI 0.62.0 or later). Use it as the job’s last step instead of the build and submit steps above:

      - name: Build and send to TestFlight
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
        run: |
          git log -1 --format=%B > "$RUNNER_TEMP/what-to-test.txt"
          vibeview build --cloud --production --platform ios --auto-submit \
            --what-to-test-file "$RUNNER_TEMP/what-to-test.txt"

VibeView sends the build to TestFlight as soon as it succeeds, with the groups in vibeview.json. The command follows the upload and fails the job if the build or the upload fails. It doesn’t wait for Apple’s processing or Beta App Review, so the job ends once Apple has the upload. VibeView keeps following the build after that: you get a notification when testers have it, and an email if it fails or needs you. Use the two-command job above when the job itself should wait until the build reaches your testers or Beta App Review, and fail if distribution fails.

Already using VibeView’s build workflow?

VibeView ships a build workflow, vibeview-build.yml, that already makes a signed production build for every v* tag. To send those builds to TestFlight, add a step after Build in the cloud, in the same job:

      - name: Send to TestFlight
        if: ${{ startsWith(github.ref, 'refs/tags/') && matrix.platform == 'ios' }}
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
          CLOUD_BUILD_ID: ${{ steps.build.outputs.cloud_build_id }}
        run: |
          git log -1 --format=%B > "$RUNNER_TEMP/what-to-test.txt"
          vibeview submit --platform ios --build "$CLOUD_BUILD_ID" \
            --what-to-test-file "$RUNNER_TEMP/what-to-test.txt" --wait-distribution

Two things to check with it:

  • VIBEVIEW_CLI_VERSION is 0.55.0 or later. The current template already is; an older copy of the file may pin an earlier CLI.
  • The job’s timeout-minutes covers the wait. Raise it by the distribution wait plus a margin for the upload (about 150 minutes), so the CLI still stops before GitHub does. GitHub-hosted jobs can run for up to 6 hours (GitHub Actions limits).

For an Apple TV app, copy the step with matrix.platform == 'tvos' and --platform tvos.

If you’d rather not keep a workflow for builds at all, the VibeView GitHub App builds version tags with no workflow file. It makes the signed build and, when "autoSubmit": true is in the platform’s submit settings in vibeview.json, sends it to TestFlight with the groups listed there. A tag carries no What to Test notes, so for external groups use a workflow like the one above.

Android, Android TV and Fire TV from the same workflow

The same commands send Android and Android TV builds to Google Play. Change the check line to vibeview submit --check --platform android, the build line to --platform android, and the submit line to vibeview submit --platform android --build "$CLOUD_BUILD_ID" --track internal (use androidtv for an Android TV app). --wait-distribution and the What to Test notes are TestFlight-only; for Google Play the command waits for the upload and exits 0 when it succeeded. A CI-only token can submit to any track, including a full release on production. Use --track internal until you mean to release.

For Fire TV, the build goes to the Amazon Appstore as an .apk:

  • The build line uses your .apk profile instead of --production: vibeview build --cloud --platform androidtv --profile amazon --json.
  • The check and submit lines add --store amazon: vibeview submit --check --platform androidtv --store amazon.
  • The submit line takes --release-notes-file (required for Amazon) instead of --what-to-test-file, and no --wait-distribution.

Submit a Fire TV app to the Amazon Appstore covers the amazon profile and the one-time setup.

Troubleshooting

SymptomWhat it meansFix
Apple already has build N for this versionThe build number was used beforeTurn on automatic build numbers. If they’re already on, set the counter to N from your own login (vibeview versions set N --app <app-id> --platform ios) and push a new tag
Unknown TestFlight group(s): … in the check stepA name in vibeview.json doesn’t match a group in App Store ConnectThe message lists the app’s groups. Fix the name and push again
External testers need What to Test notesAn external group with no noteKeep --what-to-test-file, or pass --what-to-test
The job fails with Add beta test information or Add Beta App Review contact detailsApple needs these before external testingFill them in under TestFlight details from the dashboard. VibeView looks again within 30 minutes, or run vibeview submissions check <id>
The job fails at export complianceNo Info.plist value and no saved answerAnswer once (see step 4). Builds waiting on the question continue when you save it
Build N is already being sent to testersYou re-ran the job for a build VibeView is still followingNothing to do: follow it on the build page. To send again from CI, push a new tag (a new build number). Stopping a submission needs a full-access token
These commands need a full-access API token.The message names something a CI-only token can’t doRun that command from your own login or use the dashboard
The build job fails in CI but works locallyUsually a missing production profile in the committed vibeview.json, since CI can’t answer the CLI’s questionsCommit the profile the CLI saved on your machine

Every refusal and every needs-your-action case is in Common refusals and When it needs your action.

What stays in App Store Connect

The workflow gets each build to your TestFlight groups. Submitting a version for App Store review, phased release, the store listing and screenshots, removing testers, adding internal testers and public links stay in App Store Connect (Done in the store’s console).

Next: push a test tag with only an internal group in vibeview.json, and watch the first run’s TestFlight job print each step.

Frequently asked questions

Can GitHub Actions upload to TestFlight without a macOS runner?

Yes. The build and the signing happen on VibeView’s cloud Macs and the upload happens on VibeView’s side, so every job in the workflow runs on ubuntu-latest.

Where does the App Store Connect API key live?

In VibeView, stored once by an admin. The workflow only has the CI-only VibeView token, which can use the stored key to upload but can’t read, add or remove it.

Does the job wait for Beta App Review?

No. Once the build is in Beta App Review the command exits 0, because internal groups already have it and VibeView keeps following the review. The build page shows when Apple releases it to external testers.

Can the same workflow send Apple TV builds?

Yes. Use --platform tvos in the check, build and submit commands, with the groups in the tvos entry of vibeview.json.

Can I test the build before it goes to TestFlight?

Not the signed .ipa, which simulators can’t install. Build a simulator build of the same commit and run your tagged test groups on it, as in Run mobile tests on every pull request with GitHub Actions.

Try it yourself

Open a live iOS simulator, Android emulator, Apple TV, Android TV or Roku in your browser.

Start free