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
-
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 iosonce: it asks for the build command and the.ipapath and saves them as theproductionprofile invibeview.json. In CI it can’t ask, so commit that file. -
Turn on automatic build numbers. Set
"autoIncrement": trueon theproductionprofile, 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 theVIBEVIEW_BUILD_NUMBERenvironment variable; wire it into your project once (for Expo,ios.buildNumberinapp.config.js), as Automatic build numbers shows. -
Name the groups in
vibeview.json. The workflow below passes no--group, so the CLI reads the groups from the app’ssubmitblock:{ "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.
ascKeyis only needed when your organization stores more than one App Store Connect key. -
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
ITSAppUsesNonExemptEncryptionin your Info.plist, or save the app’s answer withvibeview 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. -
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 infoto see what is missing. The CI token can read these but not change them. -
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.--latestmeans 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 (
outputsandneeds, 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 --checkasks App Store Connect whether it knows the app and accepts your stored key, and checks the group names fromvibeview.json. It exits1before you spend a build.- Secrets go through
env:. The token is passed to each step as an environment variable, never written insiderun:(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-fileat it, or pass--what-to-test "…". Notes are never read fromvibeview.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:
| Exit | When |
|---|---|
0 | In TestFlight: the build is available to your groups |
0 | In Beta App Review: internal groups already have the build, and Apple releases it to external testers once it approves it |
0 | Still waiting after 7 days: no Beta App Review verdict yet. VibeView keeps checking |
0 | Not settled after 2 h 15 min: the command prints Still going in VibeView with the build page, where VibeView keeps following it |
0 | Not followed: VibeView isn’t following this upload, and the command prints TestFlight distribution is not running for this submission |
1 | Distribution needs your action (the message says what and where), failed or was stopped, or the upload itself failed |
1 | Reading 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 |
2 | The 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_VERSIONis 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-minutescovers 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
.apkprofile 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
| Symptom | What it means | Fix |
|---|---|---|
| Apple already has build N for this version | The build number was used before | Turn 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 step | A name in vibeview.json doesn’t match a group in App Store Connect | The message lists the app’s groups. Fix the name and push again |
| External testers need What to Test notes | An external group with no note | Keep --what-to-test-file, or pass --what-to-test |
| The job fails with Add beta test information or Add Beta App Review contact details | Apple needs these before external testing | Fill 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 compliance | No Info.plist value and no saved answer | Answer once (see step 4). Builds waiting on the question continue when you save it |
| Build N is already being sent to testers | You re-ran the job for a build VibeView is still following | Nothing 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 do | Run that command from your own login or use the dashboard |
| The build job fails in CI but works locally | Usually a missing production profile in the committed vibeview.json, since CI can’t answer the CLI’s questions | Commit 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.
Related guides
In the documentation
Try it yourself
Open a live iOS simulator, Android emulator, Apple TV, Android TV or Roku in your browser.
Start free