flutter_distribute_manager 4.0.1
flutter_distribute_manager: ^4.0.1 copied to clipboard
Ship Flutter builds to QA in one command: build APK, App Bundle or IPA, then upload to Google Drive, Diawi or Firebase App Distribution with install links.
π flutter_distribute_manager #
Build your Flutter app and hand QA a working link β in one command. Android APK / App Bundle + iOS IPA β uploaded to Google Drive, Diawi and Firebase App Distribution automatically β checksummed, verified, and logged.
No Google Cloud project. No OAuth client to register. No manual Xcode archiving. No dragging files into shared folders.
$ flutter_distribute_manager
ββββββββββββββββββββββββββββββββββββββββββββββββ
flutter_distribute_manager Β· Build Β· Ship
ββββββββββββββββββββββββββββββββββββββββββββββββ
Project : my_app v1.4.2
? Which platform do you want to build?
1) Android (APK) (default)
2) iOS (IPA)
3) Android + iOS
β Built my_app_UAT_2026_06_19_0930_arm64-v8a.apk (24 MB)
[ββββββββββββββββββββ] 100% 24 MB / 24 MB 8.1 MB/s
β Verified on Drive (md5): my_app_UAT_2026_06_19_0930_arm64-v8a.apk
Share links:
Drive Β· my_app_UAT_2026_06_19_0930_arm64-v8a.apk
https://drive.google.com/file/d/β¦
Firebase Β· my_app_UAT_2026_06_19_0930.apk
https://appdistribution.firebase.google.com/testerapps/β¦
(a QR code here β scan it with the test phone to install)
π Contents #
| What it replaces Β· Why not fastlane? | Decide if this is for you |
| Install Β· Quick start | Get running in 3 minutes |
| Commands Β· Build flags | Everyday reference |
| Firebase Β· App Bundles Β· Release notes Β· Version bumps | Distribution features |
| Checksums & verification Β· Retries Β· History Β· QR codes | Know what shipped |
| CI/CD Β· JSON output Β· Exit codes | Automate it |
| How it works Β· Configuration | Understand it |
| Troubleshooting Β· FAQ | When something breaks |
π€ What it replaces #
Shipping a build to QA by hand is seven boring steps, every single time:
| Doing it by hand | With flutter_distribute_manager |
|---|---|
flutter build apk β wait β hunt for the file in build/β¦ |
One command builds it for you |
| Drag the APK into a Google Drive folder | Uploaded automatically |
| Right-click β Get shareable link β copy | Link printed in your terminal |
| Flat Drive folder β chaos after 10 builds | Organised App/Year/Month/ENV/ folders |
| "Which build is this β DEV or PROD?" | Environment stamped into every filename |
| Android and iOS are two separate chores | Both in a single run |
Bump version: in pubspec.yaml before every QA drop |
--bump build |
| Type up what changed for testers | --release-notes-from-git |
| Upload to Firebase App Distribution by hand | --upload-firebase |
| "Is this the file you built?" | SHA-256 for every artifact, Drive copies read back and verified |
| "Where's last Tuesday's UAT link?" | flutter_distribute_manager history |
| Paste the link into Slack yourself | Optional auto-notification to Slack/Discord/Teams |
cd your_flutter_app
flutter_distribute_manager
That's the whole workflow.
π₯ Why not fastlane or Codemagic? #
They're excellent β and heavier. This tool is deliberately the small one.
| flutter_distribute_manager | fastlane | Codemagic / Bitrise | |
|---|---|---|---|
| Setup time | ~3 minutes, one command | Ruby, Gemfile, Fastfile, lanes | Sign-up, connect repo, YAML |
| Runs on your laptop | β Yes, that's the point | β Yes | β Cloud-only (paid minutes) |
| Config to write | None β it asks you | A Fastfile per project | A codemagic.yaml per project |
| Cost | Free (your Drive quota) | Free | Free tier, then per-minute |
| Google Drive delivery | β Built in, folder-organised | Plugin + service account | β Not typically |
| Firebase App Distribution | β Via the official firebase CLI | β Plugin | β Built in |
| Needs a Google Cloud project | β No β rclone signs you in | β Service-account JSON | β Usually |
| Play Store / App Store submit | β Not its job | β Yes | β Yes |
| Code signing management | Passes through to Flutter | β match/sigh | β Managed |
Use this when you want QA builds out of your hands and into a tester's, right now, without standing up a pipeline. Use fastlane/Codemagic when you're automating store submissions, managing signing certificates across a team, or need a hosted build farm.
They compose fine, too β plenty of people use this for daily QA drops and fastlane for release day.
β What you'll need #
| Requirement | What it's for | How to get it |
|---|---|---|
| Flutter & Dart (Dart β₯ 3.5) | Building the app | flutter.dev/get-started |
| rclone | Uploading to Google Drive | init offers to install it via your package manager |
| Xcode (macOS only) | Building iOS IPAs | Mac App Store β full Xcode, not just the CLI tools |
| Diawi token (optional) | iOS one-tap install links | diawi.com β Account β API |
| Firebase CLI (optional) | Firebase App Distribution | npm install -g firebase-tools, then firebase login |
| git (optional) | --release-notes-from-git |
Usually already installed |
You do not need a Google Cloud project, an OAuth client ID, or a service-account key. rclone signs you in through your browser.
π» Platform support #
| Android build (APK/AAB) | iOS build | Drive upload | Diawi | Firebase | |
|---|---|---|---|---|---|
| macOS | β | β | β | β | β |
| Linux | β | β (needs macOS + Xcode) | β | β | β |
| Windows | β | β (needs macOS + Xcode) | β | β | β |
π₯ Install #
dart pub global activate flutter_distribute_manager
Make sure the global bin directory is on your PATH (add to ~/.zshrc or ~/.bashrc):
export PATH="$PATH":"$HOME/.pub-cache/bin"
Verify:
flutter_distribute_manager --version
flutter_distribute_manager --help
β‘ Quick start (3 steps) #
# 1οΈβ£ One-time setup β installs rclone, connects Google Drive, saves your Diawi token
flutter_distribute_manager init
# 2οΈβ£ Check everything is wired up correctly
flutter_distribute_manager doctor
# 3οΈβ£ From inside your Flutter project β build & ship
cd path/to/your/flutter_app
flutter_distribute_manager
The tool detects your project, asks for platform + environment, builds, uploads, and prints the share links.
π‘ Not sure what a command will do? Add
--dry-run. It prints the exact plan, checks that Flutter and Google Drive are actually reachable, and exits non-zero if the real run would fail β without building anything.
π§ Commands #
| Command | What it does |
|---|---|
flutter_distribute_manager |
Build & ship (the default command, interactive). |
flutter_distribute_manager init |
One-time setup: rclone, Google Drive auth, Diawi token, webhook. |
flutter_distribute_manager doctor |
Health check: tools, config, Drive connectivity (+ macOS Gatekeeper). Exits 1 if anything is broken. |
flutter_distribute_manager config |
View or edit saved configuration (interactive or scriptable). |
flutter_distribute_manager history |
Recent builds: versions, checksums, share links, outcome. --json, --limit N, --clear --yes. |
flutter_distribute_manager reset |
Remove saved configuration and start over. |
Every command supports --help.
π© Build flags #
Every prompt has a flag, so any build is scriptable.
| Flag | Alias | Default | Description |
|---|---|---|---|
--platform |
-p |
ask β android |
android, ios, or both. |
--environment |
-e |
ask β DEV |
DEV, UAT, or PROD. |
--app-dir |
-d |
current dir | Flutter project path (searches parent dirs too). |
--app-name |
-n |
pubspec name: |
Label used in artifact file names. |
--flavor |
none | Build flavor passed through to flutter build. |
|
--android-format |
apk |
apk, aab (App Bundle) or both. See App Bundles. |
|
--[no-]split-per-abi |
on | Split Android APKs per ABI (APKs only; off by default when only Firebase needs the APK). | |
--bump |
none | build, patch, minor or major β rewrite version: in pubspec.yaml first. |
|
--flutter-path |
auto | Explicit Flutter executable (overrides PATH/fvm detection). | |
--clean |
off | Run flutter clean before building. |
|
--team-id |
-t |
ask / saved | Apple Developer Team ID (iOS). |
--export-method |
release-testing |
development, release-testing, ad-hoc, enterprise, app-store. |
|
--scheme |
Runner |
Recorded for reference; Flutter picks the scheme from --flavor. |
|
--upload-drive |
saved / ask | Upload artifacts to Google Drive. | |
--[no-]verify-upload |
on | Read every Drive upload back and compare size + hash. | |
--upload-diawi |
ask if token set | Upload the iOS IPA to Diawi. | |
--diawi-token |
saved | Diawi API token. Prefer FDM_DIAWI_TOKEN. |
|
--upload-firebase |
saved / ask | Upload to Firebase App Distribution (needs the firebase CLI). |
|
--firebase-android-app-id |
saved | 1:<number>:android:<hex> β saved per project. |
|
--firebase-ios-app-id |
saved | 1:<number>:ios:<hex> β saved per project. |
|
--firebase-groups |
saved | Comma-separated tester group aliases (- clears). |
|
--firebase-testers |
saved | Comma-separated tester emails (- clears). |
|
--release-notes |
none | Release notes text. | |
--release-notes-file |
none | Read release notes from a file. | |
--release-notes-from-git |
off | Commit subjects since the last tag (or the last N). | |
--git-max-commits |
20 |
Most commits --release-notes-from-git lists. |
|
--notify-url |
saved | Slack/Discord/Teams webhook. Prefer FDM_NOTIFY_URL. |
|
--retry |
3 |
Upload attempts before giving up. | |
--skip-build / --upload-only |
off | Reuse the last build instead of rebuilding. | |
--dry-run |
off | Print the plan and validate readiness; build nothing. | |
--json |
off | Emit a machine-readable JSON result on stdout. | |
--[no-]qr |
on | Print a QR code per install link (interactive terminals only). | |
--yes |
-y |
off | Assume defaults; never prompt (CI/CD). |
--quiet |
-q |
off | Silence build chatter and detail lines. |
--verbose |
-v |
off | Verbose logging. |
--version |
Print version. |
β οΈ
--yeschanges the defaults. With no terminal (or with-y), the tool never asks:--platformand--environmentfall back to whatever is saved in.flutter_distribute_manager.json, and then toandroid/DEV. Pass both explicitly in CI so a fresh checkout builds what you expect.
π Secrets via environment #
A token passed as a command-line argument is visible to every user on the
machine (ps auxww) and lands in shell history and CI command echoes. Prefer:
| Variable | Replaces |
|---|---|
FDM_DIAWI_TOKEN |
--diawi-token |
FDM_NOTIFY_URL |
--notify-url |
Precedence is flag β environment β saved config.
Firebase credentials never pass through this tool at all β the firebase CLI
reads them from its own environment. They are never put on a command line,
never stored, and anything the CLI echoes back is redacted before it is logged:
| Variable | Read by | Use |
|---|---|---|
GOOGLE_APPLICATION_CREDENTIALS |
firebase CLI | Path to a service-account key with the Firebase App Distribution Admin role. Recommended for CI. |
FIREBASE_TOKEN |
firebase CLI | A firebase login:ci token (deprecated by Firebase, still supported). |
π Reset flags #
| Flag | Alias | Description |
|---|---|---|
| (no flag) | Soft reset β deletes machine + project config. Drive stays connected. | |
--all |
Full reset β also deletes the rclone remote (disconnects Google Drive). | |
--app-dir |
-d |
Project whose config to remove (default: current dir). |
--yes |
-y |
Skip the confirmation prompt (for CI). |
flutter_distribute_manager reset # keep the Google sign-in
flutter_distribute_manager reset --all # disconnect Drive too
Interactively, reset lists exactly what it will remove and asks you to type
reset to confirm β nothing is deleted by accident. If --all cannot actually
remove the remote, it says so and exits non-zero rather than claiming success.
βοΈ Config flags (scriptable) #
config is interactive by default, but every field has a flag so a
provisioning script or CI image can configure the tool without a terminal:
flutter_distribute_manager config --show # current values
flutter_distribute_manager config --show --json # machine-readable
flutter_distribute_manager config --set-remote gdrive \
--set-folder "Flutter Builds"
flutter_distribute_manager config --upload-to-drive # on by default
flutter_distribute_manager config --set-diawi-token - # clear it
Secrets are never printed back β --show reports set / ***set***, never
the value.
π₯ Firebase App Distribution #
Uploads go through the official firebase CLI (firebase-tools), with the
same retry policy as every other channel. Install it once, sign in once:
npm install -g firebase-tools # or the standalone binary
firebase login
Then pass the app id(s) the first time β they are saved in the project's
.flutter_distribute_manager.json, because an app id names one app:
flutter_distribute_manager -p android -e UAT --upload-firebase \
--firebase-android-app-id 1:1234567890:android:0a1b2c3d4e5f \
--firebase-groups qa,beta --firebase-testers lead@example.com \
--release-notes-from-git --yes
# Next time the saved settings apply:
flutter_distribute_manager -p android -e UAT --upload-firebase --yes
Find the app ids in the Firebase console β Project settings β Your apps.
- Each platform that is built needs its app id (
-p bothon a Mac needs both). A missing one, a missing CLI or a logged-out CLI is caught by the pre-flight β and by--dry-runβ before the build, exit78. - The CLI's output is parsed for the testing link (testers open it on the
device; it gets a QR code) and the console link (for the
team). Both land in
links(kind: install/kind: console), the summary, the webhook and the history. A release the CLI reports no link for is a warning, not silence. - Firebase makes every upload a separate release, so one Android binary is
sent per run: the App Bundle if one was built, otherwise a universal APK.
With Firebase on and
--split-per-abinot given explicitly, the run builds a universal APK (and says so innotes). Force--split-per-abiand onlyarm64-v8agoes to Firebase β with a warning naming the ABIs testers will not get. - No
--firebase-groups/--firebase-testers? The release is uploaded but nobody new is invited, andnotessays so. - Authentication is the CLI's business:
firebase loginlocally;GOOGLE_APPLICATION_CREDENTIALS(recommended) orFIREBASE_TOKENin CI β see Secrets via environment.doctorreports the CLI version and how it will authenticate whenever the project has Firebase configured. - iOS releases need an ad-hoc or enterprise-signed IPA, as Firebase requires.
π¦ Android App Bundles #
flutter_distribute_manager -p android --android-format aab --upload-drive --yes
flutter_distribute_manager -p android --android-format both --upload-drive --upload-firebase --yes
--android-format |
Builds | Goes to |
|---|---|---|
apk (default β unchanged) |
flutter build apk [--split-per-abi] |
Drive, Firebase |
aab |
flutter build appbundle |
Drive, Firebase |
both |
both, from the same run | APKs β Drive; the bundle β Drive and Firebase |
--flavoris passed to the bundle build and used to find the right bundle inbuild/app/outputs/bundle/<flavor>Release/(Gradle never cleans it).--skip-build/--upload-onlyfinds the last bundle the same way.--split-per-abiapplies to APKs only; a bundle always carries every ABI.- Diawi cannot install an App Bundle. It is never sent there, and the run
says so in
notes(and the summary and webhook) rather than dropping it silently. Diawi keeps receiving the iOS IPA. - Distributing a bundle through Firebase requires the Firebase project to be linked to Google Play.
π Release notes #
Pick one source per run:
--release-notes "Fixed the login crash; new onboarding"
--release-notes-file CHANGES.txt
--release-notes-from-git # since the last tag, at most 20 commits
--release-notes-from-git --git-max-commits 50
--release-notes-from-git lists commit subjects since the last tag (git describe --tags), newest first β or the last N commits when there is no tag.
When HEAD itself carries the tag (tag v1.1.0, then build it), the notes run
from the tag before it, so a release build lists what the release contains;
a tagged first commit, or no earlier tag, falls back to the last N commits.
Merges and fixup!/squash!/amend! commits are dropped, duplicates are
removed, each subject is capped at 200 characters, and "β¦ and N more" says
what was left out. Notes of any source are capped at 4,000 characters (the
run says so in notes if it cut anything).
The notes are sent to Firebase (--release-notes-file), quoted in the webhook
message (first 600 characters β on Discord, @everyone/@here/<@id> in
them never ping anyone), written next to the build as
<App>_<ENV>_<date>_RELEASE_NOTES.txt in build/distribute/ and in the
Drive folder, and returned as releaseNotes in --json.
π‘ In GitHub Actions use
actions/checkoutwithfetch-depth: 0: a shallow clone has no tags and one commit, so the notes would be one line.
A missing notes file is a usage error (64); git missing or the project not
being in a repository is a configuration error (78) β reported as a blocker
by --dry-run, with git's own message (a container checkout that git refuses
as "dubious ownership" says so, with the git config --global --add safe.directory fix). Notes that are empty after trimming (an unset CI variable) send
no notes and say so; they do not fail the build.
π Version bumps #
flutter_distribute_manager --bump build # 1.4.2+7 β 1.4.2+8
flutter_distribute_manager --bump patch # 1.4.2+7 β 1.4.3+8
flutter_distribute_manager --bump minor # 1.4.2+7 β 1.5.0+8
flutter_distribute_manager --bump major # 1.4.2+7 β 2.0.0+8
- Only the value on the top-level
version:line changes. Quotes, a trailing comment, CRLF line endings and every other byte ofpubspec.yamlare left exactly as they were. patch/minor/majoralso increment the build number when there is one: Android refuses to install a lowerversionCodeover a higher one, and the stores reject a repeated one. A pre-release suffix (-rc.1) is dropped.- No build number yet? Flutter builds
versionCode1 without one, so--bump buildon1.4.2writes1.4.2+2β+1would change nothing. - Components that do not change keep their spelling:
1.02.3+4β--bump buildβ1.02.3+5. The component that changes is written as a plain number (--bump minoron1.02.3β1.3.0). - The bump is written after the pre-flight passes and undone if the
build fails or is interrupted (unless the file was edited meanwhile), so a
failed run leaves no change behind. After a successful build it stays β
commit it. (In CI the workspace is thrown away: commit and push the bumped
pubspec.yamlin a later step, or every run bumps from the same number.) --dry-runshows the planned version and writes nothing.--jsonreportsversionBump: {part, from, to, applied}andversionis the new version.- A missing or unparseable
version:line, or a number in it too large to increment, is an error (78) that names the value it found β a dry-run blocker, with the--jsondocument still emitted.--bumpwith--skip-build/--upload-onlyis refused (64): the reused artifacts carry the old version.
π Checksums & upload verification #
Every artifact gets a SHA-256: in the summary table (short form), in
--json (artifacts[].sha256), in the webhook (short form), in the history,
and in <App>_<ENV>_<date>_SHA256SUMS.txt next to the build β locally and on
Drive. That file is in coreutils format, so a tester can check a download:
sha256sum -c MyApp_UAT_2026_06_19_0930_SHA256SUMS.txt # shasum -a 256 -c on macOS
After each Drive copy the file is read back β rclone lsjson --hash on
its folder, filtered to exactly that file name β and its size and hash
compared with the local file: SHA-256 when the remote offers it, otherwise
the MD5 that Google Drive always stores:
| Outcome | What happens |
|---|---|
| Size and hash match | β Verified on Drive (md5); verifications[].status: "verified" |
| Size or hash differ | No share link is created for that file, the run fails with exit 69 and success: false |
| The file is not on Drive (the copy "succeeded" but nothing arrived) | Same as a mismatch: status: "mismatch", exit 69 |
| No comparable hash (size matches) | status: "size-only" and a warning |
| The read-back itself keeps failing | status: "unverifiable" and a warning (the copy succeeded) |
--no-verify-upload skips the read-back (one extra rclone call per file) and
records that in notes.
π Build history #
Every build run that gets as far as finding its Flutter project appends one
line to history.jsonl in the machine config directory: time, tool version,
app, version (and bump), environment, platform and formats, every artifact
with size and SHA-256, the links per channel, and the outcome with its exit
code. That includes runs stopped by a usage or configuration error, another
build holding the lock, a failed build, and Ctrl-C (outcome: "interrupted", exit 130). Not recorded: --dry-run (it builds nothing)
and errors before a project is found (a bad flag, no pubspec.yaml).
flutter_distribute_manager history # the last 10 runs
flutter_distribute_manager history --limit 50
flutter_distribute_manager history --json | jq '.entries[0].links'
flutter_distribute_manager history --clear --yes # add --json for {"cleared": β¦}
The file is user-only (chmod 600), holds no tokens or webhook URLs, and is
never the reason a build fails: a history that cannot be written is a warning.
Each line is appended under an exclusive file lock, so builds finishing at
the same moment cannot overwrite or tear each other's entries.
An unreadable line is skipped and counted. reset leaves the history alone;
clear it with history --clear.
π Retrying a failed upload #
Each channel β Drive, Diawi, Firebase β is attempted on its own, and so is
each artifact within Firebase (a rejected APK does not cost the IPA its
release). When something fails, the run exits 69 and prints the exact
command(s) to retry only what failed:
Retry just the failed uploads with these 2 commands:
flutter_distribute_manager --upload-only -p both -e UAT --upload-drive --no-upload-diawi --no-upload-firebase --yes
flutter_distribute_manager --upload-only -p ios -e UAT --no-upload-drive --no-upload-diawi --upload-firebase --yes
Every channel is named explicitly β --upload-X or --no-upload-X β because
a saved "upload to Firebase" would otherwise re-run a channel that already
succeeded: a second Firebase release, and every tester e-mailed again. The
platform is narrowed to the artifacts that still need the channel, and the
flavor, environment and --android-format are spelled out so --upload-only
finds the same build.
π± QR codes #
When stdout is an interactive terminal, every install link β Diawi, and
Firebase's testing link β is followed by a QR code drawn with half-block
characters, so a tester can point the test phone at the screen. It is never
printed with --json, --quiet, a redirected/piped stdout, or --no-qr; a
terminal too narrow for the code gets a one-line hint instead.
π€ CI/CD #
One-time: get credentials onto the runner #
--upload-drive needs an rclone remote and this tool's config. On a fresh
runner neither exists, and init is interactive β so provision them from
secrets instead. Run init once locally, then copy
~/.config/rclone/rclone.conf into a CI secret (it contains the Drive OAuth
refresh token β treat it as a credential).
GitHub Actions #
name: Ship to QA
on:
workflow_dispatch:
push:
tags: ['v*']
jobs:
ship:
runs-on: ubuntu-latest # use macos-latest to also build iOS
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # tags + history for --release-notes-from-git
- uses: subosito/flutter-action@v2
with: { channel: stable }
- name: Install rclone
run: sudo apt-get update && sudo apt-get install -y rclone
- name: Restore credentials
env:
RCLONE_CONF: ${{ secrets.RCLONE_CONF }}
run: |
mkdir -p ~/.config/rclone
printf '%s' "$RCLONE_CONF" > ~/.config/rclone/rclone.conf
chmod 600 ~/.config/rclone/rclone.conf
- name: Install the CLI
run: |
dart pub global activate flutter_distribute_manager
echo "$HOME/.pub-cache/bin" >> "$GITHUB_PATH"
- name: Configure
run: |
flutter_distribute_manager config \
--set-remote gdrive \
--set-folder "Flutter Builds"
- name: Preflight
run: flutter_distribute_manager -p android --upload-drive --dry-run -y
- name: Build & ship
env:
FDM_NOTIFY_URL: ${{ secrets.SLACK_WEBHOOK }}
run: |
flutter_distribute_manager \
--platform android \
--environment UAT \
--app-name MyApp \
--upload-drive \
--release-notes-from-git \
--yes --json > result.json
cat result.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: build-result
path: result.json
π‘ The Preflight step is worth its 5 seconds:
--dry-runexits non-zero when Flutter is missing or the Drive token has expired, so you find out before paying for a 15-minute build.
Adding Firebase App Distribution #
- name: Install the Firebase CLI
run: npm install -g firebase-tools
- name: Firebase credentials
env:
FIREBASE_SA: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
run: |
printf '%s' "$FIREBASE_SA" > "$RUNNER_TEMP/firebase-sa.json"
echo "GOOGLE_APPLICATION_CREDENTIALS=$RUNNER_TEMP/firebase-sa.json" >> "$GITHUB_ENV"
- name: Build & ship to Firebase
run: |
flutter_distribute_manager -p android -e UAT \
--upload-firebase \
--firebase-android-app-id "${{ vars.FIREBASE_ANDROID_APP_ID }}" \
--firebase-groups qa \
--release-notes-from-git --yes --json > result.json
The service account needs the Firebase App Distribution Admin role. The
tool never reads, prints or stores the key β the firebase CLI does.
Any other CI #
flutter_distribute_manager \
--platform both -e UAT --app-name MyApp \
--upload-drive --yes --json
All human-readable output goes to stderr, so stdout carries only JSON.
π JSON output schema #
--json writes exactly one JSON document to stdout:
{
"tool": "flutter_distribute_manager",
"toolVersion": "4.0.0",
"success": true, // false when an upload failed or a Drive copy did not verify
"dryRun": false,
"app": "MyApp",
"version": "1.4.2+8", // from pubspec.yaml β after --bump, the new version
"versionBump": { // null without --bump; "applied": false in a dry run
"part": "build", "from": "1.4.2+7", "to": "1.4.2+8", "applied": true
},
"environment": "UAT", // DEV | UAT | PROD
"platform": "both", // android | ios | both
"androidFormat": "both", // apk | aab | both; null when Android was not built
"artifacts": [
{
"name": "MyApp_UAT_2026_06_19_0930_arm64-v8a.apk",
"platform": "android", // android | ios
"format": "apk", // apk | aab | ipa
"sizeBytes": 25165824,
"sha256": "9f86d081884c7d65β¦", // full lower-case hex
"path": "/abs/path/build/distribute/MyApp_UAT_β¦.apk",
"abi": "arm64-v8a" // omitted when not a per-ABI split
}
],
"links": [
{ "service": "Drive", "artifact": "MyApp_UAT_β¦.apk", "url": "https://β¦", "kind": "share" },
{ "service": "Diawi", "artifact": "MyApp_UAT_β¦.ipa", "url": "https://i.diawi.com/β¦", "kind": "install" },
{ "service": "Firebase", "artifact": "MyApp_UAT_β¦.aab", "url": "https://appdistribution.firebaseβ¦", "kind": "install" },
{ "service": "Firebase", "artifact": "MyApp_UAT_β¦.aab", "url": "https://console.firebase.googleβ¦", "kind": "console" }
],
"verifications": [ // one per Drive upload, sidecar files included; [] with --no-verify-upload
{ "service": "Drive", "file": "MyApp_UAT_β¦.apk", "status": "verified", "method": "md5" }
// status: verified | size-only | mismatch | unverifiable (+ "detail" when not verified)
],
"releaseNotes": "Changes since v1.4.1:\n- Fix login crash", // null when none
"checksumsFile": "/abs/path/build/distribute/MyApp_UAT_β¦_SHA256SUMS.txt",
"warnings": [ // non-fatal degradations; see below
"iOS was skipped: builds require macOS with Xcode (this machine is linux)."
],
"notes": [ // deliberate, by-design skips β not degradations
"Diawi cannot install Android App Bundles, so the .aab is not sent there β¦"
],
"timingsMs": { "androidBuild": 182000, "androidBundleBuild": 95000, "driveUpload": 9400 }
}
Every top-level key is always present (null or [] when unused), so a
consumer never has to test for a key's existence.
warnings is the field to watch. A run can succeed while quietly doing
less than you asked β iOS skipped on a Linux runner, Drive upload with no
shareable link, Diawi requested with no token. success: true with a non-empty
warnings array means "shipped, but not everything you asked for."
# Fail the pipeline on any degradation, not just on a non-zero exit:
jq -e '.warnings | length == 0' result.json
notes is different: it states deliberate choices (an App Bundle not offered
to Diawi, which cannot install one; --no-verify-upload; a universal APK
built for Firebase). Nothing asked for was left undone, so do not gate on it.
Even when an upload fails, the JSON document is still written (with
"success": false) so you never lose the artifact paths and the links that did
succeed.
π’ Exit codes #
| Code | Meaning | Typical cause |
|---|---|---|
0 |
Success. | β |
1 |
Generic failure. | doctor found problems; an unexpected filesystem error. |
64 |
Bad command-line usage. | Unknown command/flag, bad value, no Flutter project, missing --app-dir, two release-notes sources, --bump with --upload-only, a malformed Firebase app id or tester email, a missing --release-notes-file. |
69 |
Upload failed, or a build is already running for this project. | rclone/Diawi/Firebase failure; a Drive copy that does not match the local file, or never arrived; the concurrency lock is held. |
70 |
The build itself failed. | Gradle/Xcode error; no artifact produced; an unexpected crash. |
78 |
Missing or invalid configuration. | Flutter not found; Drive not set up or unreachable; firebase CLI missing or logged out, or no app id for a platform being built; no/unparseable version: for --bump; --release-notes-from-git without git or outside a repository; --dry-run blockers. |
130 |
Interrupted (Ctrl-C). | Standard 128 + SIGINT. |
π How files land on Google Drive #
Flutter Builds/ β your root folder (set during init)
βββ MyApp/ β --app-name
βββ 2026/
βββ June/
βββ DEV/
βββ UAT/
β βββ MyApp_UAT_2026_06_19_0930_arm64-v8a.apk
β βββ MyApp_UAT_2026_06_19_0930.aab
β βββ MyApp_UAT_2026_06_19_0930_SHA256SUMS.txt
β βββ MyApp_UAT_2026_06_19_0930_RELEASE_NOTES.txt
βββ PROD/
A month's builds share a folder, so each run's checksums and release notes
carry the run's name instead of a bare SHA256SUMS.txt that the next run
would overwrite.
Folder matching is case-insensitive, so re-runs reuse the same folders instead of piling up duplicates.
π¬ How it works #
No magic, and nothing you couldn't do by hand:
ββ 1. Detect βββββββββββββββββββββββββββββββββββββββββββββββββ
β Walk up from --app-dir for a pubspec.yaml with Flutter. β
β Resolve the toolchain: --flutter-path β fvm β PATH. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββ 2. Pre-flight βββββββββββββββββββββββββββββββββββββββββββββ
β Take a per-project lock (two builds corrupt Gradle caches).β
β Prove rclone works AND the Drive remote actually answers β
β β before you pay for the build, not after. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββ 3. Build ββββββββββββββββββββββββββββββββββββββββββββββββββ
β [--bump: rewrite version: in pubspec.yaml; undone on fail] β
β flutter build apk --release [--split-per-abi] β
β flutter build appbundle --release (--android-format) β
β flutter build ipa --release --export-method β¦ β
β Failures are matched against known signatures and turned β
β into an actionable hint (a corrupt Kotlin cache is even β
β cleaned and retried once, automatically). β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββ 4. Stage ββββββββββββββββββββββββββββββββββββββββββββββββββ
β Copy only *this* build's artifacts into build/distribute/ β
β as MyApp_UAT_2026_06_19_0930_arm64-v8a.apk, SHA-256 each, β
β and write β¦_SHA256SUMS.txt (+ β¦_RELEASE_NOTES.txt). β
β (flutter-apk/ keeps every variant ever built β picking the β
β wrong one is how a debug APK reaches QA.) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββ 5. Distribute βββββββββββββββββββββββββββββββββββββββββββββ
β rclone copy β Drive (App/Year/Month/ENV), read each file β
β back (rclone lsjson --hash) and compare, then rclone link. β
β IPA β Diawi, polled until the install link is ready. β
β APK/AAB/IPA β firebase appdistribution:distribute. β
β Each channel is independent; retries use backoff, and β
β permanent errors are not retried. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββ 6. Report βββββββββββββββββββββββββββββββββββββββββββββββββ
β Summary table + share links (+ QR codes), --json document, β
β an optional Slack/Discord/Teams post (sent on failure too) β
β and one line appended to the build history. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βοΈ Configuration #
| Scope | Location | Stores |
|---|---|---|
| Machine | ~/.config/flutter_distribute_manager/config.json( %APPDATA%\flutter_distribute_manager\config.json on Windows) |
rclone remote, Drive folder, Diawi token, notify webhook. |
| Project | .flutter_distribute_manager.json in the project root |
last platform, app name, Team ID, scheme, export method, environment; Firebase app ids, groups, testers and whether to upload there. |
| History | history.jsonl next to the machine config |
one line per real run β see Build history. |
The machine config holds API tokens, so on macOS/Linux it is written user-only
(chmod 600) via an atomic temp-file rename β there is no window in which it
sits world-readable, and an interrupted write cannot truncate a good config.
XDG_CONFIG_HOME is honoured.
Add .flutter_distribute_manager.json to .gitignore if you'd rather not
commit your per-project choices.
π― fvm-pinned projects #
If your app is pinned with fvm, you don't need a global flutter on your
PATH. The tool detects fvm (.fvmrc, .fvm/, or .fvm/fvm_config.json) and
runs the project's pinned SDK via fvm flutter β¦ automatically β so builds use
the exact version your project expects.
- It also finds a vendored SDK (
.fvm/flutter_sdk,.fvm/versions/<v>,$FVM_CACHE_PATH,~/fvm/versions/<v>) whenfvmitself isn't onPATH. doctorreportsFlutter (fvm 3.x.x)when it resolves via fvm.- If the project is pinned but nothing fvm-shaped is found, the run warns and records a warning in the result rather than silently using a different SDK.
- Override detection anytime with
--flutter-path /path/to/flutter.
π Troubleshooting #
command not found: flutter_distribute_manager after installing
The global bin directory isn't on your PATH:
export PATH="$PATH":"$HOME/.pub-cache/bin" # add to ~/.zshrc or ~/.bashrc
source ~/.zshrc
macOS shows a security popup for flutter / dart / rclone (Gatekeeper)
Run flutter_distribute_manager doctor β it lists any quarantined binaries and
prints the exact command to clear each one:
xattr -d com.apple.quarantine /path/to/binary
rclone remote β¦ not found / Google Drive not reachable
Your sign-in expired or the remote was renamed:
rclone config reconnect <remote>: # refresh the token
flutter_distribute_manager init # or redo setup
flutter_distribute_manager doctor # confirm
Note the remote name is bare β gdrive, not gdrive:.
iOS build fails immediately with an export-method error
Pass a value Flutter accepts: development, release-testing (the default β
exported as Xcode's ad-hoc), ad-hoc, enterprise, or app-store. For
signed builds also pass --team-id.
"Xcode command-line tools" are installed but the iOS build still can't archive
The Command Line Tools alone are not enough β you need full Xcode, and the toolchain has to point at it:
# Install Xcode from the App Store first, then:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
(xcode-select --install installs the Command Line Tools, which is the thing
that isn't sufficient β it will not fix this.)
Noisy FINE: / SLVR: / IO : lines on the very first run
That's pub recompiling its snapshot right after
dart pub global activate β not this tool. It disappears on the next run. Use
--quiet to trim build chatter, or --json for clean machine-readable stdout.
A build "hangs"
Almost always a signing prompt, an SDK licence question, or a CocoaPods/Gradle prompt waiting on input. Run once interactively to answer it; the tool closes child stdin and bounds output draining so daemons can't wedge the run.
Firebase: "not logged in", or a 403 on upload
Run firebase login (locally) or set GOOGLE_APPLICATION_CREDENTIALS to a
service-account key with the Firebase App Distribution Admin role (CI),
then flutter_distribute_manager doctor from the project directory. A 403
means the account can sign in but has no App Distribution rights on that
project; a 404 usually means the app id belongs to another project.
Firebase rejects the App Bundle
AAB distribution needs the Firebase project linked to Google Play (Project
settings β Integrations β Google Play). Until then use --android-format apk
for Firebase β both still puts the bundle on Drive.
"Upload verification failed" (exit 69)
The file Drive holds is not the file that was built β a truncated transfer, a
proxy rewriting content, or a name collision with another run in the same
minute. No link was shared for it. Re-run just the upload with the command the
tool prints (--upload-only β¦; see Retrying a failed upload). If your remote cannot report hashes at all,
the run reports size-only instead; --no-verify-upload turns the check off.
"Another build is already running for this project"
A per-project lock stops two concurrent builds corrupting the shared Gradle/Kotlin caches. It's an OS file lock, so it clears automatically when the other process exits β including if it crashes. If no other build exists, check the message: a setup problem with the temp directory is reported differently and the build continues unlocked.
The wrong APK got uploaded
build/app/outputs/flutter-apk/ accumulates every variant ever built and
Flutter never cleans it. The tool filters by build mode, flavor and timestamp β
but if you use flavors, pass --flavor with --skip-build too, otherwise
"the last build" means whichever variant is newest.
β FAQ #
Do I need a Google Cloud project or OAuth credentials?
No. rclone handles Google Drive sign-in in your browser during init.
Where is my Diawi token stored, and is it safe?
In the machine config file, written user-only (chmod 600) on macOS/Linux via
an atomic rename. It is never echoed to the terminal (entry is hidden), never
printed by config --show, and is redacted from logs and error messages β
including the ones Diawi itself echoes back.
Can I upload without rebuilding?
Yes β --skip-build (alias --upload-only). Add --flavor if you use flavors.
Does it work on Windows / Linux?
Yes for Android builds and Drive uploads. iOS builds require macOS + Xcode. On
Linux/Windows, --platform ios fails immediately with a clear message (exit
78) β and under --dry-run it is reported as a blocker, so the JSON document
is still emitted. --platform both builds Android and records a warning about
the skipped iOS in warnings.
How do I change a saved setting?
flutter_distribute_manager config (interactive), config --set-β¦ (scripted),
or override per-run with the matching flag.
Is this safe to run in CI?
Yes. Pass every value as a flag plus --yes for zero prompts, and --json for
a parseable result. Use --dry-run as a preflight gate. Distinct exit codes
(see above) let a pipeline react.
Does it upload my source code anywhere? No. It uploads only the built artifacts (plus their checksum and release-notes files), only to the destinations you enable, and it has no telemetry.
Does it publish to the Play Store or App Store?
No β it ships QA builds. An App Bundle built with --android-format aab is
ready for you to upload to Play, but this tool does not do it.
Where are my old share links?
flutter_distribute_manager history β every real run is logged with its links
and checksums.
π§© Programmatic use #
The building blocks are also exported as a library:
import 'dart:io';
import 'package:flutter_distribute_manager/flutter_distribute_manager.dart';
Future<void> main() async {
final project = FlutterProject.find(Directory.current.path);
if (project == null) {
stderr.writeln('No Flutter project here.');
exit(64);
}
final artifacts = await AndroidBuilder(const ProcessRunner(), Logger())
.build(project, environment: ReleaseEnvironment.uat);
for (final artifact in artifacts) {
print('${artifact.name} ${artifact.humanReadableSize}');
}
}
See example/ for a full walkthrough.
π€ Contributing #
Issues and PRs welcome at github.com/Ashutoshtripathi980/flutter_distribute_manager.
dart pub get
dart analyze # must be clean
dart test # 700 tests
π License #
MIT Β© 2026 Ashutosh Tripathi.