Skip to content

Distribute iOS & Android builds from GitHub Actions

Your CI already builds the app on every push. The step it is missing is the one that puts that build on a phone. Add a publish step with the BetaDrop CLI and the .apk or .ipa the job just produced becomes an OTA install link. Testers open it on their phone (Safari on iOS) and tap install. No cables, no tester accounts, no store upload, no review wait.

Wire that link into a pull request comment and every PR carries its own installable build. The install page includes a QR code, so a link dropped in Slack is enough for someone to scan straight off a laptop screen.

From a workflow file to an install link, in four steps

  1. Keep the build step you already have. It ends with an .apk from ./gradlew assembleRelease, or an .ipa from xcodebuild -exportArchive.
  2. Add a publish step straight after it: uses: betadrop-app/upload-action@v1, with the artifact path in file and token: ${{ secrets.BETADROP_TOKEN }}. What the Action does.
  3. Give the step an id and read steps.<id>.outputs.install-url in a later step: a pull request comment, the job summary, or a Slack message. The output.
  4. A tester opens that link on their phone, in Safari on iOS, and taps install. No store submission and no tester account.

The short version: one step

The packaged Action collapses the whole publish block into a single step. It installs the CLI, resolves the artifact path, uploads the build, exposes the link as a step output, and writes it to the run summary. A green build ends with a tester-ready link on the run page without any further wiring.

.github/workflows/android-beta.yml
name: Android beta

on:
  push:
    branches: [main]

jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"

      - name: Build release APK
        run: ./gradlew assembleRelease

      - name: Publish to BetaDrop
        id: 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 }}

Two inputs are required: file, the path to the .apk or .ipa, and token. Everything else is optional: name and notes label the build (the commit message is the obvious choice for notes), comment-on-pr posts the link on the pull request, standing-link re-points a standing link at the new build, expires-in-days, max-downloads and max-devices limit the link, inspect checks the build will install before uploading it, and cli-version pins the underlying @betadrop/cli version for reproducible pipelines. The step's main output is install-url (plus standing-link-url when a standing link is set), which the patterns below consume unchanged.

file accepts a glob, and it must match exactly one file. A pattern matching two builds fails the step rather than uploading whichever one the shell happened to list first. Narrow the glob instead of trusting ordering. Paths containing spaces are fine, which matters more on the iOS side than it sounds: build/export/My App.ipa is an ordinary Xcode output.

Pin it to @v1 as shown. That tag moves forward with backwards-compatible releases; @main is not a supported reference and nothing guarantees it is releasable at any given moment. The Action is a thin wrapper over the same BetaDrop CLI you would run locally. One upload client, so CI behaves the way your machine does. It is published on the GitHub Marketplace as BetaDrop Upload, and the source is at github.com/betadrop-app/upload-action — one YAML file, MIT, and the place to open an issue. A star there is how other teams find it instead of hand-rolling the same step.

Everything below calls the CLI directly instead. Those workflows still work and are the better choice when you want the publish step under your own control: a conditional upload, a custom build name computed in an earlier step, or a runner where you would rather not install a global npm package. The Action is the short path, not the only one.

Set it up in four steps

  1. 1

    Create a CLI token

    Log in at betadrop.app and open Settings → Developer → API tokens. Create a token. It is shown once, so copy it right away.

  2. 2

    Store it as a repository secret

    In your GitHub repository, go to Settings → Secrets and variables → Actions → New repository secret. Name it BETADROP_TOKEN and paste the token. Never write the token into the workflow file itself.

  3. 3

    Add a publish step after your build step

    The short path is the packaged Action: uses: betadrop-app/upload-action@v1, with the path to the .apk or .ipa in the file input and the secret in the token input. It installs the CLI, uploads the build, sets the install-url output, and writes the link to the run summary. To keep the step under your own control, install @betadrop/cli in the job instead and run betadrop publish on the artifact with the --ci flag, appending the printed URL to $GITHUB_OUTPUT as install-url.

  4. 4

    Consume the install-url output

    Reference steps.betadrop.outputs.install-url in any later step: post it as a pull request comment, append it to the job summary, or send it to a Slack webhook.

Steps three and four collapse into one if you use the packaged Action, which handles the install, the upload, and the output for you. The first two steps are the same either way. The token has to exist and live in a secret.

Keep the token in a repository secret

Tokens come from betadrop.app under Settings → Developer → API tokens; the same screen revokes them, so a leaked token is a thirty-second fix, not an incident. In a workflow, the token belongs in one place only: a repository secret named BETADROP_TOKEN, exposed to the publish step through env. In CI, betadrop publish reads it from the environment directly. No betadrop login step needed.

Never paste the token into the workflow file

A token inlined in YAML lands in git history and every fork of the repository. As a secret, GitHub masks it in logs. Note that pull request runs from forks do not receive secrets at all, by design, so a fork PR's publish step will fail rather than leak. Gate publishing on push or same-repo PRs if forks are common in your project.

Android: from push to install link

The complete workflow: checkout, Java 17, Gradle build, publish, and a following step that consumes the output. Note it runs assembleRelease, not bundleRelease: testers install an .apk, and BetaDrop does not accept .aab files, which are a Play Store packaging format rather than something a phone can install directly.

.github/workflows/android-beta.yml
name: Android beta

on:
  push:
    branches: [main]

jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"

      - name: Build release APK
        run: ./gradlew assembleRelease

      - name: Publish to BetaDrop
        id: betadrop
        run: |
          npm install -g @betadrop/cli
          url="$(betadrop publish app/build/outputs/apk/release/*.apk --ci)"
          echo "install-url=$url" >> "$GITHUB_OUTPUT"
        env:
          BETADROP_TOKEN: ${{ secrets.BETADROP_TOKEN }}

      - name: Add the install link to the job summary
        run: |
          echo "### Install this build" >> "$GITHUB_STEP_SUMMARY"
          echo "${{ steps.betadrop.outputs.install-url }}" >> "$GITHUB_STEP_SUMMARY"

If your release build is not signed in CI yet, publish assembleDebug output to testers in the meantime. Android installs debug builds fine, and it beats blocking distribution on release-keystore plumbing. Adjust the artifact path if you use flavors: app/build/outputs/apk/<flavor>/release/*.apk.

iOS: a signed ad-hoc IPA on macos-latest

There is no way around Apple signing in CI, and any tutorial that skips it is papering over the part that will actually break. Before this workflow can run you need two things: your Apple distribution certificate (a .p12 plus its password) and an ad-hoc provisioning profile that lists the UDID of every tester device. Base64-encode both into repository secrets as shown below, or let fastlane match manage them in an encrypted repo. Either works; what does not work is hoping the runner signs the build for you.

Commit an ExportOptions.plist with method: release-testing next to the project. That is Apple's current name for ad-hoc distribution; Xcode 15.4 deprecated the old ad-hoc value. When an exported build later refuses to install, decode the profile with the provisioning profile decoder to see exactly which devices it covers, and collect missing UDIDs with the UDID checker.

.github/workflows/ios-beta.yml
name: iOS beta

on:
  push:
    branches: [main]

jobs:
  ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - name: Import the signing certificate and ad-hoc profile
        env:
          CERT_P12_BASE64: ${{ secrets.IOS_CERT_P12_BASE64 }}
          CERT_PASSWORD: ${{ secrets.IOS_CERT_PASSWORD }}
          PROFILE_BASE64: ${{ secrets.IOS_ADHOC_PROFILE_BASE64 }}
        run: |
          security create-keychain -p ci-temp build.keychain
          security set-keychain-settings -lut 21600 build.keychain
          security unlock-keychain -p ci-temp build.keychain
          echo "$CERT_P12_BASE64" | base64 --decode > cert.p12
          security import cert.p12 -k build.keychain -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -T /usr/bin/codesign
          security set-key-partition-list -S apple-tool:,apple: -s -k ci-temp build.keychain
          # list-keychains, not default-keychain: codesign resolves an identity from the SEARCH
          # LIST, and creating a keychain does not add it there. Without this line the import
          # succeeds and the archive still fails with "No signing certificate found".
          security list-keychains -d user -s build.keychain $(security list-keychains -d user | tr -d '"')
          # Xcode 16 moved the directory it reads profiles from. Write both so the snippet works
          # on whatever the runner image ships this month.
          for d in "$HOME/Library/MobileDevice/Provisioning Profiles" \
                   "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"; do
            mkdir -p "$d"
            echo "$PROFILE_BASE64" | base64 --decode > "$d/adhoc.mobileprovision"
          done

      - name: Archive and export a signed IPA
        run: |
          # -workspace when you have one (every CocoaPods project does). With both a .xcodeproj
          # and a .xcworkspace present, xcodebuild will not guess.
          xcodebuild archive -workspace MyApp.xcworkspace -scheme MyApp -configuration Release \
            -destination 'generic/platform=iOS' -archivePath build/MyApp.xcarchive
          xcodebuild -exportArchive -archivePath build/MyApp.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath build/export

      - name: Publish to BetaDrop
        id: betadrop
        run: |
          npm install -g @betadrop/cli
          url="$(betadrop publish build/export/*.ipa --ci)"
          echo "install-url=$url" >> "$GITHUB_OUTPUT"
        env:
          BETADROP_TOKEN: ${{ secrets.BETADROP_TOKEN }}

      - name: Add the install link to the job summary
        run: |
          echo "### Install this build (registered devices only)" >> "$GITHUB_STEP_SUMMARY"
          echo "${{ steps.betadrop.outputs.install-url }}" >> "$GITHUB_STEP_SUMMARY"

BetaDrop generates the itms-services manifest automatically, so the printed URL is tap-to-install in Safari. The signing rules still apply on the tester's end: an ad-hoc build installs only on devices in the profile, and an enterprise-signed build needs the one-time trust step.

Do something with the install-url output

A link that only exists in the job log helps nobody. The publish step above exposes it as steps.betadrop.outputs.install-url; both workflows already end by appending it to the job summary, which puts a clickable link on the run page. Two more patterns cover most teams.

Comment on the pull request

The highest-leverage option: every PR gets a comment with a build reviewers can hold in their hand before approving.

The workflows on this page call the CLI directly, so the comment is a step you write — below. If you use the packaged Action instead, it is one input: comment-on-pr: true, which also edits that same comment on every later push rather than adding another. A twenty-commit PR ends with one comment holding the current build. Write it yourself when you want control over the wording.

# The job needs: permissions: pull-requests: write
- name: Comment the install link on the PR
  if: github.event_name == 'pull_request'
  uses: actions/github-script@v7
  with:
    script: |
      github.rest.issues.createComment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: context.issue.number,
        body: 'Install this build on a device: ${{ steps.betadrop.outputs.install-url }}',
      });

Post to Slack

One curl against an incoming-webhook URL. Store that URL as a secret too.

- name: Post the link to Slack
  run: >
    curl -sf -X POST -H 'Content-type: application/json'
    --data '{"text":"New beta build ready: ${{ steps.betadrop.outputs.install-url }}"}'
    "$SLACK_WEBHOOK_URL"
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Both platforms in one workflow

A matrix runs the Android and iOS legs in parallel on their own runners and reuses a single publish step. The --name flag labels each build so the dashboard reads “MyApp ios #142” instead of two identical rows; --notes accepts release notes the same way.

.github/workflows/beta-builds.yml
name: Beta builds

on:
  workflow_dispatch:

jobs:
  beta:
    strategy:
      matrix:
        include:
          - platform: android
            os: ubuntu-latest
            artifact: app/build/outputs/apk/release/*.apk
          - platform: ios
            os: macos-latest
            artifact: build/export/*.ipa
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        if: matrix.platform == 'android'
        with:
          distribution: temurin
          java-version: "17"

      - name: Build Android
        if: matrix.platform == 'android'
        run: ./gradlew assembleRelease

      # Spelled out rather than cross-referenced: this block carries a filename, so it has to
      # run as pasted. Without the import the iOS leg dies at archive time with "No signing
      # certificate found" — the same step as the iOS workflow above, gated on the matrix leg.
      - name: Import the signing certificate and ad-hoc profile
        if: matrix.platform == 'ios'
        env:
          CERT_P12_BASE64: ${{ secrets.IOS_CERT_P12_BASE64 }}
          CERT_PASSWORD: ${{ secrets.IOS_CERT_PASSWORD }}
          PROFILE_BASE64: ${{ secrets.IOS_ADHOC_PROFILE_BASE64 }}
        run: |
          security create-keychain -p ci-temp build.keychain
          security set-keychain-settings -lut 21600 build.keychain
          security unlock-keychain -p ci-temp build.keychain
          echo "$CERT_P12_BASE64" | base64 --decode > cert.p12
          security import cert.p12 -k build.keychain -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -T /usr/bin/codesign
          security set-key-partition-list -S apple-tool:,apple: -s -k ci-temp build.keychain
          security list-keychains -d user -s build.keychain $(security list-keychains -d user | tr -d '"')
          for d in "$HOME/Library/MobileDevice/Provisioning Profiles" \
                   "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"; do
            mkdir -p "$d"
            echo "$PROFILE_BASE64" | base64 --decode > "$d/adhoc.mobileprovision"
          done

      - name: Build iOS
        if: matrix.platform == 'ios'
        run: |
          xcodebuild archive -workspace MyApp.xcworkspace -scheme MyApp -configuration Release \
            -destination 'generic/platform=iOS' -archivePath build/MyApp.xcarchive
          xcodebuild -exportArchive -archivePath build/MyApp.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath build/export

      - name: Publish to BetaDrop
        id: betadrop
        run: |
          npm install -g @betadrop/cli
          url="$(betadrop publish ${{ matrix.artifact }} --ci --name "MyApp ${{ matrix.platform }} #${{ github.run_number }}")"
          echo "install-url=$url" >> "$GITHUB_OUTPUT"
        env:
          BETADROP_TOKEN: ${{ secrets.BETADROP_TOKEN }}

The iOS leg needs the same three secrets as the standalone iOS workflow and the same ExportOptions.plist with method: release-testing committed next to the project. The Android leg needs neither, which is why every iOS-only step is gated on matrix.platform.

When you don't need this

  • You ship a build every week or two, alone. CI automation pays off with frequency. If publishing is occasional, dragging the file onto the IPA upload page or the APK upload page is genuinely faster than maintaining YAML. Start there and automate when it becomes routine.
  • The build is heading to the App Store anyway. If external testers must go through the store pipeline for a release candidate, TestFlight is the right tool: you need App Store review regardless, so the store beta rail costs you nothing extra. BetaDrop covers the loop before that; the TestFlight comparison draws the line honestly.
  • Nobody needs the build on a physical phone. If reviewers only check code and CI status, a workflow artifact or the simulator is enough. Distribution is for when a human has to hold the build.

Frequently asked questions

Should I use the BetaDrop Action or call the CLI directly?

Either one. They publish identically, because the Action is a thin wrapper around the same @betadrop/cli. Reach for the Action, published on the GitHub Marketplace as BetaDrop Upload (uses: betadrop-app/upload-action@v1), when you want the shortest diff to your workflow: it installs the CLI, resolves the artifact path, uploads, exposes the install-url output, and adds the link to the run summary in one step. Call the CLI directly when you want that step under your own control — publishing conditionally, using a build name computed earlier in the job, or working on a runner where you would rather not install a global npm package.

Which version of the Action should I pin to?

Pin betadrop-app/upload-action to @v1, a moving tag that advances only with backwards-compatible releases — you get fixes without editing your workflow, and it is what the examples use. Pin to the exact release — @v1.4.0 at the time of writing (September 2026) — if you want a fully reproducible pipeline and are willing to bump it yourself. Do not reference @main: it is not a supported reference and carries no guarantee of being releasable at any given moment. The same reasoning applies to the CLI itself, which the cli-version input pins if you need it.

How do I authenticate the BetaDrop CLI in GitHub Actions?

Create a token under Settings → Developer → API tokens on betadrop.app and save it as a repository secret named BETADROP_TOKEN. In CI, betadrop publish reads that token straight from the environment, so there is no interactive login step — expose the secret as an env var on the publish step and you are done.

Can testers install the build directly from the link the workflow prints?

Yes. The URL the workflow prints is the install page itself: a tester opens it on their phone and taps install, or scans the QR code it carries. Android sideloads the .apk directly. On iOS the link has to be opened in Safari — Chrome and in-app browsers such as Slack or Gmail silently swallow the itms-services install prompt — and Apple signing still applies: an ad-hoc build installs only on devices whose UDID is in the provisioning profile, and an enterprise build needs the one-time trust step.

Can I build and upload an IPA from GitHub Actions without a Mac?

No. Building an .ipa in GitHub Actions requires a macOS runner, because producing one requires Xcode; that is why the iOS job runs on macos-latest. BetaDrop only distributes the signed build your workflow exports — it does not build, sign, or re-sign anything for you, and no distribution service can remove that requirement.

Does betadrop publish accept .aab files from CI?

No, betadrop publish rejects .aab files: BetaDrop distributes .apk and .ipa, does not re-sign builds, and an .aab has nothing installable in it for testers. Run assembleRelease for the tester build and keep bundleRelease for the Play Store upload — the two Gradle tasks coexist in the same workflow without conflict.

How big can a CI build be, and how long does the install link last?

Free accounts upload builds up to 500 MB per file. A link starts at the 3 days default and a free account can extend it to 7 days; Pro pushes retention to a year. For a per-commit CI stream the default is usually right — old builds expiring is a feature, not a bug.

Is this a TestFlight alternative for CI builds?

For ad-hoc beta distribution, yes: the install link comes straight off the runner, with no store upload, no review wait, and no tester accounts. If a build is heading to the App Store anyway, TestFlight fits that release pipeline — BetaDrop covers the faster loop before it: per-PR builds, QA on real devices, and client demos.

Ship the next build from CI

Create a token, add the secret, paste the publish step. The next push distributes itself.