Upload an IPA or APK to testers from GitLab CI
Your pipeline already builds the app on every push. What it usually doesn't do is put that build on a phone. Add a deploy stage that runs the BetaDrop CLI and the .apk or .ipa from your build stage becomes an OTA install link: a URL a tester opens on their phone to install the app, with no cable, no account and no store.
The job costs one npm install and one upload. Android sideloads the file directly. On iOS the link has to open in Safari, because the install prompt is an itms-services URL that Chrome and in-app browsers swallow without saying why.
The whole thing is a deploy stage
Here is GitLab CI APK distribution end to end. Your build job keeps whatever image and script it already has and gains one artifacts block; the publish job is new.
stages:
- build
- deploy
# Your existing build job. The only line it gains is the artifacts block.
build:android:
stage: build
script:
- ./gradlew assembleRelease
artifacts:
paths:
- app/build/outputs/apk/release/*.apk
expire_in: 1 week
publish:android:
stage: deploy
image: node:20-alpine
needs:
- job: build:android
artifacts: true
script:
- npm install -g @betadrop/cli
- INSTALL_URL="$(betadrop publish app/build/outputs/apk/release/*.apk --ci)"
- echo "INSTALL_URL=$INSTALL_URL" >> build.env
- echo "Tester install page - $INSTALL_URL"
artifacts:
reports:
dotenv: build.env
--ci is what makes the capture work. Without it the CLI prints a progress bar and a summary; with it, standard output is the install URL and nothing else, so $(…) gets a clean value. The rest of the flags are in the CLI reference. One YAML trap before you extend this: a script line containing a colon followed by a space stops being a string and starts looking like a mapping, which bites the first time somebody adds a curl -H 'Content-Type: application/json' step. Quote those lines.
Set it up in four steps
- 1
Create an API token
Log in at betadrop.app and open Settings → Developer → API tokens. Create a token and copy it immediately; the value is shown once and cannot be read back.
- 2
Add it as a masked CI/CD variable
In your project, go to Settings → CI/CD → Variables → Add variable. Key BETADROP_TOKEN, value the token, visibility Masked (or Masked and hidden). Tick Protect variable if your deploy jobs only run on protected branches and tags.
- 3
Add a deploy stage that runs the CLI
Add deploy to your stages list and a job in it that installs @betadrop/cli and runs betadrop publish on the artifact your build stage produced, with the --ci flag so the job prints only the install URL.
- 4
Capture the URL for later jobs
Echo the printed link into a build.env file as INSTALL_URL, then declare that file under artifacts:reports:dotenv. Every job in a later stage reads $INSTALL_URL as an ordinary CI/CD variable, which is how a release or notification job gets the link.
Mask the token, and protect it if you can
Tokens come from betadrop.app under Settings → Developer → API tokens, and the same screen revokes them. In GitLab the token belongs in Settings → CI/CD → Variables, keyed BETADROP_TOKEN. betadrop publish reads it from the environment, so there is no login step.
GitLab accepts Masked only for a value that is a single line, has no spaces, is 8 characters or longer, and does not collide with an existing variable name. A BetaDrop token clears all four, and a rejected one is nearly always a trailing newline that came along with the paste. Masking hides the value in job logs; it is not an access control, which is what the second checkbox is for.
Protect the variable if your deploy jobs are branch-gated
A protected variable is never injected into a pipeline on an unprotected branch or tag, so an untrusted branch cannot read it and the publish job fails loudly instead of leaking quietly. The trade-off is real: if you want a build link on every feature branch, the variable stays unprotected and anyone who can edit .gitlab-ci.yml can use the token.
Getting the build from one stage to the next
A job downloads the artifacts of every job in all previous stages by default. That is convenient until the build stage starts publishing its whole output directory, and the deploy job spends longer pulling intermediate files than uploading the one file testers want.
Two keywords fix it. artifacts:paths narrows what the build job keeps, and it takes globs, so app/build/outputs/apk/release/*.apk keeps the installable and drops the rest; needs narrows what the deploy job fetches to the jobs you name. Set dependencies to an empty array on a job that should download nothing. Give the kept artifact a short expire_in too, since the installable is on BetaDrop by then anyway.
iOS, and the macOS runner problem
Producing an .ipa takes Xcode, which takes a Mac. GitLab's hosted macOS runners are still a beta and are open to open source program participants and to Premium and Ultimate customers, so on a free plan the choice is a paid plan or your own Mac registered with gitlab-runner. A Mac mini in a cupboard is a perfectly good iOS runner, and plenty of small teams do exactly that.
The machine tags are saas-macos-medium-m1 and saas-macos-large-m2pro, the images macos-15-xcode-16 and macos-26-xcode-26 with the Xcode 26 one as default. GitLab warns that machine availability is limited and jobs can queue, which matters if your release process assumes a build lands in minutes.
build:ios:
stage: build
tags:
- saas-macos-medium-m1
image: macos-26-xcode-26
script:
# Imports the .p12 into a temporary keychain and drops the profile in place.
- ./ci/import-signing.sh
- 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
artifacts:
paths:
- build/export/*.ipa
expire_in: 1 week
publish:ios:
stage: deploy
image: node:20-alpine
needs:
- job: build:ios
artifacts: true
script:
- npm install -g @betadrop/cli
- INSTALL_URL="$(betadrop publish build/export/*.ipa --ci)"
- echo "INSTALL_URL=$INSTALL_URL" >> build.env
artifacts:
reports:
dotenv: build.env
The signing script stays a separate file because it is the same keychain import you would run on any provider. The only GitLab-specific line is the runner tag. The Apple-specific one: commit an ExportOptions.plist whose method is release-testing, Apple's current name for ad hoc distribution, which means handing a signed build to a fixed list of devices. Xcode 26 rejects the old ad-hoc value outright.
BetaDrop generates the itms-services manifest, so the printed URL is tap-to-install. Apple's rules still apply at the far end. An ad hoc build installs only on devices named in its provisioning profile, which is the signed file listing every device UDID allowed to run that build. A UDID is Apple's identifier for one specific device. Collect missing ones with the UDID checker and read back what a profile covers with the provisioning profile decoder.
Put the install URL where someone will see it
A URL that only exists in a job log helps nobody. The publish job writes it to build.env and declares that file as a dotenv report, which turns it into an ordinary CI/CD variable for every job in a later stage.
Into a release description
Tagged builds get the link on the release itself, so whoever reads the changelog can install the thing it describes.
stages:
- build
- deploy
- release
release:tag:
stage: release
image: registry.gitlab.com/gitlab-org/cli:v1.114.0
needs:
- job: publish:android
artifacts: true
rules:
- if: $CI_COMMIT_TAG
variables:
# Without this, glab has no credentials and the job fails. It logs in with the
# job token. The alternative is a GITLAB_TOKEN with api scope — and note that
# $CI_JOB_TOKEN is NOT a valid value for GITLAB_TOKEN.
GLAB_ENABLE_CI_AUTOLOGIN: "true"
script:
- glab release create "$CI_COMMIT_TAG" --notes "Install this build - $INSTALL_URL"
Pin that image tag. GitLab is replacing release-cli with glab, and a job trusting :latest for release tooling breaks on somebody else's schedule.
Into a plain artifact
When a downstream job is overkill, add echo "$INSTALL_URL" > install-url.txt and list that file under artifacts:paths. It lands in the job's artifact browser, which is enough when whoever needs the link is whoever ran the pipeline.
Coming from GitHub Actions
The publish step is the same idea in both systems; the vocabulary around it changes. There is no packaged GitLab component to install, so the CLI call is written out rather than wrapped.
| GitHub Actions | GitLab CI | What differs |
|---|---|---|
| .github/workflows/*.yml | .gitlab-ci.yml | One file at the repo root, though include: splits it up. |
| jobs, each with steps | stages, each with jobs | A GitLab job is closer to a whole Actions job than to a step. |
| Repository secret | CI/CD variable, Masked | GitHub masks every secret automatically; GitLab masking is a per-variable setting. |
| actions/upload-artifact | artifacts: paths: | Later stages fetch them with no download step. |
| $GITHUB_OUTPUT plus job outputs | artifacts: reports: dotenv: | The install URL crosses jobs either way; the dotenv file is the GitLab form. |
| runs-on: macos-latest | tags: saas-macos-medium-m1 | Hosted macOS is a paid-plan beta on GitLab. See below. |
Plenty of teams run both while a migration is half finished. The GitHub Actions guide covers that side, and the comparison of GitHub Actions, GitLab CI and Bitrise is the longer read. Everything else we plug into lives on the integrations index.
When a deploy stage isn't worth writing
- You publish every couple of weeks. Automation pays off with frequency, and until then dragging the file onto the IPA upload page or the APK upload page beats maintaining YAML that runs six times a year.
- Your iOS build has nowhere to run. On a free plan with no Mac to register, no distribution service changes that. Build locally and wire the pipeline up the day the runner exists.
- Nobody needs it on a physical device. If reviewers read the diff and watch the pipeline, an artifact and the simulator cover it. Distribution is for when a human has to hold the build.
Frequently asked questions
Can I upload an IPA to testers from GitLab CI without a Mac?
No, because an .ipa only comes out of Xcode, so the archive and export steps need a macOS runner, and GitLab's hosted macOS runners are a beta open to open source program participants and to Premium and Ultimate customers rather than something a free-tier project can reach. Registering your own Mac as a project runner works and is what most small teams do. The Android half runs on any Linux runner.
Why won't GitLab mask my BETADROP_TOKEN?
GitLab masks a value only when it is a single line with no spaces, is at least 8 characters long, and does not match the name of an existing predefined or custom CI/CD variable, so a token pasted with a trailing newline or a stray space is the usual reason the option refuses. A BetaDrop token clears all three on its own. If you also enable variable expansion on the same variable, the value may use only letters, digits and the characters _ : @ - + . ~ = and /.
How do I pass the install URL to a later job in the pipeline?
Write the URL to a dotenv file in the publish job and declare that file under artifacts:reports:dotenv, and every job in a later stage receives it as an ordinary CI/CD variable, which is how a release job or a chat notification gets the link without re-uploading anything. The file is plain KEY=value, capped at 5 KB, and keys may contain only ASCII letters, digits and underscores.
Which artifacts does the deploy stage actually need?
Only the .apk or .ipa itself, because a job downloads the artifacts of every job in all previous stages by default, so a build job that publishes its entire output directory drags hundreds of megabytes through the deploy job for no reason. Name exact paths under artifacts:paths, and add needs to fetch from one specific job. Setting dependencies to an empty array turns artifact downloads off completely.
Should BETADROP_TOKEN be a protected variable?
Protect it when your deploy jobs only ever run on protected branches or protected tags, because a protected variable is not injected into any other pipeline at all, so a branch or fork pipeline cannot read it and the publish job fails instead of leaking. Leave it unprotected if you publish from every branch. Masking is a separate setting that hides the value in logs, not an access control.
Does betadrop publish accept .aab files from GitLab CI?
No, betadrop publish rejects .aab files, because BetaDrop distributes .apk and .ipa, does not re-sign builds, and an .aab is a Play Store packaging format with nothing in it a tester's phone can install from a link, whichever CI system produced it. Run assembleRelease for the tester build and bundleRelease for the store upload; both tasks sit in the same pipeline.
How big can a GitLab CI build be, and how long does the install 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 it out to 7 days, while Pro extends the same link to a year and Studio to 10 years. For a pipeline publishing on every push the default is usually right, since per-commit builds going stale is the point.
Is a GitLab CI deploy stage a TestFlight alternative?
For ad hoc beta distribution it is, because the install link comes straight off the runner with no store upload, no review wait and no tester accounts, which is what makes a build on every merge request practical rather than ceremonial. If the build is going through App Store review anyway, TestFlight fits that release pipeline better, and BetaDrop covers the faster loop before it.
Ship the next pipeline run to a tester
Create a token, add the masked variable, paste the deploy stage. Free accounts publish builds up to 500 MB per file, and testers never need an account.