cux_ship 4.4.0
cux_ship: ^4.4.0 copied to clipboard
One command for shipping to the App Store and Google Play: upload an artifact, publish the store listing, and promote a build a store already holds.
Changelog #
4.4.0 #
Source-breaking for a Dart caller, in a minor version — read this first if
you import documents.dart or read.dart. The number does not carry the
signal, so it is here instead. Decoding is unaffected: the JSON documents
are additive, both still declare schema: 1, and a caller that only reads
--json output can upgrade without changing a line.
What breaks is constructing these types, which in practice means a hand-written test fixture:
| Type | Change |
|---|---|
AppStoreState |
four new members — an exhaustive switch over it stops compiling |
AppStoreVersionEntry |
required buildNumber, required buildNumberAsInt |
PlayReleaseEntry |
required userFraction, required audienceFraction |
AppStoreVersion |
required buildNumber |
PlayTrackRelease |
required userFraction |
required rather than a defaulted parameter is deliberate and was the one
consumer's stated preference: a fixture that keeps compiling is a fixture
quietly asserting against a document this package no longer emits. The fix is
to name the new fields — null is the right value for a version Apple has not
attached a build to, and for a rollout Play sent no fraction for.
appstore versions says which build each version is.
AppStoreVersionEntry gains buildNumber and buildNumberAsInt, and the
printed line grows build 169 where Apple named one:
1.4.0 READY_FOR_SALE MANUAL build 169
The question versionString cannot answer. Two builds of 1.4.0 are the
same version and different binaries, so "is what is live the thing I think is
live" had no route through this package: Play's tracks carry versionCodes
and Apple's version record carries a build relationship, which the listing
was not asking for.
One request, not one plus an N+1. appstore versions now sends
include=build, and the sideloaded builds resource carries the build number
in its version attribute — Apple's name for CFBundleVersion, which reads
backwards and is the easiest thing here to get wrong. AscClient grows
getAllWithIncluded for it; getAll delegates to that and drops the map, so
the pagination walk — and the check that a next link cannot send the bearer
token to another host — has one copy rather than two. included is merged
across pages, because Apple repeats a sideloaded resource on every page that
references it and a per-page map answers null for everything but the last.
Measured against a live account, because none of it was checkable from
here. Without the include a version's relationships.build carries links
and no data key at all; with it, data names the build. That is the same
shape this package already recorded for categories on appInfos, and its
generalising to this endpoint was a question rather than an assumption.
buildNumber is null for two reasons and diagnoses neither. Apple names no
build for a version in PREPARE_FOR_SUBMISSION; a request that did not carry
the include would answer null too. The second is measured not to happen, and
the first has never been observed — the account this was measured against held
six versions and every one was READY_FOR_SALE — so the field reports a null
rather than an explanation. An earlier draft printed "this package asked
wrongly" when the data key was absent, which would have fired on an
unsubmitted version: a false alarm in the state an operator is most likely to
be looking at.
No schema bump — both fields are optional and additive, and
appstore.versions still declares schema: 1. A caller that decodes a
document is unaffected, in either direction: an older documents.dart
ignores keys it does not know, and a newer one reads an older document with
both fields null.
It is a source-breaking change for a Dart caller that constructs an
AppStoreVersionEntry, which is a different thing and does not live in the
schema number. Both fields are required, so a hand-built entry — a test
fixture, most likely — stops compiling until it names them. That is
deliberate: required makes the break loud and immediate rather than leaving a
fixture quietly asserting against a document this package no longer emits. The
one Dart consumer there is builds exactly such a fixture and expects it.
And the phased release still is not carried, though the same request could
now do it for almost nothing. Nobody passes --phased. Cheap is not a reason
to add a field to a published document.
A staged rollout says how far along it is. play tracks --json carries
Play's userFraction and a derived audienceFraction beside it, and the
printed release line grows a percentage where there is one to show:
internal: "1.4.0" codes=[2132] inProgress 20%
serving shipped in 4.3.0 saying, in its own doc comment, that it "is not a
fraction: a 1% staged rollout and a finished one both read true".
PlayTrackRelease read three of TrackRelease's seven fields and this was one
of the four it dropped — so it is a hole in a document this package already
publishes rather than a new capability, which is the whole of why it belongs in
a publishing tool. docs/design/rollout-state.md argues that, and argues the
halves that are not being built.
The case for it is not the staged rollout it looks like. The 1%-reads-like-100% case that motivated the ask printed no caveat at all in the consumer that asked — the release itself was on screen, without a percentage and without a comment — so the fraction would have sat unread there. What wanted a number was a summary grid that renders every destination unconditionally and had nowhere to get one.
A second case is sharper and is not a reason to build this, which is worth
separating. At the time of writing that consumer rendered "testers are being
given the release before it" for a halted release, and it is false for one
tester in five at a 20% halt — they have the build and they keep it, because
the rollout stopped rather than rolled back. A real defect, and one fixable
without any new field, by saying some testers have it — which that consumer
has since done. It was found while answering a question from this work, and
fixed without needing anything from it, and the two halves of that are worth
keeping apart: a defect somebody finds on their own is weak evidence for a
change, and one a question surfaced is stronger evidence for the question
rather than for the field. audienceFraction makes the sentence exact rather
than making it correct.
Two fields, because Play omits its own number exactly when it means one.
Google sets userFraction only for inProgress and halted, so a completed
rollout carries none — and a caller reading Play's field alone gets null for
the release that reached everybody, then has to learn from Google's
documentation that this is what 100% looks like.
userFractionis Play's, exactly as sent,nullexactly when Play sent none. Spelled with Google's own key rather than as a*Rawsibling, because ours is called something else and the name is therefore free. Its nullness is load-bearing: it is what tells this package's inference from Google's measurement, so anaudienceFractionof1.0beside a null is ours and one beside a1.0is Play's. That holds even if Google's documented0 < fraction < 1does not — a range this package cites from googleapis' generated dartdoc and has measured against no live account.audienceFractionis ours:1.0forcompleted,0.0fordraft, Play's number forinProgressandhalted, andnullfor the three ways of not being told — plus a fourth, aninProgressrelease Play sent no fraction for, which is Play contradicting its own documentation and not a case to guess at. The rule isPlayReleaseStatus.audienceFraction, public from the start, so a consumer's fixtures can call it rather than restate it — the asymmetryservingwas made public to fix.
Named for what it measures rather than for the process, and completed is
what earns the name. audienceFraction asks who has this build, and under
that question 1.0 for a completed release is obviously right. Under
rolloutFraction it is odd — there is no rollout; it is over. The one state
where this package answers something Play declined to say is the state where
only one of the two names can say it without contradicting itself.
It does not say who, and the doc comment refuses three readings: not a fraction of the app's users but of the track's audience, not adjusted for country targeting, which this document does not carry, and not a statement about which devices Play chose.
AppStoreState names four states Apple has always had. IN_REVIEW,
PENDING_APPLE_RELEASE, PROCESSING_FOR_APP_STORE and
REPLACED_WITH_NEW_VERSION were absent, so the most ordinary mid-release
state on the Apple side decoded as unknown — a member whose own
documentation tells the reader Apple sent something this version does not name.
A version Apple is looking at right now is not a version in a state nobody has
seen. Each has an independent sighting in this repository: IN_REVIEW alone
appears twice in app_store.dart, once in cli.dart and seven times in
app_info_states_test.dart.
This does not contradict "members are never added because a store added a
value": that rule is about Apple adding one, and none of these is new. It also
does not move the wire in either direction — a new document's "inReview"
decodes as unknown on an older documents.dart, because the generated
decoder passes unknownValue, with appStoreStateRaw carrying IN_REVIEW
beside it. It is a source-breaking change for a Dart caller with an
exhaustive switch over AppStoreState, which is the one cost, and it is
listed here rather than in the schema number because it is not a schema change.
Checked against the only Dart consumer there was before shipping without a
pre-release: at the time of writing AppStoreState appeared once in that tree,
in a fixture calling .read(), and nothing switched on it.
And no derived boolean over them, which is the one place this release argues
against its own convention. "Is it still in review" looks exactly like the
next serving, and a field answering it was designed to four drafts and one of
them was built before being removed. What removed it was not an argument but a
table: the one consumer that would read it renders five distinct outcomes
across these states — not submitted, queued, in review, approved-and-waiting-
on-a-human, Apple publishing — so any boolean over them is a coarsening of what
it already prints rather than an answer it lacks. appStoreState answers the
question directly now that inReview is a member of it.
The reasoning still travelled, which is the part worth keeping: Apple's
READY_FOR_REVIEW is not submitted and differs from WAITING_FOR_REVIEW by
exactly that while reading almost identically. Written down here to justify a
field that no longer exists, it found two live defects in the consumer's own
tree — an unsubmitted version reported as queued with Apple, and
PENDING_DEVELOPER_RELEASE sharing a cell with PENDING_APPLE_RELEASE.
docs/design/rollout-state.md records all four drafts.
serving's own sentence is corrected, and the fraction is what exposed it.
It said it answers whether a release "is in front of any of the track's
audience" while returning false for halted — and Google's wording for a
halted release is "Users who already have these APKs are unaffected", so a
halted rollout is in front of the fraction that installed it. The boolean
was right and the sentence was wrong; it now says still being handed to new
users, which is what it computes and what an operator asking "is this rollout
stopped" means. Nothing about the value changed.
No schema bump. Every field here is optional and additive, which is the
manifest's compatibility rule and the one --json adopted unchanged. Both
play tracks --json and appstore versions --json still declare schema: 1.
And all three entries now hold their key sets in a test, which only
appstore.builds did. A document nobody decided to widen should not widen, and
two of the three could until the fields above went in — appstore.versions
gains no key here and gains the list anyway, which is what caught the one field
this release built and then removed.
Not a wait, and that is recorded rather than deferred.
docs/design/rollout-state.md §"The wait" is the argument: because the
intermediate state is the answer, a wait is the wrong container for it — its
contract is block-until-over, which delivers the terminal state and discards
every intermediate one. Nothing is blocked on a rollout percentage the way
what-to-test is blocked on processing, the duration is hours to days rather
than minutes, and both stores already notify. What a caller polls on its own
schedule is a read, and this is that read.
And not Apple's phased release, which stays open with its cost measured:
appstore promote --phased writes /v1/appStoreVersionPhasedReleases and
nothing here reads it back, and carrying it would need AscClient.getAll to
stop discarding included or an N+1 over an uncapped version listing. The
fraction there is the thing not to build — Apple sends currentDayNumber, not
a percentage, and the day-to-percent table is in Apple's support documentation
rather than in the API. The two stores are not symmetric here and the document
should not pretend they are.
Four dangling [references] in the published dartdoc now resolve, and CI
checks that they do. documents.dart's dartdoc is the statement of the
--json format — pub.flutter-io.cn renders it per version, and it is the only place a
consumer can read the format without reading an encoder. A reference that
resolves to nothing renders there as bare text in square brackets, so the
specification pointed at a name the reader could not follow.
AppStoreBuildEntry.usablelinked[serving], which is onPlayReleaseEntry; the paragraph contrasts the two, so it is now[PlayReleaseEntry.serving].PlayReleaseEntry.servinglinked[statusKnown], which does not exist. The paragraph tells a caller to read the status whenhaltedanddraftneed different advice, so it is[status]— the field it already names one paragraph up.AppStoreBuild.uploadedDatelinked[lines], which is onAppStoreBuilds. It isAppStoreBuild.linethat renders the field.uploadRecordForopened with "Whether[command]is an invocation that records an upload". It stopped taking aCommandwhen it was split out to be testable — the last paragraph of that same comment explains why — and the first line was left behind. It now names[commandName].
The check is tool/check.sh --docs, and CI runs it as
tool/check.sh --docs-only <member>. dart analyze cannot see any of this,
because a doc comment is a comment; this is the second such defect, after the
orphaned /// block a consumer found in the published 4.3.0-dev.2. It fails
on unresolved doc reference specifically rather than on any warning, because
AscPlatform is exported from both documents.dart and read.dart and
dartdoc calls that ambiguous on every run. Opt-in locally because dart doc
costs about ninety-five seconds on this package against ninety for the entire
existing suite.
A fifth was created the same afternoon, by the change above. serving's
corrected wording linked [halted] bare, and halted is a member of
PlayReleaseStatus — caught by running this check against that branch before
either had landed. That is what the check is for, and it is better evidence
for it than the mutation it was written with: the defect class is still being
produced by people who have spent the day reading about it.
4.3.0 #
The store reads become documents, and the format becomes classes.
appstore builds, appstore versions and play tracks take --json, and
package:cux_ship/documents.dart is what a Dart caller decodes them into.
Shipped first as 4.3.0-dev.1 and 4.3.0-dev.2, so the consumer this was
designed with could port against it before the shape froze. That port is
merged. It found two API gaps and no format problems — no key moved between
the two pre-releases, and moving from one to the other deleted two workarounds
rather than requiring a re-port. Both gaps are the two accessors below.
appstore builds, appstore versions and play tracks take --json. The
listings become documents a caller decodes instead of prose a caller greps —
for a shell status, jq at a terminal, or a CI step reading one number, none
of which can call a Dart library.
The document is the build manifest's envelope, unchanged: an integer schema a
reader refuses when it is not one it knows, optional fields that do not bump
it, and repo-local keys under x. It adds kind — appstore.builds,
appstore.versions, play.tracks — because the counters are per kind, so a
reader has to know which one applies before it can validate the number. An
unrecognized kind is refused on the same terms.
stdout carries the document and nothing else. Every other line moves to stderr for that invocation, the document is built whole and written once at the end rather than streamed as it is assembled, and a failure leaves stdout empty and reports on stderr. Check the exit code first; there is no error document to branch on, deliberately, because the failure that matters most here is Play's 403 saying the service account was never granted this app, and that is prose a human acts on.
display carries the same lines the command prints, and its text is not
promised. It is always an array of strings — at the document level and on
every item — and that is promised: an item's rendering stays addressable
apart from its document's. The two are not the same rendering, so do not derive
one from the other: a builds document renders twenty at most while builds
carries everything Apple returned, a version item spends two lines, and a
tracks document ends with an uploaded-bundles line that belongs to no track.
And a document's display is never empty, which matters most where there
is nothing to show: an empty listing renders no builds at all — nothing has ever been uploaded rather than an empty array, because a caller iterating
nothing prints nothing, and a store the output said nothing about reads as a
store with nothing wrong.
Build numbers stay strings and the integers travel beside them.
buildNumber is a String because CFBundleVersion may be dotted;
buildNumberAsInt and the new newestBuildNumberAsInt are null rather than
zero for anything that is not a single integer. Emitting only the string would
have handed a shell caller exactly the comparison newestBuildNumber's own
documentation forbids — its remedy is via the newest build object, which is a
route no shell caller has. Note that a build manifest's buildNumber is a JSON
integer and refuses anything else: both are right for their own document, and a
consumer reading both has two types under one key.
package:cux_ship/documents.dart is the format, as classes. A Dart caller
decodes the output with AppStoreBuildsDocument.fromJson,
AppStoreVersionsDocument.fromJson and PlayTracksDocument.fromJson instead
of hand-writing a reader — and the API docs pub.flutter-io.cn renders for those classes
are the published statement of the format, which is where to look for the keys,
their types and their possible values. The commands stay commands: nothing here
talks to a store, so a caller keeps spawning and keeps both the printed command
line and per-step secrets exec --only.
Ask the question, not the vocabulary. usable, editable and expired
already meant an App Store caller never had to learn Apple's states. Two new
fields, both bool?, and the null is the point:
-
serving— the Play side answered nothing, so asking "is this rollout stopped" meant comparing Google's status strings. True for a completed rollout and one in progress, false for a halted one and an unsent draft, and null for a status this version does not name, or for Play's ownstatusUnspecified. Aboolwould have to report a possibly-healthy rollout as reaching nobody, or call an unrecognized state healthy. It is not a fraction: a 1% staged rollout and a finished one both readtrue. -
needsNewUpload— whether a build can only be fixed by uploading another. False while Apple is processing and for a usable build, true once Apple has refused the binary and for an expired build, including one that processed cleanly, null for a state nobody here names.usablealone hides this, and that cost a consumer a real defect: it readusable == falseas "wait for VALID", which is right forPROCESSINGand advice to wait forever forFAILEDandINVALID, where Apple has refused the binary and the fix is a new upload.Phrased as the action, and readable on its own in every state. The rule is
ProcessingState.needsNewUpload, so it has one definition rather than joining the copies of it already in the App Store client.
Both are emitted as fields rather than offered as Dart getters, so a shell
caller gets them too. usable and editable stay plain bool and fail
closed — usable == false means "not known to be usable", which is the right
default for a flag gating an action rather than a report.
A store's vocabulary arrives twice: as ours, and as theirs.
processingState carries this package's closed vocabulary — processing,
valid, failed, invalid, unknown — and the new processingStateRaw
carries Apple's own word, exactly as sent. Same for appStoreState,
releaseType and Play's status, each with a *Raw sibling. Write against
ours; fall back to theirs in the one case ours cannot cover.
unknown is a value of our vocabulary rather than a hole in it: a state Apple
ships tomorrow arrives as "unknown", keeps its real name in the *Raw field,
and survives a round trip. Members are never added because a store added a
value — a Dart switch expression must be exhaustive, so that would break a
consumer's build on Apple's release schedule rather than on this package's.
kind and platform are this package's own, are closed, and an unrecognized
one is refused rather than degraded. A null field means the store sent nothing,
which stays a different fact from unknown.
uploadedDate is Apple's string rather than a DateTime, deliberately: the
rendered docs are read by people who are not in Dart, and
DateTime.toIso8601String is not the spelling Apple sends.
Not appstore wait, and not on a guess. The consumer this was built for
spawns it, logs its stdout for a human, and consumes only the exit code —
nothing decodes a line, so line-delimited progress events would have been built
for nobody.
The contract, and why JSON rather than YAML, is docs/design/json-output.md.
The short version: measured against this repository's own yaml: ^3.1.0, an
unquoted versionName: 1.10 parses as the double 1.1.
And the "store's own printed lines" overstatement is corrected in
read.dart, both reads.dart headers, two lines doc comments and the
README. They are composed from parsed fields, so what they buy is one formatter
and not fidelity to a store's format.
Two accessors the port asked for #
Additions, neither a rename, and the document format is unchanged: one is a getter and one is a static, so no key moved.
AppStoreBuildsDocument.newestreturns the newestAppStoreBuildEntry, not just its number.newestBuildNumberanswers which number; this answers which build, so a caller wantingneedsNewUpload,expiredorprocessingStateRawof it no longer has to find the entry.buildsis ordered newest-first andnewestBuildNumberis that element's, sobuilds.firstwas already correct — but a promise a reader has to go and find is not an accessor a test can hold, and re-deriving "which one is newest" is the ordering this package has been wrong about twice.PlayReleaseStatus.serving(status)is now public, matchingProcessingState.needsNewUpload. It was a private function in the encoder while its twin was reachable, so a consumer's test fixtures could call one derived rule and had to restate the other — a second copy of a rule this package owns, in a tree it cannot see, which is the drift the derived field exists to prevent.
Both came out of the port rather than from review, which is what publishing a pre-release ahead of it was for.
A third thing came from the same direction after dev.2 and changed no code:
the doc comment written for AppStoreBuildsDocument.newest was orphaned
between two members, so the paragraph explaining why that member is a getter
rendered under nothing at all. Found by the consumer reading the published
dartdoc — which is the document this format is specified by, so a hole in it is
a hole in the spec. dart analyze is silent on a dangling /// block; a test
is not, now.
4.2.0 #
secrets exec no longer hands the sops identity to its child.
SOPS_AGE_KEY and SOPS_AGE_KEY_FILE are removed unconditionally, as
keychain exec has removed them since 3.0.0 — and the reason that command's
comment gives is an argument about --only rather than about archives. --only
promises that a credential nobody named is absent from the child; a child
holding the identity does not need one to have been placed, because it can
decrypt the file and take every one. So wherever the identity was the
environment variable rather than a key file — which is to say in CI — --only
narrowed nothing at all. Not a gap beside the promise; the promise.
It was an omission rather than a decision. 3d6ee16 wired --only into both
exec commands and stripped in one, and git log -S finds no commit in which
secrets.dart ever removed either name.
Whether this breaks you is a narrow intersection, and worth checking rather
than assuming. It is a no-op for any consumer whose identity is the default
~/.config/sops/age/keys.txt: there is no variable in the child's environment
to remove, and sops keeps resolving that path. It bites only a consumer that
supplies SOPS_AGE_KEY and nests a value-touching subcommand — secrets place, pack, clean or add — inside the wrapper, which then cannot
decrypt. secrets list is unaffected: sops leaves the mapping keys in
plaintext, which is what makes it the identity-free pre-flight. The fix for the
one composition that pays is the one this tool already gives for the sibling
command — run the two as siblings rather than nesting them, a wrapped read and
an unwrapped write.
docs/design/only-selector.md
records the measurement, including the seven scripts checked in the consumer
that reported it and the one that nests.
cux_ship appstore what-to-test writes the TestFlight "What to Test" on a
build Apple already holds. An upload carrying an artifact is three phases —
transfer the binary, wait five to fifteen minutes for processing, then write
the notes — and they are not the same kind of work: the transfer is exclusive
(Apple accepts one CFBundleVersion once) and bounded, the wait is shareable and
long, the notes are exclusive and take seconds. Run as one command the whole
thing inherits the worst of each, so a caller shipping iOS and macOS from one
commit serialises both uploads end to end and pays both waits with the machine
idle. appstore wait already moved the middle phase; this is the piece that
made moving it useless, because setWhatToTest had exactly one call site and
it was inside the branch that did the waiting. The decomposition is now whole:
upload --skip-waiting, wait, what-to-test, and beta-release where a
group is wanted. Shaped like beta-release down to the refusals — it needs a
processed build and refuses rather than waiting for one, because a command
that quietly blocked would put the two phases back together under a new name.
It reads /v1/apps and /v1/builds and writes /v1/betaBuildLocalizations,
and nothing else — so it names no App Store version and never reads the
appInfos record that a version in review locks, which is what makes the split
usable during a review rather than only between them.
--skip-waiting no longer drops the release notes quietly. It skipped the
wait, and the notes are written after the wait, so it skipped those too — its
own help said so and called itself a debugging flag, which is not a sentence a
caller reaching for concurrency reads as being about them. The run uploaded
fine, exited zero, and the build reached testers with no notes at all. It now
prints the commands that finish the job, carrying the run's build number, its
platform and whichever notes flag it was given, so they can be pasted. The
--beta-group refusal, which was already there, names beta-release the same
way.
A warning and not a refusal, which is the second answer rather than the
first. The first version refused --skip-waiting alongside an explicit
--changelog or --release-notes, by analogy with the --beta-description
refusal beside it. Review found the analogy is with the wrong flag.
--beta-group names an action the run cannot perform; --changelog names a
file, and so does --release-notes — the variable holding the second is
called notesPath. Refusing them sorted callers by directory layout: a
repository with CHANGELOG.md at its root never types the flag and was warned,
while one keeping it at docs/CHANGELOG.md must pass it on every invocation
and was refused, for an otherwise identical wrapper. --release-notes was
worse — it has no inferred default at all, so a caller keeping notes in a file
rather than a changelog could never have used --skip-waiting.
Every suggested command line carries the platform the run was for. iOS and
macOS are given the same build number from one commit by design, so a remedy
that omits --platform macos names the other platform's build and looks
right. The three call sites share one function for it, finishAfterSkippedWait
— the important one prints after the artifact has gone up, past every
credential, where no test can reach it, and a suggestion nothing exercises goes
stale silently.
Both stores' already-uploaded branch is tested, and until now neither
was. play upload reuses a versionCode Play already holds and appstore upload reuses a build Apple already holds, rather than failing — which is what
makes re-running a partly-failed release the same command typed again. Both
guards were correct and unexercised, because runPlay and runAsc built their
API client at the point of use from the environment: the branches were
reachable only by uploading to a real store. runPlay now takes an
androidPublisher and runAsc an ascClient, each replacing the client it
would otherwise build, and each closing only what it opened. Nine mutations
were watched failing, including the two that matter most — a fake that ignored
filter[preReleaseVersion.platform] would have made an iOS upload reuse the
macOS binary of the same build number invisible, and one that answered
bundles.list for the open edit rather than for the app would have made the
Play branch unreachable. That second one is now checked against Play rather
than believed: a fresh edit listed a versionCode uploaded five days earlier
by a different, long-committed edit, which is the semantic the guard needs and
the one no same-session test can tell apart from edit-scope. The call takes an
editId and so reads as edit-scoped, so the reason it works is written at the
call — it is the claim to falsify if a re-run ever stops being recognised. Neither runPlay nor runAsc is exported; this
widens no public surface. play data-safety refuses a supplied client
rather than ignoring one: it posts through a plain authenticated client, so
one passed to it would be dropped — and the first version left that to reveal
itself through the missing credential, which is true of CI and false of a
developer machine inside a secrets exec shell, where the run would have sent
a real declaration and Play would have filed it as a pending review.
The upload record is documented as commit provenance, and nothing else.
uploaded/vX.Y.Z+N says which commit an artifact was built from — a git fact
no store knows — and has doubled as a proxy for "was this uploaded", which it
answers badly: it is written before the store is contacted, so it over-reports,
and one record covers every store a commit reached. Since 4.1.0 the stores
answer that themselves, per store and per Apple platform, so the README now
sends the question there. No behaviour changed.
docs/design/upload-record-scope.md
records why this did not become per-store tags, which is what that document
was waiting to be asked.
A macOS .pkg is cross-checked like every other format. A live upload
printed cross-check: no reader for pkg — build number and version name taken on trust beside the iOS path's agree with Payload/Runner.app/Info.plist, so
a repository shipping both Apple platforms from one commit had artifact-level
confirmation for half of every release — and the macOS half is where a mismatch
is least likely to be noticed, because the build number is shared with iOS and
looks right everywhere else.
The design had priced this as expensive, because the app's Info.plist sits
inside a gzipped cpio inside a xar. It is not where the answer is: pkgbuild
and productbuild copy CFBundleShortVersionString and CFBundleVersion into
each component's PackageInfo, since the installer compares them against what
is on disk before it will replace it — and that file is a member of the
archive's table of contents. No payload is decompressed.
How many bundles a component describes depends on which tool built it. An
App Store package describes the installed app and nothing else; a
pkgbuild --root one lists every bundle in the payload, so an embedded
framework and a login item appear beside the app carrying their own version
numbers. The bundle that answers is therefore the one the component says it is
versioned by — the installer's own designation, right for either shape — and
the cross-check line names it: agree with design.codeux.howitwent.pkg/PackageInfo (./How It Went.app). A package that
names no such bundle, names several, or installs two apps is refused rather
than guessed at.
docs/design/build-lifecycle.md
§8 records the measurement, including what the naive reading would have
returned.
Two paths that returned before their finally now release the HTTP client
they opened. appstore signing is account-wide, so it returns before the app
is resolved — and therefore before the block whose finally closes the client,
which it has never been covered by. play tracks, listing and version-code
released after their switch rather than in a finally, so a throw out of it
skipped the release; that is narrower than it sounds, since each of those
catches the API's own errors itself, and what escapes is a dropped socket or a
response shaped wrongly. Both were harmless while these functions only ever ran
in a process about to exit. They are not, now that a client can be supplied and
the call made in-process — which is also why the release is conditional on
whose client it is: one that was handed in belongs to whoever handed it in.
4.1.0 #
package:cux_ship/read.dart answers what the stores hold, as objects. A
Dart consumer's release train was reading four regular expressions off this
command's stdout to answer "which build does each store hold" — an arrangement
that breaks silently on both sides, since a listing's format is not something
either party agreed to. Thirteen exported names: PlayReads and its track
model, AppStoreReads and its build and version models, AscPlatform, the two
exceptions a wait can end in, and the per-poll progress a wait now reports.
Every export carries a show clause and a test fails if one does not, because
an export without one hands out the whole library and reads exactly like an
export with one. Reads only — upload, promote, beta-release and the
listing publish 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 away from a step with no use for it.
docs/design/read-api.md
records the decision,
including why the store clients did not become their own packages.
Every read carries the store's own printed lines beside the parsed values, and the commands print those same lines. One formatter rather than two: a consumer that renders store output verbatim — because a status that re-renders a store's table misreports the day the format changes, silently — needs the lines it prints to be the lines this command prints.
appstore builds lists newest first, by build number rather than by Apple's
lexical sort. Apple's sort=-version puts build 9 above build 10.
appstore build-number has always sorted numerically before answering, so the
two commands could name different builds from the same account; they share the
comparator now. This changes the order of printed output.
A build number is exposed as an integer as well as a string, because the
lexical mistake reappears one layer up. AppStoreBuilds.newestBuildNumber is
a String — CFBundleVersion is one, and Apple accepts 1.2.3 — and the first
consumer compared it against an integer out of a git tag, which is right only
while every build number has the same width and wrong at 1000. Found in code
that had already merged there, by a suite whose fixtures were all equal-width.
AppStoreBuild.buildNumberAsInt is the form to compare and order by, null
rather than zero for a version string that is not a single integer, and the
listing's own comparator reads through it so there is one rule rather than two.
AppStoreBuilds.newest is its companion: the build behind newestBuildNumber,
and the pair to newestUsable.
appstore wait reports each poll to a callback, and the printing is one
caller of it. Unchanged as a command — the same two ==> lines, and now
suppressed on a build Apple refuses on the first poll, where "waiting for Apple
to process" described something that was not about to happen. A library caller
passes its own reporter and gets every poll including the one that ends the
wait, so a log can record how a wait ended rather than only that it stopped.
4.0.1 #
An App Store upload or promote defaults to the platform's tree in a split
store/appstore/{ios,macos} layout. The publish path defaulted to
store/appstore alone, which in that layout is the parent — a README and two
subtrees, nothing the loader recognizes — so every Apple promote in the
repository that has the layout passed --metadata store/appstore/<platform>
by hand, while verify had been resolving the same layout correctly through
appStoreTrees since the layout was introduced. Two answers to "which tree is
this platform's" is how one of them stays wrong. Found by an audit of that
repository's release scripts, as a workaround that only existed because of the
tool. A flat layout is unchanged; an explicit --metadata still wins; a split
layout missing the platform still yields the parent, which the loader refuses
naming the path, rather than a silent listing-less publish.
The missing-credentials message for App Store Connect names cux_ship secrets exec. It named tool/with-secrets.sh and docs/RELEASING-APPLE.md
— one repository's wrapper from before secrets exec replaced it, which no
consumer has — in the one message an operator meets on their first run on a
machine without credentials, while the Play message beside it had said secrets exec all along. Two comments and the no-such-app message pointed at the same
absent document and are corrected with it.
verify checks that the version in pubspec.yaml has a changelog section.
checkChangelogFile walks the headings the file has, so the version it lacks
was the one it never looked at, and the uploaders refused it late: on Play
after the confirmation prompt and inside an open edit. Two of a consumer's
scripts grepped for ^## <version> themselves before calling anything here,
which is the check verify should have been. An empty section passes — it is
a decision, and what release finish leaves behind — and only an absent one is
reported. --changelog names the file and --app-dir the pubspec, as
everywhere else.
release finish --build-number N finds the released commit through the
upload record. The README called resolving a build number to a commit the
project's business, because allocating one is; but uploaded/v{version}+{build}
is this tool's own record, written from the manifest's gitSha before the
store is contacted, and until now it was written on upload and ignored on
finish. Both consumers dereferenced it by hand or passed HEAD, which during a
promotion is very often not the promoted commit. Without --commit, and with
tag.upload on, exactly one tag in the configured format with that build and
the version globbed resolves the commit and is named in the output; more than
one is refused naming them; none is refused too, naming --commit, because
with recording on a build without a record is a wrong number or one from before
recording, and HEAD answers neither. A first draft fell back to HEAD there and
said so, and review measured that on a real repository as "tagging HEAD"
several commits past anything built, followed by doing it. --commit wins
outright, and the tag is not consulted; HEAD remains the default only when
nothing asked for a lookup.
A --dry-run upload says it would record, not that it did. recordUpload
returns created under a dry run to mean the tag would be written, and the
upload commands printed that as recorded this upload with no tag written
locally or on origin. Found by a consumer rehearsing an upload against 4.0.0 and
then checking origin for the record: the line had printed, git tag -l was
empty, and the absence read as a push that had failed. The house rule is to
print what happened, and this line did not.
4.0.0 #
The Play uploader checks an image's alpha channel and bit depth, and stops
parsing PNG headers itself. _loadImages validated extension, count and
dimensions before uploading and checked neither — so play upload --metadata
sent a listing Play refuses during ingestion, having read the very header that
says so.
It re-checks rather than trusting verify, and the reason is that verify is
not on this path: runPlay does call checkPlayTree, but only when the
repository declares listing requirements, so a project with no play: block in
.cux-ship.yaml uploaded with that check skipped entirely — and it is the
project least likely to have run cux_ship verify first.
The second hand-rolled header parser is gone with it. It read dimensions and
nothing else, which is how one of the two copies of an image check ended up
never being told about alpha; readImageInfo and imageEncodingProblem in
cux_ship_verify now answer for both. A re-check that can disagree with the check
is worth less than no re-check, because it makes a green verify mean less than
it says.
screenshots flatten reduces 16 bits per channel to 8, and used to preserve
it. flattenPng returned "already opaque" with nothing to write whenever the
image had fewer than four channels, so a 48-bit RGB capture was left exactly as
found; and where it did rewrite, convert(numChannels: 3) preserves the source
format, so a 16-bit RGBA capture came back with the alpha channel gone and the
depth untouched. Both are reachable from one capture — a macOS --no-chrome
fallback writes depth 16 — which made this the remedy those checks name for a
state it could not reach.
Conversion rescales rather than truncating: 65535 becomes 255 and 256 becomes 1, where taking the low byte of each would satisfy "the depth is 8 now" having thrown the picture away. Below 8 bits is left alone, matching the checks — a palettised PNG's entries are already 8-bit and no store has been seen to refuse one.
And it flattens a greyscale-with-alpha PNG, which the same gate called
opaque. PNG colour type 4 decodes to two channels, so "fewer than four
channels" declared "already opaque" a file the checks refuse for its alpha
channel — and pointed the user back at this command. The gate now asks whether
the image has an alpha channel; where such a file carries real transparency it
is promoted to RGBA before compositing, because compositeImage reads a
two-channel colour as fully opaque.
FlattenOutcome still answers only about the alpha channel, and the depth is
FlattenResult.reducedBitDepth beside it. The two are independent, and an enum
answering both would have to invent a precedence and then hide whichever answer
lost. --check reports both and now exits 2 on a file whose only problem is
depth, which it previously passed.
This needs the cux_ship_verify that ships store_image.dart, and the
constraint here still says ^1.9.0. Raise it on the release branch, once that
version exists: the workspace cannot resolve a constraint naming a version no
member is at, so a feature branch cannot raise it and then be tested. Publishing
cux_ship against ^1.9.0 would let a consumer resolve a cux_ship_verify
without imageEncodingProblem in it, and fail to compile.
Play's data safety declaration is its own command. play upload sent the
CSV on every run that was given one, and Play files each POST as a pending
App content → Data safety change whether or not an answer moved — so an app
uploaded weekly accumulated one unsubmitted review per upload, against a
declaration nobody had touched in months. The line it printed, data safety declaration updated, said the same thing on the run that changed something and
the run that changed nothing, which is why this took so long to notice.
Added after publication, 2 September 2026. This shipped with the fix unverified in the only way that counts, and it is now verified. A send that does not happen leaves no trace: no output distinguishes "it stopped sending" from "it never checked", so every run against a real store during development was consistent with both. Only Play Console's App content → Data safety page tells them apart, and only a person can read it. How It Went's build 141 went to internal under this behaviour and no new pending change appeared. One upload, one reading, the negative result intended. Recorded here rather than folded into the text above, because 4.0.0's published tarball says what it said on the day and this note is a fact from after it.
Nothing can send it only when it differs. The listing images can: each carries
a sha256, so a publish says 4 phoneScreenshots unchanged and sends nothing.
applications.dataSafety is write-only — v3 has no GET for the labels and the
POST answers $Empty — so a run genuinely cannot tell an unchanged declaration
from a changed one. Remembering the last CSV sent would need state outside the
repository, which is worse than the problem.
So sending it is an act you perform, not a thing that happens to you:
cux_ship play data-safety # store/play/data-safety.csv
cux_ship play data-safety --csv other.csv
cux_ship play data-safety --dry-run # checks it, sends nothing, offline
It takes no track, no artifact and no release notes, because it is not a
release — it describes the app. It opens no edit. It prints data safety declaration sent, never updated: with no read of what Play holds, a run
knows what it posted and not what changed.
No comparable tool found does otherwise. fastlane's supply and Gradle Play
Publisher do not send the declaration at all; the CLI that does spells it
gpc data-safety update, and fastlane keeps Apple's equivalent in
upload_app_privacy_details_to_app_store rather than inside deliver.
play upload still checks the declaration, says so, and no longer has flags
for it. --data-safety and the never-released --send-data-safety are gone;
the CSV is found at store/play/data-safety.csv and structure-checked on every
upload, because the frequent command is the cheap place to learn the export is
malformed. A declaration only ever validated by the command that publishes it
is validated on the worst possible day.
It prints data safety declaration checked; sending it is "cux_ship play data-safety", because a silent check and an absent one look identical: the
first consumer to upgrade dropped the flag, saw an upload that mentioned the
declaration nowhere, and could not tell whether the validation had survived or
left with the flag. One of those readings quietly costs you the check. Same
failure verify closed on its success path in 3.4.0.
Breaking, deliberately loudly — and legibly. A build script passing
--data-safety on every upload, which is the intended usage and the shape that
caused this, now fails:
cux_ship play: --data-safety was removed. Sending the data safety declaration is its own command now:
cux_ship play data-safety
An upload still checks store/play/data-safety.csv on every run and no longer
sends it. …
Failing is the point: the alternative was accepting the flag and silently no
longer publishing, which is the same class of quiet wrongness as the updated
line. But the first consumer to upgrade read the parser's own answer —
Could not find an option named "--data-safety" followed by a usage dump — as
cux_ship being broken rather than as the flag having moved, and they were
right to. That is what a parser says about a typo, and it cannot tell a typo
from a deletion. So --data-safety is still declared, hidden, for no reason
other than to be refused with somewhere to go — and --send-data-safety
beside it, which never shipped and so can only be held by somebody who tracked
this branch, because the one place it is written down is here.
Drop the flag from the script, and run cux_ship play data-safety on the
occasions you actually edit the CSV.
3.6.2 #
A listing publish that is going to be refused is refused before it writes
anything. _publishAscListing acquired the appInfos record before writing
app-level fields and the version after them — so a run against a version
Apple will not let anyone edit wrote the content rights, the categories, the
age rating and the localized name, and only then took a 409 naming the
version. Half applied, and needing a diff against the store to notice.
That satisfied "acquire before write" for each resource separately while
breaking it for the pair, which is not a weaker form of the rule but a
different one that happens to coincide when there is a single resource. Every
acquisition that can refuse now happens before any write: the writable record,
the age-rating declaration, the version name, the version, and the review
contact — which refuses a half-set APPLE_REVIEW_CONTACT_* environment and
had been read beside the write it feeds, so its own reason for existing
described what it then did.
It needs app-level drift and an unusable version together, which is the
combination nobody arranges deliberately and everybody meets after a
rejection. Found by a consuming project pointing a --listing-only run at a
released version; it wrote nothing only because that tree happened to match.
A publish that changes nothing now writes nothing, including the version text. 3.6.1 compared the app-level half and the screenshots before writing them, and rewrote the copyright, the review notes and every localized listing field regardless — so a run against an unchanged tree still sent seven PATCHes carrying identical values.
Harmless per request, which is why it survived, and seven more chances to exit non-zero having already written something — the failure shape the rest of this package is built to avoid. The boundary was not a decision: each comparison in 3.6.1 exists because a specific defect demanded one, and these fields had no defect behind them. The rule stopped where the bugs did.
The review contact is part of the comparison, not just the notes: Apple refuses an update carrying notes without the whole contact beside them, so the pair is what gets written and the pair is what has to match. A field Apple did not report counts as differing, as everywhere else here.
Found by a consuming project upgrading to 3.6.1 and reading its own
--listing-only --dry-run output — the same run that confirmed the screenshot
skip working for the first time.
3.6.1 #
appstore promote can say how the release should start, and always says how
it will. appStoreVersions.releaseType decides what happens once Apple
approves — wait for somebody to press release, or go out on approval — and
there was no flag for it. Asked how a release should go out, a maintainer chose
automatic; the version cux_ship created was MANUAL, because that was
hardcoded. Nothing was rejected and nothing was reported: the decision had
nowhere to go, and the default stood. It only did not matter because manual is
the safer end of that mistake.
--release-type MANUAL|AFTER_APPROVAL now carries it. An unset flag still
changes nothing — a new version is created MANUAL as before, an existing
one is left exactly as App Store Connect has it. A tool that normalised the
unset case to its own preference would be the same failure running the other
way. SCHEDULED is refused rather than approximated: Apple takes it, but only
beside an earliestReleaseDate this tool has no way to send, and a version
already scheduled is refused rather than silently rescheduled.
And the run prints the effective release type, read back from the record Apple acknowledged rather than from the flag — print effective configuration, never intended. That line is the half that would have caught this, because the run it needed to catch passed no flag at all.
promote no longer fails because the other platform is in review. An app
has one set of app-level records shared by both platforms, so
promote --platform macos refused with 409 whenever the iOS side sat in
WAITING_FOR_REVIEW — and refused at the first thing a promotion does, so
nothing downstream ran: no version created, no build attached, no submission
made. The gate was this package's own invention. Apple accepts a PATCH
against an appInfos record in that state. editableVersionStates is
unchanged, because it is also what refuses a push against a version that is
with Apple, and that refusal is correct; the app-level half now has its own
list.
And it does not fail for a listing it was not going to change. The app-level half opened by demanding an editable record before anything had decided whether the tree carried app-level content at all, so a promotion whose listing already matched App Store Connect failed for want of a record it would never have touched. It now reads the records once, compares field by field, and asks for something to write to only when something needs writing — which is the ordinary case on a release where the categories, the age rating and the name have not moved since the last one. A run that changes nothing writes nothing.
Content rights is the exception that proves the shape: it is an attribute of
the app rather than of an appInfos record, so it is no longer gated on
acquiring one — and it is still skipped when it already matches. Two
properties, and it needed both.
Where a refusal is still right, it now says which field and what to do. A value that could not be read counts as differing rather than as matching, because skipping a write on the strength of not knowing is how a listing silently fails to publish. Refusals name the fields that would have been written and the states that would have accepted them, and the two 409s no longer read alike: "no record can be written to" is answered in App Store Connect, "Apple rejected this value" is answered in the tree.
Writability is a whitelist. A record under active review, and any state this
package has not enumerated, is refused rather than written to — Apple has
changed that enum before, and an unrecognised state that refuses costs
somebody a minute where one that writes changes something nobody examined.
One consequence worth knowing: a record whose appStoreState Apple does not
report is now refused too, where it used to be treated as editable. It only
arises when a write is actually needed.
A promotion that fails after creating the version now says so. Creating it is one of the first things a promotion does, so a failure anywhere after that exits non-zero having already changed what App Store Connect holds — and "exit 1" reads as "nothing happened". The failure now names the version it left behind and that a rerun will adopt rather than duplicate.
The App Store rejects emoji in "What's New" too, and this package said it
did not. The stripper existed and was applied to TestFlight only, under a
notice that told you in so many words that "the App Store release notes keep
them". That claim was reasoned from published listings carrying emoji — which
is evidence about the description, a different field with a different rule —
and nothing had ever exercised it, because the only prior submission for the
app in question was a first release and a first release has no "What's New" at
all. The first second-release to meet it got a 409 naming 🧭, 🗺, 🌍 and a
bare variation selector, after the version had been created and the build
attached.
Release notes are now stripped for both surfaces, by one rule rather than two lists that would drift apart. The App Store notice names the exact characters removed, with code points, because one of them is invisible and because this is copy a shopper reads: publishing something other than what CHANGELOG.md says should never be quiet. Play still gets them verbatim, and the changelog's "emoji are welcome and encouraged" rule is unaffected — absorbing Apple's restriction is the tool's job, not the changelog's.
Screenshots that have not changed are no longer re-uploaded, and a
screenshot Apple rejects is no longer reported as uploaded. --metadata
deleted the whole screenshot set and re-uploaded every file on every run. That
was recorded as known and harmless. It was neither: four assets going up
immediately before a submission is what made promote --metadata fail with
appStoreVersions '…' is not in valid state — a message about the version, for
a problem with its screenshots, on a run that had reported every one of them
uploaded. Measured: the identical request, replayed forty minutes later with
no upload, was accepted.
The set is now compared before it is deleted, by delivery state, by file name,
and by the same MD5 Apple stores as sourceFileChecksum — which this package
already computed in order to commit it. Order is part of the comparison,
because screenshots are shown in the order they were uploaded and the same
files rearranged are a different listing. Anything that cannot be shown to
match is re-uploaded, so the cost of doubt is an upload nobody needed rather
than a listing nobody updated. Only an asset Apple reports as COMPLETE
counts as published: the checksum is committed before ingestion finishes, so
name and checksum alone would match one that later failed, or one still in
flight when an earlier run gave up — republishing neither, which is both of
the defects below arriving back through the skip meant to remove them.
Underneath that was the quieter defect. Committing an upload returns a
verdict, and the response was discarded — so a screenshot refused during
ingestion for an alpha channel or the wrong dimensions printed uploaded and
was then simply absent from the listing, with nothing in the run saying so.
The run now waits for Apple to finish with each asset and reports a rejection
in Apple's own words. With unchanged screenshots skipped there is usually
nothing to wait for, so the wait costs nothing on the release where nothing
moved.
A category write now checks that nothing else moved. A category PATCH
names only the relationships the metadata tree declares, so it always omits
the rest — the other category, and the four subcategory slots this package has
never managed. Everything says omission leaves them alone: JSON:API specifies
it, established clients of this API distinguish omitting a relationship from
setting it explicitly null, and partial category documents go out against a
very large number of apps without lost subcategories being a known problem.
That is a good argument, and it was still only inference about somebody's published listing. On the rare run that writes a category at all, the six relationships are now read before and after, and anything that moved without the tree asking is reported. If the reading cannot be taken, it says so rather than passing quietly — a check that silently did not happen is otherwise indistinguishable from one that found nothing. It never fails the run: the write has already landed by then, and a diagnostic that can take down a publish is worse than the uncertainty it was added to remove.
Fixed: promote --metadata published the whole listing twice, clearing
and re-uploading every screenshot both times, under a comment claiming it
published in one place only. Two conditions at two sites that had to stay
complementary by discipline; now one decision, read at both.
screenshots flatten picks the PNG filter by measuring instead of assuming,
and store screenshots get about a third smaller. encodePng defaults to
PngFilter.paeth, which is the right guess for a photograph and a poor one for
a screen capture: a store screenshot is mostly flat panels of one colour with a
single photographic region, and a per-scanline predictor that helps the
photograph hurts everything around it. On a 2880x1800 macOS capture, paeth
produced 3,953,681 bytes where no filtering produced 2,685,629 — 32% smaller,
and smaller than the RGBA original the flatten was handed.
The filter is now chosen by trying them and keeping the smallest, so which one wins is a property of the picture rather than a default: a listing of dense photographs may still choose paeth, and it gets paeth. The compression level is deliberately left alone — the same measurement puts level 9 within 0.8% of level 6 on every filter, which is noise against the time it costs on files that can be tens of megabytes.
It matters because these bytes are committed. A listing's screenshots live in the repository that ships them, where they are paid once in history and again in every worktree.
3.6.0 #
appstore beta-groups prints the groups an app has, and the kind of each.
A group's name is the one input --beta-group cannot infer, default or
guess, and nothing printed one: the only command that touched groups filtered
by exact name, so a caller who did not already know the name had to leave the
tool and read App Store Connect. The kind comes with it because the kind is
half the answer — assignment alone delivers an internal group within minutes
and delivers an external one nothing until beta review passes, so choosing a
name from this list is choosing what a release costs.
And the lookup that misses now names the groups that exist. no beta group called "X" was true and unhelpful: filtering by exact name answers only about
the name asked for, so the refusal withheld the one string the caller was
missing — from a response the command was already entitled to make. It now
lists what the app has, with each kind, or says the app has none at all and
that groups cannot be created over the API.
Found from the consuming end, staging a build to external testers on a release where the group's name was not written down anywhere: 3.5.0 made the release work and left no way to discover what to call it.
A group whose kind Apple did not report is refused at release, not
guessed. The kind was read == true, which collapsed "false" and "absent":
a betaGroups resource not carrying isInternalGroup — a sparse fieldset, an
API change — silently read as external, on the one field a beta release
branches on. The defaults are not symmetric, which is why neither is taken:
external submits an internal group for beta review and fails at Apple, where
somebody sees it; internal assigns an external group and prints done, the
silently hollow release this flow exists to prevent. The release path now
refuses an unknown kind before anything is written. The two display sites —
the beta-groups listing and the miss's group list — print unknown instead
and carry on, because a diagnostic must not blank itself over one malformed
group, and "Apple did not tell me" points at the cause where a guessed kind
points nowhere. Both listings are now also sorted by name, since the API
promises no order and stable output is diffable output.
3.5.1 #
Manifests written by 3.5.0 name producer "cux_ship 3.4.2" — the release
bumped the pubspec and not the hand-maintained cuxShipVersion, and nothing
on the publish path runs the test that exists for exactly that drift. The
constant is right again, and the release script now refuses to publish when
the two disagree, beside its version-set-by-HEAD guard: the same class of
silence, where nothing fails and a wrong provenance string reads exactly like
a right one.
The committed-notes guard now looks through a symlink, not at it. git status -- <link> answers for the link object, which stays clean while the
file it points at is mid-edit — and the link's parent decided which repository
was asked, the wrong one for a link into another checkout. A repository
keeping CHANGELOG.md as a root symlink shipped an uncommitted section
straight past the guard that way. Refusals through a link now name the
resolved file, the one to commit.
3.5.0 #
External TestFlight groups are a release, not an assignment #
--beta-group used to add the build to the group and stop — complete for an
internal group, which receives an assigned build within minutes, and silently
hollow for an external one, which receives nothing until the build passes
Apple's beta review. The flag now reads the group's kind and carries an
external release the rest of the way: the Beta App Description is reasserted,
the build is submitted for beta review — idempotently, a retried job finds the
first run's submission instead of a 409 — and the closing line reads back the
externalBuildState Apple now reports, because what a run sent is not
evidence of what arrived. The internal path is unchanged to the byte.
appstore beta-release is promote's TestFlight sibling. For the build
somebody else's job uploaded — CI, usually — where upload has nothing left
to carry:
cux_ship appstore beta-release --build-number 52 --beta-group "External Testers"
--build-number is required, not defaulted to the newest, for the reason
wait requires it: a release to testers is a release of a specific build,
and "newest" would release somebody else's upload.
The description is owned like every listing field.
store/appstore/listings/<locale>/beta_description.txt present means
reasserted on every release; absent means App Store Connect owns it and
nothing here touches it; only no description anywhere — no file, no locale
in the console holding one — is refused, before anything has been written.
--beta-description <file> overrides the tree; it is a file option and never
a bare string, so what testers read went through a working tree rather than
shell history, and like the changelog it must be committed.
New refusals, each closing a silent failure: --beta-description against an
internal group, or without any --beta-group, publishes nothing and says so
instead; --skip-waiting with --beta-group asked for incompatible things
and used to skip the group step while still printing done; a prior beta
review submission Apple REJECTED is a hard failure rather than a green
no-op, because a build is submitted at most once and re-running does not
resubmit.
promote --beta-group no longer publishes the listing. The option's help
always said a group promotion creates no App Store version and publishes no
listing; the inferred store/appstore tree made both claims false. The
inference is suppressed under --beta-group, and an explicit --metadata
alongside is refused as the contradiction it is.
3.4.2 #
Windows #
deps install pins sops and age for windows_x64, and the exec paths run
there. Reported as a pin-table addition; it was four things before a pin was
reached, each unreachable until the one before it was fixed:
- sops names its Windows asset by architecture alone —
sops-v3.13.3.amd64.exe, with nowindowsin it, where every other platform is<os>.<arch>. - age ships a
.zipwhere every other platform ships.tar.gz, which decides the URL and whethertargets-xfor-xzf; GNU tar cannot read a zip. - Binaries need
.exeon disk, and every lookup had to agree — the install wrotesops.exewhilefindSopsasked forsops, then fell back tosh -c command -v, and there is noshon a Windows runner. - A shebang is a POSIX kernel feature.
./ci.shis handed toCreateProcess, which cannot run it. Shell scripts are launched through Git Bash, chosen by location —C:\Windows\System32\bash.exeis the WSL launcher, and on a runner with no distribution it exits 255 saying so.
windows_arm64 is deliberately unpinned: sops publishes arm64.exe, age
publishes no windows-arm64 archive, and half a toolchain is worse than the
message saying what to install.
Signal watching now asks the platform first. ProcessSignal.sigterm.watch()
raises on Windows, asynchronously, and secrets exec carried on running — so the
watcher that cleans up a decrypted key on Ctrl-C was not armed, and armed and
unarmed looked identical. Little is actually lost there: Windows has no POSIX
SIGTERM, SIGINT is what Ctrl-C sends, and Dart watches that fine.
Known limitation on Windows: a Dart grandchild's console #
Under secrets exec or keychain exec on Windows, a Dart process spawned
below the child can die silently. Isolated to one boundary by a consumer, with
a four-level repro: bash chains and command substitutions are fine at any depth,
and the defect turns on precisely at a Dart parent spawning with
ProcessStartMode.inheritStdio — below which descendants lose the console in
both directions, and a Dart grandchild dies with exit 255 before its first write.
The workaround is to redirect that tool's output to a file, which restores it completely. That is a workaround, not a fix, and is named as one here.
Not fixed in this version on purpose. The mitigation in our layer would be to pipe and forward rather than inherit, which costs interactive children, TTY detection and correct stream interleaving — a trade worth making only if the SDK says the behaviour is intended. A minimal repro exists for that question.
Two runners recording the same build is no longer read as a collision #
A release matrix could not record an upload. Jobs that share a commit and a
build number — playstore and playstoredev, ios and macos — each mint their own
annotated tag object for the same name and the same commit: different timestamp,
different message, therefore a different object id. Git refuses to replace one
with the other and rejects the push as ! [rejected] ... (already exists),
which is the same words as a genuine collision.
The push treated any rejection as that collision, so the second job's upload was blocked having done nothing wrong — and because the record is written before the store is contacted, the release half-shipped: one store took the build and the others refused to start. AuthPass hit this on a real stable push.
Origin is now asked what its tag actually names, exactly as the local path already asks:
git ls-remote origin 'refs/tags/<name>^{}'
The ^{} is the fix. Without it ls-remote answers with the tag object id,
and comparing those reports a collision on every parallel release and never on a
real one. Same commit is alreadyRecorded; a different commit is still
UploadCollisionException and still exit 3. A rejection with no remote tag at
all — credentials, network, a hook — re-runs the push so git's own message
reaches the operator rather than an invented one.
The existing remote-collision test used a different commit, which is the rare case; a matrix sharing one commit is every release. The same-commit case has its own test now, and it fails against the old push.
3.4.1 #
The fixes below have tests that die when the fix is removed #
Found by review, and the most useful thing in it: reintroducing the guard bug
left all 464 tests green, and deleting the collision's catch clause did too.
Both fixes were as untested as the defects they fixed, and both defects were the
silent kind — so the suite would have watched either walk back in.
The guard's decision is now uploadRecordFor, split out of the store plumbing
so it can be exercised without a store, a network or a repository; three of its
cases go red against the old guard. The collision drives the real binary in a
git repository holding a colliding record, and observes exit 3 — reachable
because the record is written before the store is contacted.
Neither test could have been written against the old shape. That is the finding rather than an aside: a decision buried in a method that needs credentials to reach is a decision nothing will check.
A listing-only push no longer demands a build it did not upload #
play upload --metadata publishes a store listing and hands over no artifact —
documented, with a worked example. Repairing the recording guard made every such
run refuse under tag.upload.enabled, before any store contact, because the
record's scope was "the upload command ran" rather than "an artifact reached the
store". An operator supplying the previous build's numbers to get past it would
have been told the upload collided — exit 3, on a run that uploaded nothing.
Recording now requires an artifact in the run: --aab, --artifact, or one the
manifest names.
An Android manifest carrying neither value is a refusal, not trust #
Every valid .apk and .aab declares versionCode and versionName. Finding
neither means the walk lost its place, and reporting that as "taken on trust"
was the same collapse the cross-check exists to prevent, one level in. Related:
_pooled treated any out-of-range string index like the format's explicit
0xFFFFFFFF sentinel, so a desynced read produced a plausible partial answer
instead of an error.
A partial check now names the half it did not check — build number agrees with …, version name taken on trust. It used to print only what it had compared, so
"checked one of two" and "checked both" differed by which nouns appeared.
Smaller, all from the same review #
readApkFactsandreadAabFactscaught onlyFormatException, but the walk's reads areByteDataand raiseRangeError— so a manifest declaring sizes past its own end escaped as an unhandled Dart error with forty frames, out of a binary whose exit codes are a documented interface.unzipexit 1 means "warnings, and the extraction succeeded". It was refused as "not the archive its extension claims", which is both wrong and wrongly worded. Exit 11 ("no matching files") is now its own sentence too.release finishcomputed the tag name before consulting--no-tag, so a run that had just decided not to tag could refuse over the name it would not write. TheProjectExceptionhandler also still prefixed every message with--app-dir:, which this version made a lie — config and tag-naming errors now reach it too, and were being blamed on a flag the operator had not passed.tag.upload.formatwithouttag.upload.enabledvalidated the format and then wrote nothing. It is refused as the contradiction it is; defaulting it on would hand a repository a push-credential requirement it never asked for.
The cross-check runs at manifest write too, not only at upload #
The design said both chokepoints and only the upload one was built, so
manifest write printed no cross-check line at all — meaning "no reader for
apk" and "checked and agreed" were the same output, one command away from where
that distinction is the entire point. Reported by a second consumer wiring the
writer.
It runs before the file is written, so a mismatch leaves no manifest rather than a wrong one on disk for somebody to find and believe.
A collision now has an exit code a shell can see #
UploadCollisionException has existed since 3.3.0 with a doc comment promising
it "gives the CLI a distinct exit code". Nothing mapped it, so the only thing a
wrapper could observe was 1 — indistinguishable from the failure release
scripts deliberately swallow (|| exitCode=$?, so re-running a release for a
build the store already holds is a no-op). A collision exited through that same
path and the release finished green having published nothing.
uploadCollisionExit is 3. A ReleaseException now also prints its sentence
and exits 1 rather than escaping as an unhandled Dart error, because forty
frames of stack bury the one line an operator needs.
tag.release — release finish stops hardcoding its tag name #
tag:
release:
enabled: true # default, unlike upload
format: v{version} # default
The name was 'v' + version, in the source. A repository whose releases are
named rel/1.2.3, or carry a platform prefix, needed a fork. {build} is
optional here and required in tag.upload.format — a release names a version,
an upload record names one upload of it.
--no-tag overrides enabled: true, and the log says which asked: not tagging: --no-tag against not tagging: tag.release.enabled is false. A flag
and a config key answering the same question is how a setting comes to appear
inert.
.apk cross-check #
Binary XML (axml) — a chunked format with a string pool, structurally unlike the
.aab's protobuf, so a second reader rather than a tweak. Verified against a
real profile .apk and aapt2 dump xmltree, which agreed on versionCode 1
and versionName 1.1.0-profile.
The trap it sets, and the reason the type has to decide where to look: reading
the data word for a string attribute yields a pool index printed as a
number — a plausible wrong answer rather than a failure.
Both string-pool encodings are now covered by tests. The UTF-16 branch had
none — not from the fixtures here, which build UTF-8 pools, and not from the
three production .apks it was validated against, which are all UTF-8 — so it
shipped on the strength of the spec alone. The two layouts share nothing: UTF-8
stores two lengths per string and UTF-16 stores one, counted in different units,
with a different continuation bit. Non-ASCII cases are in both, because ASCII is
exactly where the encodings agree and an ASCII fixture cannot tell a working
reader from a lucky one.
A reader that cannot read is no longer reported as no reader at all #
readApkFacts, readAabFacts and readIpaFacts returned null when the archive
could not be opened — and null upstream means this format has no reader. So a
missing unzip, a truncated download, or a file that is not the archive its
extension claims all printed cross-check: no reader for aab — build number and version name taken on trust: a sentence that reads like ordinary operation,
while the check silently stopped running for the rest of that machine's life.
Failing to read now raises, naming the cause. Null is reserved for the formats
that genuinely have no reader (pkg, dmg, msix, snap, deb, archives),
which stay trusted-out-loud as before. Reported by Copilot, which raised it as a
suppressed comment on the review.
Separately, an archive that opens but carries neither value now reports that —
carried neither value — taken on trust, naming the file it read — rather than
falling back to the no-reader sentence.
A tag format that wants a build number and has none is refused #
tag.release.format is checked for {version} at parse time, but {build} is
legal there and whether a build number exists is a property of the invocation
rather than the file. The substitution filled it with an empty string, so
v{version}+{build} under release finish wrote and pushed v1.2.3+ — wrong
by one trailing character, in a name nobody reads twice.
provenance: is now tag: — breaking, and free #
tag:
upload:
enabled: true
format: uploaded/v{version}+{build} # default
There is no compatibility window, and the failure is total rather than
partial. Neither version accepts both spellings and there is no alias: 3.4.0
knows provenance and not tag, 3.4.1 knows tag and not provenance, and an
unknown top-level key stops every command — not only the ones that write a
tag. So the constraint and .cux-ship.yaml must move in one commit, and
afterwards there is no version to fall back to without editing the file back.
This is the opposite shape from 3.3.0 → 3.4.0, where constraint-first was survivable because the newer version tolerated the block being absent. Raised by a consuming repository that was about to migrate and read the code rather than the notes to find it out — which is the wrong way round, and why it is written here now.
provenance was specialist vocabulary twice over: the art world's word for an
object's ownership history, and — in software — the supply-chain term for
signed attestations (npm --provenance, SLSA, sigstore). A reader arriving
from the second expects a transparency log and gets one annotated git tag. A
name that promises more than it delivers is worse than one that says nothing.
Every comparable tool calls this a tag: fastlane's add_git_tag,
semantic-release's tagFormat, Maven's tagNameFormat, npm's
git-tag-version.
Breaking in name only, and safe to break: the feature never worked (below), so no repository has it set in anger.
tag: is a namespace for every kind of tag this tool writes, not just uploads.
tag.release lands in this same version, so release finish no longer
hardcodes v{version} — a repository whose releases are named rel/1.2.3, or
carry a platform prefix, stops needing a fork. It is enabled by default, where
an upload record is opt-in, because a release tag is what this tool has always
written.
Singular tag: rather than tags: because the config's own convention is
plural-holds-a-list (locales, screenshots), singular-holds-a-map (play,
appstore).
provenance.record-uploads recorded nothing unless --commit was typed by
hand — which is every caller using --manifest, the flag whose whole purpose
is supplying that commit. So the feature has never worked for its intended
caller since it shipped in 3.3.0.
The guard asked ArgResults.options.contains('commit'), which holds what was
provided or defaulted. What it meant to ask — and what its own doc comment
says — is whether the subcommand declares the option, so that a parser without
it is skipped rather than interrogated. Those two disagree exactly when an
option exists and was not given, which is the case that matters.
Nothing failed. The guard returns early and silently, so record-uploads: true
read as working for as long as nobody went looking for the tag. Found by turning
it on in a real repository, uploading, and finding no tag.
3.4.0 #
The pre-release becomes a release. 3.4.0-dev.1 was published so the
repository driving these changes could use them before the flag names settled;
they have settled, and a second project has now wired the same commands into its
own release path. Everything below landed under that pre-release or after it.
manifest write --derived-from — a repackaged artifact inherits its provenance #
cux_ship manifest write --artifact authpass_1.9.15_amd64.deb \
--platform linux --format deb \
--derived-from authpass-1.9.15.tar.gz.manifest.json \
--packaging gitSha=bbbb1111 --packaging repo=authpass-deb
No build facts retyped. The .deb's manifest carries the tarball's gitSha,
dirty, versionName and buildNumber as its own, because build-manifest.md
requires inherited facts hoisted to top level always — a reader that knows
nothing about derivation must still get true answers from the fields it already
reads.
The flag takes the parent's manifest, not a hand-assembled entry. The spec
says a repackager writes [parent] + parent's own derivedFrom; making each
producer implement that is how one producer's version of it ends up subtly
wrong. Pointing at the parent lets the chain build itself, and it stays flat and
nearest-first through however many 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 is the normal case after downloading both. An artifact and its sidecar upload non-atomically, so a fetch can straddle the two; this turns that skew into a loud refusal rather than a chain that records a derivation from bytes nobody has.
Explicit flags still win, so a repackager that genuinely knows better can say so — but it has to say so.
The manifest is checked against the artifact, not only against its digest #
==> how-it-went-1.1.0-65.aab — build 65 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, a stale artifact copied over a fresh manifest's neighbor. In all of those
the manifest honestly describes the wrong artifact and every upload flag is
correct.
That check existed, at the store: Play parses an uploaded bundle and reports its
versionCode, and play upload compares afterwards. Right answer, learned after
transferring 69 MB. verify() now reads the values out of the artifact it is
already holding — 0.77 s including VM startup, on a real 69 MB bundle.
| Format | Read from | How |
|---|---|---|
aab |
base/manifest/AndroidManifest.xml |
zip entry + a minimal aapt2-proto walk |
ipa |
Payload/*.app/Info.plist |
zip entry + plutil |
| everything else | — | trusted, and said so out loud |
A format with no reader prints that it was trusted. cross-check: no reader for pkg — build number and version name taken on trust is a different line from
success, because "not checked" and "checked and fine" must not render the same.
The .aab walk needed no bundletool and no Java. Neither that nor aapt2 is
necessarily installed, and aapt2 dump xmltree refuses an .aab outright, so a
hundred lines of Dart is not a shortcut around a heavier tool — it is the only
local option. The walk is two levels deep rather than five: XmlAttribute.value
already carries "65" as a string beside the compiled integer, so nothing
decodes Item or Primitive.
Reading and deciding are separate functions. Getting bytes out of a zip needs a
zip; deciding what a disagreement means does not, and a test that must build an
.aab to assert on a comparison is one nobody writes the third case for.
Two defects a real upload found, and one refactor #
A placeholder build number is refused. buildNumberAssigned: false says
allocation failed and the number is a stand-in. The field was read, written and
printed as UNASSIGNED, and verify() never acted on it — so the only guard
anywhere was a shell if in one consuming repository, and every other
--manifest caller would have shipped build 0. A specified refusal that exists
everywhere except in the code is the worst kind: the field's presence is what
stops anyone looking. No override; --allow-dirty does not wave it through.
The build number could become the version name. The Play path resolved
opt('version-name') ?? buildNumber ?? defaults.versionName, so with the flag
omitted Play would have taken a release named 65 (65) — and the changelog
lookup went after a section headed 65, failing with "CHANGELOG.md has no
section for 65". The message named the wrong thing, so the fix an operator
would reach for is adding a ## 65 heading. Unreachable while every caller
passed --version-name; reachable the moment one switched to --manifest, and
found on the first real upload through that path. Now
resolveVersionName({explicit, fromManifest}), a signature with nowhere to put
a build number.
Versions are parsed into Version (pub_semver). The bump path hand-rolled
^(\d+)\.(\d+)\.(\d+)$ and answered two questions with one pattern: what is
a version, and which versions this will bump. 1.0.3-beta and 1.0.3+41 are
valid semver and are still refused — that is policy, not parsing, and it now
reads as a check on isPreRelease and build. The two failures report
differently. pubspecVersion returns Version? and FinishOptions.version is
a Version, which removed an Object inference in runner.dart where a
String? and a Version? met in a ?? chain.
Ordering comes with the type: 1.0.10 above 1.0.9, and build metadata
excluded from precedence — the rule vX.Y.Z+<build> tags depend on. The
dependency was already in the lockfile transitively, so nothing new enters
anyone's graph. cux_ship_verify stays zero-dependency.
verify finds a split App Store tree, and checks both halves #
A repository shipping to both Apple platforms keeps store/appstore/ios and
store/appstore/macos. The default resolved to their parent, which holds a
README and two subdirectories and nothing a validator recognizes — so a bare
cux_ship verify reported store/appstore holds no info/, no listings/ and no age-rating.json — nothing to publish on a repository that was entirely fine.
A check that cries wolf on a healthy repository is how people learn to skip the check, which is the failure the offline verifier exists to prevent, committed by the verifier. Reported by a second project hitting it during a real release, where the workaround was three invocations naming each tree.
Now: a split layout is found and every tree is checked in one run, each
against its own platform's rules. --platform narrows to one, and is still
required alongside --appstore when two platforms are declared — one path
cannot say which platform it is, and applying the wrong one refuses a macOS
listing for lacking iPhone screenshots.
--changelog, --appstore, --play and --data-safety now render as
--changelog=<path> in the usage, and the stale "Defaults to store/appstore"
help texts say what the defaults now do.
cux_ship manifest write — one producer for the file every upload is named by #
cux_ship manifest write --artifact dist/how-it-went-1.1.0-53.aab \
--platform android --format aab \
--version-name 1.1.0 --build-number 53 \
--git-sha "$SHA" --no-dirty
==> wrote dist/how-it-went-1.1.0-53.aab.manifest.json
android/aab 1.1.0 (53) d9c394b sha256:8f3ac91b0d24
--manifest gave this package a reader in 3.4.0-dev.1. The file it reads was
still written by a shell heredoc in each consuming repository — so the schema
existed twice in prose and nowhere in code, and a field one of them omitted was
invisible until an upload weeks later published an artifact described by the
wrong numbers. The round trip is now a test rather than a convention.
Three things it refuses, each earned:
- The digest is computed here, never passed in. Taken from the artifact as it stands, so a digest recorded before signing cannot be written at all. Otherwise that fails verification on every real release rather than never.
--dirtyhas no default. It must be given as--dirtyor--no-dirty, because a build script that forgot the flag would certify every dirty build as clean and nothing downstream could tell.- A short
--git-shais refused. The 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 are never derived, because this
runs after the build: a tree that moved in between would be invisible here
and wrong in the file. The build knows both when it starts and passes them down.
--out renames the manifest within 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: the artifact is recorded as a basename resolved against the manifest's
directory — which is what keeps a dist/ tree movable between machines — so a
manifest written elsewhere parses cleanly and then cannot find its artifact.
buildNumber is written as a JSON integer, which is what the schema always
said and what the first writer got wrong — it emitted a string. Nothing failed,
because the reader stringifies whatever it finds; that is exactly how a spec and
its only implementation drift apart with every test green. A value that is not
an integer is now refused rather than coerced.
Manifest schema 2 #
Read alongside schema 1, which stays readable for as long as anything writes
it — refusing it would strand every dist/ already on disk, and a reader that
cannot read yesterday's build is one nobody can adopt one repository at a time.
New fields, all optional: flavor, builtAt, producer, toolchain,
gitTag, buildNumberAssigned, packaging, derivedFrom, and x for
repo-local keys that no shared tool reads. Schema 1's variant is renamed
format and both spellings are read — variant means flavor-plus-buildType in
Gradle, and one consuming repository has six flavors that share a version and
a build number, so the two needed separate names.
buildNumberAssigned: false says the number is a placeholder because allocation
failed, so an upload can refuse it rather than ship under a number that means
nothing. Absent reads as true: a schema-1 manifest never carried the field and
is not claiming otherwise.
3.4.0-dev.1 #
A pre-release, published so the repository that drove these changes can use them before they are settled. The three additions below are in use but not yet proven by a second consumer; treat the flag names as provisional until 3.4.0.
--manifest — name the build once, and check 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
Supplies --artifact (or --aab), --build-number, --version-name and
--commit from a build manifest written beside the artifact. Explicit flags
still win — a manifest is inference, and inference loses to what was typed.
It verifies the artifact against the digest the manifest records, which is
the part no flag can give you: every argument can be correct while the bytes
belong to a different build — a dist/ edited, half-written, or left from an
earlier build whose manifest was replaced without its artifact being rewritten.
A manifest recording a dirty tree is refused unless --allow-dirty.
Schema 1; an unknown schema is refused rather than read optimistically.
This reverses a stated decision — both store libraries said they knew nothing about manifests, on the grounds that a project's upload script had already checked one. That held while every project had such a script. It stopped when provenance moved into this tool in 3.3.0, since a record of which commit shipped cannot be written by a command forbidden to know the commit; and it never held for the Apple side, where no such script has ever existed.
Recording which commit an upload came from #
play upload and appstore upload can write an annotated tag naming the commit
an artifact was built from, before contacting the store. Off unless declared:
# .cux-ship.yaml
provenance:
record-uploads: true
Off by default because recording pushes a tag to origin, so enabling it for
every consumer would turn a missing push credential into a total upload block
rather than a skipped record.
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 provenance.tag, which must contain {build}.
The tag records an upload attempted, not accepted: it is written before the store is contacted, so a signature refusal leaves it standing over an artifact nobody received. That is the right trade — the alternative failure mode is shipped but unprovable — but consumers must read these as attempts.
release refspecs #
refs/buildnumbers/* and the build-number notes ref sit outside refs/heads
and refs/tags, so a clone's default refspec ignores them and a fresh clone has
no allocation history until something fetches it explicitly. Run once per clone.
It appends to remote.<remote>.fetch and never replaces it.
3.3.0 #
release finish now refuses a tag that names a different commit, and pushes
one it finds already there. Both are behavior changes on a path every release
runs, so read this before upgrading — nothing else in this release is reachable
from the command line yet.
A failed tag push used to be permanent #
The tag was pushed only on the run that created it. So a run whose push failed
— an expired token, a runner without the route, a network blip — left a tag that
no later run would ever publish: every repeat found it locally, logged
v1.0.3 already exists at abc1234 — leaving it alone, and finished green while
the remote never got it. The release then had no tag anywhere a later reader
would look, and nothing said so.
The push now runs whether or not this invocation created the tag. Pushing a tag
the remote already holds at the same commit is a no-op; pushing one it holds at
a different commit is rejected, which is the collision below arriving from the
side a local check cannot see. The — leaving it alone half of that log line is
gone, because it was no longer true.
An existing tag at another commit is now an error #
It used to log and carry on, including the version bump. One version
recorded against two commits is one of them being wrong, and continuing left
whichever it was standing as the record of what shipped. It now throws, in
--dry-run too, and the bump does not happen.
If you have a release in that state, the fix is to decide which commit shipped and retag deliberately. This refuses rather than choosing for you.
Commit arguments are resolved before they are compared #
--commit HEAD and --commit abc1234 used to work on the first run and fail on
the second. The tag side of the comparison is rev-parse output — always a full
40-character SHA — while the other side was whatever you passed, so the
legitimate repeat was reported as a version naming two commits. That
accusation is the loudest error this command can raise and it was false.
Both sides now go through the same resolution, which also turns a commit that is
simply absent from a shallow clone into a message saying so, instead of git's
fatal: bad object type.
Internal: recording what reached a store #
recordUpload writes an annotated tag naming the commit an artifact was built
from, so a build number still resolves to a commit after git gc has taken
the branch that contained it. It is not wired into play upload or appstore upload yet and there is no way to reach it from the CLI in this release; it is
published now so the repositories that will call it can be built against it.
Two things worth knowing before that wiring lands, because both change how a caller has to behave:
- The tag records an upload attempted, not accepted. It is written before the store is contacted — tagging afterwards would make the failure mode "shipped but unprovable" — so a signature refusal leaves the tag standing over an artifact nobody received. Anything reading these tags must read them as attempts.
- A collision throws
UploadCollisionException, distinct from an ordinaryReleaseException, so a wrapper can tell it apart. Release scripts routinely call the upload under|| exitCode=$?so that re-running a release for a build the store already holds is a no-op. A collision exiting through that same path would be downgraded to a tolerated failure and the release would finish green with the loudest error unreported.
3.2.1 #
Every appstore subcommand crashed in 3.2.0 if the repository declared an
appstore: block. Upgrade immediately; 3.2.0 should not be used.
Unhandled exception:
Invalid argument(s): Could not find an option named "--require-screenshot-type".
#1 _derivationProblem (package:cux_ship/runner.dart:1535:12)
#2 _AscSubcommand.run (package:cux_ship/runner.dart:190:15)
upload, promote, wait, builds, versions, signing and
screenshot-types all died before doing anything. Nothing reached Apple and
nothing was published wrongly — the command could not start. verify and the
whole Play side were unaffected.
_derivationProblem read --require-screenshot-type out of ArgResults. That
option is declared on verify alone; the function was also wired into the App
Store path, where argResults is a parser that has never heard of it.
And 3.2.0 left no way back. A repository that adopted the declaration could neither upload on 3.2.0 nor fall back to 3.1.0, which refuses the config outright:
--app-dir: .cux-ship.yaml has unknown keys: appstore, play
known keys: app-dir, apple
3.2.0's own notes said to land the constraint bump and the config in one commit. That is right for migration ordering, and it removes the rollback — which those notes did not say and should have. Anyone who followed them was stuck on both sides until this release.
The fix, and why it is not the one-line guard #
_derivationProblem takes the flag's value as a parameter now instead of
reading it. verify passes what its flag supplied; the App Store path passes an
empty set, because it has no such flag.
Guarding the read — args.options.contains(…) before multiOption — works, and
leaves the function still asking a question about a parser it does not own,
which is the same trap for the next helper shared across two commands. Declaring
the option on the App Store parser would grow the CLI surface to fix internal
wiring. A parameter makes the question unaskable.
Worth saying plainly #
The crash was in the error-reporting path 3.2.0 added to close a silent-pass hole. That guard existed to make an underived screenshot requirement loud rather than silent; instead it killed the command and named a flag the user never passed. A check that cannot start is not a stricter check.
And the ordering is the general lesson, in the words of the project that hit it: loudness added at the wrong layer is quieter than what it replaced. The guard ran before the validation that would have produced a useful message, so it did not merely fail to help — it hid the thing that would have.
It shipped because no test ran a subcommand against a repository that declares a
store block — the crash needs one to fire, and every fixture in this package had
none. test/subcommand_smoke_test.dart now starts every subcommand against both
shapes: a repository declaring appstore: and play:, and one declaring
neither. It asserts only that a command gets past argument handling and into its
own body, which is the whole class of failure this was.
Found by a consuming project, one command after adopting the config, by somebody doing something unrelated.
3.2.1 means uploads tell you the truth, not that they succeed #
If a project has an interpolated PRODUCT_BUNDLE_IDENTIFIER — per-configuration
suffixes, design.codeux.example$(BUNDLE_ID_SUFFIX) — the next thing it meets
is 3.1.0's refusal:
PRODUCT_BUNDLE_IDENTIFIER in ios/Runner.xcodeproj/project.pbxproj is
'…$(BUNDLE_ID_SUFFIX)', which Xcode expands at build time and this can only
read as text — pass --bundle-id
That is correct and is not a regression: --bundle-id is required for those
projects and always was. But 3.2.0's crash fired before it, so two blockers
were stacked and only the second becomes visible once this release removes the
first. Nothing further is broken; there is simply a second thing to pass.
3.2.0 #
A repository can declare what its listings must carry, and verify reads it.
appstore:
locales: [en-US]
screenshots: # optional — derived when absent
ios: [APP_IPHONE_67, APP_IPAD_PRO_3GEN_129]
play:
locales: [en-US]
screenshots: [phoneScreenshots, tenInchScreenshots]
--require-screenshot-type and --require-locale never varied between runs;
they vary between apps, which is what .cux-ship.yaml is for. Three consuming
repositories had each hand-written the same wiring to say them.
Breaking, in a minor. Read this one. #
A project that has a listing tree and does not declare it now fails
verify. Semantically this is a major; it ships as a minor deliberately,
because the alternative is a major every few days and a version number that has
stopped carrying information. The compensation is that the break is loud, early,
and says what to do:
.cux-ship.yaml: appstore: is not declared, and store/appstore exists — so the
tree is checked but nothing says which locales it must carry […] Declaring
appstore has been required since 3.2.0: add appstore.locales, e.g.
locales: [en-US]
The failure names the version that introduced the requirement, because the question a reader actually has is not what is missing but why did this work yesterday.
Nothing checks less than it did before. A present tree is still validated against everything intrinsic to it, declared or not — an upgrade that quietly checked less would be the exact failure this release exists to remove. What the declaration adds is the one thing that cannot be inferred: which locales the listing must carry, so that one silently disappearing is noticed.
Take this bump in a commit that also adds the config, in the same way the secrets file moves with a constraint that admits a new shape.
verify gains the Play half #
--play, --data-safety, and the checks behind them. Play was covered for
release-note length and nothing else.
data-safety.csv is the reason this release is ordered the way it is.
play upload --dry-run discards its edit rather than validating it, and skips
the declaration outright; a real run sends it as a separate POST, deliberately
after the commit, because it is not versioned with a release. That ordering is
right and cannot be changed — so a broken declaration fails after the release
is public, and an offline check is the only place it can be caught at all.
Structure, never content: whether the answers are true is a question about a particular app, and this cannot answer it. There is also nothing to specify — the file is Play's own export and every row states its own answer requirement.
Screenshot types are derived from TARGETED_DEVICE_FAMILY #
{APP_IPHONE_67, APP_IPAD_PRO_3GEN_129} is Apple's current requirement for a
universal app, not a fact about any app: the 6.7" class replaced the 6.5" one.
A project holding those names in its own config holds a value that ages into a
rejection after upload; one derived here is fixed for everybody by taking a new
version. appstore.screenshots overrides it when Apple and this table disagree.
macOS has no TARGETED_DEVICE_FAMILY, so that side is a constant rather than a
lookup — two mechanisms behind one word, said out loud.
TARGETED_DEVICE_FAMILY gets 3.1.0's refuse-on-ambiguity rule. It is the same
multi-target shape that forced PRODUCT_BUNDLE_IDENTIFIER's.
The declared requirements are enforced on appstore upload, not only in verify #
A requirement that is a property of the repository and is honoured by one
command out of two reads as a standing fact and is not one. upload is the
command that reaches Apple.
Listing URLs are checked for reachability, and never block #
A privacy policy URL that 404s is a rejection, and the site is usually deployed by a different command than the app. This runs on the upload path, always, and reports rather than fails: a URL can be legitimately dead at exactly one release — a policy site deployed after the app — and a gate there would fail correctly and teach the bypass.
It is not in cux_ship_verify and never will be. That package has no
dependencies and makes no network calls, and verify --help's "No network, no
credentials" is an invariant, not a description of what it currently does.
Two checks were written, run against a live listing, and deleted #
Both would have failed a listing a store is serving right now:
- a response id belongs to one question — Play reuses them across questions by design. 22 problems on an accepted declaration.
- an enumerated set of answer requirements — exports also use
SINGLE_CHOICEandOPTIONAL. 88 problems.
Play's published "longest side at most twice the shortest" is absent for the same reason. A 20:9 phone capture is 2.22:1, and the reductio needs no counterexample: Play mandates a 1024x500 feature graphic, which is 2.048:1.
A check that fails what a store accepts is worse than one that passes vacuously, because the first response to a red board over a live listing is to find the flag that turns it off. No dimension or length rule should ship without being run against a real listing and asked whether it would have failed something already published.
3.1.0 #
Inference refuses an ambiguous project instead of taking the first match.
PRODUCT_BUNDLE_IDENTIFIER and DEVELOPMENT_TEAM are read from the Xcode
project as before when the project names one of them. When it names several,
the command stops and lists them rather than picking.
The one thing to check on upgrade, because a caret constraint takes this without asking: a project that was inferring by luck now needs a flag. It stops at the start of a release rather than partway through, and names the project and the candidates — but it can stop a run that worked yesterday. Two real projects were relying on that luck, in different ways and neither visibly:
- One puts an app extension's target first, so the first match was the appex
rather than the app. Its uploads were correct only because the release script
passes
--bundle-id— remove that flag and it would have talked to Apple about the extension. - One interpolates
design.codeux.howitwent$(BUNDLE_ID_SUFFIX), which Xcode expands at build time and this can only read as literal text. Apple answered 404 for an app by that name, which sends you to look at the app record and the API key before the project.
head -1 on a file that may legitimately name several things is not a rule,
it is a coin toss that has been landing the same way.
The refusal says which failure it is #
Three situations a single null cannot tell apart, so they no longer share a message: nothing could be read, several were read, or the one that was read is not literal text. Only the first is answered by pass the flag. Reporting the others as the first is what sends someone to check their credentials — the sentence has to name the project, or the afternoon goes elsewhere.
An absent iOS project is unchanged and still just absent. That is ordinary, not a problem, and "none could be read from the Xcode project" was already right.
secrets add certificate --from-keychain defaults --team #
From DEVELOPMENT_TEAM, as keychain exec --team already did. It was the last
value in an Apple setup still typed by hand, out of a file already being parsed
for the bundle identifier. A team retyped once a year during a certificate
rotation is where a transposition survives — the wrong one exports somebody
else's identity and surfaces much later as a profile mismatch that never
mentions the team.
keychain exec --team gets the ambiguity refusal too, and there it is a
tightening rather than a convenience: expectTeam is optional, so an ambiguous
project used to become an unchecked one. Silently dropping the check that
exists to catch a certificate from another account is a worse answer than the
guess it replaced.
--no-metadata on appstore upload #
Uploads the build and leaves the store listing alone.
Omitting --metadata never did this: the default is inferred from
store/appstore whenever that directory exists, so there was no way to put a
build on TestFlight without also publishing the listing. When the App Store
version is locked by review — WAITING_FOR_REVIEW or IN_REVIEW, both
ordinary states — the listing push fails with a 409.
It failed last. The binary and the TestFlight notes were already up, so the command did everything asked and then exited non-zero, which invites the one response that is wrong: run it again.
A TestFlight build is not a version submission and needs no listing. Apple
reviews the listing together with the version, which is why upload is where
metadata belongs and promote still never publishes it — that part is
unchanged and deliberate.
--metadata and --no-metadata together are refused rather than ordered.
3.0.0 #
keychain exec gives its child the keychain it made and nothing else.
Everything further is named at the call site:
keychain exec --profile ios_appstore -- tool/build.sh # APPLE_KEYCHAIN only
keychain exec --only tokens.marks -- tool/build.sh # and that token
keychain exec --only ssh_keys.github_deploy -- ci-release.sh # and the deploy key
secrets exec is unchanged by default — it places everything, because a
general-purpose wrapper that hands over nothing is inert — and takes the same
--only when a caller wants to narrow it.
The selector is family or family.instance, comma-separated or repeated:
tokens, tokens.marks, apple.certificates.distribution.
Why the default is nothing rather than something better #
This replaces a hardcoded three-family withhold list that no caller could see, extend, or ask for something different from. The obvious replacement was a better default, and three were tried: "the minimum that signs", then "withhold values and pass paths", then two rules applied per variable. Each needed more structure than the last, ending in one rule, one guarantee and a seven-row table.
The reason is structural rather than a failure to pick well. A default has to satisfy every consumer, so it is the union of their needs, and a union is wrong for each of them individually. A union has no explanation, only a membership list — which is why it could not be written down in a way anyone could hold in their head. A call-site selector is one consumer's own set, and it has a reason that fits in the flag.
It also could not be derived. keychain exec wraps a build script, so what
that script consumes happens below this command's arguments — in one real case
four layers down inside a function, with the variable's name in a printf
format string. A rule of "do not withhold a credential whose variable appears in
the command line" was proposed and would have withheld the one token that must
never be withheld: it failed on the example it was designed around.
SOPS_AGE_KEY is stripped from keychain exec's child #
Unconditionally, with no way to readmit it. It is the master key to the whole file, value-shaped, and not a credential in the file — so no selector can name it and no default covered it, while an Xcode script phase writes the whole environment into a build log.
Nothing outside cux_ship reads it. The one composition that did — a nested
secrets exec inside a keychain exec child — is replaced by running the two
as siblings, which is a change in the consuming project rather than here.
This is also what makes the archive guarantee true rather than nearly true. A child that could decrypt the file could mint the App Store key the archive is meant not to hold.
Check the advice your own tooling prints #
This release changes no message cux_ship prints. It can invalidate messages
you print. Grep your own scripts for a remedy naming keychain exec without
--only:
no temporary keychain — run this through `cux_ship keychain exec`
Correct for years, and incomplete at 3.0.0. Someone who follows it gets a
keychain, clears that check, and dies further down on the credential the empty
default no longer places — one error handing them to the next, each true on its
own. Name the whole invocation instead, --only included.
The asymmetry is what hides this. Advice naming secrets exec survives
unchanged, because that command kept its everything default; only keychain exec moved. The two read as the same kind of sentence, so a sweep for one does
not suggest a sweep for the other.
Worth doing even though your build passes. The loud version of this failure is the lucky one — a later check that refuses outright. Where the credential is read by something that merely carries on without it, an optional feature quietly staying off or a signing step choosing a different identity, nothing fails and the sentence that sent them there still reads as correct.
Behaviour worth knowing #
- A selector that does not resolve is fatal, and the three mistakes get three messages. Unknown to the schema, a named instance absent from the file, and a section named instead of a family are different errors; telling someone their spelling is wrong when it is not sends them looking in the wrong place.
- A known family that is empty in this file is reported, not fatal. Naming a
family is a scope; naming an instance is an existence claim. That is the line
decideProfilealready draws. - Resolution is against what the process holds, not the file. Under
secrets exec --only x -- keychain exec --only y --, the inner command asking for something the outer stripped fails there, naming it, rather than four layers down inside a build. A partial match is fatal too. - Selection removes, it does not merely decline to place. An outer wrapper has already put everything in the environment before an inner one runs.
--keystoreand--api-keyare refused alongside--only, which already names the instance they exist to choose.--only apple.profiles.*is refused pointing at--profile, andplaced.*pointing atsecrets place.secrets exec --onlywarns when it omits a keystore the file holds. A missing Android keystore is the one absence that is silent: Gradle falls through to the debug key and produces an artifact only the store rejects.
Fixed on the way #
secrets exec never passed includeParentEnvironment: false to its child.
Process.start merges the map into the parent's environment, so anything
removed from the map came back. It had never removed anything, so it had never
needed the flag — and the moment --only gave it something to remove, the
removals silently did nothing. keychain exec has carried the flag, and a
comment warning about exactly this, since 1.9.1.
2.3.2 #
-
An App Store Connect error that blocks a submission now says where to fix it. "Unable to Add for Review — an Admin must provide information about the app's privacy practices in the App Privacy section" stops a release and names no remedy, and the natural reading of an API error is that the caller sent something wrong. The printed failure now adds the console path, that an Admin must do it (a Developer-role account cannot, which the error does not say), and that it is per app rather than per version.
It also says plainly that cux_ship cannot check this in advance and will not pretend to. App Privacy is absent from the App Store Connect API — not readable, not writable — so nothing here can tell you the section is incomplete before Apple does. That was checked rather than assumed: Apple's API index enumerates the areas it automates and privacy is not among them, the
Appresource carriesappEncryptionDeclarationsandaccessibilityDeclarationsand nothing for data usage, and fastlane's privacy action authenticates with a web session because the endpoints are not in the official API.Only this one error is annotated. An error that already names its field is actionable as printed, and a paragraph on every failure trains people to stop reading the paragraph.
2.3.1 #
-
Platformis extracted asxml1rather thanjson. On amacos-15runnerplutil -extract Platform jsonexits 1 with empty stderr — not an absent key, not a malformed profile, a subprocess that fails and says nothing.keychain exec --profiletherefore refused every provisioning profile on that runner, which is every GitHub job usingmacos-latestat the time of writing.Measured on a real runner rather than predicted, and found in one line because 2.3.0 had already stopped reporting a failing
plutilas an absent key. The old message asserted the one thing that was false; the new one named the command, the exit code, and the absence of any explanation to pass on.This is the second failure of
-extract … jsonon that OS — the first wasDeveloperCertificates, an array of<data>which JSON cannot represent and plutil refuses outright.Platformis an array of strings, which JSON represents perfectly well, so the two have no cause in common except the format.xml1works on both macOS versions.UUID,NameandExpirationDatestay onraw, which has never failed and is unambiguous for a scalar.
2.3.0 #
-
cux_ship secrets addputs a credential into the file. Until nowsecretscould read one five ways and write one —pack, and only for aplacedfileplacehad already written — so adding a certificate, a profile or a token meant a hand-rolledruby | sops setpipeline in every project. Those encode the schema path by hand, write one field at a time so an interrupted run leaves a half-credential, and handsops seta bare string when it wants JSON — which is accepted, stored wrong, and authenticates as garbage.secrets add certificate distribution dist.p12 --password-file pw secrets add profile ios_appstore app.mobileprovision secrets add api-key upload ~/Downloads/AuthKey_ZHGL57YJVC.p8 secrets add token artifact --env ARTIFACT_TOKEN --value-file tokA name and an artifact, positionally. No
--p12/--p8/--file: the artifact is identified by its contents. That names the real mistake rather than the wrong flag, and catches a correctly named file with the wrong thing inside — which happens, because people rename downloads. An api key'sidandkindare read back out of Apple'sAuthKey_/ApiKey_naming, so the two fields most often got wrong are two nobody types.Every field of a credential lands in one write, making the partial state
secrets execrefuses unrepresentable rather than merely reported. Adding over an existing credential is refused;--replaceis the rotation verb. Passwords and values are never arguments, because an argument is visible tops; a certificate's password is checked against the.p12before anything is written. -
cux_ship secrets checkdecrypts and reports whether each credential actually works, as verified, failed or opaque — the last being one this tool can never authenticate, such as a token. Onlyfailedcolours the exit code; an opaque credential is not an error and must not read as one, or the check becomes something people learn to skip past.Its unique value is the cross-checks, which no single-artifact command can perform: above all whether a stored profile still embeds a certificate the file actually holds. That is not derivable from either artifact alone — a Developer ID profile can outlive the certificate inside it by a decade, so its expiry says nothing about whether it still holds a usable certificate, and replacing a certificate silently invalidates every profile issued against it.
-
cux_ship secrets removeretires one. Doing it by hand was the same failure class as adding by hand, at the worst possible moment — having just decided something is compromised. -
Replacing a certificate names the profiles it invalidates. Every profile issued against a certificate stops working the moment that certificate is replaced, and no artifact says so — the profile keeps its own expiry date. Established before the write, while the outgoing certificate is still there to fingerprint; afterwards the evidence is gone. When it cannot be established it says so rather than reporting that none were affected, which is a different claim and the only reassuring one.
-
secrets add certificate --from-keychainbuilds the.p12out of a macOS keychain, with a generated password, for onboarding a machine that has an identity but no file. Ported from a shell version in a sibling project, for three traps it already encodes — each of which produces a file that looks correct: pairing the certificate with its key onlocalKeyIDrather thanfriendlyName(macOS names the two bags differently, so a friendlyName filter yields a.p12that imports cleanly and cannot sign); matching the certificate kind as well as the team, since Apple Development carries the sameOU=; and checking expiry on every candidate, because a keychain never sheds the expired ones. -
Renames.
secrets keysis nowsecrets list: the file holdsapi_keys,ssh_keysandkeystores, so "keys" read as a category rather than as the credential names it actually prints.appstore awaitis nowappstore wait, taking the build number positionally — it was required anyway, andawait/builds/build-numbershared tokens while meaning unrelated things.appstore uploadtakes--artifact, with--ipaand--pkgas accepted spellings:--platform macosis first-class, so a macOS release passing its.pkgto a flag called--iparead as though macOS had been bolted onto an iOS-shaped command, and--pkg— what anyone tries first — was rejected outright. -
deps updateis hidden from--help. It re-pins cux_ship's own sources and refuses to run outside a cux_ship checkout, so to every consuming project it was a documented way to get an error. Still runs when typed. -
A failing
plutilno longer reads as an absent key. The per-key loop recorded a value only on exit 0, so a tool that could not do what was asked, a key that is genuinely missing, and a plist that would not parse all arrived at the same message — and the one it printed named the least likely cause. That cost a consuming project an afternoon bisecting macOS versions to learnPlatformhad been present all along. The same bug bit again while building the cross-check, whereplutil -extract … jsonrefuses a<data>array outright ("Invalid object in plist for JSON format") and the empty result silently checked nothing.
2.2.0 #
-
cux_ship appstore awaitexposes the wait thatappstore uploadalready did internally. Uploading and waiting can now run on different machines, which matters because the wait is a poll against a REST endpoint that needs no Xcode, no keychain and no signing material — only the API key — while the runner that built the artifact is billed at 10× for being macOS. It also finishes--skip-waiting, which until now said a caller would wait later and left nothing to wait with.--build-numberis required rather than defaulting to the newest build: the point of waiting elsewhere is to wait for a specific build, and "newest" would succeed on somebody else's upload. The three outcomes keep the meaningsuploadgave them — success, the 422 for a build Apple refused, and the timeout that names where the reason actually is. -
Every certificate's remaining life is reported at import, by
keychain exec, in the words the developer-account audit already uses. A profile carries its own expiry and a certificate carriesnotAfter; they are independent, and nothing read the second. A profile valid for a year that embeds a certificate dying next week passed every check and failed inside codesign — and a project on automatic signing had no profile to check at all. Reported for every certificate imported, not only the one that signs, because an installer certificate expiring is aproductbuildfailure at the end of a Mac App Store run. A warning, never a refusal.
2.1.0 #
-
The App Store review contact comes from the environment, as
APPLE_REVIEW_CONTACT_FIRST_NAME,_LAST_NAME,_EMAILand_PHONE, and is sent with every review-notes write.Not from the metadata tree, and the asymmetry is deliberate. Every other listing field is a file beside
info/; a name, an e-mail address and a mobile number are one person's, they are the same person's across every project using this package, and at least one of those projects is a public repository. A phone number in git history outlives whatever the repository's visibility was on the day it was committed, and unlike a leaked key it cannot be rotated. The environment keeps the choice with each project — a sops file, a CI secret, a shell — which is where every other credential here already lives.All four or none: a partial set is refused before anything is written, since Apple wants them together. The phone is checked against the format Apple's own rejection describes —
+then the country code — because that refusal otherwise arrives mid-push with several fields already landed.Sent on create and update, which is the part that is not guessable: creating a review detail with notes alone succeeds, and updating one is then refused without the whole contact. So the second push of an unchanged file failed where the first had worked.
-
appstore upload --metadatanow publishes review notes, fromreview-notes.mdbesideinfo/andlistings/, intoappStoreReviewDetails.noteson the version.This is the piece of a listing whose absence costs a review cycle rather than a rejection. An app with no content of its own — anything that needs the user's own files before it shows anything — opens to an empty screen, and a reviewer with no sample data concludes it does nothing. That comes back days later as "we were unable to evaluate your app", with nothing to fix, and it is invisible beforehand because every other part of the listing uploaded fine.
The notes are created where the version has none and patched where it has some — with the contact above sent alongside either way.
Parsing, the marker that keeps internal checklists out of it, and the 4000-character check are
cux_ship_verify1.8.0 — socux_ship verifyfails an over-long note with no network at all.
2.0.0 #
The Play service account is passed as a path, not as JSON. secrets exec
now writes the account to a file and exports
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH. GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is
gone — not deprecated, not exported alongside.
Why #
A Google private key reached four public CI logs. An xcode script build phase writes its whole environment into the build log, and a public repository's action logs are public, so a variable holding a key printed the key. It was the only credential this package passed by value; every other one was already a path, and no other one leaked. That is not a coincidence, and it is the rule this release makes universal:
A secret passed as a value can escape through anything that echoes its environment. A secret passed as a path cannot.
1.9.0 and 1.9.1 mitigated it by letting a caller withhold the credential from
commands that cannot need it. That was worth having and it is still here, but it
was never the fix: withholding is not compositional. Each layer can speak for its
own child and no further, so a secrets exec nested inside a keychain exec
reintroduced the value for its own subtree — and "build under keychain exec,
upload under secrets exec" is the natural shape, so the mitigation failed
exactly where the pattern was most idiomatic. A path cannot be reintroduced in a
form that matters, because what comes back is a filename in a temp directory
that has already been removed.
Migrating #
One line per consumer. Read the file instead of the variable:
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > /tmp/sa.json
- supply --json_key /tmp/sa.json
+ supply --json_key "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"
There is deliberately no window in which both are exported. A window is the only variant in which something can quietly keep using the by-value path: the key stays in every consumer's environment for its whole duration while the release notes claim it was removed, which converts a known problem into a believed-solved one. It is less safe than either alternative, not a middle course between them.
Not migrating is loud rather than silent. An unmigrated consumer finds the
variable unset and dies on its first line naming the cause — which is the
criterion this package already applies elsewhere, and the reason the new name is
…_PATH rather than the old name with new meaning. A consumer reading a path
from a variable still called …_JSON would post a filename to Google and fail
at the API, describing neither the file nor the script.
1.9.1 #
Withholding did not survive being nested, which is the composition 1.9.0's own
notes recommend. cux_ship secrets exec -- cux_ship keychain exec -- build
gave the build every credential the file holds, including the Play service
account private key by value — the exposure keychain exec was added to close,
reintroduced by the documented way of using it. Upgrade if you nest them. A
project with keychain exec outermost was never affected.
Two causes, and the second is the instructive one:
- Declining to add a variable does nothing when it is already there. The environment begins as a copy of this process's own, so under nesting the outer command has already placed every credential. Withheld families are now removed, however they arrived.
- Removing them from the map did not remove them from the child.
Process.startmerges the map into the parent's environment unlessincludeParentEnvironmentis false, so every deletion was restored from this process's own environment — exactly the case that matters, since under nesting the parent is what holds the credential.
The second was invisible from inside: the removed from the environment line
printed the correct full list throughout, because it reports what this process
did to a map rather than what the child received. Only reading the child's own
environment showed otherwise.
A limitation this does not fix #
Withholding does not propagate downward through a second secrets exec. The
natural shape —
cux_ship keychain exec -- sh -c 'build && cux_ship secrets exec --api-key k -- upload'
— builds without a Play credential and then, for the duration of the upload child, has one again: the inner call loads the file fresh and withholds nothing. That is correct for an uploader that needs it, and it means the by-value private key is present at exactly the moment most likely to be wrapped in CI logging.
This is not fixable by adding more withholding, and the attempt would be worse
than the disease — a secrets exec that withheld the Play account by default
would break every Android upload that does not know to ask, which is all of
them. The fix is to stop passing it by value at all, exporting a path like
every other credential here, and that changes the contract and belongs to the
next major version. Until then, an Apple build should not be wrapped around a
Play upload in a job whose log is public.
- The reserved-name collision guard is derived from the withholding table rather than listed beside it. They were the same nine names written twice, and drift is silent in both directions: a name missing from the guard lets a project token overwrite a real credential, and one missing from the table leaves a secret in a child's environment the caller believes it withheld.
1.9.0 #
Purely additive. Nothing in the 1.8.0 secrets contract moves, and a project that does not sign Apple builds sees no difference.
-
cux_ship keychain exec -- <command>runs a command with the project's Apple signing identity in a keychain that is created for it and destroyed however the run ends. macOS only, and it refuses rather than continuing anywhere else — a build that carries on without the keychain signs with whatever the machine happens to hold, and exits zero.The login keychain is never read. That is the point rather than a side effect: a signing identity that comes from installed machine state makes the same commit sign differently on two laptops, with nothing saying so.
It consumes the 1.8.0 schema rather than extending it —
apple.certificates.*andapple.profiles.*assecrets execalready materializes them. Reads them from the environment when they are already there, sosecrets exec -- keychain exec -- …composes, and otherwise decrypts the file itself. Which of the two happened is printed, not inferred.The wrapped command gets
APPLE_KEYCHAIN, and is expected to passOTHER_CODE_SIGN_FLAGS="--keychain $APPLE_KEYCHAIN"to xcodebuild. Not optional: the login keychain cannot be removed from the search list — that would drop Apple's intermediates and leave the leaf chaining to nothing — so pinning is the only thing that makes signed with the certificate we imported true rather than likely. A stale identity of the same name in the login keychain is not hypothetical; there was one on the author's machine, three months old, while this was being written.It consolidates two implementations that had each found a different subset of this platform's sharp edges, and adds four things neither had:
- Garbage collection of keychains left by a killed run. A trap covers a failed build, Ctrl-C and SIGTERM. It covers neither SIGKILL nor the power going out, and what survives those is a distribution private key in a keychain that stays unlocked for the rest of its timeout. The pid in the filename is what makes staleness checkable, and nothing was checking it.
- A refusal when a named provisioning profile has expired, at import
rather than inside codesign, which reports it without using the word. Only
when named with
--profile: the secrets file holds every profile a project has, so failing on any expired one would mean an unused Developer ID profile lapsing breaks every App Store release, naming a profile that build never touches. - Quote-aware parsing of the keychain search list. Both sources strip
quotes with
trorsed, which corrupts the list for anyone whose home directory contains a space — the output is quote-delimited precisely to permit that. - A diagnosis rather than an assertion when there is no usable identity.
find-identity -valone cannot separate "the .p12 had no private key" from "the key is here and the certificate does not chain", and those need opposite fixes.
-
loadSecretstakeswithhold, naming credential families the caller declares it does not consume. Additive: the default withholds nothing, sosecrets execis unchanged.It exists because a caller that knows its child's platform knows more than this file can.
keychain execwithholds all three it can, each for its own reason, and says so rather than going quiet:-
android.keystores— an Apple signing command's child cannot sign an Android artifact. Refusing to start because the file held two keystores locked the first consumer out of the command entirely. -
android.play_service_account— the one credential this tool passes by value rather than as a path, and therefore the one that can escape through anything that echoes its environment. An Xcode script build phase writes its whole environment into the build log; this variable reached a public CI log that way, in full, in a project that ships from one.Every other credential is a filename in a temp directory that no longer exists by the time anyone reads the log. Withholding it here is a mitigation, not the fix — the fix is to write it to a file and export a path like everything else, which changes the contract and so belongs to the next major version. A consumer still running an Apple build under plain
secrets execshould unset the variable before invoking xcodebuild. -
apple.api_keys— signing needs no App Store key. Nothing is placed unless--api-keynames one: not the singular variables and notAPI_PRIVATE_KEYS_DIR, so the.p8is never written. A build step can deliberately hold no App Store credential — which is what lets CI sign without holding anything able to create or revoke signing material — and requiring a key to obtain a keychain would give that property away.The asymmetry with the keystore above is the argument, and it is worth keeping: a keystore that fails to arrive is silent, because Gradle falls through to the debug config and Play rejects the artifact after a full upload. An App Store key that fails to arrive is loud, because its consumer dies on the first line naming the cause.
An unrecognized family name is refused rather than ignored, since one misspelled withholds nothing while reporting success — and for the Play account that means a private key in an environment the caller believes it excluded.
-
-
ProjectContext.developmentTeamreadsDEVELOPMENT_TEAMfrom whichever Xcode project has one, ignoring the empty assignment Xcode writes for a target without a team. It is what lets the identity check ask whether the certificate belongs to the account this project builds for — a certificate from another account imports perfectly and fails much later as a profile mismatch that never mentions certificates.
1.8.0 #
A minor version that changes the secrets file's shape. The number is deliberate rather than an oversight: three projects use this, all owned by one person and all checked out side by side, and none of them had a reason to depend on 1.x meaning anything yet. A 2.0.0 two days after 1.0.0 would have claimed a stability that never existed. Read this section as the breaking one it is — the migration is at the end and takes about ten minutes per project.
-
The file's shape is now a schema, not a naming convention. Credentials live at a position in a declared tree rather than being recognized by their name, and one walker reads that tree.
secrets keysandsecrets execcall it and differ only in whether they keep the values, so "is this recognized" has exactly one answer.This replaced six defects with one cause — three consumers each recovering structure from a string, slightly differently. Among them: a complete Android keystore that validated, reported nothing amiss, and set no environment variables at all; and
secrets keysreporting a file as refused thatsecrets execaccepted, with the test meant to pin them comparing one set to itself and therefore unable to fail for any input. -
Any credential can appear more than once, under a name. An app that ships to the App Store, notarizes a direct download and signs a
.pkghas three Apple certificates; one that keeps a scoped upload key alongside an Admin key for reading the portal has two API keys of different kinds. Previously the vocabulary could express one of each.Instance names never become variable names — uppercasing is not injective, so
distanddist_p12would mint the same variable. Selection fills the fixed names instead: exactly one instance is the default, two or more must be named with--keystoreor--api-key, and a name that is not in the file is an error listing what is, rather than a fall back that would run an Admin-gated read with a scoped key. -
Certificate kinds are a closed set —
distribution,developer_id,mac_installer— sodevelopr_idis refused. Keystore, profile, token and ssh key names are the project's own, which is the one level no schema can police. -
api_private_key_filenameis replaced bykind: team | individual, and the filename is derived from it and the id. The filename is the only signal of which claims Apple is sent; storing it as well meant a third copy that could disagree with the other two, which is why 1.7.2 had to cross-check them. -
tokens:andssh_keys:hold what this tool will never understand — an artifact host, a deploy key. Each declares the variable it exports, validated as[A-Z][A-Z0-9_]*and refused if it collides with a name materialization sets, so a token cannot quietly redirectANDROID_KEYSTORE_PATH. -
placed:holds files the build reads from the working tree, withsecrets place,secrets cleanandsecrets pack. Some credentials are source — a compiler and an analyzer read them from fixed paths — so they cannot live in a temp directory and cannot vanish when a command exits.Their guarantee is a different one and weaker, and it is stated rather than implied: not plaintext never outlives the run but plaintext never enters history. A target is refused if it is not ignored, if git already tracks it (
.gitignoredoes not apply to tracked files, so onegit add -fmakes a path publishable forever), if it crosses into a submodule, if it leaves the repository once symlinks are resolved, or if it is a symlink or a directory. All three verbs compare content:placerefuses to overwrite an edited file,cleanremoves only what still matches, andpackre-encrypts an edit back. -
apple.profilesis gone from.cux-ship.yaml. The secrets file names the profiles; a second declaration of one fact is a second thing that can drift.apple.signing: manual | automaticstays, because it is a repository-level choice and not derivable from which blobs happen to be present — and it decides more than it sounds like:-allowProvisioningUpdatesis a portal write and an individual key cannot read the portal at all, so automatic signing requires a team key that reaches every app in the team. -
path,envandkindare stored in cleartext, sosecrets keysand the placement pre-flight work with no identity. That is a real disclosure — a path tells a reader where a project keeps things — and it buys a pre-flight that needs no key. Enforced both ways: the walker refuses an encrypted value in those fields and names theunencrypted_regexto add, and a schema whose secret-bearing field took one of those names fails the test suite.
Migrating a project #
Environment variables are unchanged, so nothing that consumes credentials
needs editing — build.sh, upload.sh and Gradle keep reading the same
names.
The tool and the file have to move together, and no order avoids a window where they disagree. Both directions fail loudly, and the message says which way round you are: an older version reading a new file complains that something "nests deeper than a credential goes", while this version reading an old file reports unrecognized keys. Neither can mistake the other's file for a valid one, so the window is an inconvenience rather than a hazard — but do these in one sitting.
-
Raise the constraint and upgrade. A minor bump does not compel this the way a major one would:
^1.7.1already permits 1.8.0, so a resolvedpubspec.lockstays where it is anddart pub getalone will not move you onto the version that can read the new shape.dart pub upgrade cux_ship # or raise the constraint to ^1.8.0 -
Add to
.sops.yaml, under the rule that matches your secrets file:unencrypted_regex: '^(path|env|kind)$'Inert until the file holds a
placed:,tokens:,ssh_keys:orapple.api_keysentry, so a project with only a keystore and a service account needs nothing from it yet. Add it anyway: otherwise its absence surfaces months later, from a file that had been working, the first time somebody adds one of those. -
Restructure the file. Needs the age identity, because sops binds each value to its key path — a textual rename fails authentication. Plaintext never touches disk:
sops -d secrets/release.yaml \ | <your restructuring> \ | sops -e --filename-override secrets/release.yaml /dev/stdin \ > secrets/release.yaml.new && mv secrets/release.yaml.new secrets/release.yaml1.7.x 1.8.0 keystore_p12_base64,keystore_password,key_aliasandroid.keystores.<name>.{base64, password, key_alias}play_service_account_json_base64android.play_service_account.json_base64api_key_id,api_private_key_base64,api_issuer_idapple.api_keys.<name>.{id, private_key_base64, issuer_id}api_private_key_filenameapple.api_keys.<name>.kind: team | individualdistribution_p12_base64,distribution_p12_passwordapple.certificates.distribution.{p12_base64, password} -
Run
cux_ship secrets keys. It needs no identity, reports every credential by path, and names anything half configured — so it will tell you whether the result is right before you try to build with it.
1.7.2 #
-
An individual App Store Connect key was classified as a team key.
secrets execmaterialized every key asAuthKey_<id>.p8, and bothaltooland this tool's JWT builder read that prefix to decide which claims to send — Apple names an individual keyApiKey_<id>.p8and a team keyAuthKey_<id>.p8. So an individual key routed throughsecrets execwas sentissinstead ofsub: userand got a bare 401, after a full build.The issuer id cannot stand in for the prefix:
altooldocuments--api-issueras required alongside--api-key, so an individual key legitimately carries one too. The filename is the only signal, which is why inventing it destroyed the distinction.New optional
api_private_key_filenamecarries the name Apple gave the key. Absent, the behavior is exactly as before —AuthKey_<id>.p8— so a project with a team key needs no change. A project with an individual key must now set it. The value is checked against(ApiKey|AuthKey)_<id>.p8and againstapi_key_id, because it becomes a filename in a directoryaltoolsearches, and a mismatched id produces a filealtoollooks straight past.Predates the Dart port —
with-secrets.shrenamed identically. Found by the AuthPass maintainers while migrating ontosecrets exec. -
cux_ship_verifyis not released alongside this one. It has no changes, and the dependency on it now names the oldest version that works rather than the newest — bumping in lockstep made the repository unresolvable as a git dependency for the whole window between a commit and its publish.
1.7.1 #
From a security audit. No high-severity defects were found; these are the three worth acting on.
- A malformed decrypted secrets file could print its own contents.
package:yamlrenders a parse error with the offending source line and a caret under it, and the whole exception was being interpolated into a message that goes to stderr — so a decrypted private key could land in a terminal or a CI log. It needs a hand-mangled file that still decrypts, which is why it is not higher, but it is the difference between an error and a disclosure. Both YAML error paths now print the bare reason and no source. deps updatenow validates what it writes. The version and hash come from GitHub over the network and were interpolated into generated Dart source inside single quotes, so a value carrying a quote could inject code that runs on the next analyze. Maintainer-only and behind a reviewed diff; two regexes are cheaper than relying on the review.- Pagination cannot carry the bearer token off-origin.
getAllfollowedlinks.nextto whatever host it named, with the Authorization header attached. Reaching it would require Apple's own TLS response to be attacker-controlled — but "the token only goes to Apple" should be a property of this code rather than of Apple's response.
1.7.0 #
-
cux_ship secrets keys— lists the credential names in a secrets file without decrypting it, says which heading each sits under, marks any namesecrets execwould refuse, and ignores thesops:metadata block. Worth running before adopting a new version, since an unrecognized key stopssecrets execoutright. -
Fixes a wrong command in the bundled skill, which is why this exists. It recommended
grep -oE '^[a-z_]+:|^[[:space:]]+[a-z_]+:'for the same job, and that character class omits digits — so against a real file with nine credentials it reported four and hid five, including the entire Android keystore, the Play service account and the App Store private key. Every name it hid was one carrying key material, and the output read as a clean bill of health.The command replaces the advice rather than correcting it: it shares the key-walking with the parser that enforces the rules, so the two cannot drift. Reported by a consumer migrating from 1.5.1.
1.6.0 #
First release on pub.flutter-io.cn. Versions before this one were consumed as git refs; they are in the git history and in this repository's tags.
- Two packages instead of five. The App Store and Play clients moved into
lib/src/appstore/andlib/src/play/here, and the changelog parser and App Store metadata model moved intocux_ship_verify, which now has no dependencies at all. The split is about what a consumer's lockfile gets: a release machine wants googleapis and an image codec, a test suite does not. lib/verify.dartstill re-exportscux_ship_verify, so nothing breaks — but a test suite should now depend oncux_ship_verifydirectly rather than reaching the checks through this package.- Resolved as a pub workspace, so the repository has one lockfile and each package still declares ordinary hosted constraints.
1.5.1 #
- Read the headings a real secrets file groups its credentials under.
secrets execwalked only top-level keys, so a file grouping values underandroid:andapple:— which is how they are actually written — was refused with none of its credentials found.
1.5.0 #
Broken; use 1.5.1. Its secrets parser refuses any grouped secrets file.
--app-dirand.cux-ship.yaml, so a repository whose Flutter app is a subdirectory can userelease finishat all. The repository ownsCHANGELOG.mdandstore/; the app directory ownspubspec.yaml,android/andios/.cux_ship secrets exec— decrypt a sops file, run a command with the credentials in its environment, remove them however the run ends.cux_ship deps install— fetch sops and age by pinned hash into.bin/.api_issuer_idis optional. An individual App Store Connect key has none, and requiring one ruled out the credential that scopes CI to a single app.