How to Automate Mobile App Distribution with CI/CD
You just finished a two-hour debugging session. The fix is merged, the build passes, and it's time to get the new APK to your testers. So you open your CI dashboard, download the artifact, log into your distribution platform, click through the upload wizard, type in the release notes, select the right app, the right group, and hit upload. Ten minutes of manual work for something that should have happened automatically when the build succeeded.
Now multiply that by every build, every day, across every branch your team works on. Manual distribution is a tax on your velocity — small enough to ignore on any single day, large enough to matter over weeks and months.
This guide covers how to automate mobile app distribution using TestApp.io's CLI tool (ta-cli) integrated with every major CI/CD platform. By the end, every successful build will be automatically distributed to your testers with zero manual intervention.
Why Automate Distribution?
Before diving into implementation, let's be explicit about what you gain:
- Speed. Builds reach testers in minutes after a merge, not hours after someone remembers to upload.
- Consistency. Every build is uploaded the same way with the same metadata. No missed release notes, no wrong app IDs, no "I uploaded the debug build by mistake."
- Traceability. Automated uploads include commit hashes, branch names, and build numbers. You always know exactly which code a tester is running.
- Reliability.
ta-cliuses resumable upload chunked uploads, which means large IPA and APK files don't fail halfway through on flaky CI network connections. Uploads resume from where they left off.
The overhead of setting this up is measured in minutes. The time savings compound over every build for the life of the project.
Meet ta-cli
ta-cli is TestApp.io's command-line tool for publishing builds. It runs on Linux, macOS, and Windows, and is designed specifically for CI/CD environments.
The basic command:
./ta-cli publish \
--api_token=YOUR_API_TOKEN \
--app_id=YOUR_APP_ID \
--release_notes="Your release notes here" \
--file=path/to/app.apkKey Flags
| Flag | Description |
|---|---|
--api_token | Your TestApp.io API token. Always store this as a CI secret — never hardcode it. |
--app_id | The ID of the app in TestApp.io. Found in your app settings. |
--release_notes | Release notes for this build. Supports dynamic values from your CI environment. |
--file | Path to the build artifact. Supports APK, and IPA files. |
ta-cli automatically detects the file type and extracts metadata (version name, version code, bundle identifier) from the binary. You don't need to specify these manually.
resumable upload Chunked Uploads
Mobile build artifacts are large. An IPA file can easily be 100MB or more. APK files can be similarly hefty. Uploading these over CI/CD network connections — which can be throttled, shared, or occasionally unstable — is a reliability problem.
ta-cli solves this with resumable upload (resumable upload protocol) chunked uploads. The file is split into chunks and uploaded sequentially. If a chunk fails, only that chunk is retried — not the entire file. If the network drops and recovers, the upload resumes from the last successful chunk.
This is especially important for CI environments where you're paying per-minute for build time. A failed upload that has to restart from zero on a 15-minute timeout costs real money.
Platform-by-Platform Setup
Let's walk through setting up automated distribution on every major CI/CD platform. Each example assumes you've already configured your build pipeline to produce the artifact (APK, or IPA). We're adding the distribution step.
GitHub Actions
GitHub Actions is the most common CI/CD platform for mobile teams. Here's how to add TestApp.io distribution as a step in your workflow.
name: Build and Distribute
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build-and-distribute:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build APK
run: ./gradlew assembleRelease
- name: Upload to TestApp.io
run: |
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
chmod +x ta-cli
./ta-cli publish \
--api_token=${{ secrets.TESTAPPIO_API_TOKEN }} \
--app_id=${{ secrets.TESTAPPIO_APP_ID }} \
--release_notes="Build from ${{ github.ref_name }} - ${{ github.sha }}" \
--file=app/build/outputs/apk/release/app-release.apkSetup steps:
- Go to your GitHub repository Settings → Secrets and variables → Actions.
- Add
TESTAPPIO_API_TOKENwith your API token from TestApp.io. - Add
TESTAPPIO_APP_IDwith your app's ID. - Add the upload step after your build step in the workflow file.
For iOS builds on macOS runners, swap the download URL:
- name: Upload IPA to TestApp.io
run: |
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Darwin-x86_64 -o ta-cli
chmod +x ta-cli
./ta-cli publish \
--api_token=${{ secrets.TESTAPPIO_API_TOKEN }} \
--app_id=${{ secrets.TESTAPPIO_APP_ID }} \
--release_notes="iOS build from ${{ github.ref_name }}" \
--file=build/MyApp.ipaBitrise
Bitrise is popular among mobile teams for its first-class support for iOS and Android builds. Adding TestApp.io distribution is a Script step in your workflow.
workflows:
deploy:
steps:
- android-build@1:
inputs:
- variant: release
- script@1:
title: Upload to TestApp.io
inputs:
- content: |
#!/bin/bash
set -e
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
chmod +x ta-cli
./ta-cli publish \
--api_token="$TESTAPPIO_API_TOKEN" \
--app_id="$TESTAPPIO_APP_ID" \
--release_notes="Bitrise build #$BITRISE_BUILD_NUMBER from $BITRISE_GIT_BRANCH" \
--file="$BITRISE_APK_PATH"Setup steps:
- In the Bitrise Workflow Editor, go to Secrets.
- Add
TESTAPPIO_API_TOKENandTESTAPPIO_APP_IDas secret environment variables. - Add the Script step after your build step.
- Bitrise exposes the build artifact path as
$BITRISE_APK_PATH(Android) or$BITRISE_IPA_PATH(iOS), so you can reference it directly.
CircleCI
CircleCI workflows use jobs and steps. Here's how to add TestApp.io distribution to your pipeline.
version: 2.1
jobs:
build-and-distribute:
docker:
- image: cimg/android:2024.01
steps:
- checkout
- run:
name: Build APK
command: ./gradlew assembleRelease
- run:
name: Upload to TestApp.io
command: |
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
chmod +x ta-cli
./ta-cli publish \
--api_token="$TESTAPPIO_API_TOKEN" \
--app_id="$TESTAPPIO_APP_ID" \
--release_notes="CircleCI build #$CIRCLE_BUILD_NUM from $CIRCLE_BRANCH" \
--file=app/build/outputs/apk/release/app-release.apk
workflows:
build-deploy:
jobs:
- build-and-distribute:
filters:
branches:
only:
- main
- developSetup steps:
- In your CircleCI project, go to Project Settings → Environment Variables.
- Add
TESTAPPIO_API_TOKENandTESTAPPIO_APP_ID. - Add the upload step to your job definition.
Fastlane
Fastlane is the de facto automation tool for iOS and Android build pipelines. TestApp.io provides a dedicated Fastlane plugin: fastlane-plugin-testappio.
# Gemfile
gem 'fastlane-plugin-testappio'# Fastfile
default_platform(:ios)
platform :ios do
desc "Build and distribute to TestApp.io"
lane :distribute do
build_app(
scheme: "MyApp",
export_method: "ad-hoc",
output_directory: "./build"
)
testappio(
api_token: ENV["TESTAPPIO_API_TOKEN"],
app_id: ENV["TESTAPPIO_APP_ID"],
release_notes: "Fastlane build from #{git_branch}",
file: lane_context[SharedValues::IPA_OUTPUT_PATH]
)
end
end
platform :android do
desc "Build and distribute to TestApp.io"
lane :distribute do
gradle(
task: "assemble",
build_type: "Release"
)
testappio(
api_token: ENV["TESTAPPIO_API_TOKEN"],
app_id: ENV["TESTAPPIO_APP_ID"],
release_notes: "Fastlane Android build from #{git_branch}",
file: lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]
)
end
endSetup steps:
- Add the plugin to your Gemfile and run
bundle install. - Set
TESTAPPIO_API_TOKENandTESTAPPIO_APP_IDas environment variables (or use a.envfile that's gitignored). - Add the
testappioaction to your lane after the build step.
The Fastlane plugin automatically picks up the build artifact path from Fastlane's lane context, so you don't need to hardcode file paths.
Jenkins
Jenkins pipelines use Groovy-based Jenkinsfiles. Here's how to add TestApp.io distribution.
pipeline {
agent any
environment {
TESTAPPIO_API_TOKEN = credentials('testappio-api-token')
TESTAPPIO_APP_ID = credentials('testappio-app-id')
}
stages {
stage('Build') {
steps {
sh './gradlew assembleRelease'
}
}
stage('Distribute') {
steps {
sh '''
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
chmod +x ta-cli
./ta-cli publish \
--api_token="$TESTAPPIO_API_TOKEN" \
--app_id="$TESTAPPIO_APP_ID" \
--release_notes="Jenkins build #${BUILD_NUMBER} from ${GIT_BRANCH}" \
--file=app/build/outputs/apk/release/app-release.apk
'''
}
}
}
}Setup steps:
- In Jenkins, go to Manage Jenkins → Manage Credentials.
- Add
testappio-api-tokenandtestappio-app-idas Secret text credentials. - Reference them in your Jenkinsfile using the
credentials()function.
Xcode Cloud
Xcode Cloud uses custom build scripts. Add a post-build script to upload the IPA after a successful archive.
Create the file ci_scripts/ci_post_xcodebuild.sh in your project:
#!/bin/bash
set -e
# Only run after successful archive
if [ "$CI_XCODEBUILD_ACTION" != "archive" ]; then
exit 0
fi
# Find the IPA
IPA_PATH=$(find "$CI_ARCHIVE_EXPORT_PATH" -name "*.ipa" -print -quit)
if [ -z "$IPA_PATH" ]; then
echo "No IPA found. Skipping upload."
exit 0
fi
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Darwin-x86_64 -o ta-cli
chmod +x ta-cli
./ta-cli publish \
--api_token="$TESTAPPIO_API_TOKEN" \
--app_id="$TESTAPPIO_APP_ID" \
--release_notes="Xcode Cloud build #$CI_BUILD_NUMBER" \
--file="$IPA_PATH"Set TESTAPPIO_API_TOKEN and TESTAPPIO_APP_ID as environment variables in your Xcode Cloud workflow settings.
GitLab CI/CD
stages:
- build
- distribute
build-android:
stage: build
image: jangrewe/gitlab-ci-android
script:
- ./gradlew assembleRelease
artifacts:
paths:
- app/build/outputs/apk/release/
distribute:
stage: distribute
image: alpine:latest
dependencies:
- build-android
before_script:
- apk add --no-cache curl
script:
- curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
- chmod +x ta-cli
- ./ta-cli publish
--api_token="$TESTAPPIO_API_TOKEN"
--app_id="$TESTAPPIO_APP_ID"
--release_notes="GitLab CI build #$CI_PIPELINE_IID from $CI_COMMIT_REF_NAME"
--file=app/build/outputs/apk/release/app-release.apk
only:
- main
- developAzure Pipelines
trigger:
branches:
include:
- main
- develop
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Gradle@3
inputs:
gradleWrapperFile: 'gradlew'
tasks: 'assembleRelease'
- script: |
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
chmod +x ta-cli
./ta-cli publish \
--api_token="$(TESTAPPIO_API_TOKEN)" \
--app_id="$(TESTAPPIO_APP_ID)" \
--release_notes="Azure build #$(Build.BuildId) from $(Build.SourceBranchName)" \
--file=app/build/outputs/apk/release/app-release.apk
displayName: 'Upload to TestApp.io'Codemagic
workflows:
android-distribute:
name: Android Build & Distribute
scripts:
- name: Build APK
script: ./gradlew assembleRelease
- name: Upload to TestApp.io
script: |
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
chmod +x ta-cli
./ta-cli publish \
--api_token=$TESTAPPIO_API_TOKEN \
--app_id=$TESTAPPIO_APP_ID \
--release_notes="Codemagic build #$CM_BUILD_ID" \
--file=app/build/outputs/apk/release/app-release.apkTravis CI
language: android
script:
- ./gradlew assembleRelease
after_success:
- curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
- chmod +x ta-cli
- ./ta-cli publish
--api_token="$TESTAPPIO_API_TOKEN"
--app_id="$TESTAPPIO_APP_ID"
--release_notes="Travis CI build #$TRAVIS_BUILD_NUMBER from $TRAVIS_BRANCH"
--file=app/build/outputs/apk/release/app-release.apkAdvanced Patterns
The examples above cover the basics. Here are patterns that make automated distribution even more useful.
Dynamic Release Notes from Commit Messages
Hardcoded release notes like "new build" aren't helpful for testers. Instead, generate release notes from your commit history:
# Get commits since last tag
RELEASE_NOTES=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"- %s" 2>/dev/null || git log -10 --pretty=format:"- %s")
./ta-cli publish \
--api_token="$TESTAPPIO_API_TOKEN" \
--app_id="$TESTAPPIO_APP_ID" \
--release_notes="$RELEASE_NOTES" \
--file=app-release.apkThis gives testers a clear list of what changed in each build, making their testing more targeted and effective.
Branch-Based Distribution
Different branches often mean different testing contexts. You might want different app IDs or release notes depending on the branch:
# GitHub Actions example
- name: Upload to TestApp.io
run: |
curl -Ls https://github.com/testappio/cli/releases/latest/download/ta-cli-Linux-x86_64 -o ta-cli
chmod +x ta-cli
if [ "${{ github.ref_name }}" = "main" ]; then
APP_ID="${{ secrets.TESTAPPIO_APP_ID_PRODUCTION }}"
NOTES="Release candidate from main - ${{ github.sha }}"
else
APP_ID="${{ secrets.TESTAPPIO_APP_ID_DEV }}"
NOTES="Dev build from ${{ github.ref_name }} - ${{ github.sha }}"
fi
./ta-cli publish \
--api_token=${{ secrets.TESTAPPIO_API_TOKEN }} \
--app_id="$APP_ID" \
--release_notes="$NOTES" \
--file=app/build/outputs/apk/release/app-release.apkConnecting CI Uploads to Version Lifecycle
Automated uploads become even more powerful when connected to TestApp.io's version lifecycle. Builds uploaded via CI automatically appear in the version's Releases tab, giving you:
- A chronological record of every build produced during a version's lifecycle.
- Direct install links for testers — no digging through CI artifacts.
- Linkage between builds, tasks, and blockers within the version context.
- Quality metrics that aggregate across all builds in a version.
When a tester reports a blocker from a CI-uploaded build, it's automatically linked to that build. When a developer pushes a fix and CI uploads a new build, the tester can immediately install and verify. The entire loop �� build, distribute, test, report, fix, rebuild, verify — happens without manual intervention.
Security Best Practices
A few reminders for keeping your CI/CD distribution pipeline secure:
- Never hardcode API tokens. Always use your CI platform's secret management. Every platform listed above supports encrypted environment variables or credential stores.
- Use separate tokens for CI and human access. If a CI token is compromised, you can rotate it without affecting your team's personal access.
- Restrict branch triggers. Don't automatically distribute builds from every feature branch. Limit automated distribution to branches that matter — typically
main,develop, andrelease/*. - Review CI logs. Ensure your CI configuration doesn't echo API tokens in build logs. Most CI platforms mask secrets automatically, but verify this for your setup.
Troubleshooting Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Upload fails with auth error | Invalid or expired API token | Regenerate token in TestApp.io settings and update CI secrets |
| "File not found" error | Build artifact path is wrong | Check your build output directory — Gradle, Xcode, and Flutter all use different paths |
| Upload hangs or times out | Large file on slow connection | resumable upload chunked upload should handle this, but check your CI runner's network and any proxy settings |
| Wrong app or version | Incorrect app ID | Verify the app ID in your TestApp.io app settings matches your CI secret |
| Release notes are empty | Environment variable not resolved | Ensure your CI syntax for variable interpolation is correct for the platform |
Wrapping Up
Automating mobile app distribution with CI/CD is one of those investments that pays off immediately. The first time a build is automatically uploaded, distributed, and available for testing within minutes of a merge — with proper release notes, metadata, and version linkage — you'll wonder why you ever did it manually.
The setup is straightforward regardless of your CI platform. Download ta-cli, add your credentials as secrets, and add the publish command after your build step. resumable upload chunked uploads handle the reliability, metadata extraction handles the details, and version lifecycle integration handles the traceability.
Start with one pipeline, prove the value, and expand from there.
Get started with TestApp.io and connect your first CI/CD pipeline today. For platform-specific setup details, check the help center.