vydanne 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -229,13 +229,15 @@ already right. `VYDANNE_FLATTEN=1` reads a flat screenshot folder rather than pe
229
229
 
230
230
  | Command | In plain English |
231
231
  |---|---|
232
- | `preflight` | **Run this first.** Checks the listing is complete, nothing is over a character limit, no locale mentions the other app store — and that the screenshots on the store are your *current* ones, not a stale set. It also asserts the fields no local file drives and that no diff can therefore see: copyright, the App Review contact, the age rating, the primary category and the content-rights declaration. Those are each set by their own command, so a listing that never ran one had them EMPTY while this printed green. Green means submittable. |
232
+ | `preflight` | **Run this first.** Checks the listing is complete, nothing is over a character limit, no locale mentions the other app store — and that the screenshots on the store are your *current* ones, not a stale set. It also asserts the fields no local file drives and that no diff can therefore see: copyright, the App Review contact, the age rating, the primary category and the content-rights declaration. Those are each set by their own command, so a listing that never ran one had them EMPTY while this printed green. Green means submittable. Also refuses a **blank screenshot** — a file with almost no distinct colours in the middle of the frame, i.e. one where the app drew nothing. |
233
233
  | `prepare` | Starts the next release: creates the App Store version you're preparing and attaches your newest build to it. **Needed before `fill` on an app that already has a version on sale** — until a draft exists there is nothing for the listing text to go into. Reuses the draft if it's already there, so it's safe to re-run. It does *not* submit. |
234
234
  | `push` | **The whole release, one command**: runs `prepare` → `fill` → `previews` → `age-rating` → `review-contact` → `accessibility` → `preflight`, in that order, stopping at the first failure. Dry run without `--apply`, like everything else. Ends at a green preflight — it never submits. |
235
235
  | `diff` | Shows exactly what's different between your files and what's live. Nothing is changed — a safe preview. |
236
236
  | `fill` | Uploads your listing text and screenshots. Handles iPhone, iPad and Mac. Refuses to upload text that names the other mobile platform. |
237
237
  | `previews` | Uploads App Preview videos. |
238
238
  | `inspect` | Shows the app's current state in the store. Read-only. With `--store google` it also lists every track carrying a release — track, versionCode, version name, status, and the rollout percentage of a staged release. |
239
+ | `releases` | Every version this app has ever had, with the build each one shipped, the commit that build number resolves to, and whether that commit is tagged. Read-only. `inspect` cannot answer this — it reports the version in preparation or the live one and nothing behind them. With `--store google` it reports what each track is serving; Play keeps no history of superseded releases, so tag as you release. |
240
+ | `withdraw` | Takes a version back **out of** App Store review so it can be edited — the step `prepare` and `prerelease` both tell you to do by hand. Refuses a **live** version, exactly as `prerelease --store google` refuses the production track: it retrieves what is waiting and never touches what customers have. Undoes a submission; never makes one. Dry run without `--apply`. |
239
241
  | `locales` | Lists your languages and Apple's code for each — and warns about any language the App Store doesn't offer. |
240
242
  | `appinfo` | Sets the App Store **category** and the **content-rights** answer — the two app-level facts that block *Add for Review* and belong to no single release. Declared as `categories` (Apple's ids: `GAMES`, `GAMES_PUZZLE` — never display names) and `contentRights: false` for an app that shows no third-party content. Left undeclared, `contentRights` is not written at all, so an answer already given in App Store Connect is never overwritten by a default nobody chose. |
241
243
  | `age-rating` | Sets the age rating. `rating: "4+"` needs nothing else; any higher rating is described feature-by-feature in `ageRating` and Apple computes the band from it. |
package/SKILL.md CHANGED
@@ -214,7 +214,7 @@ who it's for → honest close. Keep it scannable; lead each bullet with the payo
214
214
  live) · `privacy` (prints answers for the UI — the API can't reach Apple's iris host) · `iap` (validate +
215
215
  RGB flatten) · `compliance` (US self-classification PDF) · `bridge` (zdymak's output → the folders
216
216
  `fill` reads) · `diff` (what differs vs live, text AND media by checksum) · `preflight`
217
- (completeness gate + cross-store lint + stale-screenshot check) · `inspect` · `auth` (what credentials
217
+ (completeness gate + cross-store lint + stale-screenshot check) · `inspect` · `releases` · `auth` (what credentials
218
218
  resolved, and from where) · `locales` · `version`.
219
219
 
220
220
  **`bridge` maps by DIRECTORY, not by target.** zdymak writes each shot to `<dir || target>`, so a
@@ -302,10 +302,92 @@ name, status, and a staged rollout's percentage — for every track that carries
302
302
  with no release are omitted. That is the question after any upload, and it could not be answered
303
303
  from this tool at all before 0.11.
304
304
 
305
- `--store google` routes `inspect` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
305
+ ## `releases` what shipped, and from which commit
306
+
307
+ `inspect` reports the version in preparation or the one live now. It structurally cannot show the
308
+ ones behind them, and that gap has already cost: three of Niva's release tags carry messages saying
309
+ they could not be confirmed *"because historical build numbers are not exposed by vydanne inspect"*,
310
+ and all three were wrong — by 9, 3 and 23 commits. They had been tagged at the commits that bumped
311
+ `MARKETING_VERSION`, which is the intuitive place and the wrong one, because the build is archived
312
+ days later. The data was always one query away: `appStoreVersions?include=build`.
313
+
314
+ `releases` is that query plus the two columns that make it useful — the **commit** each build number
315
+ names and whether it carries a **tag**. Both stores in this portfolio derive the build number from
316
+ `git rev-list --count HEAD` (Android's `versionCode`, iOS's `CURRENT_PROJECT_VERSION` via
317
+ `Scripts/build-number.sh`), which makes it reversible.
318
+
319
+ **Every mapping is verified, never assumed.** `rev-list --reverse` is ordered, not counted, so on a
320
+ merged history the Nth line need not be the commit with N ancestors — the candidate's own count has
321
+ to match. An app that does not build this way, or a build number that is not a commit count, gets a
322
+ stated reason instead of a confident wrong answer. That check has already caught a real one: a build
323
+ numbered `1` sitting above a `131`, which is a `getOrDefault(1)` fallback firing when the git count
324
+ failed at archive time. That binary cannot be traced to a commit at all.
325
+
326
+ Only what is **serving users** is nagged about — `READY_FOR_SALE`, and the `production` track. A
327
+ version in review has not shipped, and this portfolio pushes to `alpha` before every promotion, so
328
+ tagging either would make the tag mean nothing.
329
+
330
+ With `--store google` it reports what each track carries now. **Play keeps no history**: there is no
331
+ endpoint for superseded production releases, so a game that pushed several under one version name
332
+ has no record of the earlier ones. Uploaded bundles are listed for context and must never be tagged
333
+ from — uploaded is not released. Tag as you release.
334
+
335
+ `--store google` routes `inspect` · `releases` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
306
336
  (OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
307
337
  The AAB binary and the (YouTube-URL) promo video stay outside vydanne.
308
338
 
339
+ ## Blank screenshots — the check that had to exist
340
+
341
+ Palon uploaded a device frame containing pure white and an iOS status bar to a version that reached
342
+ App Store review, and every existing gate passed it. Presence: the file is there. Naming: it is
343
+ named right. Freshness: it matches the store exactly. A blank file satisfies all three, and the
344
+ chain around it is built to be faithful — zdymak photographs whatever the app draws and reports
345
+ success, `bridge` copies what it is handed, `fill` uploads it. Nothing was looking at the pixels.
346
+
347
+ `preflight` now measures distinct colours in the middle of each local screenshot, where a device
348
+ frame's screen sits. Whole-frame colour counts do not separate — a caption band and its gradient
349
+ contribute thousands of their own — but over the centre the failure scored **28** against
350
+ **1500–6800** for real screens, with the quietest genuine screenshot in the portfolio at 270. The
351
+ threshold sits 4x clear of both, so it is a fault detector rather than an opinion about how busy a
352
+ screenshot should be.
353
+
354
+ It found a second one on its first run, in a game that has not shipped yet.
355
+
356
+ **It is a backstop, not a proof, and the limit is structural.** A composed screenshot's measurement
357
+ depends on its composition: this crop works where the device is full-bleed and the middle of the
358
+ image is the screen, which is what every app here produces, but a layout that insets the device on a
359
+ generous background puts gradient in the crop — zdymak's own demo composition scores 204 on a frame
360
+ whose screen is pure white. No tighter crop fixes it: at one tight enough to exclude background the
361
+ blanks land on 8–10 and the quietest genuine screenshot lands on 9.
362
+
363
+ The reliable gate is upstream. `zdymak` measures the **capture**, which has no frame, background or
364
+ caption and separates with no crop at all — under 400 distinct colours *and* over 95% one colour.
365
+ This check exists because vydanne is the last thing before the store and sees screenshots that never
366
+ came from zdymak.
367
+
368
+ ## `withdraw` — the step that was a web form
369
+
370
+ A version sits in `WAITING_FOR_REVIEW` with something wrong in it — a blank screenshot, a build
371
+ numbered by a fallback — and every command that could fix it refuses, correctly, because Apple locks
372
+ a submitted version. `prepare` said *"withdraw it in App Store Connect, then re-run this"*.
373
+ `prerelease` said *"re-pointing it would mean withdrawing that submission, which is your call."*
374
+ Three files said the same thing and none of them could do it.
375
+
376
+ **It is the mirror of Play's production refusal, not a new kind of power.** `prerelease --store
377
+ google` refuses the `production` track outright because *"that release is a human's to make"*. This
378
+ is the same rule pointed the other way: it takes back what is **waiting** and will not touch what
379
+ customers have — a `READY_FOR_SALE` version is refused flatly. And it stays on the safe side of the
380
+ line vydanne draws everywhere: it UNDOES a submission and never makes one. Submitting stays manual.
381
+
382
+ Withdrawing is not free, so the states are enumerated rather than filtered. `WAITING_FOR_REVIEW`
383
+ costs a place in the queue and nothing else. `IN_REVIEW` discards a review already in progress and
384
+ is announced as such. `PENDING_DEVELOPER_RELEASE` is refused — that version is *approved and waiting
385
+ on you*, and throwing that away to change a screenshot is almost never what anyone means.
386
+
387
+ It waits for the version to actually leave review before returning. Apple passes through
388
+ `CANCELING` first, and a command that returned there would send you straight into a `fill` that
389
+ fails on a still-locked version.
390
+
309
391
  ## `--apply` — writes are opt-in
310
392
 
311
393
  **Every store-mutating command is a DRY RUN without `--apply`**: `prepare` · `push` · `fill` ·
package/bin/vydanne.mjs CHANGED
@@ -142,6 +142,10 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
142
142
  bridge map zdymak's store-assets output onto the folders \`fill\` reads (locale codes,
143
143
  device prefixes, Play paths). Local files only; --dry-run previews.
144
144
  inspect read-only ASC state
145
+ releases every version ever shipped, with the build and the commit it came from —
146
+ and whether that commit is tagged. --store google: what each track serves
147
+ ✎ withdraw take a version back OUT of App Store review so it can be fixed. Refuses a
148
+ LIVE version, the way --store google refuses the production track
145
149
  diff show what differs between local (metadata/screenshots/previews) and ASC
146
150
  preflight verify submission-completeness (the gotcha checker)
147
151
  ✎ prerelease upload the build for testers — .ipa to TestFlight (internal groups only),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "App Store Connect + Google Play submission prep — the companion to zdymak (media). Native Node (no fastlane/Ruby/Python): localized listings, screenshot/preview/icon upload, ratings, review contact, accessibility & privacy labels, IAP, export docs, build upload to TestFlight / a Play closed track, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. Never submits for review. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
5
5
  "keywords": [
6
6
  "app-store-connect",
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import zlib from "node:zlib";
3
+
4
+ /**
5
+ * **Is this store screenshot a picture of nothing?**
6
+ *
7
+ * Palon shipped one. `store-assets/appstore-iphone-6.9/05-buildinglight.png` is a device frame
8
+ * containing pure white and an iOS status bar — no board, no header, no queue — and it was uploaded
9
+ * to a version that reached App Store review. Nothing in the chain could have noticed: zdymak
10
+ * photographs whatever the app draws and reports success, the asset bridge copies what it is
11
+ * handed, `fill` uploads it, and `preflight`'s existing checks are about presence, naming and
12
+ * freshness, all of which a blank file passes perfectly.
13
+ *
14
+ * The one screenshot that broke was the only one in the set shot in the LIGHT palette — the least
15
+ * trodden path in the capture, photographed once a release and looked at by nobody afterwards.
16
+ *
17
+ * ### It measures the CENTRE, and that is what makes the number mean something
18
+ *
19
+ * Measured across Palon's four asset sets, distinct colours over the whole frame do not separate:
20
+ * the caption band and its gradient contribute thousands of their own, so the blank frame scored
21
+ * 1821 against 7283 for a good one — a real gap, but one that depends entirely on how much
22
+ * decoration a game's frame happens to carry. Over the middle of the image, where the device screen
23
+ * sits, the same files score **28 against 3256–6811**. The quietest genuine screenshot in the
24
+ * portfolio (Palon's Android `fresh`, a nearly empty opening board) scores 408.
25
+ *
26
+ * Those are full-pixel counts over the crop; the implementation samples every second pixel, which
27
+ * scales them down but not the separation. See [BLANK_BELOW] for the figures the threshold is
28
+ * actually set from.
29
+ *
30
+ * ### It is a BACKSTOP, and the limit is worth stating rather than discovering
31
+ *
32
+ * A composed screenshot is a device frame on a background under a caption, so any measurement of it
33
+ * depends on the composition. This crop works for the frames every app in this portfolio produces,
34
+ * where the device is full-bleed and the middle of the image IS the screen. On a layout that insets
35
+ * the device on a generous background, the same crop catches gradient instead: zdymak's own demo
36
+ * composition scores 204 on a frame whose screen is pure white, and would pass here.
37
+ *
38
+ * There is no crop that fixes it. Measured across the portfolio, at a crop tight enough to exclude
39
+ * background the blanks land on 8, 9 and 10 — and the quietest genuine screenshot lands on 9.
40
+ *
41
+ * The reliable gate is upstream: `zdymak` measures the CAPTURE, which has no frame, background or
42
+ * caption, and separates unambiguously with no crop at all. This one exists because vydanne is the
43
+ * last thing before the store and sees screenshots that never came from zdymak. It catches the
44
+ * failure that actually shipped; it is not a proof that a listing has no blank in it.
45
+ *
46
+ * ### PNG only, and decoded here rather than by a dependency
47
+ *
48
+ * vydanne has no image dependency and should not grow one for this. PNG is enough — every
49
+ * screenshot in the portfolio is one — and anything else is reported as unchecked rather than
50
+ * guessed at, because a false "blank" on a release would be worse than the gap it closes.
51
+ */
52
+
53
+ /** Distinct colours in the middle of a PNG, or null when the file cannot be read as one. */
54
+ export function centreColours(file) {
55
+ let png;
56
+ try {
57
+ png = decodePng(fs.readFileSync(file));
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (!png) return null;
62
+ const { width, height, pixels } = png;
63
+ const x0 = Math.floor(width * 0.2);
64
+ const x1 = Math.floor(width * 0.8);
65
+ const y0 = Math.floor(height * 0.25);
66
+ const y1 = Math.floor(height * 0.85);
67
+ const seen = new Set();
68
+ // Every 2nd pixel on both axes. Sampling matters more than it looks: at every 4th, the quietest
69
+ // real screenshot in the portfolio (a nearly empty opening board) fell from 408 distinct colours
70
+ // to 126 and would have been reported as blank, while the genuinely blank one stayed at 28
71
+ // whatever the step. A false "blank" on a release would be worse than the gap this closes.
72
+ for (let y = y0; y < y1; y += 2) {
73
+ for (let x = x0; x < x1; x += 2) {
74
+ const i = (y * width + x) * 4;
75
+ seen.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]);
76
+ // Enough to pass; stop counting. The exact figure only matters below the threshold.
77
+ if (seen.size > BLANK_BELOW) return seen.size;
78
+ }
79
+ }
80
+ return seen.size;
81
+ }
82
+
83
+ /**
84
+ * Fewer distinct colours than this in the centre and the screen drew nothing.
85
+ *
86
+ * Measured at the sampling above: the blank screenshot scores **28**, the quietest genuine one in
87
+ * the portfolio **270**, and a normal one 1500–6800. This sits 4x clear of both, which is the point
88
+ * — near either edge it would be a taste threshold rather than a fault detector.
89
+ */
90
+ export const BLANK_BELOW = 120;
91
+
92
+ /**
93
+ * Minimal PNG reader: IHDR + IDAT, 8-bit RGB/RGBA, non-interlaced. Returns null for anything else,
94
+ * which the caller reports as unchecked. Enough for every screenshot any of these pipelines make.
95
+ */
96
+ function decodePng(buf) {
97
+ if (buf.readUInt32BE(0) !== 0x89504e47) return null;
98
+ let pos = 8;
99
+ let width = 0;
100
+ let height = 0;
101
+ let colourType = -1;
102
+ let bitDepth = 0;
103
+ const idat = [];
104
+ while (pos < buf.length) {
105
+ const len = buf.readUInt32BE(pos);
106
+ const type = buf.toString("ascii", pos + 4, pos + 8);
107
+ const data = buf.subarray(pos + 8, pos + 8 + len);
108
+ if (type === "IHDR") {
109
+ width = data.readUInt32BE(0);
110
+ height = data.readUInt32BE(4);
111
+ bitDepth = data[8];
112
+ colourType = data[9];
113
+ if (data[12] !== 0) return null; // interlaced
114
+ } else if (type === "IDAT") {
115
+ idat.push(data);
116
+ } else if (type === "IEND") {
117
+ break;
118
+ }
119
+ pos += 12 + len;
120
+ }
121
+ if (bitDepth !== 8 || (colourType !== 2 && colourType !== 6)) return null;
122
+ const channels = colourType === 6 ? 4 : 3;
123
+ const raw = zlib.inflateSync(Buffer.concat(idat));
124
+ const stride = width * channels;
125
+ const out = Buffer.alloc(width * height * 4);
126
+ let prev = Buffer.alloc(stride);
127
+ for (let y = 0; y < height; y++) {
128
+ const filter = raw[y * (stride + 1)];
129
+ const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride));
130
+ unfilter(filter, line, prev, channels);
131
+ for (let x = 0; x < width; x++) {
132
+ const s = x * channels;
133
+ const d = (y * width + x) * 4;
134
+ out[d] = line[s];
135
+ out[d + 1] = line[s + 1];
136
+ out[d + 2] = line[s + 2];
137
+ out[d + 3] = channels === 4 ? line[s + 3] : 255;
138
+ }
139
+ prev = line;
140
+ }
141
+ return { width, height, pixels: out };
142
+ }
143
+
144
+ /** The five PNG line filters, in place. */
145
+ function unfilter(filter, line, prev, bpp) {
146
+ for (let i = 0; i < line.length; i++) {
147
+ const a = i >= bpp ? line[i - bpp] : 0;
148
+ const b = prev[i];
149
+ const c = i >= bpp ? prev[i - bpp] : 0;
150
+ switch (filter) {
151
+ case 1: line[i] = (line[i] + a) & 0xff; break;
152
+ case 2: line[i] = (line[i] + b) & 0xff; break;
153
+ case 3: line[i] = (line[i] + ((a + b) >> 1)) & 0xff; break;
154
+ case 4: {
155
+ const p = a + b - c;
156
+ const pa = Math.abs(p - a);
157
+ const pb = Math.abs(p - b);
158
+ const pc = Math.abs(p - c);
159
+ line[i] = (line[i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff;
160
+ break;
161
+ }
162
+ default: break;
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,105 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /**
4
+ * **Which commit a shipped build number names** — for repos whose build number is a commit count.
5
+ *
6
+ * That is the convention across the kupalinka portfolio: Android's `versionCode` comes from
7
+ * `git rev-list --count HEAD` in `composeApp/build.gradle.kts`, and iOS's `CURRENT_PROJECT_VERSION`
8
+ * from a `Scripts/build-number.sh` that runs the same command. It makes a store's build number a
9
+ * REVERSIBLE fact, which is the only reason a release tag is worth anything: symbolicating a crash
10
+ * or reproducing a store binary needs the tree the archive was cut from, and a version string does
11
+ * not identify one.
12
+ *
13
+ * ### Nothing here assumes the convention holds — it VERIFIES it, per build
14
+ *
15
+ * vydanne is pointed at apps that do not build this way, so a mapping that trusted the convention
16
+ * would confidently name the wrong commit for them. Every answer below is checked
17
+ * (`rev-list --count` on the candidate must equal the build number) and an unverifiable one comes
18
+ * back as a stated reason rather than a guess.
19
+ *
20
+ * The check is not ceremony even where the convention does hold. `rev-list --reverse` is ORDERED,
21
+ * not counted: on a history with merges the Nth line is not necessarily the commit with N
22
+ * ancestors, so indexing alone is right only by luck on a linear repo.
23
+ *
24
+ * It has already caught a real one — a build numbered `1` sitting in App Store Connect above a 131,
25
+ * which is a `getOrDefault(1)` fallback firing when the git count failed at archive time. That
26
+ * binary is not traceable to a commit by any means, and the honest output is to say so.
27
+ */
28
+
29
+ function git(args) {
30
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
31
+ }
32
+
33
+ /**
34
+ * The repo's commits oldest-first, or null when this is not a git checkout.
35
+ *
36
+ * Read once and passed around: resolving each build separately would re-walk the whole history per
37
+ * version, and a listing command should not cost more than the API call it is annotating.
38
+ */
39
+ export function commitOrder() {
40
+ try {
41
+ return git(["rev-list", "--reverse", "HEAD"]).split("\n").filter(Boolean);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Every tag pointing at [sha], dereferenced so annotated tags are found too. */
48
+ export function tagsAt(sha) {
49
+ try {
50
+ return git(["tag", "--points-at", sha]).split("\n").filter(Boolean);
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Resolve one build number.
58
+ *
59
+ * Returns `{ sha }` when the count checks out, or `{ why }` naming what stopped it — which is the
60
+ * useful half. "This build has no commit" is a finding about the release, not a gap in the tool.
61
+ */
62
+ export function commitForBuild(order, build) {
63
+ const n = Number(build);
64
+ if (!order) return { why: "not a git checkout" };
65
+ if (!Number.isInteger(n) || n < 1) return { why: `build "${build}" is not a number` };
66
+ if (n > order.length) {
67
+ return { why: `build ${n} is past HEAD (${order.length} commits) — built elsewhere, or on an unmerged branch` };
68
+ }
69
+ const sha = order[n - 1];
70
+ let count;
71
+ try {
72
+ count = Number(git(["rev-list", "--count", sha]));
73
+ } catch {
74
+ return { why: "git rev-list failed" };
75
+ }
76
+ if (count !== n) {
77
+ return { why: `commit ${sha.slice(0, 9)} has ${count} ancestors, not ${n} — build number is not a commit count here` };
78
+ }
79
+ return { sha };
80
+ }
81
+
82
+ /**
83
+ * **A build number that did not grow is not a commit count**, whatever `rev-list` says about it.
84
+ *
85
+ * This exists because the count check alone is not enough, and the hole is not hypothetical: Vodar's
86
+ * pending 1.2 is attached to a build numbered `1`, sitting in App Store Connect above a 131. One
87
+ * genuinely IS the ancestor count of the repo's first commit, so [commitForBuild] verified it and
88
+ * cheerfully named a commit from the week the project started — a confident, wrong answer of exactly
89
+ * the kind this module exists to refuse.
90
+ *
91
+ * A commit count only ever grows, so a build that is not greater than one already shipped cannot be
92
+ * one. In practice it is a fallback firing when git was unavailable at archive time — `getOrDefault(1)`
93
+ * in the gradle file, or the same default in `Scripts/build-number.sh` — and the binary it names
94
+ * cannot be traced to a tree by any means.
95
+ *
96
+ * Flagging is the safe direction. Two releases whose builds were uploaded out of order would be
97
+ * reported as suspect and are worth a look anyway; a silently wrong commit is not.
98
+ */
99
+ export function outOfSequence(build, priorMax) {
100
+ const n = Number(build);
101
+ if (priorMax == null || !Number.isInteger(n)) return null;
102
+ if (n > priorMax) return null;
103
+ return `build ${n} is not above ${priorMax} from an earlier release — a commit count only grows, ` +
104
+ "so this is a build-number fallback and the binary is not traceable to a commit";
105
+ }
@@ -1,6 +1,8 @@
1
1
  import { green, red, yellow, LIMITS, VERSION_FIELDS } from "../util.mjs";
2
2
  import { reportCrossStore } from "../crossStore.mjs";
3
- import { localScreenshots, remoteScreenshots, compareShots, localScreenshotLocales, unknownScreenshotDirs } from "../screenshots.mjs";
3
+ import path from "node:path";
4
+ import { localScreenshots, remoteScreenshots, compareShots, localScreenshotLocales, unknownScreenshotDirs, screenshotBase } from "../screenshots.mjs";
5
+ import { centreColours, BLANK_BELOW } from "../blankShot.mjs";
4
6
 
5
7
  // Verify a listing is submission-complete the CORRECT way — each localization read by id (not the sparse
6
8
  // list), char limits, primary-locale coverage, per-platform — and warn on the gotchas before ASC does.
@@ -111,13 +113,33 @@ export async function run(config, client) {
111
113
  const count = Object.values(remote).reduce((n, m) => n + m.size, 0);
112
114
  // Only the primary listing MUST have screenshots — Apple falls back to it for any locale without.
113
115
  if (!count && code === config.primaryLocale) problems.push(`${platform}/${code}: no screenshots`);
116
+
114
117
  // Freshness is judged only where a local set exists: no local screenshots means there is nothing
115
118
  // to compare against, not that the store's are wrong. A display type the store has and local
116
119
  // lacks is likewise left alone — `fill` never deletes by omission, so this does not flag it.
117
120
  for (const [dt, L] of Object.entries(localScreenshots(platform, code, config))) {
121
+ const slot = dt.replace("APP_", "");
122
+
123
+ // IS THERE A SCREEN IN THE SCREENSHOT? Every other check here is about presence, naming and
124
+ // freshness, and a blank file passes all three perfectly — which is how Palon uploaded a
125
+ // device frame containing pure white and an iOS status bar to a version that reached App
126
+ // Store review. zdymak photographs whatever the app draws and reports success, the bridge
127
+ // copies what it is handed, `fill` uploads it. This is the only place in the chain that
128
+ // looks at the pixels. It runs before the comparison below on purpose: a blank file that
129
+ // matches the store is worse news than one that does not.
130
+ for (const name of L.keys()) {
131
+ const colours = centreColours(path.join(screenshotBase(platform, config), code, name));
132
+ if (colours !== null && colours < BLANK_BELOW) {
133
+ problems.push(
134
+ `${platform}/${code}: screenshot ${slot}/${name} is BLANK — ${colours} distinct ` +
135
+ "colours in the middle of the frame, so the app drew nothing. Re-capture it; " +
136
+ "uploading this ships a white rectangle to the store.",
137
+ );
138
+ }
139
+ }
140
+
118
141
  const c = compareShots(L, remote[dt] || new Map());
119
142
  if (!c) continue;
120
- const slot = dt.replace("APP_", "");
121
143
  if (c.kind === "count") problems.push(`${platform}/${code}: screenshots ${slot} local ${c.local} / remote ${c.remote} — the store set is not the local one (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
122
144
  else if (c.kind === "renamed") problems.push(`${platform}/${code}: screenshots ${slot} ${c.names.length} local file(s) not on the store by name (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
123
145
  else if (c.kind === "stale") problems.push(`${platform}/${code}: screenshots ${slot} STALE — ${c.names.length} of ${c.of} differ in content; green would ship the old art (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
@@ -68,6 +68,50 @@ async function altool(args, credentials) {
68
68
  return run_("xcrun", full, { maxBuffer: 32 * 1024 * 1024 });
69
69
  }
70
70
 
71
+ /**
72
+ * **The highest build number this app has, which is not the same as the newest.**
73
+ *
74
+ * [newestBuild] sorts by upload date, and that is the wrong baseline for a gate: on 2026-09-04
75
+ * Vodar's newest upload WAS the broken one, a build numbered `1` sitting above a `131`, so
76
+ * comparing against it would have compared 1 with 1 and waved it through. The invariant is about
77
+ * the maximum, so read the maximum.
78
+ */
79
+ async function appBuilds(client) {
80
+ const { json } = await client.get(
81
+ `/v1/builds?filter[app]=${client.appId}&sort=-uploadedDate&limit=200&fields[builds]=version,processingState`,
82
+ );
83
+ let highest = null;
84
+ const held = new Map();
85
+ for (const b of json.data || []) {
86
+ const raw = b.attributes?.version;
87
+ held.set(String(raw), b);
88
+ const n = Number(raw);
89
+ if (Number.isFinite(n) && (highest == null || n > highest)) highest = n;
90
+ }
91
+ return { highest, held };
92
+ }
93
+
94
+ /**
95
+ * The CFBundleVersion inside the .ipa, read before anything is uploaded.
96
+ *
97
+ * The Play side has always read the versionCode out of the bundle before the transfer; Apple's did
98
+ * not, so the first place a build number became visible was App Store Connect — after the upload,
99
+ * which is after it is too late. macOS-only tools are fine here: this command already requires
100
+ * `xcrun altool`.
101
+ */
102
+ async function ipaBuildNumber(ipa) {
103
+ try {
104
+ const { stdout } = await run_(
105
+ "/bin/sh",
106
+ ["-c", `unzip -p ${JSON.stringify(ipa)} 'Payload/*.app/Info.plist' | plutil -convert json -o - -`],
107
+ { maxBuffer: 32 * 1024 * 1024 },
108
+ );
109
+ return JSON.parse(stdout).CFBundleVersion ?? null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
71
115
  /** The newest build Apple has for this app, if any. */
72
116
  async function newestBuild(client) {
73
117
  const { json } = await client.get(
@@ -99,9 +143,43 @@ export async function run(config, client, credentials) {
99
143
  console.log(green("prerelease → App Store Connect (TestFlight)"));
100
144
  console.log(` archive: ${path.relative(process.cwd(), ipa) || ipa} (${size} MB)`);
101
145
 
146
+ // THE BUILD NUMBER GATE. Read from the archive, checked before the transfer.
147
+ //
148
+ // Apple does not enforce this: CFBundleVersion only has to be unique within a marketing version,
149
+ // so a build numbered `1` uploaded above a `131` is accepted in silence. That happened on
150
+ // 2026-09-04 — a build-number fallback fired when git was unavailable at archive time, nothing
151
+ // failed anywhere, and the result is a binary that cannot be traced to a commit, tagged, or
152
+ // symbolicated. Google refuses the equivalent upload outright; this is the missing half.
153
+ //
154
+ // Refusing costs a re-archive. Accepting costs a permanently untraceable release.
155
+ const declared = await ipaBuildNumber(ipa);
156
+ const { highest, held } = await appBuilds(client);
157
+ // Already on the app? Then this is not a new upload, it is the second half of one — the archive
158
+ // went up earlier and the version it was meant for was locked in review at the time. That is the
159
+ // exact shape `withdraw` creates, and re-uploading is both impossible (Apple refuses a repeated
160
+ // build number) and unnecessary. Skip to attaching it.
161
+ const alreadyUp = declared != null && held.has(String(declared));
162
+ if (declared == null) {
163
+ console.log(yellow(" build ? — could not read CFBundleVersion from the archive; not gated"));
164
+ } else {
165
+ console.log(` build ${declared} (CFBundleVersion, read from the archive)`);
166
+ if (alreadyUp) {
167
+ console.log(green(" already uploaded — skipping the transfer, attaching it below"));
168
+ } else if (highest != null && Number(declared) <= highest) {
169
+ // Not above the highest, and not one Apple holds: the number went BACKWARDS. That is a
170
+ // build-number fallback firing, not a re-run, and the two are worth telling apart — the
171
+ // first version of this gate refused both and made the legitimate case impossible.
172
+ console.error(red(` refusing: build ${declared} is not above ${highest}, which this app already has.`));
173
+ console.error(" A build number that did not grow is almost always a fallback firing — check that");
174
+ console.error(" the archive was made where git works, or pass BUILD_NUMBER=<n> deliberately.");
175
+ console.error(" Apple would accept this upload; it is not recoverable afterwards.");
176
+ return false;
177
+ }
178
+ }
179
+
102
180
  // Validate before uploading. A rejected upload has already cost the transfer; altool's validation
103
181
  // catches the common refusals (entitlements, missing icons, bad version) in seconds.
104
- try {
182
+ if (!alreadyUp) try {
105
183
  await altool(["--validate-app", "-f", ipa, "-t", "ios"], credentials);
106
184
  console.log(green(" validated"));
107
185
  } catch (e) {
@@ -113,12 +191,16 @@ export async function run(config, client, credentials) {
113
191
  // The binary upload is the one mutation that does NOT go through the ASC client, so the client-level
114
192
  // dry-run gate cannot see it — it has to be refused here, or a dry run would ship a build to TestFlight.
115
193
  // Validation above has already run, which is the useful half: exactly Play's "validate, then discard".
116
- if (client.dryRun) {
194
+ //
195
+ // Only when there IS an upload, though. With the build already on the app the rest of this command
196
+ // is ordinary client calls, which the client's own dry run already plans and withholds — returning
197
+ // here would hide the re-point that is the entire remaining point of the run.
198
+ if (client.dryRun && !alreadyUp) {
117
199
  console.log(yellow(" DRY RUN — archive validated, NOT uploaded. Re-run with --apply to send it to TestFlight."));
118
200
  return true;
119
201
  }
120
202
 
121
- try {
203
+ if (!alreadyUp) try {
122
204
  await altool(["--upload-app", "-f", ipa, "-t", "ios"], credentials);
123
205
  console.log(green(" uploaded"));
124
206
  } catch (e) {
@@ -135,7 +217,7 @@ export async function run(config, client, credentials) {
135
217
  return false;
136
218
  }
137
219
 
138
- const build = await waitForProcessing(client, before);
220
+ const build = alreadyUp ? held.get(String(declared)) : await waitForProcessing(client, before);
139
221
  if (!build) {
140
222
  console.log(yellow(" build not visible yet — Apple is still ingesting it."));
141
223
  console.log(" It will appear in TestFlight shortly; nothing further is needed here.");
@@ -0,0 +1,106 @@
1
+ import { commitOrder, commitForBuild, outOfSequence, tagsAt } from "../buildCommit.mjs";
2
+ import { green, yellow } from "../util.mjs";
3
+
4
+ /**
5
+ * **Every version this app has ever had on the App Store, with the build each one shipped.**
6
+ *
7
+ * `inspect` cannot answer this and never could: it reports the version in preparation, or the live
8
+ * one, and nothing behind them. That gap has a cost on the record — three of Niva's release tags
9
+ * carry messages saying so in their own words, *"Not confirmed against App Store Connect:
10
+ * historical build numbers are not exposed by vydanne inspect"* — and all three turned out to be
11
+ * wrong, by 9, 3 and 23 commits. They had been tagged at the commits that bumped
12
+ * `MARKETING_VERSION`, which is the intuitive place and the wrong one, because the build is archived
13
+ * days later.
14
+ *
15
+ * The history was never actually unavailable. `inspect` just does not ask for it:
16
+ * `appStoreVersions?include=build` returns every version with the build attached to it. This command
17
+ * is that one request, plus the two columns that make it actionable.
18
+ *
19
+ * ### Read-only, and the reason it is a command rather than a note
20
+ *
21
+ * Tagging a release is the one job here that has to be right months later and cannot be checked by
22
+ * looking. A wrong tag is not a wrong label — it silently sends whoever is chasing a crash report to
23
+ * a tree that never shipped, and nothing about it looks wrong.
24
+ */
25
+ export async function run(config, client) {
26
+ await client.findApp(config.bundleId);
27
+ console.log(`APP ${client.app.attributes.name} (${config.bundleId}) id=${client.appId}`);
28
+
29
+ const r = await client.get(`/v1/apps/${client.appId}/appStoreVersions?include=build&limit=200`);
30
+ if (r.status >= 300) {
31
+ console.log(yellow(` could not read versions — HTTP ${r.status}`));
32
+ return false;
33
+ }
34
+ const builds = Object.fromEntries(
35
+ (r.json.included || []).filter((i) => i.type === "builds").map((b) => [b.id, b.attributes.version]),
36
+ );
37
+ const versions = (r.json.data || []).filter((v) => config.platforms.includes(v.attributes.platform));
38
+ if (!versions.length) {
39
+ console.log(" no versions on this app");
40
+ return true;
41
+ }
42
+
43
+ const order = commitOrder();
44
+ if (!order) console.log(yellow(" not a git checkout — commit and tag columns unavailable"));
45
+
46
+ // Newest first: the question is almost always about the last one or two.
47
+ versions.sort((a, b) => String(b.attributes.createdDate).localeCompare(String(a.attributes.createdDate)));
48
+
49
+ // The highest build seen among versions OLDER than each row, so a build that went backwards can be
50
+ // recognised. Computed oldest-first, then the table prints newest-first.
51
+ const priorMax = new Map();
52
+ let running = null;
53
+ for (const v of [...versions].reverse()) {
54
+ priorMax.set(v.id, running);
55
+ const b = Number(builds[v.relationships?.build?.data?.id]);
56
+ if (Number.isInteger(b)) running = running == null ? b : Math.max(running, b);
57
+ }
58
+
59
+ console.log(` ${"version".padEnd(9)}${"state".padEnd(23)}${"date".padEnd(12)}${"build".padEnd(7)}${"commit".padEnd(11)}tag`);
60
+ const untagged = [];
61
+ for (const v of versions) {
62
+ const a = v.attributes;
63
+ const build = builds[v.relationships?.build?.data?.id];
64
+ const shipped = a.appStoreState === "READY_FOR_SALE";
65
+ let commit = "-";
66
+ let tag = "-";
67
+ let note = "";
68
+ const backwards = build == null ? null : outOfSequence(build, priorMax.get(v.id));
69
+ if (build == null) {
70
+ note = "no build attached yet";
71
+ } else if (backwards) {
72
+ note = yellow(backwards);
73
+ } else {
74
+ const got = commitForBuild(order, build);
75
+ if (got.sha) {
76
+ commit = got.sha.slice(0, 9);
77
+ const tags = tagsAt(got.sha);
78
+ tag = tags.length ? tags.join(" ") : yellow("(untagged)");
79
+ // Only a version actually on sale earns a nag. One in review has not shipped, and a tag
80
+ // saying it has is worse than no tag — tag it when it goes live.
81
+ if (!tags.length && shipped) untagged.push([`ios/${a.versionString}+${build}`, got.sha.slice(0, 9)]);
82
+ } else {
83
+ note = yellow(got.why);
84
+ }
85
+ }
86
+ // Padded on the PLAIN text, then coloured. `padEnd` counts ANSI escapes as characters, so
87
+ // colouring first silently shortens every highlighted cell and the columns stop lining up.
88
+ console.log(
89
+ ` ${a.versionString.padEnd(9)}${cell(a.appStoreState, 23, shipped ? green : null)}` +
90
+ `${String(a.createdDate).slice(0, 10).padEnd(12)}${String(build ?? "-").padEnd(7)}` +
91
+ `${commit.padEnd(11)}${tag}${note ? " " + note : ""}`,
92
+ );
93
+ }
94
+
95
+ if (untagged.length) {
96
+ console.log(`\n ${untagged.length} shipped version(s) carry no tag:`);
97
+ for (const [name, sha] of untagged) console.log(` git tag -a ${name} ${sha} -m "…"`);
98
+ }
99
+ return true;
100
+ }
101
+
102
+ /** Pad to [width] on the plain text, then colour — see the note at the call site. */
103
+ function cell(text, width, colour) {
104
+ const padded = String(text).padEnd(width);
105
+ return colour ? colour(String(text)) + padded.slice(String(text).length) : padded;
106
+ }
@@ -0,0 +1,127 @@
1
+ import { green, yellow, red } from "../util.mjs";
2
+
3
+ /**
4
+ * **Take a version back out of App Store review.**
5
+ *
6
+ * The gap this fills was a real one, found by hitting it: a version sits in `WAITING_FOR_REVIEW`
7
+ * with something wrong in it — a blank screenshot, a build numbered by a fallback — and every
8
+ * command that could fix it refuses, correctly, because Apple locks a submitted version. `prepare`
9
+ * says *"withdraw it in App Store Connect, then re-run this"*. `prerelease` says *"re-pointing it
10
+ * would mean withdrawing that submission, which is your call."* Three files said the same thing and
11
+ * none of them could do it, so the one step between a known defect and its fix was a web form.
12
+ *
13
+ * ### It is the mirror of Play's production refusal, not a new kind of power
14
+ *
15
+ * `prerelease --store google` refuses the `production` track outright — not behind a flag —
16
+ * because *"that release is a human's to make"*. This is the same rule pointed the other way: it
17
+ * will take back something that is **waiting**, and it will not touch what customers already have.
18
+ * A version in `READY_FOR_SALE` is refused, flatly, the way `production` is.
19
+ *
20
+ * And it stays on the safe side of the line vydanne draws everywhere else: this UNDOES a
21
+ * submission, it never makes one. Submitting for review remains the human's click.
22
+ *
23
+ * ### Withdrawing is not free, so the states are enumerated rather than filtered
24
+ *
25
+ * `WAITING_FOR_REVIEW` costs a place in the queue and nothing else — the review has not begun.
26
+ * `IN_REVIEW` costs a review already in progress and starts it over, which is a different decision,
27
+ * so it is allowed but announced. Everything else is refused with the reason, because a state this
28
+ * command cannot help with is worth naming: `PENDING_DEVELOPER_RELEASE` is *approved* and waiting
29
+ * for you, and throwing that away to change a screenshot is almost never what anyone means.
30
+ */
31
+
32
+ /** States where a submission exists and taking it back is what the operator meant. */
33
+ const WITHDRAWABLE = {
34
+ WAITING_FOR_REVIEW: "queued but not yet picked up — withdrawing costs the place in the queue",
35
+ IN_REVIEW: "ALREADY BEING REVIEWED — withdrawing discards a review in progress and starts over",
36
+ };
37
+
38
+ /** States where there is nothing to withdraw, each with the reason it is not an error. */
39
+ const NOTHING_TO_DO = {
40
+ PREPARE_FOR_SUBMISSION: "not submitted — it is already editable",
41
+ DEVELOPER_REJECTED: "already withdrawn",
42
+ REJECTED: "already out of review (Apple rejected it)",
43
+ METADATA_REJECTED: "already out of review (metadata rejected)",
44
+ INVALID_BINARY: "already out of review (invalid binary)",
45
+ };
46
+
47
+ export async function run(config, client) {
48
+ await client.findApp(config.bundleId);
49
+ console.log(green(`withdraw → ${client.app.attributes.name} (${config.bundleId})`));
50
+
51
+ const versions = await client.get(
52
+ `/v1/apps/${client.appId}/appStoreVersions?limit=10&fields[appStoreVersions]=versionString,appStoreState,platform`,
53
+ );
54
+ const mine = (versions.json.data || []).filter((v) => config.platforms.includes(v.attributes.platform));
55
+ if (!mine.length) {
56
+ console.log(" no versions on this app");
57
+ return true;
58
+ }
59
+ const version = mine[0];
60
+ const state = version.attributes.appStoreState;
61
+ const label = `${version.attributes.versionString} (${state})`;
62
+
63
+ // LIVE IS REFUSED, and this is the whole safety of the command. Play's side will not write the
64
+ // production track; this will not touch the version customers are downloading. Same rule, and
65
+ // neither is behind a flag.
66
+ if (state === "READY_FOR_SALE") {
67
+ console.error(red(` refusing: ${label} is the version customers have.`));
68
+ console.error(" This command takes a version out of REVIEW. Removing a live version from sale");
69
+ console.error(" is a different act with a different blast radius, and it is not this tool's.");
70
+ return false;
71
+ }
72
+ if (NOTHING_TO_DO[state]) {
73
+ console.log(` ${label}: ${NOTHING_TO_DO[state]} — nothing to withdraw`);
74
+ return true;
75
+ }
76
+ if (!WITHDRAWABLE[state]) {
77
+ console.error(red(` refusing: ${label} is not a state this command can take back.`));
78
+ console.error(" Withdrawable states are WAITING_FOR_REVIEW and IN_REVIEW. A version that is");
79
+ console.error(" approved and waiting on you, or mid-processing, is a decision to make in the UI.");
80
+ return false;
81
+ }
82
+
83
+ // The submission, not the version, is what gets cancelled — a review submission can carry more
84
+ // than the app version (in-app purchases, for one), and Apple takes the whole thing back at once.
85
+ const subs = await client.get(`/v1/apps/${client.appId}/reviewSubmissions?limit=10`);
86
+ const open = (subs.json.data || []).find((s) => WITHDRAWABLE[s.attributes.state]);
87
+ if (!open) {
88
+ console.log(yellow(` ${label} says it is in review, but no open submission was found.`));
89
+ console.log(" Nothing was changed. This is worth looking at in App Store Connect.");
90
+ return false;
91
+ }
92
+
93
+ console.log(` version ${label}`);
94
+ console.log(` submission ${open.id} submitted ${String(open.attributes.submittedDate).slice(0, 19)}`);
95
+ console.log(yellow(` ${WITHDRAWABLE[state]}`));
96
+
97
+ const r = await client.patch(`/v1/reviewSubmissions/${open.id}`, {
98
+ data: { type: "reviewSubmissions", id: open.id, attributes: { canceled: true } },
99
+ });
100
+ if (r.status >= 300) {
101
+ console.error(red(` cancel failed — HTTP ${r.status}: ${JSON.stringify(r.json).slice(0, 300)}`));
102
+ return false;
103
+ }
104
+ // In a dry run the client records the PATCH instead of sending it, so the CLI's own
105
+ // "N store write(s) withheld" summary stays accurate — an early return here would have reported
106
+ // zero writes and printed "the store already matches local", which is the opposite of true.
107
+ if (client.dryRun) {
108
+ console.log(yellow(" DRY RUN — the submission is untouched. Re-run with --apply to withdraw it."));
109
+ return true;
110
+ }
111
+ console.log(green(` cancelled — submission is ${r.json.data.attributes.state}`));
112
+
113
+ // Apple moves through CANCELING before the version becomes editable, and a command that returned
114
+ // at "CANCELING" would send the operator straight into a `fill` that fails on a locked version.
115
+ // Waiting the few seconds is the difference between a command that worked and one that appeared to.
116
+ for (let i = 0; i < 10; i++) {
117
+ const v = await client.get(`/v1/appStoreVersions/${version.id}?fields[appStoreVersions]=appStoreState`);
118
+ const now = v.json.data?.attributes?.appStoreState;
119
+ if (now && now !== state) {
120
+ console.log(green(` version ${version.attributes.versionString} is now ${now} — editable again`));
121
+ return true;
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, 3000));
124
+ }
125
+ console.log(yellow(" cancelled, but the version has not left review yet — re-check in a moment."));
126
+ return true;
127
+ }
@@ -48,8 +48,11 @@ export async function run(config, client) {
48
48
  for (const t of edited) {
49
49
  const res = await client.trackReleases(t.track);
50
50
  // 404 is information, not a failure: the app has never shipped to this track. "Does not
51
- // exist" and "exists but empty" are different facts and neither earns a row.
52
- if (res.status === 404) continue;
51
+ // exist" and "exists but empty" are different facts and neither earns a row — and 204 is the
52
+ // second of them, a track that exists and carries nothing, with no body to report. It was
53
+ // being reported as "could not be read", which put a warning on every app with an empty
54
+ // `beta` track. Found while writing `releases` against the same endpoint.
55
+ if (res.status === 404 || res.status === 204) continue;
53
56
  if (res.status !== 200) {
54
57
  // ANYTHING ELSE MUST NOT BE SWALLOWED. `req` returns a status rather than throwing, so an
55
58
  // earlier version of this that caught exceptions caught nothing at all — a 403 produced
@@ -0,0 +1,133 @@
1
+ import { commitOrder, commitForBuild, tagsAt } from "../../buildCommit.mjs";
2
+ import { green, yellow } from "../../util.mjs";
3
+
4
+ const LIFECYCLE = {
5
+ RELEASE_LIFECYCLE_STATE_PUBLISHED: "published",
6
+ RELEASE_LIFECYCLE_STATE_IN_REVIEW: "in review",
7
+ RELEASE_LIFECYCLE_STATE_DRAFT: "draft",
8
+ RELEASE_LIFECYCLE_STATE_HALTED: "halted",
9
+ };
10
+
11
+ /**
12
+ * **What each track is serving, resolved to the commit it was built from.**
13
+ *
14
+ * The overlap with `inspect --store google` is deliberate and small: that command answers "what
15
+ * state is this listing in", this one answers "what do I tag". Same source of truth — the non-edit
16
+ * `tracks/<t>/releases` endpoint, because the edit's own view is the last thing WRITTEN rather than
17
+ * what is serving — plus the commit and tag columns.
18
+ *
19
+ * ### Play cannot give you a history, and pretending otherwise would be the bug
20
+ *
21
+ * `tracks/<t>/releases` returns what a track carries NOW. There is no endpoint for superseded
22
+ * production releases, so a game that pushed several of them under one version name has no record of
23
+ * the earlier ones — Niva did exactly that, which is why its `marketingVersion` comment exists at
24
+ * all. `edits.bundles` lists every versionCode ever uploaded and is shown below for that reason, but
25
+ * uploaded is not released and a tag must never be minted from it.
26
+ *
27
+ * So: tag as you release. This command can confirm what is out there today and cannot reconstruct
28
+ * what was out there last year.
29
+ *
30
+ * ### No out-of-sequence check here, and that is Google's doing rather than an omission
31
+ *
32
+ * The Apple command needs [outOfSequence] because App Store Connect accepted a build numbered `1`
33
+ * above a `131` — a build-number fallback firing silently. Play refuses that upload outright:
34
+ * a versionCode must exceed every code already used for the package, enforced at `edits.bundles`.
35
+ * The invariant the whole commit mapping rests on is therefore guaranteed on this store and merely
36
+ * hoped for on the other, which is worth knowing when a build number looks wrong.
37
+ */
38
+ export async function run(config, client) {
39
+ const g = config.google;
40
+ console.log(`PLAY ${g.packageName}`);
41
+
42
+ const order = commitOrder();
43
+ if (!order) console.log(yellow(" not a git checkout — commit and tag columns unavailable"));
44
+
45
+ const editId = await client.newEdit();
46
+ let uploaded = [];
47
+ const rows = [];
48
+ // Tracks that could not be read. A command whose job is "what still needs a tag" must never let a
49
+ // transient failure read as "nothing else to tag" — the releases endpoint is quota-limited ("The
50
+ // service is currently unavailable", "Listing releases quota exceeded") and a normal day reaches
51
+ // it, so a missing row is a state to announce rather than one to quietly skip.
52
+ const unreadable = [];
53
+ try {
54
+ const edited = (await client.listTracks(editId)).json.tracks || [];
55
+ for (const t of edited) {
56
+ const res = await client.trackReleases(t.track);
57
+ // 404 = never shipped to this track. 204 = the track exists and carries nothing, with no body
58
+ // to report. Both are "no rows", not failures; reporting 204 as unreadable put a yellow
59
+ // warning on every app with an empty `beta` track, which is most of them.
60
+ if (res.status === 404 || res.status === 204) continue;
61
+ if (res.status !== 200) {
62
+ unreadable.push(`${t.track} (${res.json?.error?.message || `HTTP ${res.status}`})`);
63
+ continue;
64
+ }
65
+ for (const rel of res.json.releases || []) {
66
+ for (const art of rel.activeArtifacts || []) {
67
+ rows.push({
68
+ track: t.track,
69
+ code: art.versionCode,
70
+ name: rel.releaseName || "-",
71
+ state: LIFECYCLE[rel.releaseLifecycleState] || rel.releaseLifecycleState || "?",
72
+ });
73
+ }
74
+ }
75
+ }
76
+ uploaded = ((await client.req("GET", `/edits/${editId}/bundles`)).json.bundles || [])
77
+ .map((b) => b.versionCode).sort((a, b) => a - b);
78
+ } finally {
79
+ await client.deleteEdit(editId);
80
+ }
81
+
82
+ if (unreadable.length) {
83
+ console.log(yellow(` INCOMPLETE — could not read: ${unreadable.join(", ")}`));
84
+ console.log(yellow(" Re-run before concluding anything is untagged; this is usually a quota blip."));
85
+ }
86
+ if (!rows.length) {
87
+ console.log(unreadable.length ? " no readable track carries a release" : " nothing published — no track carries a release");
88
+ } else {
89
+ console.log(` ${"track".padEnd(12)}${"code".padEnd(7)}${"name".padEnd(13)}${"state".padEnd(11)}${"commit".padEnd(11)}tag`);
90
+ const untagged = [];
91
+ for (const row of rows) {
92
+ let commit = "-";
93
+ let tag = "-";
94
+ let note = "";
95
+ const got = commitForBuild(order, row.code);
96
+ if (got.sha) {
97
+ commit = got.sha.slice(0, 9);
98
+ const tags = tagsAt(got.sha);
99
+ tag = tags.length ? tags.join(" ") : yellow("(untagged)");
100
+ // Only `production` is a release to users. A closed track is a test, and a tag claiming
101
+ // otherwise is worse than no tag — this portfolio ships to `alpha` before every promotion,
102
+ // so nagging about those would nag on every single release.
103
+ if (!tags.length && row.track === "production" && row.state === "published") {
104
+ untagged.push([`play/${row.name}+${row.code}`, got.sha.slice(0, 9)]);
105
+ }
106
+ } else {
107
+ note = yellow(got.why);
108
+ }
109
+ const live = row.track === "production" && row.state === "published";
110
+ console.log(
111
+ ` ${row.track.padEnd(12)}${String(row.code).padEnd(7)}${row.name.padEnd(13)}` +
112
+ `${cell(row.state, 11, live ? green : null)}${commit.padEnd(11)}${tag}${note ? " " + note : ""}`,
113
+ );
114
+ }
115
+ if (untagged.length) {
116
+ console.log(`\n ${untagged.length} production release(s) carry no tag${unreadable.length ? " (of the tracks that could be read)" : ""}:`);
117
+ for (const [name, sha] of untagged) console.log(` git tag -a ${name} ${sha} -m "…"`);
118
+ }
119
+ }
120
+
121
+ if (uploaded.length) {
122
+ console.log(`\n bundles uploaded (${uploaded.length}): ${uploaded.join(", ")}`);
123
+ console.log(" Uploaded is NOT released — never tag from this line. Superseded production");
124
+ console.log(" releases are not retrievable from the Play API at all, so tag as you release.");
125
+ }
126
+ return true;
127
+ }
128
+
129
+ /** Pad to [width] on the plain text, then colour — `padEnd` counts ANSI escapes as characters. */
130
+ function cell(text, width, colour) {
131
+ const padded = String(text).padEnd(width);
132
+ return colour ? colour(String(text)) + padded.slice(String(text).length) : padded;
133
+ }
package/src/registry.mjs CHANGED
@@ -31,6 +31,16 @@ export const COMMANDS = {
31
31
  // compliance above), so it is not marked `writes` — it has its own `--dry-run` instead.
32
32
  bridge: { mod: "bridge", client: false },
33
33
  inspect: { mod: "inspect", client: true },
34
+ // Version HISTORY, which `inspect` structurally cannot show — it reports the version in
35
+ // preparation or the live one and nothing behind them. Read-only, and the reason it exists is on
36
+ // the record: three of Niva's release tags say in their own messages that they could not be
37
+ // confirmed "because historical build numbers are not exposed by vydanne inspect", and all three
38
+ // were wrong. The data was always one query away.
39
+ releases: { mod: "releases", client: true },
40
+ // Takes a version back OUT of review, and it is the mirror of the Play side refusing the
41
+ // production track: it will retrieve something that is waiting, and it will not touch what
42
+ // customers already have. `writes` because it changes the store — dry run without --apply.
43
+ withdraw: { mod: "withdraw", client: true, writes: true },
34
44
  diff: { mod: "diff", client: true },
35
45
  preflight: { mod: "preflight", client: true },
36
46
  // Uploads the .ipa to TestFlight. Needs the credentials as well as the client: the REST API
@@ -42,6 +52,7 @@ export const COMMANDS = {
42
52
  // (src/play/commands/). Store-specific commands (Apple accessibility/iap; Play data-safety) aren't shared.
43
53
  export const PLAY_COMMANDS = {
44
54
  inspect: { mod: "inspect" },
55
+ releases: { mod: "releases" },
45
56
  preflight: { mod: "preflight" },
46
57
  diff: { mod: "diff" },
47
58
  fill: { mod: "fill", writes: true },
package/types/index.d.ts CHANGED
@@ -38,6 +38,18 @@ export type CommandName =
38
38
  */
39
39
  | 'bridge'
40
40
  | 'inspect'
41
+ /**
42
+ * Version history with the build each release shipped, resolved to the commit that build number
43
+ * names (`git rev-list --count`) and whether it carries a tag. Read-only. With `--store google`,
44
+ * what each track is serving — Play keeps no history of superseded releases.
45
+ */
46
+ | 'releases'
47
+ /**
48
+ * Take a version back out of App Store review so it can be edited — the step `prepare` and
49
+ * `prerelease` both tell you to do by hand. Refuses a LIVE version, mirroring the Play side's
50
+ * refusal of the production track. Undoes a submission; never makes one. Dry run without `--apply`.
51
+ */
52
+ | 'withdraw'
41
53
  | 'diff'
42
54
  | 'preflight'
43
55
  /**