cux_ship

Release tooling for shipping a Flutter or Dart app to the App Store and Google Play: upload an artifact, publish the store listing, promote a build that is already up, and turn a CHANGELOG.md into the notes each store shows.

Extracted from Hold the Wheel with its history intact — git log --follow on any file here reaches back past the extraction. Most comments in this code record a specific incident, and that is the point of keeping the history: git blame is usually the only way to recover why a line exists.

Inside: a hand-written App Store Connect REST client (ES256 JWT auth, the three-step asset upload, screenshot and listing metadata, TestFlight notes, promotion), and a Google Play client on googleapis that uploads a bundle to a track, publishes the listing and promotes between tracks.

There is a second package, and which one you want depends on what you are doing.

Package Depend on it when
cux_ship You are shipping. The command, and everything behind it. Pulls googleapis, an image codec, an HTTP client and a JWT signer, because publishing needs all of them.
cux_ship_verify You are checking. The offline half — the changelog parser, the App Store metadata model, and the checks over both. No dependencies at all, which is what makes it right for a dev_dependency that runs in your test suite on every push.

cux_ship depends on cux_ship_verify, never the reverse. That direction is the whole design: reaching the checks through the CLI is what would put googleapis in the lockfile of somebody who only wanted to know whether a release note is too long.

The command

cux_ship appstore upload            play upload            release finish
         appstore promote           play promote           release refspecs
         appstore beta-release      play tracks            screenshots flatten
         appstore what-to-test      play listing           verify
         appstore beta-groups       play version-code      secrets add
         appstore builds            play data-safety       secrets check
         appstore versions                                 secrets list
         appstore screenshot-types                         secrets remove
         appstore build-number                             secrets exec
         appstore wait                                     secrets place
         appstore signing                                  secrets clean
                                                           secrets pack
                                                           keychain exec
                                                           deps install
                                                           deps check
                                                           manifest write

Run it from a project root and it works out the rest. The applicationId comes from Gradle, the bundle identifier from the Xcode project, the version from pubspec.yaml, and CHANGELOG.md / store/appstore / store/play from where they conventionally sit. Flags override; they are not requirements. So the normal case is a bare subcommand:

cux_ship play promote        # internal → production, newest build, notes from CHANGELOG.md
cux_ship appstore promote    # newest processed build → App Store review

An App Store promotion submits for review; what happens once Apple approves is the version's release type. New versions are created MANUAL — somebody presses release — and existing ones are left as App Store Connect has them. --release-type AFTER_APPROVAL says go out on approval instead, and the run prints the effective value read back from Apple either way. It is a different axis from --phased, which is how fast a release rolls out once it starts.

Anything that becomes public asks first, printing everything it inferred, so a wrong guess is visible before it is acted on rather than after:

About to release to production on Google Play. This is public immediately.
  app           design.codeux.holdthewheel
  to track      production
  from track    internal
  versionCode   newest on the "internal" track
  notes from    /path/to/CHANGELOG.md

Proceed? [y/N]

--yes skips the question. With no terminal and no --yes the command refuses rather than assuming yes, so a CI job that gained an interactive step fails loudly instead of releasing on a default. --dry-run never asks — it writes nothing, and a prompt there would only teach the habit of answering yes.

When the app is not the repository root

In a monorepo the Flutter app is a subdirectory and the release is still a property of the repository. Both halves of that matter:

The repository owns CHANGELOG.md and store/. The app directory owns pubspec.yaml, android/, ios/ and macos/.

That is not a compromise between two conventions. A version lives in pubspec.yaml because Flutter puts it there, and platform identifiers live under android/ and ios/ for the same reason. The changelog and the store listing describe what shipped — and in a monorepo most of what a user notices usually changed in some package other than the app.

Say it once, in .cux-ship.yaml at the repository root:

app-dir: app

This is a property of the repository rather than of a command, which is why the file is the normal home for it — a shell script that drives several cux_ship invocations would otherwise repeat one constant at every call site, which is exactly the "keep three copies in step" that inference exists to remove. --app-dir overrides it and CUX_SHIP_APP_DIR sits between the two, in that order.

An unknown key in that file is an error, not something skipped. It is read silently before every command, so a misspelt key that is quietly ignored is a setting that appears to be applied and is not. For the same reason an app-dir that does not exist, or is outside the repository, stops the command instead of being inferred past — the alternative is every inferred value turning back into a required flag, and the first symptom being a command asking for a --package it has always worked out for itself.

Without any of this, nothing changes: a project whose app is its repository needs no file and reads exactly as it always did.

beta-release is promote's TestFlight sibling

cux_ship appstore beta-release --build-number 52 --beta-group "External Testers"

It gives a build TestFlight already holds to a beta group, and builds and uploads nothing — the case where CI uploaded the build and upload has nothing left to carry. An internal group receives the build by assignment alone. An external one receives nothing that way, so the release carries on: the Beta App Description is reasserted from store/appstore/listings/<locale>/beta_description.txt when that file exists (and left to the console when it does not), the build is submitted for beta review, and the closing line reads back the state Apple now reports. --build-number is required rather than defaulted to the newest, for the same reason appstore wait requires it: "newest" would release somebody else's upload.

An upload is three phases, and each one runs on its own

appstore upload --artifact … transfers the artifact, waits five to fifteen minutes for Apple to process it, then writes the TestFlight notes and any --beta-group release. Those are not the same kind of work:

Phase Contends for Takes
the transfer one CFBundleVersion, which Apple accepts once minutes
the processing wait nothing — any machine with the API key can poll seconds to 15 minutes
notes, beta group one build's records seconds

Run together, the whole command inherits the exclusivity of the transfer and the duration of the wait. Shipping iOS and macOS from one commit then means serialising two uploads end to end, with the machine idle for the whole of the second wait.

How much that is worth varies more than the docs suggest. Two measured runs on one account, 9 September 2026: a 28 MB iOS build was usable 135 seconds after its transfer finished, and a 67 MB macOS build 44 seconds after its own. Neither was ever seen processing — a build is absent from /v1/builds and then present and VALID, so appstore wait is polling through an invisible window rather than watching a reported state. At those numbers the split saves tens of seconds, not the ten minutes the 5–15 figure implies; the figure is a tail, and the 45-minute default timeout is sized for it. The structural reason to split stands whatever the number is — a wait that anything can do should not hold a slot only one thing can — but size the benefit from your own account rather than from this table.

So each phase has a command:

cux_ship appstore upload --no-metadata --manifest dist/ios/manifest.json --skip-waiting &
cux_ship appstore upload --no-metadata --platform macos --manifest dist/macos/manifest.json --skip-waiting &
wait

cux_ship appstore wait 52 &                       # both polls run at once
cux_ship appstore wait --platform macos 52 &
wait

cux_ship appstore what-to-test --build-number 52 --yes
cux_ship appstore what-to-test --platform macos --build-number 52 --yes

what-to-test writes the TestFlight "What to Test" and nothing else, taking the text from the same CHANGELOG.md section upload would have used. Splitting is a choice: a single appstore upload still does all three, and is what a release with one platform should keep doing.

None of these three touches an App Store version, so all of them work while one is in review. what-to-test reads /v1/apps and /v1/builds and writes /v1/betaBuildLocalizations; the record review locks is appInfos, which only the listing publish reads — and an upload carrying an artifact never publishes the listing, whatever --metadata says. --no-metadata above is not what buys that: it declines the offline listing validation, so a tree that is incomplete for reasons unrelated to this build cannot refuse an upload that was never going to publish it. Worth passing on a TestFlight upload for that reason alone.

It refuses rather than waits. A build Apple is still processing gets appstore wait <build> named at it, not a second blocking poll under another name — the point of the split is that a command does something or waits for something, never both. --build-number is required, for the same reason wait and beta-release require it.

And --skip-waiting no longer loses the notes quietly. It skips the wait, and the notes are written after the wait, so it used to skip those too — its help said so, called itself a debugging flag, and a caller reaching for it to get concurrency had no reason to read that as being about them. It now prints the commands that finish the job, carrying the run's own build number, its platform, and whichever notes flag it was given:

==> not waiting for processing, as asked
    so the TestFlight notes are NOT set. Finish elsewhere:
      cux_ship appstore wait 52
      cux_ship appstore what-to-test --build-number 52

A warning and not a refusal, deliberately. --changelog and --release-notes say where the text lives, not write it now: a repository that keeps its changelog anywhere but the root has to pass --changelog on every invocation, and --release-notes has no inferred default at all — so refusing them would sort callers by directory layout rather than by intent, and would shut the whole decomposition to anyone who keeps notes in a file. --beta-group is different and is still refused: it names an action the run cannot perform.

promote is per-store; release finish is per-release

appstore promote and play promote change no version and touch no git. A build number belongs to a commit; both stores promote that same build; so the version they publish is the same one — which only holds if promotion cannot move it.

The repository-side half is its own command, run after every store has been promoted:

cux_ship release finish --build-number 41

It tags the released commit and moves the branch to the next patch version, with an empty changelog section for it. That second part is not a convenience: a released version is public, so every later build would otherwise claim a name that is already in front of users, and a release build should refuse in that state — meaning a release would quietly break the next push. Doing it here means that state never exists.

Always a patch bump, because it is the only choice that cannot be wrong before the work exists. Calling it a 1.1.0 instead is an ordinary commit afterwards.

Repeating it is safe, because a release is exactly the situation where something fails half way and gets run again: a tag that already names this commit is fine, and a branch already past the released version is not bumped.

A tag that names a different commit is an error, and so is the bump with it, --dry-run included. That is one version recorded against two commits, so one of them is wrong and this refuses rather than choosing; retag deliberately.

A repeat also pushes a tag an earlier run created but failed to push. That used to be permanent: the push ran only on the run that created the tag, so every later run found it locally, said it was leaving it alone, and finished green while the remote never received it — leaving the release untagged anywhere a later reader looks.

Without --commit, the build number finds the commit through the upload record. uploaded/v1.0.3+41 names the commit build 41 was built from — it is written from the manifest's gitSha before the store is contacted, which is the whole reason it exists — so with tag.upload on, --build-number 41 looks for exactly one tag in the configured format with that build and the version globbed, dereferences it, and says which tag answered. None is refused, naming --commit: with recording on, every upload since carries a record, so no match means a wrong number or a build from before recording, and HEAD is the least likely answer to either. More than one is refused naming them: one build number on two commits is the collision the record exists to make visible. --commit wins outright, and the tag is not consulted. HEAD is the default only when nothing asked for a lookup — no --build-number, or recording off.

--commit       what to tag; without it, --build-number is looked up in the
               upload record when tag.upload is on, and HEAD otherwise. Any
               commit-ish; resolved before it is compared, so a short sha or
               HEAD names what you meant
--version      what was released; defaults to that commit's pubspec.yaml
--branch       where the bump belongs; defaults to main
--no-tag / --no-bump / --no-push / --dry-run

--manifest — name the build once

If your build script writes a manifest beside its artifact, hand that over instead of retyping what is in it:

cux_ship appstore upload --platform ios --manifest dist/ios/manifest.json
==> how-it-went-1.1.0-51.ipa — build 51 of 1.1.0 from fef65ce, digest verified

It supplies --artifact (or --aab), --build-number, --version-name and --commit. Explicit flags still win — a manifest is inference, and inference loses to what was typed.

It also verifies the artifact against the digest the manifest records, which catches a dist/ that was edited, half-written, or left over from an earlier build whose manifest was replaced without its artifact being rewritten. Every flag correct and the bytes belonging to a different build is not a failure that announces itself.

Hash the artifact you are going to upload, after signing. If a producer records the digest before the signature is applied, this check fails on every real release rather than never — the sort of thing found at the worst possible moment, on a path nobody exercises until it matters. The reference producer copies the signed artifact into dist/ and hashes that copy.

A build the manifest marks as coming from a dirty tree is refused unless you pass --allow-dirty, because the commit it names does not then describe what is inside the artifact. If your own script already refuses dirty builds, the two have to agree: a build yours deliberately allows will otherwise pass your check and fail this one.

Schema 1, and an unknown schema is refused rather than read optimistically — every value the upload is named by comes from this file:

{ "schema": 2, "platform": "ios", "versionName": "1.1.0", "buildNumber": 51,
  "gitSha": "fef65ce…", "dirty": false, "format": "ipa",
  "artifact": "how-it-went-1.1.0-51.ipa", "sha256": "…" }

artifact is relative to the manifest, so a dist/ tree stays movable.

manifest write — the other half, so the schema exists once

cux_ship manifest write --artifact dist/android/app-1.1.0-53.aab \
  --platform android --format aab \
  --version-name 1.1.0 --build-number 53 \
  --git-sha "$SHA" --no-dirty

Before this, every consuming repository wrote the file with a shell heredoc — so the schema existed in prose in each of them and in code in none, and a field one producer omitted was invisible until an upload weeks later published an artifact described by the wrong numbers. The writer sits beside the reader, which makes the round trip a test rather than a convention.

Three things it refuses, each of which fails silently otherwise:

  • The digest is computed here and cannot be passed in. A digest recorded before signing fails verification on every real release rather than never, so it must not be expressible. Run this after signing.
  • --dirty has no default. Give it as --dirty or --no-dirty; a script that forgot the flag would certify every dirty build as clean.
  • An abbreviated --git-sha is refused. 40 hex characters for a sha1 repository, 64 for sha256. A reader normalizes whatever it is given, which is exactly what lets a seven-character sha survive here and break a tool that does not.

--git-sha and the dirty flag are inputs and never derived: this runs after the build, where a tree that moved in between is invisible.

--out writes the manifest under a different name in the artifact's own directory — for a build that keeps one artifact per platform directory and wants a fixed manifest.json its uploader can name without globbing. Anywhere else is refused, because the artifact is recorded as a basename resolved against the manifest's directory.

--derived-from — a repackaged artifact inherits its provenance

cux_ship manifest write --artifact app_1.9.15_amd64.deb \
  --platform linux --format deb \
  --derived-from app-1.9.15.tar.gz.manifest.json \
  --packaging gitSha=… --packaging repo=…

No build facts are retyped: the .deb's manifest carries the tarball's gitSha, dirty, versionName and buildNumber as its own, because a reader that knows nothing about derivation must still get true answers from the fields it already reads.

It takes the parent's manifest, not a hand-assembled entry, so the chain assembles itself and stays flat and nearest-first through any number of steps — a .snap from a .deb from a tarball records both ancestors and still reports the tarball's commit. The parent is digest-checked when its artifact sits beside its manifest, which turns a fetch that straddled a non-atomic upload into a refusal rather than a derivation recorded from bytes nobody has.

The cross-check — the manifest is compared against the artifact

Every --manifest upload reads the build's own values back out of the artifact:

==> app-1.1.0-66.aab — build 66 of 1.1.0 from bd8d32f…, digest verified
    cross-check: build number and version name agree with base/manifest/AndroidManifest.xml

The digest proves the bytes are the ones the manifest was written for. It cannot notice that the build disagreed with the values the script passed — an export step rewriting CFBundleVersion, a Gradle override, a variable that evaluated empty. In each of those the manifest honestly describes the wrong artifact and every flag is correct.

aab is read from base/manifest/AndroidManifest.xml (aapt2 protobuf), apk from AndroidManifest.xml (binary XML — a different encoding, so a separate reader), ipa from Payload/*.app/Info.plist, and pkg from the PackageInfo of the component it installs, which is where the installer records the app's two values so it can compare them against what is on disk. Nothing decompresses a payload. A format with no reader is trusted out loudcross-check: no reader for dmg — build number and version name taken on trust — because "not checked" must not render the same as "checked and fine".

A .pkg describes every bundle it installs — an embedded framework and a login item carry their own version numbers — so the one that answers is the one the package says it is versioned by, and the line names it:

    cross-check: build number and version name agree with Runner.pkg/PackageInfo (./Runner.app)

Recording which commit an upload came from

Off unless a repository asks for it:

# .cux-ship.yaml
tag:
  upload:
    enabled: true

tag: is the namespace for every kind of tag this tool writes, and both kinds are implemented. upload records one upload of one build and is off unless asked for; release names the tag release finish writes and is on, defaulting to the v{version} this tool has always written.

tag:
  release:
    format: rel/{version}    # default: v{version}

A release format must contain {version}, or every release would collide under one name. {build} is allowed but not required — and if a format asks for one when the command has none, the tag is refused rather than written with the gap left empty, because v1.2.3+ is wrong by a single trailing character in a name nobody reads twice.

With it on, play upload and appstore upload write an annotated tag naming the commit the artifact was built from, before contacting the store:

cux_ship play upload --manifest dist/android/manifest.json

--manifest supplies the commit, so nothing has to read it out by hand:

cux_ship play upload --commit "$(jq -r .gitSha dist/android/manifest.json)"

--commit is not inferred. An upload job routinely runs on a different checkout from the build — a workflow_run trigger, a repackaging step, a retry hours later — so HEAD is not the answer, and a record naming the wrong commit is worse than no record at all. It is your build manifest's gitSha.

Before the store rather than after, because a record written afterwards makes the failure mode shipped but unprovable — an artifact in front of users whose commit nobody can name. Written first, a failure fails an upload that had not happened yet. It follows that the tag records an upload attempted, not accepted: a signature refusal leaves it standing over an artifact nobody received. Read them as attempts.

So the tag answers "which commit", and only the store answers "what does it hold". Those are two questions and the tag is authoritative on exactly one of them: the commit an artifact was built from is a git fact no store knows, and what each store is currently serving is a store fact git can only approximate — per store, and per Apple platform, which drift apart in the ordinary case rather than the exotic one. Until 4.1.0 the tag was the only machine-readable answer to either, so consumers used it for both and had to hedge every status line they printed. It is not any more: ask play tracks, appstore builds, or package:cux_ship/read.dart — one call per store, answered by the store. A status that reports the tag as what a store holds is reporting an intention as an outcome.

The same name at a different commit is a hard error — one build number reaching two commits — and it raises UploadCollisionException, distinct from an ordinary failure so a wrapper that tolerates "this build is already uploaded" does not tolerate this too.

The default name is uploaded/v{version}+{build}, and the namespace is a correctness property rather than a preference: a release guard that asks "has this version shipped" by taking the highest v* tag reads a bare v1.0.4+56 as a released 1.0.4 — sort -V ranks build metadata above the version it annotates — and then refuses to build 1.0.4, naming a release that never happened. Override with tag.upload.format, which must contain {build}.

The namespace protects that guard and not every reader — check yours. A consuming repository found the second one the hard way: its build script derives the version name with git describe --exact-match, which returns whatever tag HEAD carries. After an upload tagged the commit, the next platform built in the same release read uploaded/v1.1.0+67 as its release tag, stripped a leading v that was not there and everything after the +, and refused with uploaded/v1.1.0 against a pubspec saying 1.1.0.

git describe --exact-match --match 'v*' is the fix there. The general point is that these tags are now on your release commits, so anything that reads tags sees them — and only a multi-platform release that interleaves build and upload produces it, which is why no test had the shape.

release refspecs — so a clone can see the build numbers

refs/buildnumbers/* and refs/notes/buildnumbers are outside refs/heads and refs/tags, so a clone's default refspec ignores them: a fresh clone has no allocation history until something fetches it explicitly.

cux_ship release refspecs        # once per clone

It appends to remote.origin.fetch and never replaces it — the branch refspec already there is what every other git operation depends on. --remote names a different remote, --dry-run says what it would do.

appstore signing reads the account, not the app

Automatic signing — xcodebuild -allowProvisioningUpdates, and Xcode whenever it signs a device build — registers App IDs, capabilities, app groups and profiles without mentioning it. That is mostly what you want, and it is the reason a project can drop match and its encrypted certificate repository entirely. The cost is that the account accumulates, silently, and the first sign of it is usually a registration refused with "An App ID with Identifier … is not available" — which means Xcode created that id months ago, often for a target since renamed.

cux_ship appstore signing

Certificates first, because they are the only capped category and the only one shared by every app in the team: exhaust the cap and nothing signs, for any app. Then App IDs, marking the ones Xcode registered (it names them XC <dotted id>) and separating this project's from the rest. Then profiles, with their state and expiry.

It writes nothing, ever — no flag makes it destructive. Prune from the portal; anything automatic signing still needs, it recreates on the next build.

Consuming it

Install it once and use it from anywhere:

dart pub global activate cux_ship
cux_ship --help

Or pin it, which is what a project that releases from CI should do — a runner should not resolve "whatever is newest today" in the middle of a release:

# tool/cux_ship/pubspec.yaml — a tiny package whose only job is to pin this
name: my_app_ship
publish_to: 'none'
environment:
  sdk: ^3.12.2

dependencies:
  cux_ship: ^1.6.0
cd tool/cux_ship && dart run cux_ship --help

A separate package rather than a dependency of your app, because none of this belongs in what you ship — and because dart run resolves against the package it is invoked from. Everything the command needs is inferred from the repository around it, so the directory it lives in does not matter.

Most projects end up with both, and the two drift apart silently. Scripts run the pinned one through dart run; anything typed at a prompt runs the global one, because that is what is on PATH. Bumping the lockfile does nothing for cux_ship … typed by hand, and neither install mentions the other — so a fix can be installed, resolved, and still absent from the command you are actually running. That has already cost someone twenty minutes concluding a released fix had not landed when it had.

dart pub global list | grep cux_ship                 # the global one
grep -A5 'name: cux_ship$' tool/cux_ship/pubspec.lock | grep version  # the pinned one

Neither is --version, because there is no such flag — the command reports nothing about itself, which is part of why the two can disagree unnoticed. The $ in that grep is load-bearing: without it the pattern also matches cux_ship_verify, which is the next entry in the lockfile, so it prints two versions with the wrong one first. Two people wrote that grep unanchored before it was noticed.

Worth checking first whenever a version's behaviour is not what its changelog says.

The pinning problem is in your documentation, not your scripts. Scripts are already safe: anything that cds into the pinned package before dart run cannot reach the global install, and that is nearly every script that exists. What resolves PATH is the bare cux_ship … in a README, a runbook, or a usage header — every command a person types by hand. Two projects measured themselves after hitting this: one had 4 pinned invocations against 63 documented bare ones, and the other, whose documented commands all name a wrapper script, had one. Same package, same lockfile, an order of magnitude apart in exposure.

So a repository that documents a bare command name has not pinned it, however carefully it pinned the package. The wrapper script is not protecting the scripts — they were never at risk. It is protecting the reader.

If your test suite uses the checks, depend on cux_ship_verify directly rather than reaching them through package:cux_ship/verify.dart. That re-export still works and is kept for compatibility, but it brings the whole CLI — googleapis included — into the lockfile of every contributor.

Reading the stores as JSON

appstore builds, appstore versions and play tracks take --json. For a caller that is not a Dart program — a shell status, jq at a terminal, a CI step reading one number:

cux_ship appstore builds --platform ios --json | jq -r '.newestBuildNumberAsInt'

Three things worth knowing before you parse one:

  • stdout carries the document and nothing else. Every other line goes to stderr, the document is written whole and once at the end, and a failure leaves stdout empty and says why on stderr. Check the exit code first.
  • schema is an integer and is counted per kind. Refuse a schema you do not recognize rather than reading optimistically; read kind first, because it is what says which counter applies.
  • display is for showing a human, and its text is not promised. It is always an array of strings, at the document level and on each item, and that nesting is promised — as is its never being empty: an empty listing renders a sentence saying so, not an empty array. Print those lines rather than rendering the fields yourself — a caller that renders the same model its own way reports something different from what this command reports, silently.

Build numbers are strings, because CFBundleVersion may be dotted; buildNumberAsInt and newestBuildNumberAsInt are the integers to compare with, null rather than zero when the version is not a single integer.

An App Store version names its build. buildNumber is the CFBundleVersion Apple has attached to it and buildNumberAsInt is the form to compare — the answer to "is what is live the thing I think is live", which versionString cannot give because two builds of 1.4.0 are the same version. Both are null when Apple names no build, which is every version before it is submitted.

A staged rollout carries two fractions, and the second is the one to read. Play sets userFraction only for inProgress and halted, so it is null for the release that reached everybody; audienceFraction is this package's answer and is 1.0 there. It measures who has the release rather than what the rollout is doing, so it reads correctly beside serving in either order — a halted release is serving: false with a non-zero fraction, and both are true at once.

The whole contract, and why it is JSON rather than YAML, is docs/design/json-output.md.

From Dart, decode it into our classes rather than writing a reader. package:cux_ship/documents.dart is the format — every key, its type, its nullability and its vocabulary, with fromJson for each document. The API docs pub.flutter-io.cn renders for it are the published statement of the format, so there is no second description to drift.

import 'dart:convert';
import 'package:cux_ship/documents.dart';

final result = await Process.run('cux_ship', [
  'appstore', 'builds', '--platform', 'ios', '--json',
]);
if (result.exitCode != 0) {
  throw StateError(result.stderr as String);   // stdout is empty; stderr says why
}
final builds = AppStoreBuildsDocument.fromJson(
  jsonDecode(result.stdout as String) as Map<String, dynamic>,
);
print(builds.newestBuildNumberAsInt);          // compare this against a git tag
print(builds.builds.first.usable);             // not processingState == 'VALID'

Ask the question, not the vocabulary. usable, editable, expired, mayBecomeUsable and serving are there so a caller never opens Apple's or Google's documentation.

Two of them are bool?, and the null is the point. serving is true for a completed rollout and one in progress, false for a halted one and an unsent draft, and null when Play sent a status this version does not name — a bool would have to report a possibly-healthy rollout as reaching nobody, or call an unrecognized state healthy, and both are claims nobody can stand behind. needsNewUpload answers "can this only be fixed by uploading another": false while Apple is processing and for a usable build, true once Apple has refused the binary or the build has expired, null for a state nobody here names. That distinction is not academic: usable alone reads as "wait for VALID" for every state, which is advice to wait forever for the two where the fix is to upload a different build.

Each of those reads correctly on its own, in every state — deliberately, so that no pair has to be read in a particular order to be safe.

usable and editable stay plain bool and fail closed, so usable == false means "not known to be usable" rather than "not usable" — the right default for a flag that gates an action rather than a report.

A store's vocabulary reaches you twice: as ours, and as theirs. processingState is this package's closed vocabulary — processing, valid, failed, invalid, unknown — and processingStateRaw is Apple's own word, exactly as sent. Same for appStoreState, releaseType and Play's status. Write against the first; fall back to the second in the one case the first cannot cover.

unknown is a value of our vocabulary rather than a hole in it, so a state Apple ships tomorrow arrives as "unknown" with its real name in the *Raw field, and survives a round trip intact. Members are never added because a store added a value — a Dart switch expression must be exhaustive, so that would break your build on Apple's schedule rather than ours.

kind and platform are ours and closed, and an unrecognized one is refused rather than degraded. A null field means the store sent nothing, which is not the same as unknown.

This adds nothing to what the command does: these are value types over what it printed. Reads that happen in your process — giving up the printed command line and per-step --only — are package:cux_ship/read.dart instead.

Reading the stores from Dart

package:cux_ship/read.dart answers what the stores hold, as objects. For a release script written in Dart rather than shell, which would otherwise spawn this command and match regular expressions against what it printed:

import 'package:cux_ship/read.dart';

final play = await PlayReads.open(packageName: 'design.codeux.example');
try {
  final tracks = await play.tracks();
  for (final line in tracks.lines) {
    log.writeln(line);            // what `play tracks` prints, verbatim
  }
  print(tracks.newestVersionCodeOn('internal'));
} finally {
  play.close();
}
final apple = await AppStoreReads.open(
  bundleId: 'design.codeux.example',
  platform: AscPlatform.ios,
);
try {
  final builds = await apple.builds();
  print(builds.newestBuildNumber);      // the newest Apple holds
  print(builds.newestUsable?.buildNumber);  // the newest one promotable now
  final versions = await apple.versions();
  print(versions.version('1.4.0')?.appStoreState);
} finally {
  apple.close();
}

appstore wait is here too, as AppStoreReads.awaitBuild, with an onProgress callback called once per poll — including the poll that ends the wait — so a caller streaming a forty-five-minute wait to a log writes its own heartbeat instead of scraping one.

Every result carries lines beside its fields, and the command prints those same lines. Print them and read the fields; a status that renders the same model its own way reports something different from what this command reports, and does so silently. They are this package's sentences, composed from the parsed fields — what they buy is one formatter, not fidelity to a store's own format.

Reads only, and that is the design. Nothing here uploads, promotes or publishes a listing — those stay commands, because the printed command line is what makes a failed release step resumable by hand, and per-step secrets exec --only … is what keeps a credential out of a step that has no use for it. In-process reads do need the credentials in the calling process, so a stage reading both stores runs under one secrets exec carrying both.

Every exported name is a semver promise and the list is deliberately short — docs/design/read-api.md says what is on it and why the store clients are not their own packages.

Credentials

No uploader reads a secrets file or knows what a keychain is. Every credential arrives as an environment variable — GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH for Play, and APPLE_API_KEY_ID, APPLE_API_ISSUER_ID and APPLE_API_PRIVATE_KEY_PATH for the App Store — so they work unchanged if those ever come from Vault, another CI's secret store, or a shell you exported by hand.

Every one of those is a path, and that is deliberate: a variable holding a filename is safe to print, and things print environments. See 2.0.0 in the changelog for the incident that settled it.

secrets exec is how they get there when you have nothing better, and it is deliberately a separate command rather than something the uploaders do: there is exactly one place that creates plaintext and one that destroys it, and the encryption choice stays swappable because of that split.

cux_ship secrets exec -- tool/build.sh --release android
cux_ship secrets exec -- cux_ship play upload

It decrypts secrets/release.yaml with sops, puts the credentials in the child's environment, and materializes the three that a tool can only open as a file — the Android keystore, the App Store Connect .p8 and the distribution certificate — into a private temp directory it removes however the run ends, including on Ctrl-C. The child runs with the repository root as its working directory.

--only narrows that to what the child actually consumes:

cux_ship secrets exec --only apple.api_keys.upload -- tool/upload.sh

The selector is family or family.instance, comma-separated or repeated. On this command it is optional — omitted, everything is placed, because a general-purpose wrapper that hands over nothing is inert. On keychain exec it is the only way anything arrives at all; see below.

Naming a subset removes what is not named, rather than merely declining to place it. That matters under nesting, where an outer wrapper has already put everything in the environment before an inner one runs.

# secrets/release.yaml, before sops encrypts the values
keystore_base64:              # the Android upload key, and its
keystore_password:                # password and alias. All three or none
key_alias:
key_password:                     # only for a keystore whose key password differs

play_service_account_json_base64:

api_key_id:                       # App Store Connect. Both, or neither
api_private_key_base64:
api_issuer_id:                    # team keys only — see below

distribution_p12_base64:          # only a machine with an empty keychain
distribution_p12_password:        # needs these. Both, or neither

Headings are allowed and mean nothing. Group them however the file reads best — one level deep, and the heading is discarded:

android:
  keystore_base64:
  keystore_password:
  key_alias:
apple:
  api_key_id:
  api_private_key_base64:

A credential does not become a different credential because of the heading it was filed under, so the names that matter are the leaves. The same leaf under two headings is refused rather than resolved — which one wins is not something to guess at with a credential.

keychain exec — signing, and a child that holds nothing

cux_ship keychain exec --profile ios_appstore -- tool/build.sh --release ios

It imports the signing certificate into a keychain that exists for the length of one command and is destroyed however that command exits, installs the profiles you name, and sets APPLE_KEYCHAIN. The login keychain is never read — a build whose identity comes from whatever a developer happens to have installed is a build nobody can reproduce.

The wrapped command is expected to pass OTHER_CODE_SIGN_FLAGS="--keychain $APPLE_KEYCHAIN" to xcodebuild. That is not a nicety: this command cannot remove the login keychain from the search list without taking Apple's intermediate certificates with it, so pinning codesign to ours is the only thing that makes "signed with the certificate we imported" true rather than likely.

Its child gets APPLE_KEYCHAIN and nothing else. No tokens, no keys, and not the sops identity. Whatever else the child needs is named:

cux_ship keychain exec --only ssh_keys.github_deploy -- tool/release.sh

That is knowledge only the call site has. This command wraps a build script, so what the script consumes happens below this command's arguments — in one real project four layers down inside a function, with the variable's name inside a printf format string. Nothing here can read that, and a rule that tried withheld the one token that must never be withheld.

SOPS_AGE_KEY is stripped unconditionally and cannot be readmitted. It is the master key to the whole file, and a child that can decrypt makes the useful guarantee here — that an archive cannot hold a key able to create or revoke signing material — hollow. If a child of yours needs to decrypt, run the two commands as siblings rather than nesting them.

--only governs the environment. It does not govern the keychain. The keychain this command builds holds every certificate the file has, and the child is given its path — so a child named only tokens.marks can still sign with any of them. That is deliberate rather than an oversight: a release run legitimately signs with the App Store certificate, notarizes with Developer ID and signs a .pkg with the installer certificate, and xcodebuild takes one --keychain. Splitting them would replace one keychain with a set the child has to choose between.

Assume it, rather than assuming otherwise. Which certificates the keychain holds is a different axis from what the child's environment holds, in the same way --profile is — and if it ever needs controlling it wants its own selector beside --profile, not an overload of this one.

Reading a secrets file without decrypting it

cux_ship secrets list

sops keeps key names in cleartext and encrypts only the values, so the shape of a file can be read with no identity and no decryption — nothing secret can reach a terminal or a transcript. It lists every credential, names the heading each sits under, marks any name secrets exec would refuse, and ignores the sops: metadata block.

Worth running before adopting a new version, since an unrecognized key stops secrets exec outright. It shares its notion of "a credential name" with the parser that enforces that, which is the only reason it can be trusted: a pre-flight check that approximates the real rules is one that eventually disagrees with them.

Three things stop the command rather than being worked around, and each is a failure that is otherwise silent:

  • An unrecognized key. A misspelt keystore_pasword means the credential never arrives, Gradle falls through to the debug key, and Play rejects the artifact after a full upload.
  • A half-configured group. Same outcome, from the other direction — which is why the groups above are marked "all or none".
  • A missing identity. Locally that is ~/.config/sops/age/keys.txt; in CI it is the single SOPS_AGE_KEY secret, so changing CI provider means moving one value.

It reports what it loaded rather than what it was asked for, so a credential that quietly is not in the file is visible before the command runs instead of three minutes into one.

Putting a credential in

cux_ship secrets add certificate distribution dist.p12 --password-file pw
cux_ship secrets add profile ios_appstore app.mobileprovision
cux_ship secrets add api-key upload ~/Downloads/AuthKey_ZHGL57YJVC.p8
cux_ship secrets add token artifact --env ARTIFACT_TOKEN --value-file tok
cux_ship secrets remove token fosshub

A name and an artifact, positionally, in that order. There are no --p12 / --p8 / --file flags: the artifact is identified by its contents. That is not only shorter to remember — it names the actual mistake when there is one ("this is a PEM private key, not a PKCS#12 bundle; did you mean add api-key?") and it catches what an extension cannot, a correctly named file with the wrong thing inside, which happens because people rename downloads.

The shape is not perfectly uniform, and the exceptions are stated rather than discovered: token takes no file — its value comes from --value-file or stdin — and play-account takes no name.

What it works out so nobody has to type it: the schema path, the base64, the JSON quoting, and an api key's id and kind, read back out of Apple's own AuthKey_ / ApiKey_ naming. Those last two are the fields most often got wrong, and the filename is the only signal altool ever gets about which kind of key it holds. It prints a certificate's subject and expiry and a profile's uuid, name and platform, so what was added is visible rather than assumed.

Two properties this exists for:

  • Every field lands in one write. Field-at-a-time writing is what makes half-credentials possible, and a half-credential is the dangerous state — a keystore with no password does not fail as "you forgot the password", Gradle falls through to the debug key. The partial state secrets exec refuses is now unrepresentable rather than merely reported.
  • It refuses to overwrite. --replace is the rotation verb. Silently replacing a signing key is worse than any partial write.

Replacing a certificate also names the profiles that were issued against the one going away, established before the write while the outgoing certificate is still there to fingerprint:

replaced apple.certificates.distribution

** 3 profiles were issued against the certificate you just replaced:
     apple.profiles.ios_appstore
     apple.profiles.ios_appstore_autofill
     apple.profiles.macos_appstore

Nothing else reports that coupling, and no artifact carries it — see secrets check below.

Building the .p12 from a keychain

cux_ship secrets add certificate distribution --from-keychain --team 64ZPC769JY

The onboarding path, for when there is an identity in the keychain but no file. It exports the certificate and its private key, builds a .p12 with a generated password, stores both, and removes the plaintext. macOS only, and macOS will ask permission — that prompt has to be granted.

The password is generated rather than accepted here, unlike every other path: we are building the bundle, nothing ever has to type its password, and one a human picks is one they reuse.

Three traps it encodes, all of which produce a file that looks fine:

  • It pairs on localKeyID, never on friendlyName. macOS labels the certificate bag with the certificate's name and the key bag with whatever the key was imported as — usually the account holder — so the two share no friendlyName. Filtering on it matches the certificate, misses the key, and builds a .p12 that imports without complaint and cannot sign.
  • It matches the certificate kind as well as the team, because an Apple Development certificate carries the same OU= and would otherwise be exported silently, producing builds the App Store refuses.
  • It checks expiry on every candidate. A keychain accumulates every distribution certificate a team has ever held and never sheds the expired ones, so "the certificate for this team" is usually several.

Passwords and token values are never command-line arguments — an argument is visible to every ps on the machine — so they arrive by --password-file, --value-file, or a prompt. A certificate's password is checked against the .p12 before anything is written: a bundle stored with the wrong password is accepted everywhere until something tries to sign with it, which is a full CI cycle away and does not look like a password problem when it arrives.

Checking that the credentials work, and agree

cux_ship secrets check

Three levels, each needing strictly more than the last and answering something the last cannot:

needs answers
cux_ship verify nothing are the release inputs sound
cux_ship secrets list no identity what is in the file
cux_ship secrets check an identity do the credentials work, and agree

Every credential is reported verified, failed, or opaque, and the exit code is non-zero only for failed. Opaque is not a to-do: it is a credential this tool cannot ever authenticate — a token, whose validity is the service's to judge — and it must not colour the exit code or the check becomes something people learn to skip past. There is deliberately no way for the secrets file to describe how to verify a token: a command or URL per token would make a credential file into something that executes, and the property worth keeping is that cux_ship cannot be tricked into spending a token it holds.

Run it after adding a credential, after a rotation, and when onboarding a machine — the moments an upload-time warning is structurally too late for. "Is anything about to expire" is a different question, continuous, and well served by a warning during upload.

The cross-checks are the part nothing else can do. A single-artifact command sees one credential; only something holding the whole decrypted file can ask whether a stored profile still embeds a certificate the file actually holds:

apple.profiles.macos_developerid                 verified  expires in 6563d
apple.profiles.macos_developerid ↔ certificates  verified  embeds apple.certificates.developer_id

That pairing is not derivable from either artifact alone. The profile above outlives the certificate inside it by more than a decade, so its own expiry date says nothing about whether it still holds a usable certificate — and replacing a certificate silently invalidates every profile issued against it, with nothing to notice until codesign fails partway through an archive.

The pairing needs security cms, so it is macOS-only. On anything else it is reported as opaque rather than skipped, because a cross-check that quietly does not run reads exactly like one that ran and found nothing wrong.

deps — sops and age, pinned by hash

secrets exec needs a sops binary. deps install fetches it, and age alongside it, into .bin/ at the repository root:

cux_ship deps install     # whatever is pinned and .bin/ lacks
cux_ship deps check       # report only; non-zero if anything is missing

Project-local rather than system-wide, so a laptop and a CI runner run the same bytes and neither needs a package manager. Both are single static Go binaries with no runtime of their own, which is most of why they were chosen.

The checksums are not ceremony: these files are downloaded and then executed, and a version pin alone only means "some build of 3.13.3". A download lands in <name>.part and is moved into place only once its hash matches, so an interrupted or tampered fetch is never picked up as installed.

The pins live in this repository, not in yours — bumping them is a cux_ship release rather than an edit in every project. deps update re-pins to the latest upstream releases and rewrites deps_pins.dart, so it only works inside a cux_ship checkout; a consumer gets new pins by moving the ref it depends on. It is hidden from --help for that reason — to every consuming project it is a documented way to get an error — but it still runs when typed.

APPLE_API_ISSUER_ID is optional, and leaving it out means something. A team key — Users and Access > Integrations > Team Keys — has an issuer id and a role that applies to every app in the team; team keys cannot be scoped to particular apps. An individual key is generated by one App Store Connect user, inherits that user's role and their app restrictions, and has no issuer id at all. Set the variable for the first kind and omit it for the second; everything downstream follows, including the sub: user claim Apple wants and altool's --api-key-subject.

An individual key is how a CI credential is kept from reaching every app you own. It cannot read the developer portal, though — certificates, identifiers and profiles are team resources and are refused whatever role the user has — so appstore signing says so and stops rather than reporting an empty account.

One command needs a stronger key than the rest. Uploading a build and editing a listing are App Store Connect operations, and an App Manager key does them. appstore signing reads certificates, identifiers and profiles, which are the developer portal — Apple gates that separately and only an Admin key reaches it. A key's role cannot be changed after it is created, so a team that wants both from one key has to create it as Admin.

An App Manager key is not refused with anything that says so; it gets a 403 naming nothing. appstore signing therefore fetches its three collections independently, reports whichever it could read, names the rest as refused, and only exits non-zero when all three were, which is a fact about the key rather than a finding about the account.

Keeping the guards

cux_ship_verify exists because two things worth checking live in the consumer, not here: the real CHANGELOG.md, and the real App Store metadata tree.

As a command:

cux_ship verify --appstore store/appstore \
  --require-screenshot-type APP_IPHONE_67 \
  --require-screenshot-type APP_IPAD_PRO_3GEN_129

The command also checks the one thing the library cannot: that the version in pubspec.yaml has a section at all. checkChangelogFile walks the headings the file has, so the version it lacks is the one it never sees — and the uploaders refuse that version late, on Play after the prompt and inside an open edit. An empty section passes; it is a decision, and what release finish leaves.

Or from your own tests, which is where it earns its keep — it then runs on every push rather than only at release time. Take cux_ship_verify as a dev_dependency for this; it has no dependencies of its own:

import 'package:cux_ship_verify/cux_ship_verify.dart';
import 'package:test/test.dart';

void main() {
  test('every changelog section fits both stores', () {
    expect(checkChangelogFile('CHANGELOG.md'), isEmpty);
  });

  test('the committed store tree would be accepted', () {
    expect(
      checkAppStoreTree(
        'store/appstore',
        requireScreenshotTypes: {'APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'},
      ),
      isEmpty,
    );
  });
}

Both stores enforce their limits after the artifact has been uploaded, which is far too late. These run offline, need no credentials, and fail on the push that introduces the problem.

Flattening screenshots

cux_ship screenshots flatten removes the alpha channel from PNG screenshots, which every simulator and emulator capture carries even when every pixel is opaque; where the alpha is genuinely opaque the channel is dropped exactly, and where it is not the image is composited onto a background rather than having the channel discarded, because discarding it turns a blank capture into a solid black rectangle.

It also reduces 16 bits per channel to 8 — a 48-bit PNG, which is what a macOS --no-chrome capture writes, and which Play's "24-bit PNG" rules out and Apple has been observed to refuse at ingestion. That rescales rather than truncating, so the picture survives; below 8 bits is left alone, because no store has been seen to refuse a palettised screenshot.

It sits at the top level rather than under appstore because stripping an alpha channel is an operation on an image, and because both stores refuse one — Apple says "Images can't include alpha channels or transparencies" and Play asks for "JPEG or 24-bit PNG (no alpha)" in every slot this tool uploads but the app icon, which is the one image either store wants an alpha channel in. --check reports what would change and exits 2 without rewriting, for CI.

It is deliberately a separate step from publishing: both upload paths refuse an alpha channel rather than silently fixing one, so the corrected file is the one committed and reviewed.

Not implemented

  • App preview videosappPreviewSets and appPreviews in the App Store Connect API. Screenshots are handled; videos are not. They use the same three-step reservation/upload/commit flow as a screenshot asset, so the shape is already here, but nothing has been written or tested.
  • Resolving a build number to a commit through anything but the upload record. release finish --build-number 41 finds the released commit through uploaded/v*+41 when tag.upload is on, because that tag is this tool's own record and names the manifest's gitSha. How a project allocates build numbers is still its own business — Hold the Wheel uses git-buildnumber and a notes ref — so with recording off, or for a build uploaded before it was turned on, the commit has to be resolved there and passed as --commit.

Development

The repository is a pub workspace: dart pub get once at the root resolves both packages against one lockfile, while each still declares ordinary hosted constraints — which is what lets them be published without any path: rewriting at release time. dart format, dart analyze --fatal-infos and dart test run per package; CI runs both as a matrix and adds dart pub publish --dry-run, so the metadata that gates a release is checked on every push rather than discovered at release time.

Lint rules live in the root analysis_options.yaml, which both packages include: package:lints rather than package:flutter_lints, because none of this depends on Flutter.

lib/src/appstore/ and lib/src/play/ are libraries, not executables. Each exposes a command enum, a parser builder and a run function, and lib/runner.dart wires them into the tree — so --bundle-id is described once, in the file that reads it, rather than restated by whatever presents it.

Licence

Apache-2.0. See LICENSE and the NOTICE.