storebound/docs/ship-step.md

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: --yes is all it takes. No owner gate, no bypass mode needed (docs/bypass.md).
  • First release with --yes: exit 3, error.code OWNER_STEP, message "first release of this app ... the owner approves it once", error.consoleUrl the 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 its processingState; 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.