Distribute your app to testers from Codemagic

Codemagic already builds the artifact. What it hands you afterwards is a file on a build page, and a file on a build page is not something a tester can install. Add one publish step with the BetaDrop CLI and the same .apk or .ipa becomes an OTA install link instead. OTA is short for over-the-air: the tester opens the link on their phone and the app installs from the browser, with no cable and no store review.

A 50 MB APK is live about thirty seconds after the upload starts, and testers never make an account. The install page carries a QR code, so a link pasted into Slack is enough for someone to scan it off a laptop screen.

A Flutter workflow that ends with an install link

Codemagic is Flutter-first, so this is the shape most readers need. The only new thing is the last step in scripts:. It installs @betadrop/cli, publishes the APK, and writes the returned URL both to the build log and to a file that gets collected as an artifact.

codemagic.yaml
workflows:
  flutter-beta:
    name: Flutter beta
    instance_type: linux_x2
    max_build_duration: 30
    environment:
      groups:
        - betadrop        # holds BETADROP_TOKEN, added in the UI with Secret ticked
      flutter: stable
      node: lts           # the CLI runs on Node; pin it so every build agrees
    triggering:
      events:
        - push
      branch_patterns:
        - pattern: main
    scripts:
      - name: Get Flutter packages
        script: flutter pub get

      - name: Build a release APK
        script: flutter build apk --release --build-number=$BUILD_NUMBER

      - name: Publish to BetaDrop
        script: |
          npm install -g @betadrop/cli
          # --ci prints the install URL and nothing else, so it captures cleanly.
          install_url=$(betadrop publish build/app/outputs/flutter-apk/app-release.apk \
            --ci --name "MyApp $CM_BRANCH #$BUILD_NUMBER")
          echo "$install_url" > "$CM_BUILD_DIR/install-url.txt"
          echo "Testers install from: $install_url"
    artifacts:
      - build/**/outputs/**/*.apk
      - install-url.txt

--ci makes the CLI print the install URL and nothing else, which is what lets install_url=$(...) capture it cleanly rather than pulling a URL out of decorated human-readable output. --name labels the build so your dashboard reads “MyApp main #142” instead of forty identical rows; $CM_BRANCH and $BUILD_NUMBER are both built-in Codemagic variables. Codemagic's macOS images ship Node already (the Xcode 26.0 image lists node 24.7.0 and npm 11.5.1), so the install is a few seconds of network rather than a toolchain setup; node: in the environment block pins the version so every instance type agrees.

Note where the step sits. Steps in scripts: stop running the moment an earlier one fails, so a build that does not compile never publishes anything. That is not true of publishing:, which is the subject of a section further down and the single most common way this integration goes wrong.

Set it up in four steps

  1. 1

    Create an API token

    Log in at betadrop.app and open Settings → Developer → API tokens. Create a token — it is shown once, so copy it before you leave the screen.

  2. 2

    Add it as a secret Codemagic variable

    Open your Codemagic app settings, go to the Environment variables tab, and add a variable named BETADROP_TOKEN. Tick the Secret option so Codemagic encrypts the value and hides it from the UI and the build logs. Every variable added in the Codemagic UI must belong to a group, so give it one — betadrop is a fine name.

  3. 3

    Import the group in codemagic.yaml

    List the group under the workflow's environment section: environment: groups: - betadrop. Until the group is imported there, the variable does not reach the build machine and betadrop publish will fail with an authentication error.

  4. 4

    Publish the artifact as the last build step

    Add a step to the end of the scripts: block that runs npm install -g @betadrop/cli and then betadrop publish on the file your build produced, with the --ci flag so only the install URL is printed. Capture that URL into a shell variable, echo it into the build log, and write it to a file you list under artifacts:.

The same four steps work on any CI that can run Node, which is all of them. The CLI documentation covers the flags in full, and the integrations index has the equivalent recipe for other providers.

Where the token lives

Codemagic has no per-variable inline encryption in the YAML file. Secrets are added in the UI and pulled in by group name, and that indirection is the part people trip over. Add BETADROP_TOKEN under your app settings in the Environment variables tab, tick Secret, and give it a group. Codemagic then encrypts the value and keeps it out of both the UI and the build logs.

Every variable added in the Codemagic UI has to belong to a group, and a group only reaches the build machine if the workflow imports it under environment: groups:. Miss that line and the variable simply is not there. Tokens themselves come from betadrop.app under Settings → Developer → API tokens, which is also where you revoke one. For a whole team, use Global variables and secrets in team settings instead of repeating the variable per app.

Never put the token in codemagic.yaml

codemagic.yaml lives in your repository, so a token written into it lands in git history and in every fork and clone. Rotating it is a thirty-second job from the token screen; scrubbing it from history is not.

Artifact paths for Flutter and native builds

Paths in a Codemagic workflow are relative to $CM_BUILD_DIR, the absolute path of the cloned repository on the builder, so the same string works in scripts: and in artifacts:. These are the ones you actually pass to betadrop publish.

BuildCommandPath to publish
Flutter Androidflutter build apk --releasebuild/app/outputs/flutter-apk/app-release.apk
Flutter iOSflutter build ipa --releasebuild/ios/ipa/*.ipa
Native Android./gradlew assembleReleaseapp/build/outputs/apk/release/*.apk
Native iOSxcode-project build-ipabuild/ios/ipa/*.ipa

The globs Codemagic's own samples use for the artifacts: list are broader: build/**/outputs/**/*.apk for Flutter Android, app/build/outputs/**/*.apk for a native Gradle build. Those are fine for collection and wrong for publishing, because a glob that matches two files leaves the CLI guessing which build your testers get. Narrow it for the publish step.

Do not point it at an .aab. BetaDrop rejects Android App Bundles, and correctly so: a bundle is a Play Store packaging format with nothing installable inside it. Run bundleRelease for the store and assembleRelease for your testers. Both tasks coexist in one workflow. There is more on the Flutter side of this in the Flutter app distribution guide.

The iOS workflow, and the signing type that breaks it

Ad hoc distribution is Apple's name for installing a signed build on a fixed list of devices without going through the App Store. A provisioning profile is the file that authorises it, and it contains the UDID — a unique 25 or 40 character device identifier — of every device allowed to run the build.

So the signing type in ios_signing decides whether any of this works. Codemagic's docs are explicit that a third-party distribution service wants ad_hoc, and that app_store is for TestFlight. Get it wrong and the upload still succeeds; the failure surfaces later, on a tester's phone, as an install that stops without explaining itself.

codemagic.yaml
workflows:
  ios-beta:
    name: iOS beta
    instance_type: mac_mini_m2
    max_build_duration: 60
    environment:
      groups:
        - betadrop
      ios_signing:
        # ad_hoc, not app_store. Codemagic's signing docs say to use ad_hoc with a
        # third-party distribution service; an app_store-signed .ipa uploads fine and
        # then refuses to install anywhere outside TestFlight.
        distribution_type: ad_hoc
        bundle_identifier: com.example.myapp
      flutter: stable
    scripts:
      - name: Get Flutter packages
        script: flutter pub get

      - name: Install pods
        script: find . -name "Podfile" -execdir pod install \;

      - name: Set up code signing settings on Xcode project
        script: xcode-project use-profiles

      - name: Build the IPA
        script: |
          flutter build ipa --release \
            --build-number=$BUILD_NUMBER \
            --export-options-plist=/Users/builder/export_options.plist

      - name: Publish to BetaDrop
        script: |
          npm install -g @betadrop/cli
          install_url=$(betadrop publish build/ios/ipa/*.ipa --ci)
          echo "$install_url" > "$CM_BUILD_DIR/install-url.txt"
          echo "Testers install from: $install_url"
    artifacts:
      - build/ios/ipa/*.ipa
      - install-url.txt

xcode-project use-profiles is a Codemagic CLI tool that applies the fetched profiles to the Xcode project and writes the export options plist that flutter build ipa reads, so you never hand-maintain one. When an exported build refuses to install, decode the profile with the provisioning profile decoder to see which devices it actually covers, and collect the missing identifiers with the UDID checker.

Getting the link in front of people

A URL that only exists in a build log helps nobody. Echo it and it is one click away on the build page. Write it to install-url.txt and list that file under artifacts:, and it survives as part of the build record alongside the binary. The version worth setting up, though, pushes the link to wherever your testers already are.

codemagic.yaml
# Both keys sit at the workflow level, alongside environment: and artifacts:.
    scripts:
      # ... your build steps ...

      - name: Publish to BetaDrop
        script: |
          npm install -g @betadrop/cli
          betadrop publish build/ios/ipa/*.ipa --ci > "$CM_BUILD_DIR/install-url.txt"
          touch ~/SUCCESS

    publishing:
      scripts:
        - name: Post the install link to Slack
          script: |
            # Post-publish scripts run even when the build FAILED — Codemagic skips them
            # only for a cancelled or timed-out build. CM_BUILD_STEP_STATUS is Workflow
            # Editor only, so the marker file from the build step is the guard.
            if [ ! -f ~/SUCCESS ]; then exit 0; fi
            install_url=$(cat "$CM_BUILD_DIR/install-url.txt")
            payload=$(printf '{"text":"New beta build: %s"}' "$install_url")
            curl -sf -X POST -H 'Content-type: application/json' \
              --data "$payload" "$SLACK_WEBHOOK_URL"
      email:
        recipients:
          - [email protected]

The guard in that publishing script is the whole point of showing it. Codemagic runs post-publish scripts even when the build failed, skipping them only for a cancelled or timed-out build, so an unguarded Slack post announces a beta that does not exist. The marker file is Codemagic's own documented workaround, and it exists because CM_BUILD_STEP_STATUS is a Workflow Editor variable that is not available in codemagic.yaml at all.

You may see $CM_ARTIFACT_LINKS suggested for this. It is a JSON list of the artifacts Codemagic collected, with a name, type and URL for each, and it is genuinely useful for archiving. It is not an install link: those URLs serve the raw file, and iOS will not install an .ipa from a plain download without an itms-services manifest beside it.

When you don't need this

  • The build is going to TestFlight anyway. Codemagic publishes to App Store Connect natively, and if your external testers have to sit behind Apple's beta review for a release candidate, you are paying that cost regardless. Sign app_store and use it. What this page covers is the loop before that one, where the wait is the problem.
  • Codemagic already sends the build somewhere. It ships integrations for Firebase App Distribution, email and Slack notifications, and its own tester-group distribution. If one of those is working for your team, an extra service is churn, not an upgrade.
  • Nobody has to hold the build. If reviewers only read the diff and check that CI is green, an artifact download is enough. Distribution is for when a person needs the app on a real phone. If that person is you, once a fortnight, dragging the file onto the IPA upload page or the APK upload page beats maintaining YAML.

Frequently asked questions

How do I distribute an app to testers from Codemagic?

Add a publish step to the end of the scripts: block in codemagic.yaml that installs @betadrop/cli with npm and runs betadrop publish on the artifact your build just produced, and that step prints an over-the-air install link testers open on their own phone, with no store upload, no review wait and no tester accounts to create.

Where does BETADROP_TOKEN go in Codemagic?

It goes in a variable group: open your Codemagic app settings, go to the Environment variables tab, add BETADROP_TOKEN with the Secret option ticked so the value is encrypted and hidden from the UI and build logs, assign it to a group, and import that group in codemagic.yaml under environment: groups:. Team-wide variables live under Global variables and secrets instead.

Should the upload run in scripts: or in publishing: scripts:?

Put it in scripts:, as the last build step, because steps in that block stop running the moment an earlier one fails, so a build that does not compile never publishes a broken artifact to your testers under a name that says it is the latest beta. Codemagic's post-publish scripts behave the opposite way: they run even when the build failed, and are skipped only when a build is cancelled or times out, so anything you put there needs a guard of its own.

Which artifact path do I pass to betadrop publish?

A Flutter Android build lands at build/app/outputs/flutter-apk/app-release.apk, a Flutter or native iOS build at build/ios/ipa/*.ipa, and a native Gradle build at app/build/outputs/apk/release/*.apk, with every one of those paths resolved relative to $CM_BUILD_DIR, the absolute path of the cloned repository on the builder. That is why the same string works in scripts: and in artifacts:. Keep the publish path narrow enough to match exactly one file, or the CLI is guessing which build your testers get.

Can testers install a Codemagic build straight from the printed link?

Yes — the URL the publish step prints is the install page itself, so a tester opens it on their phone, taps install, and watches the app land on the home screen, or scans the QR code on that page straight off a laptop screen without touching a cable. Android sideloads the .apk directly. On iOS the link has to be opened in Safari, because Chrome and in-app browsers such as Slack or Gmail silently swallow the itms-services install prompt, and Apple's signing rules still apply: an ad hoc build installs only on devices whose UDID is in the provisioning profile.

Does the Codemagic artifact link install on a phone by itself?

No — the URLs in $CM_ARTIFACT_LINKS and the download buttons on the Codemagic build page point at the raw file, and an iPhone cannot install an .ipa from a plain file download, because iOS needs an itms-services manifest served next to the binary. That manifest is what BetaDrop generates for every upload. Android is more forgiving and will sideload a downloaded .apk once the browser is allowed to install packages.

Which iOS signing type should the Codemagic workflow use?

Use ad_hoc: Codemagic's own signing documentation says to set distribution_type to ad_hoc when a third-party distribution service is involved, and app_store when the build is heading for TestFlight, and getting those two backwards is the most common reason a Codemagic build uploads cleanly and then refuses to install on anybody's phone. An app_store-signed .ipa fails on the tester's end, not in your build log, which makes it a slow thing to diagnose.

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

Free accounts upload builds up to 500 MB per file, a link starts at the 3 days default, a free account can push that out to 7 days, and Pro extends retention to a year, which is far longer than a per-commit CI stream normally wants. Leave the default alone for CI builds. Old ones expiring on their own is what keeps the dashboard readable.

Ship the next Codemagic build to a phone

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

iMobile Designs
Developed by iMobile Designs
Made with
in India