Build Distribution
Connect your CI pipeline so every build lands in App Center automatically — no manual uploads.
How it works
CI pipeline
→ POST /api/v1/builds (tapflow relay)
→ Build appears in App Center
→ Team opens browser, picks a device, and tests
PO / PM: does it match the spec?
Designer: does it match the design?
Backend: is the API wired up correctly?
QA: any bugs to report?The CI job uploads the build artifact. From there, anyone on the team can test directly in the browser — no IDE, no device setup needed.
Manual review
This guide covers manual review: CI delivers the build, and people test it.
To have CI replay flows, or to let an LLM agent drive the device, see MCP in CI/CD. Both are experimental.
Any build tool
tapflow works on the built artifact, not the build tool. A native Xcode or Gradle build, React Native, Flutter, or a cloud build service all produce a build you upload the same way. Once you have an artifact (.app.zip, .tar.gz/.tgz, or .apk), the rest of this page is the same.
Prerequisites
| Requirement | Notes |
|---|---|
| tapflow relay | Running and reachable from your CI environment |
| Personal access token (PAT) | Create an API-type token in Settings → Tokens (it carries builds:write). Admins, Developers and QA see this page |
How CI reaches the relay
Your CI job has to reach the relay's POST /api/v1/builds. The relay is meant to stay on the same internal network as the agents (Deployment options), so the path depends on where CI runs.
| Relay setup | How CI uploads |
|---|---|
| LAN only (default) | Cloud runners (GitHub-hosted and the like) cannot reach a LAN relay. Upload from a self-hosted runner on the internal network, using the relay's internal address (http://192.168.x.x:4000) |
| VPS + rathole tunnel | Open the relay for external access and CI can upload from anywhere via the public URL (https://your-vps.com) — the smoothest fit for cloud CI |
| Tailscale tunnel | Only tailnet members can connect, so the CI runner has to be on the tailnet |
The relay does not go on a cloud host
Deploying the relay to fly.io, Railway, or similar puts the agent→relay path over the internet and the stream breaks (unsupported). When you need public access, keep the relay on the internal network and expose it through a tunnel. The VPS is the tunnel host, not the relay host.
1. Generate a token
In the dashboard, go to Settings → Tokens → New token. The Tokens page is shown to Admins, Developers and QA; the token belongs to whoever creates it.
- Name: something descriptive, e.g.
GitHub Actions - Expiration: 30 days by default. Pick 7, 30, 60 or 90 days, a custom number (1–365), or No expiration. For CI, 90 days or less is recommended.
- Type: API, which grants
view, builds:write
Copy the token — it is shown only once. Store it as a CI secret (e.g. TAPFLOW_PAT).
2. Upload the build
Call POST /api/v1/builds after the build artifact is ready.
# iOS (.app.zip)
curl -X POST https://your-relay/api/v1/builds \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@MyApp.app.zip" \
-F "status=In Progress" \
-F "label=$GIT_BRANCH"
# Android (.apk)
curl -X POST https://your-relay/api/v1/builds \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@MyApp.apk" \
-F "status=In Progress" \
-F "label=$GIT_BRANCH"status=In Progress signals to the team that the build is ready for review.
Use label to attach context — branch name, ticket number, or a short description. It shows on the build's row in App Center and is found by its search.
When the build file's version or build number does not say which build this is — several QA builds of the same version, say — send version_name and build_number too. They are used instead of the file's values to show and group the build in tapflow; the file itself is not changed. A label longer than 100 characters is cut to 100; version_name and build_number are refused over 100.
curl -X POST https://your-relay/api/v1/builds \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@MyApp.app.zip" \
-F "label=$GIT_BRANCH" \
-F "build_number=$CI_PIPELINE_ID"iOS builds
.ipa files are not supported. For a simulator build, upload .app.zip or .tar.gz/.tgz. Build .app.zip with xcodebuild -sdk iphonesimulator and zip the .app folder; a .tar.gz/.tgz is what a cloud simulator build produces.
3. Post build metadata (optional)
Attach commit and branch info as a comment so reviewers know what changed.
BUILD_ID=$(curl -sf -X POST https://your-relay/api/v1/builds \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@MyApp.app.zip" \
-F "status=In Progress" \
-F "label=$GIT_BRANCH" | jq -r '.id')
curl -X POST https://your-relay/api/v1/comments \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "build_id=$BUILD_ID" \
-F "body=Branch: $GIT_BRANCH
Commit: $GIT_SHA
$GIT_COMMIT_MSG"GitHub Actions example
This example assumes a self-hosted macOS runner that can reach the relay's internal address. If you have opened the relay on a public URL, as with VPS + rathole, you can switch runs-on to a cloud runner such as macos-latest.
Self-hosted runners and pull requests
A self-hosted runner executes whatever code the workflow checks out, on a Mac inside your network. The if: below skips pull requests opened from forks, so only branches in your own repository build there. Keep it, and don't attach self-hosted runners to a public repository that accepts outside pull requests.
name: Upload to tapflow
on:
push:
branches: [main, 'release/**']
pull_request:
jobs:
upload:
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: [self-hosted, macos]
steps:
- uses: actions/checkout@v4
- name: Build app
run: |
xcodebuild -scheme MyApp -sdk iphonesimulator \
-configuration Debug \
CONFIGURATION_BUILD_DIR=build/Debug
cd build/Debug && zip -r MyApp.app.zip MyApp.app
- name: Upload to tapflow
env:
TAPFLOW_PAT: ${{ secrets.TAPFLOW_PAT }}
TAPFLOW_RELAY_URL: ${{ secrets.TAPFLOW_RELAY_URL }}
BRANCH: ${{ github.head_ref || github.ref_name }}
COMMIT: ${{ github.sha }}
COMMIT_MSG: ${{ github.event.head_commit.message || github.event.pull_request.title }}
run: |
BUILD_RESPONSE=$(curl -sf -X POST "$TAPFLOW_RELAY_URL/api/v1/builds" \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@build/Debug/MyApp.app.zip" \
-F "status=In Progress" \
-F "label=$BRANCH")
BUILD_ID=$(echo "$BUILD_RESPONSE" | jq -r '.id')
COMMENT="Branch: $BRANCH"$'\n'"Commit: $COMMIT"$'\n'"$COMMIT_MSG"
curl -sf -X POST "$TAPFLOW_RELAY_URL/api/v1/comments" \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "build_id=$BUILD_ID" \
-F "body=$COMMENT"Build status reference
| Status | Meaning |
|---|---|
Backlog | Uploaded but not yet ready for review |
In Progress | Ready — team can start testing |
Done | Stakeholders approved |
Rejected | Issues found, needs fixes |
CI sets In Progress on upload. Done and Rejected are set manually after review.