Distribute an APK or IPA to testers from Bitrise

Bitrise already builds and signs your app. Deploying that build to a device is the hop after the green checkmark, and it is a single Script Step — about eight lines — dropped in directly after Android Build or Xcode Archive & Export for iOS.

The Step installs the BetaDrop CLI and publishes whatever artifact the previous Step exported. Back comes an OTA install link: a URL a tester opens on their phone to download and install the app straight from the web, with no cable, no store, and no review queue. The page carries a QR code, so a link pasted in Slack is enough to scan off a laptop screen.

Testers need no BetaDrop account and no Bitrise seat. They tap the link and install. Bitrise has its own install page as well, and the comparison further down is honest about when you should just use it.

The whole integration is one Script Step

Here it is in full. Paste it into bitrise.yml, or add a Script Step in the Workflow Editor and drop the script body into the Content field.

bitrise.yml
- script@1:
    title: Publish to BetaDrop
    inputs:
    - content: |-
        #!/usr/bin/env bash
        set -euo pipefail

        npm install -g @betadrop/cli

        url="$(betadrop publish "$BITRISE_SIGNED_APK_PATH" --ci \
          --name "$BITRISE_APP_TITLE #$BITRISE_BUILD_NUMBER" \
          --notes "$BITRISE_GIT_MESSAGE")"

        # Readable from the NEXT Step onward, never from this one.
        envman add --key BETADROP_INSTALL_URL --value "$url"
        echo "Install this build: $url"

Three things in there are worth knowing rather than copying blindly.

--ci makes the CLI print exactly one line on standard output: the install URL. No progress bar, no QR block, no summary. That is what lets url="$(betadrop publish …)" capture it without parsing anything. Warnings go to stderr, where they belong.

envman add is how a Bitrise Step hands a value to later Steps. Its values take effect from the next Step onward, so the variable you just set is not readable in the Step that set it. That trips people up roughly once each.

The quotes around "$BITRISE_SIGNED_APK_PATH" are load-bearing: a module or product name containing a space turns an unquoted path into two arguments, and the CLI takes exactly one file.

Where Bitrise leaves the binary

You never hardcode an output directory on Bitrise. Each build Step exports the path it wrote, and the next Step reads it. These are the ones that matter for distribution, straight from each Step's own step.yml, checked August 2026.

Bitrise output environment variables for build artifacts: the variable, the Step that exports it, and what it contains.
VariableExported byWhat it holds
$BITRISE_IPA_PATHXcode Archive & Export for iOSLocal path of the created .ipa file. One path.
$BITRISE_APK_PATHAndroid BuildPath of the generated APK. Re-exported by Android Sign as pipe-separated path(s).
$BITRISE_SIGNED_APK_PATHAndroid SignPath of the signed APK. If the build makes several, this is the last one. Publish this.
$BITRISE_AAB_PATHAndroid BuildPath of the generated AAB. BetaDrop rejects .aab files, so never pass this.
$BITRISE_APP_TITLEBitrise CIYour project's title on bitrise.io. Good raw material for --name.
$BITRISE_GIT_MESSAGEBitrise CICommit message, PR title, or the message you typed when triggering by hand. Feed it to --notes.

Read the second and third rows together — that pair is the one real trap here. Android Sign re-exports $BITRISE_APK_PATH as path(s) joined with pipe characters when a build produces more than one APK, and any tool expecting a filename then fails on something that looks almost right. $BITRISE_SIGNED_APK_PATH is documented as a single path, so that is what every example here publishes. No signing Step at all? Publish $BITRISE_APK_PATH and keep the build to one variant.

Add BETADROP_TOKEN as a 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 rather than an incident. In Bitrise it belongs on the Secrets tab of the Workflow Editor, keyed BETADROP_TOKEN. Nothing else is needed: betadrop publish reads it from the environment, so there is no login Step in any of the workflows below.

Secrets, not App Environment Variables

Bitrise redacts Secret values in the build log and prints [REDACTED] in their place; an ordinary env var is printed as typed, and set -x in a Script Step will echo it. Bitrise also withholds Secrets from pull request builds unless you switch on Expose for pull requests on that Secret, so a PR-triggered Workflow fails at the publish Step instead of handing a token to a fork.

Set it up in five steps

  1. 1

    Create an API token

    Log in at betadrop.app and open Settings → Developer → API tokens. Create a token and copy it straight away, because it is shown once and never again.

  2. 2

    Store it as a Bitrise Secret

    In the Bitrise Workflow Editor, open the Secrets tab and add a Secret named BETADROP_TOKEN with the token as its value. Leave the 'Expose for pull requests' toggle off unless you specifically want PR-triggered builds to publish.

  3. 3

    Add a Script Step after the build Step

    Drop a Script Step into the Workflow directly after Android Build, Android Sign, or Xcode Archive & Export for iOS. Its script installs @betadrop/cli with npm and then calls betadrop publish.

  4. 4

    Point the command at the right output variable

    Pass $BITRISE_SIGNED_APK_PATH, $BITRISE_APK_PATH or $BITRISE_IPA_PATH depending on which Step ran last. Add --ci so standard output is nothing but the install URL, and label the build with --name and --notes.

  5. 5

    Export the URL for later Steps

    Run envman add --key BETADROP_INSTALL_URL --value "$url" so any Step after this one can read the link. envman values take effect from the next Step onward and are never visible inside the Step that set them.

The Android workflow, end to end

Clone, build, sign, publish. Note build_type: apk on the Android Build Step: the alternative is aab, and an app bundle is a Play Store packaging format with nothing in it a phone can install. Keep a second Workflow building the .aab for the store if you need one.

bitrise.yml
workflows:
  android-beta:
    steps:
    - git-clone@8: {}

    - install-missing-android-tools@3:
        inputs:
        - gradlew_path: "$PROJECT_LOCATION/gradlew"

    - android-build@1:
        inputs:
        - project_location: "$PROJECT_LOCATION"
        - module: "$MODULE"
        - variant: "$VARIANT"
        # apk, not aab. A phone cannot install an .aab.
        - build_type: apk

    - sign-apk@2:
        inputs:
        - android_app: "$BITRISE_APK_PATH"

    - script@1:
        title: Publish to BetaDrop
        inputs:
        - content: |-
            #!/usr/bin/env bash
            set -euo pipefail
            npm install -g @betadrop/cli
            url="$(betadrop publish "$BITRISE_SIGNED_APK_PATH" --ci \
              --name "$BITRISE_APP_TITLE #$BITRISE_BUILD_NUMBER" \
              --notes "$BITRISE_GIT_MESSAGE")"
            envman add --key BETADROP_INSTALL_URL --value "$url"
            echo "Install this build: $url"

    - deploy-to-bitrise-io@2: {}

Step versions here are the ones these examples were tested against; leave whatever your bitrise.yml already pins. No release keystore on Bitrise yet? Drop the Android Sign Step, publish $BITRISE_APK_PATH, and ship a debug build in the meantime — Android installs those fine, and it beats blocking distribution on keystore plumbing for a week.

Want to check the artifact before wiring any of this up? Drag it onto the APK upload page and watch one build travel the whole path first.

The iOS workflow, end to end

Signing is the part that actually breaks, so it is worth naming the pieces. Ad hoc distribution is Apple's term for a signed build that installs outside the App Store. A provisioning profile is the Apple-issued file that lists which devices such a build may install on, identified by UDID — the unique identifier of an iOS device, 25 characters on current hardware and 40 on older models. Miss a device from the profile and that tester's install fails, no matter which service hosts the file.

Bitrise handles the certificates and profiles for you when you connect an App Store Connect API key and set automatic_code_signing: api-key.

bitrise.yml
workflows:
  ios-beta:
    steps:
    - git-clone@8: {}

    - xcode-archive@6:
        inputs:
        - project_path: "$BITRISE_PROJECT_PATH"
        - scheme: "$BITRISE_SCHEME"
        # The Step's own value_options are development / app-store / ad-hoc /
        # enterprise; it maps them to Xcode's newer names for you.
        - distribution_method: ad-hoc
        - automatic_code_signing: api-key

    - script@1:
        title: Publish to BetaDrop
        inputs:
        - content: |-
            #!/usr/bin/env bash
            set -euo pipefail
            npm install -g @betadrop/cli
            url="$(betadrop publish "$BITRISE_IPA_PATH" --ci \
              --name "$BITRISE_APP_TITLE #$BITRISE_BUILD_NUMBER" \
              --notes "$BITRISE_GIT_MESSAGE")"
            envman add --key BETADROP_INSTALL_URL --value "$url"
            echo "Install this build: $url"

    - deploy-to-bitrise-io@2: {}

distribution_method: ad-hoc is correct as written. Apple deprecated the old export-method names in Xcode 15.4 and Xcode 26 rejects them outright, but the Bitrise Step keeps its own option names and maps them across, so you write ad-hoc here even though a hand-written ExportOptions.plist now wants release-testing.

BetaDrop generates the itms-services manifest, so the printed URL is tap-to-install in Safari. Apple's rules are unchanged by that: an ad hoc build installs only on devices in the profile, and an enterprise-signed build still needs the one-time trust step. Collect missing device identifiers with the UDID checker before you regenerate, or test the export by hand on the IPA upload page.

In three places, and only the first is automatic.

In the build log

The echo line prints it in the Script Step's output, so it is one click from the Bitrise build page. Fine for you, useless for anyone who does not open build logs. Every build also lands on your BetaDrop dashboard with its own link.

As an env var for later Steps

envman add put it in $BETADROP_INSTALL_URL. Any Step after the publish Step can read it, which is what makes the next pattern work.

In the channel testers watch

A build nobody can find is a build nobody tests. One more Script Step and a webhook URL stored as a Secret:

bitrise.yml
- script@1:
    title: Announce the build
    inputs:
    - content: |-
        #!/usr/bin/env bash
        set -euo pipefail
        curl -sf -X POST -H 'Content-type: application/json' \
          --data "{\"text\":\"New beta build: $BETADROP_INSTALL_URL\"}" \
          "$SLACK_WEBHOOK_URL"

Bitrise already has a way to do this

It has two, and you should know what they do before adding anything.

The Deploy to Bitrise.io Step carries an input called Enable public page for the App?, and it defaults to true. With it on, Bitrise publishes the artifact to a public install page that anyone holding the URL can install from without a Bitrise account. Bitrise documents that page as permanent and without access controls, which is either exactly what you want or exactly what you don't.

Release Management is the layer above it, and it is a real product rather than a checkbox: tester distribution and store submission from one dashboard, plus shareable install links that take an optional expiry and an optional access code. Its rules match Apple's and Google's instead of routing around them — iOS builds have to carry development, ad hoc, or enterprise provisioning, and on Android only APKs go to testers. That is the same line BetaDrop draws, because it is the platforms' line and nobody gets to move it.

So the honest case for BetaDrop here is narrow: portability. betadrop publish is the same command on a laptop, in a GitHub Actions workflow and in a Bitrise Script Step, so the install link doesn't change shape when a team builds locally or when you move CI. If everything you ship goes through Bitrise and always will, Release Management is the more coherent product and you should use it. If the build is heading to the App Store anyway, so is TestFlight.

Frequently asked questions

Which Bitrise environment variable should I pass to betadrop publish?

Android Build exports BITRISE_APK_PATH, the Android Sign Step exports BITRISE_SIGNED_APK_PATH, and Xcode Archive & Export for iOS exports BITRISE_IPA_PATH, so pass whichever one the Step immediately before your Script Step produced; BITRISE_SIGNED_APK_PATH and BITRISE_IPA_PATH each hold a single absolute path; BITRISE_APK_PATH holds one path from Android Build, but Android Sign re-exports it as pipe-separated paths when a build produces more than one APK. On Android, prefer the signed variable whenever a signing Step runs, because that is the file a phone will actually accept.

Why does my Bitrise build publish the wrong APK, or fail with a bad path?

Almost always because the Android Sign Step re-exports BITRISE_APK_PATH as a list of paths joined with pipe characters when the build produces more than one APK, and the CLI takes exactly one file, so the whole pipe-joined string arrives as a filename that does not exist. Pass BITRISE_SIGNED_APK_PATH instead, which Bitrise documents as a single path, or narrow the build to one variant.

How do I give Bitrise a BetaDrop token?

Create the token on betadrop.app under Settings → Developer → API tokens, then add it in the Bitrise Workflow Editor on the Secrets tab under the key BETADROP_TOKEN, and the CLI reads it from the environment with no login Step at all. Bitrise redacts Secret values in the build log and prints [REDACTED] in their place, so a token that reaches a log line is not exposed.

Do pull request builds on Bitrise get the token?

Not by default: Bitrise withholds Secret values from pull request builds unless you turn on 'Expose for pull requests' for that Secret, so a PR-triggered Workflow fails at the publish Step rather than handing the token to a fork. That default is the right one for a public repository. On a private repo where every PR comes from a branch you already control, exposing it is a reasonable call.

Can testers install from the link without a Bitrise account?

Yes, and they need no BetaDrop account either: the URL the Script Step prints is a public install page carrying a QR code, and a tester opens it on the phone and taps install. Android sideloads the APK directly. On iOS the page has to be opened in Safari, because the itms-services URL that triggers an over-the-air install is silently ignored by Chrome and by in-app browsers such as Slack.

Does BetaDrop accept the .aab that Android Build can produce?

No: leave build_type set to apk on the Android Build Step for the tester lane, because BetaDrop distributes .apk and .ipa files only and never re-signs anything, while an .aab is a Play Store packaging format with nothing installable in it for a phone. Keep a separate aab build for the store upload — the two build types coexist in one bitrise.yml without conflict.

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

Free accounts publish builds up to 500 MB per file, a link opens at the 3 days default and a free account can push that to 7 days, while Pro extends retention to a year. For a per-commit Bitrise stream the default is usually what you want, because a build from four commits ago is not one a tester should still be installing.

Do I still need the Deploy to Bitrise.io Step?

Keep it if you want artifacts, logs and test reports attached to the Bitrise build page, since that Step is what uploads them, and it also creates Bitrise's own public install page while its 'Enable public page for the App?' input stays true. Publishing to BetaDrop is independent of it: the Script Step uploads straight from the runner, so neither Step needs the other to work.

Should I use Bitrise Release Management instead?

If your releases already run through Bitrise end to end and you want tester distribution and store submission in one dashboard, Release Management is the coherent choice and BetaDrop is not trying to replace it. BetaDrop fits when you want the publish step to be portable: the same betadrop publish command runs on a laptop, in GitHub Actions and in a Bitrise Script Step, so the install link never depends on which CI produced the build.

Put a link at the end of your next Bitrise build

Create a token, add the Secret, paste the Script Step. The CLI reference has every flag, the integrations index covers the other pipelines, and the Flutter distribution guide picks it up if that is what Bitrise is building.

iMobile Designs
Developed by iMobile Designs
Made with
in India