Turn a green CI build into an install link a tester can tap. This action uploads the .ipa or
.apk your job just built to BetaDrop and hands back an over-the-air
install link — no TestFlight review wait, no Play track, no tester accounts. Testers open the link
on their phone and install from the browser.
- uses: betadrop-app/upload-action@v1
with:
file: app/build/outputs/apk/release/*.apk
token: ${{ secrets.BETADROP_TOKEN }}That is the whole integration. Create the token under Settings → Developer → API tokens at betadrop.app, store it as a repository secret, and the next push produces a link.
Three places, so nobody has to go looking for it:
| Step output | steps.<id>.outputs.install-url, for the rest of your workflow. |
| Run summary | On the workflow run's own page — the whole team sees it, not just whoever triggered the build. Nothing to add. |
| Pull request | With comment-on-pr: true, a comment that edits itself on every later push. |
Every green build ends with this on the run summary. It is rendered below by the same Markdown the action writes, so this is the panel itself rather than a picture of one (the link is an example):
![]()
Install on a device ·
https://betadrop.app/install/?i=EXAMPLEOpen it on a phone to install over the air — no TestFlight, no tester accounts.
With comment-on-pr: true, the same link also arrives on the pull request, and every later push
edits that comment instead of adding another:
https://betadrop.app/install/?i=EXAMPLEOpen it on a phone to install over the air. Built from
0123456· published by BetaDrop
| Instead of | What changes |
|---|---|
| TestFlight external testing | No Beta App Review between a green build and a tester's phone, and no TestFlight app or invite to accept. Still ad-hoc signing rules — see the UDID note under Notes. |
| Play internal testing | No Play Console upload step, no track, no tester list to keep in sync — you hand out a URL. |
| Diawi / similar OTA services | Same idea, wired into CI with one step, plus standing links, PR comments and a step output. |
| Firebase App Distribution | No Firebase project, no tester SDK, no invite acceptance — the link works in a plain mobile browser. |
Both platforms, one step, one token.
- name: Publish to BetaDrop
id: betadrop
uses: betadrop-app/upload-action@v1
with:
file: app/build/outputs/apk/release/*.apk
token: ${{ secrets.BETADROP_TOKEN }}
- name: Use the link
run: echo "Install at ${{ steps.betadrop.outputs.install-url }}"jobs:
beta:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: Build the .ipa
run: fastlane gym --export_method release-testing
- name: Publish to BetaDrop
uses: betadrop-app/upload-action@v1
with:
file: "*.ipa"
token: ${{ secrets.BETADROP_TOKEN }}fastlane is preinstalled on the macos images, so fastlane gym runs without a setup step —
use bundle exec fastlane gym only if your repository has a Gemfile pinning it.
release-testing is Apple's current name for ad-hoc distribution. gym copies this value
straight into the ExportOptions plist's method key, where Apple deprecated ad-hoc in
Xcode 15.4 — and Xcode 26, which is what macos-latest resolves to on GitHub-hosted
runners, rejects it outright. If your fastlane is old enough to reject the new name, pass
it through export_options: { method: "release-testing" } in a Fastfile instead. Either
way the export runs before this action does, so getting it wrong fails the build a step
early.
jobs:
beta:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up JDK
uses: actions/setup-java@v6
with: { java-version: "17", distribution: "temurin" }
- name: Build release APK
run: ./gradlew assembleRelease
- name: Publish to BetaDrop
uses: betadrop-app/upload-action@v1
with:
file: app/build/outputs/apk/release/*.apk
token: ${{ secrets.BETADROP_TOKEN }}
notes: ${{ github.event.head_commit.message }}Set comment-on-pr and the reviewer gets the link without opening Actions:
jobs:
beta:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # required for the comment
steps:
- uses: actions/checkout@v7
- run: ./gradlew assembleRelease
- uses: betadrop-app/upload-action@v1
with:
file: app/build/outputs/apk/release/*.apk
token: ${{ secrets.BETADROP_TOKEN }}
comment-on-pr: trueEvery later push to the branch edits that same comment rather than adding another, so a twenty-commit PR ends with one comment holding the current build — not twenty holding stale ones.
If the comment cannot be posted (usually a missing pull-requests: write), the step says so as a
warning and the run stays green: the build published, and the link is still an output and still on
the run summary. On an event with no pull request — a push to main, a manual dispatch — the
comment is skipped silently.
Prefer to build the comment yourself? The link is a normal step output:
- name: Publish to BetaDrop
id: betadrop
uses: betadrop-app/upload-action@v1
with:
file: "*.apk"
token: ${{ secrets.BETADROP_TOKEN }}
- run: echo "Install at ${{ steps.betadrop.outputs.install-url }}"| Input | Required | Default | Description |
|---|---|---|---|
file |
yes | — | Path to the .ipa/.apk. Globs allowed; must match exactly one file. |
token |
yes | — | A BetaDrop API token (bd_live_…). Create one under Settings → Developer → API tokens at betadrop.app, and store it as a repository secret. |
name |
no | — | Override the build name shown on the install page. |
notes |
no | — | Release notes for this build — e.g. the commit message. |
standing-link |
no | — | A standing link slug to point at this build once it is live — e.g. acmebeta. That one URL, and any "Download from BetaDrop" button embedded with it, then serves this build with nothing to click. Pro and Studio plans; claim the name first. A name that cannot be used fails the step before anything is uploaded. Needs @betadrop/cli 0.3.0+ — the step verifies this before uploading. |
channel |
no | — | Deprecated alias for standing-link — the same feature under its previous name. Permanently accepted, so a workflow already pinning it keeps working. If both are set, standing-link wins. |
expires-in-days |
no | — | Stop the install link working after this many days. Clamped to your plan's maximum; the log says when. Needs @betadrop/cli 0.4.0+. |
max-downloads |
no | — | Stop it after this many downloads. Needs @betadrop/cli 0.4.0+. |
max-devices |
no | — | Stop it after this many different devices. Set more than one limit and the link stops at whichever comes first. Needs @betadrop/cli 0.4.0+. |
inspect |
no | false |
Check the build will install from a link before uploading it, and fail the step with nothing uploaded if it will not. Needs @betadrop/cli 0.4.0+. |
comment-on-pr |
no | false |
Post the install link as a comment on the pull request, editing that same comment on later pushes. Needs pull-requests: write. Skipped on events with no PR. |
github-token |
no | ${{ github.token }} |
Token used for that comment. Override only if it should come from another account. |
cli-version |
no | latest |
Version of @betadrop/cli to use. Pin for reproducible pipelines. |
| Output | Description |
|---|---|
install-url |
The OTA install link for the uploaded build. |
standing-link-url |
The standing link's URL when standing-link (or channel) was set; empty otherwise. |
channel-url |
Deprecated alias for standing-link-url. Same value, kept so existing workflows keep working. |
A new build means a new install link, which means re-sending it. A standing link is a fixed URL you re-point at each new build — hand it out once and stop re-sending:
- name: Publish to BetaDrop
id: betadrop
uses: betadrop-app/upload-action@v1
with:
file: app/build/outputs/apk/release/*.apk
token: ${{ secrets.BETADROP_TOKEN }}
standing-link: acmebeta
- run: echo "Testers keep using ${{ steps.betadrop.outputs.standing-link-url }}"If the build uploads but the standing link could not be moved, the step fails — a pipeline that asked for one must not go green when it did not move.
standing-link needs @betadrop/cli 0.3.0 or
newer. The step asks the installed CLI which spelling it understands and uses that, so a pinned
0.3.0 keeps working unchanged; if the version is older than either, the step names it rather than
surfacing commander's noise. Every other input works on any published version.
The expensive signing mistakes surface late: a build exported with an App Store profile, a profile
that expired last week, or an APK Android Studio marked testOnly all upload fine and then fail on a
tester's phone. inspect: true reads the build first and fails the step — with nothing uploaded —
when it would not install from a link:
- uses: betadrop-app/upload-action@v1
with:
file: build/*.ipa
token: ${{ secrets.BETADROP_TOKEN }}
inspect: true
expires-in-days: 14The run log lists each check. It covers the provisioning profile's type and expiry, a profile issued
for a different bundle ID, unsigned and testOnly APKs, and v1-only signatures on apps targeting
Android 11 or later.
Webhooks live on your account rather than in the workflow, so every upload path — this action, the CLI, the dashboard — posts to the same place. Add one once:
npx @betadrop/cli webhooks add https://hooks.slack.com/services/T000/B000/XXXXDiscord webhook URLs work the same way, and any other URL receives signed JSON. See https://betadrop.app/docs/webhooks/.
Wrap the standing link in the "Download from BetaDrop" badge and paste it in your README — one
Markdown image link, no script. With standing-link: set on the step above, every merge updates
what the button installs and the README never changes.
[](https://betadrop.app/install/acmebeta?ref=badge)Copy the exact snippet (dark or light, HTML or Markdown) from the embed panel under any build's install link. Details and guidelines: https://betadrop.app/download-button/.
cli-version defaults to latest, which means a green pipeline can change behaviour on a day you
did not touch it. Pin it once you are past setup:
- uses: betadrop-app/upload-action@v1
with:
file: "*.ipa"
token: ${{ secrets.BETADROP_TOKEN }}
cli-version: "0.4.0"the installed @betadrop/cli … supports neither --standing-link nor --channel — the version
that resolved is older than 0.3.0, which is where the flag landed. Set cli-version to 0.3.0 or
newer.
The PR comment never appears — check the run log for the warning. The usual cause is the job
lacking permissions: pull-requests: write; the other is an event with no pull request, which is
reported as a notice. Forked-PR runs get a read-only token by default and cannot comment at all.
No file matches '…' — the glob ran before the build wrote anything, or wrote somewhere else.
Add a run: ls -R step before this one to see what the build actually produced. A pattern that
resolves only to directories counts as no match.
'…' matches N files — deliberate. Publishing an arbitrary one of several builds is worse than
failing, so narrow the pattern until it names one.
Publish did not end with an install URL — the CLI printed something after the link, which
breaks the contract this action reads. Report it; the last line of the run log is the useful detail.
It downloads on the phone but will not open (iOS) — see the note about UDIDs below.
- Works on
ubuntu,macos, andwindowsrunners (Node is preinstalled on all GitHub-hosted images) — this repo's own selftest runs the shipped script on all three. - A glob matching more than one file fails the step rather than uploading an arbitrary build — narrow the pattern.
- Pass
tokenas a repository secret. A token pasted literally into a workflow is registered as a mask by the step, but it is still committed to your repository in clear. - Build links follow your account's retention settings; see pricing.
- An ad-hoc iOS build uploaded from CI still only installs on registered devices. If a tester reports "it downloads but won't open", their UDID was not in the provisioning profile when the IPA was signed. Send them the UDID checker — it reads the UDID off the iPhone itself, so they don't need a Mac or a cable — then add the device in the Apple Developer portal and re-sign.
- This action is a thin wrapper over the same
@betadrop/cliyou'd use locally, so CI behaves exactly like your machine.
The same publish step, on the other two surfaces — one account, one set of API tokens:
@betadrop/clisource |
betadrop publish app.ipa from your terminal. Prints the install link and a scannable QR code. |
@betadrop/mcpsource |
MCP server — publish builds, list history and manage tokens by asking Claude, Cursor or Copilot, without leaving the editor. |
Issues and pull requests are welcome — see SECURITY.md for reporting a vulnerability privately. If this saved you a TestFlight round-trip, a ⭐ helps other mobile teams find it.
MIT