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 OTAinstall 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.

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

    Install @betadrop/cli in the job, run betadrop publish on the .apk or .ipa the job just built with the --ci flag, and append 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.

A packaged one-step Action is being prepared for the GitHub Marketplace. Until it ships, the CLI is the supported path — and as the workflows below show, it is only a three-line step.

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 — and 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, and Xcode 26 refuses 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 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

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 printed by betadrop publish --ci is the install page: 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. Producing an .ipa requires Xcode, which 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 distributes .apk and .ipa files and does not re-sign builds, so 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 30 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.

iMobile Designs
Developed by iMobile Designs
Made with
in India