diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a65c1..51d014f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 ] [--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. diff --git a/docs/commands.md b/docs/commands.md index c71643e..0d3ee56 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -168,6 +168,7 @@ run in the background with a log (`storebound shots render > .storebound/render. | `ws apps import --account ` | 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 ] [--json]` | API, read-only | per platform: live and next version, build, track, review state, rollout % (docs/ship-step.md) | | `release plan [--app a]` | API, read-only | what is left for that version on each store, in fix order, with the owner's next step | diff --git a/docs/ship-step.md b/docs/ship-step.md new file mode 100644 index 0000000..dc9aa02 --- /dev/null +++ b/docs/ship-step.md @@ -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//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 ` + +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 ` + +`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 ` + +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 -- `). +- `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 --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 +``` diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 0ee01c1..54f25ad 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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.