Skip to main content

flutter_distribute_manager 4.0.1 copy "flutter_distribute_manager: ^4.0.1" to clipboard
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 #

pub package license: MIT Dart platforms

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.

⚠️ --yes changes the defaults. With no terminal (or with -y), the tool never asks: --platform and --environment fall back to whatever is saved in .flutter_distribute_manager.json, and then to android / 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 both on 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, exit 78.
  • 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-abi not given explicitly, the run builds a universal APK (and says so in notes). Force --split-per-abi and only arm64-v8a goes 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, and notes says so.
  • Authentication is the CLI's business: firebase login locally; GOOGLE_APPLICATION_CREDENTIALS (recommended) or FIREBASE_TOKEN in CI β€” see Secrets via environment. doctor reports 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
  • --flavor is passed to the bundle build and used to find the right bundle in build/app/outputs/bundle/<flavor>Release/ (Gradle never cleans it). --skip-build / --upload-only finds the last bundle the same way.
  • --split-per-abi applies 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/checkout with fetch-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 of pubspec.yaml are left exactly as they were.
  • patch/minor/major also increment the build number when there is one: Android refuses to install a lower versionCode over a higher one, and the stores reject a repeated one. A pre-release suffix (-rc.1) is dropped.
  • No build number yet? Flutter builds versionCode 1 without one, so --bump build on 1.4.2 writes 1.4.2+2 β€” +1 would 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 minor on 1.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.yaml in a later step, or every run bumps from the same number.)
  • --dry-run shows the planned version and writes nothing. --json reports versionBump: {part, from, to, applied} and version is 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 --json document still emitted. --bump with --skip-build/--upload-only is 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-run exits 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>) when fvm itself isn't on PATH.
  • doctor reports Flutter (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.

3
likes
160
points
333
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#cli #flutter #release #distribution #automation

License

MIT (license)

Dependencies

args, crypto, http, path

More

Packages that depend on flutter_distribute_manager