docs/ship-step.md, pitfall 110 (pipeline exit 7 and double uploads), CHANGELOG Unreleased

This commit is contained in:
CRTkafa 2026-10-23 13:13:28 +08:00
parent 06a0b424f5
commit 2e888a01cc
4 changed files with 149 additions and 0 deletions

View file

@ -3,6 +3,12 @@
## Unreleased
- Ship-step contract for release pipelines (docs/ship-step.md): `play build | upload | release`, `apple build | upload | submit | resubmit` exit 0 ok, 1 failure, 2 usage, 3 needs the owner (with `error.code` and the console deep link), 4 store refused, 7 nothing to do; `--json` ends with exactly one result line on stdout, `--result-file` writes it atomically (also on failure), `--progress json` gives one JSON line per phase on stderr.
- `--env-file <path>` loads KEY=VALUE secrets before the credentials resolve (POSIX: mode 600 or stricter) and every known secret value is scrubbed from stdout, stderr and the result file; `--version-from version.json` takes the version and build ({version, build}, {versionName, versionCode}, {marketing, build}).
- Re-runs never upload twice: `play upload` with a versionCode Play has and `apple upload` with a build number App Store Connect has end with exit 7 and what is there; `play release` with the same release and `apple submit` of an already submitted build too.
- `play upload --track production` (the pipeline's update step; `--status draft` keeps it for the Console) and `--version-code N` to pick the AAB.
- New `storebound status [--app <name|store id|folder>] [--json]`: per platform the live and next version, build, track, review state and rollout %, read-only. `release status --app` also takes a folder or a store id.
- Desktop: every read goes through one registry: one store call per read however many buttons are clicked, overlapping buttons (Refresh all, card refresh, Check again, Import, Test) are busy and disabled together, a read just finished is not repeated, and all reads share the scheduler's rate limits and backoff.
- Desktop: tray mini card on left click (rejections, releases moving, crash alerts, recent low reviews, refresh, quick actions); right click keeps the menu.

View file

@ -168,6 +168,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `ws apps import --account <profile>` | API, read-only + local cache | the apps a store account sees, paired with the folders (the desktop's rules) |
| `ws desktop export [--out f \| --merge [--apply]]`, `ws desktop import` | local | the workspace to and from the desktop app's state (labels, never keys) |
| `release status [--app a,b] [--versions]` | API, read-only | both stores on one train per version: build > testing > review > live > rollout % |
| `status [--app <name, store id or folder>] [--json]` | API, read-only | per platform: live and next version, build, track, review state, rollout % (docs/ship-step.md) |
| `release plan <version> [--app a]` | API, read-only | what is left for that version on each store, in fix order, with the owner's next step |
</details>

130
docs/ship-step.md Normal file
View file

@ -0,0 +1,130 @@
# 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` |
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` |
| 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`.
## `--json`: one final line
stdout carries exactly one line, at the end, also on failure:
```json
{"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:
```json
{"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.
## `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:
```json
{"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
```sh
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
```

View file

@ -1311,3 +1311,15 @@ that fails without the fix (see CONTRIBUTING.md).
- **Do:** write the notes without other platforms or stores; `[play]` / `[apple]` tags split shared notes.
- **Guard:** `storebound check` reports the "What's new" of every Play language, `play build` refuses before Gradle runs
(`notesGuard`), `play upload` / `play release` refuse before any edit (`refusePlayNotes`).
## 110. A pipeline read "exit 7" as a failure, or uploaded the same build twice
- **What happens:** a CI stage re-ran after a crash (or a retry) and either uploaded the same versionCode / build
number again (Play 403 "APK specifies a version code that has already been used", App Store Connect "redundant
binary upload") or failed the job on exit 7, which used to mean "processing timeout" for `apple upload` alone.
- **Do:** call the ship commands under the ship-step contract (`--json` / `--result-file`, docs/ship-step.md): exit 7
is "nothing to do, it is there", with the track and state in the result; exit 3 names what the owner must do with
the console deep link (`error.consoleUrl`); exit 4 is the store refusing. Treat 0 and 7 as success.
- **Guard:** `cli/lib/ship-step.ts` holds the one exit-code mapping; `play upload` and `apple upload` check the store
before sending and stop with exit 7 (`tests/ship-step.test.ts`, fake stores); `--env-file` values are scrubbed from
every output.