9.4 KiB
Ship step contract
How a release pipeline (a Jenkins node, a Forgejo runner, any CI job) calls storebound without a person. The
commands below keep their normal behaviour in a terminal; the contract switches on when a run passes --json,
--result-file, --progress json, --env-file or --version-from. The code is in one module:
cli/lib/ship-step.ts.
| Command | step | store |
|---|---|---|
storebound play build |
build |
play |
storebound play upload (--track internal|alpha|beta|production) |
upload |
play |
storebound play release (promote / several tracks in one edit) |
release |
play |
storebound apple build |
build |
apple |
storebound apple upload |
upload |
apple |
storebound apple submit |
submit |
apple |
storebound apple resubmit |
resubmit |
apple |
storebound apple release (--manual-release) |
release |
apple |
A write still needs --yes in a pipeline: without it the confirmation is declined and the run ends with exit 3
(CONFIRMATION_REQUIRED). apple submit without --yes is a dry run (exit 0, state: "planned").
Exit codes
| Code | Meaning | error.code |
|---|---|---|
| 0 | ok | - |
| 1 | failure (build failed, network, timeout, blockers) | FAILED, TIMEOUT, BLOCKERS |
| 2 | usage (unknown flag, bad value, bad --version-from / --env-file) |
USAGE, ENV_FILE |
| 3 | needs the owner | AGREEMENT_PENDING, SIGN_IN_REQUIRED, COMPLIANCE_ANSWER_MISSING, CONSOLE_STEP, CONFIRMATION_REQUIRED, OWNER_STEP |
| 4 | the store refused it (4xx answer, ITMS error, invalid build, rejected upload) | STORE_REFUSED, BUILD_INVALID |
| 7 | nothing to do: the build / release is on the store already (the result names what is there) | - |
An exit 3 carries error.consoleUrl: the absolute console deep link of the page where the owner acts (App Store
Connect Business > Agreements, Users and Access > Integrations, the encryption / App Privacy / age rating pages; Play
Console API access, Data safety, Content ratings, Publishing overview). The links come from
cli/lib/console-links.ts, with the app's console ids when the config knows them.
Note: apple upload and apple testflight wait keep their old exit 7 for a processing timeout when the contract is
off; under the contract a timeout is exit 1 TIMEOUT and an invalid build exit 4 BUILD_INVALID.
Updates and first releases
The production and review steps (play upload --track production, play release --production completed|inProgress,
apple submit, apple resubmit, apple release --manual-release) read from the store whether this is an update of
an app that is already live or its first release (Play: the production track holds a completed / inProgress / halted
release; App Store: a version of the platform is or was on sale; cli/lib/release-kind.ts). The result carries
releaseKind.
- Update:
--yesis all it takes. No owner gate, no bypass mode needed (docs/bypass.md). - First release with
--yes: exit 3,error.codeOWNER_STEP, message "first release of this app ... the owner approves it once",error.consoleUrlthe page where the owner acts, nothing written. After the owner's yes: re-run with--first-release(or bypass mode on). - Already on the store (the versionCode on production with that status, the version submitted or released): exit 7, nothing sent.
# Android production update
storebound play release build/android/app.aab --production completed --yes --json --result-file out/release.json --env-file /run/secrets/ship.env
# (or a staged rollout: --production inProgress --fraction 0.05 --yes)
# iOS update: attach the uploaded build, submit, and later release the approved version
storebound apple submit --build 42 --yes --json --result-file out/submit.json --env-file /run/secrets/ship.env
storebound apple release --manual-release --yes --json --result-file out/release.json --env-file /run/secrets/ship.env
case $? in 0|7) ;; 3) echo "owner: $(jq -r .error.consoleUrl out/release.json)"; exit 1 ;; *) exit 1 ;; esac
--json: one final line
stdout carries exactly one line, at the end, also on failure:
{"ok":true,"step":"upload","store":"play","track":"production","version":"1.2.0","build":"42","state":"completed","url":"https://play.google.com/console/u/0/developers/<dev>/app/<app>/tracks/production","warnings":[]}
| Key | |
|---|---|
ok |
true for exit 0 and 7 |
step, store |
see the table above |
track |
Play track(s) (internal, production, internal,production); null on Apple |
version, build |
marketing version and build number / versionCode, as strings |
state |
built, planned (dry run), Play release status (completed, draft, inProgress), App Store processingState (VALID, PROCESSING) or version state (WAITING_FOR_REVIEW), failed |
url |
the console page to look at |
warnings[] |
e.g. a versionCode that is on Play but on another track |
error |
{code, message, consoleUrl?} when not ok |
artifact |
build steps: the AAB / IPA path |
The command's own --json fields follow in the same object when they do not clash (versionCode, sha256,
tracks, aab, processingState ...), so readers of the old output keep working. Every log line goes to stderr.
Under storebound serve and flows (STOREBOUND_MACHINE=1) a bare --json keeps the old result line.
--result-file <path>
The same JSON, written atomically (a temp file in the same folder, then a rename), on success and on failure. Also written for an unknown flag (exit 2).
--progress json
One JSON line on stderr per phase: {"phase":"upload","pct":40,"message":"4/10 parts"}. Phases: start,
prebuild, build, sign, verify, upload (with pct), commit, processing, submit, done. Other stderr
lines are plain logs: read the lines that start with {.
--env-file <path>
KEY=VALUE lines (# comments, export prefix, quotes; \n inside double quotes) loaded into the environment
before the credentials resolve; the file wins over the environment. On macOS / Linux the file must not be readable by
group or others (mode 600 or stricter), else exit 2. On Windows it is read as is. Every value of the file, and every
environment variable whose name says secret (*_KEY, *PASSWORD*, *TOKEN*, ASC_P8, PLAY_SERVICE_ACCOUNT ...),
is replaced with [redacted] in stdout, stderr and the result file (multi-line keys line by line).
--version-from <version.json>
Reads the version and build for the step. Accepted shapes:
{"version": "1.2.0", "build": 42}
{"versionName": "1.2.0", "versionCode": 42}
{"marketing": "1.2.0", "build": "42"}
(marketingVersion and buildNumber work too.) What it sets: play build --version-name + --version-code,
play upload --version-code (picks the AAB with that versionCode), play release --version-code (without an
.aab), apple build --version + --build-number, apple upload --build, apple submit / resubmit --version
--build. A flag given by hand must agree with the file (else exit 2). An Android build must be an integer.
Idempotency
play upload: a versionCode already in Play's bundle list is never uploaded again: exit 7 with the track and status it has (a warning when it is not on the asked track:play release --version-code N --<track> <status>).apple upload: a build number App Store Connect already has (a build, or a finished build upload) is exit 7 with itsprocessingState; nothing is sent.play release: every track already holds the same release: exit 7, nothing committed.apple submit: the version is already submitted (or further) with this build, or the submission is in review: exit 7.apple release --manual-release: the version is released (READY_FOR_SALE...) and none waits for release: exit 7.
storebound status
storebound status --app <name|store id|folder> --json
Read-only, before any step: per platform the release reaching users (live) and the one above it (next), each with
version, build, track, state, phase, reviewState and rolloutPct. --app takes an app folder path, a
workspace app name or a store id (bundle id, package name, App Store app id). One JSON line:
{"apps":[{"app":"notes","folder":"/srv/notes","platforms":[{"platform":"android","store":"play","live":{"version":"1.2.0","build":"42","track":"production","state":"inProgress","phase":"rollout","reviewState":null,"rolloutPct":20},"next":null}],"errors":[],"warnings":[]}],"at":"2026-10-23T10:00:00.000Z"}
Exit 1 when a store could not be read (platforms[].error).
A pipeline stage
storebound play build --version-from version.json --env-file /run/secrets/ship.env --json --result-file out/build.json --progress json --yes
storebound play upload --track production --version-from version.json --env-file /run/secrets/ship.env --json --result-file out/upload.json --yes
case $? in 0|7) ;; 3) echo "owner: $(jq -r .error.consoleUrl out/upload.json)"; exit 1 ;; *) exit 1 ;; esac
--pre-check and --evidence (upload steps)
apple upload and play upload take --pre-check "<command with {file}>" (config upload.preCheck): the command runs
(sh -c, project root) before any byte is sent. A nonzero exit is exit 1 with error code PRE_CHECK_FAILED. --evidence <path.json> (config upload.evidence) writes {file, sha256, size, command, exitCode, stdoutTail, timestamp, storeboundVersion}, also on failure; the same object is the result's preCheck key.