vydanne 0.9.0 → 0.11.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
@@ -218,6 +218,11 @@ Two details worth knowing:
218
218
  > Upgrading from ≤ 0.5? The Apple half used to write immediately — `vydanne fill` now needs `--apply`.
219
219
  > `VYDANNE_COMMIT=1` still works as an alias so existing Play scripts keep running, but prefer the flag.
220
220
 
221
+ `fill` takes four more overrides, all off by default: `VYDANNE_REPLACE=1` replaces a store screenshot
222
+ set instead of leaving the live one alone (what you want after re-rendering them), and
223
+ `VYDANNE_SKIP_METADATA=1` / `VYDANNE_SKIP_SCREENSHOTS=1` do one half of the job when the other is
224
+ already right. `VYDANNE_FLATTEN=1` reads a flat screenshot folder rather than per-locale ones.
225
+
221
226
  <br>
222
227
 
223
228
  ## What each command does
@@ -230,7 +235,7 @@ Two details worth knowing:
230
235
  | `diff` | Shows exactly what's different between your files and what's live. Nothing is changed — a safe preview. |
231
236
  | `fill` | Uploads your listing text and screenshots. Handles iPhone, iPad and Mac. Refuses to upload text that names the other mobile platform. |
232
237
  | `previews` | Uploads App Preview videos. |
233
- | `inspect` | Shows the app's current state in the store. Read-only. |
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. |
234
239
  | `locales` | Lists your languages and Apple's code for each — and warns about any language the App Store doesn't offer. |
235
240
  | `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. |
236
241
  | `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. |
@@ -379,7 +384,15 @@ Notes are truncated to Play's 500-char cap with a warning. The versionCode comes
379
384
  manifest — vydanne reads it out of the `.aab` locally and reports which changelog file each locale
380
385
  resolves to *before* the upload, so a wrong file costs a re-run, not a re-release. Re-uploading a used
381
386
  code fails loudly instead of silently replacing a binary. Overrides: `VYDANNE_AAB`, `VYDANNE_TRACK`,
382
- `VYDANNE_RELEASE_NAME`.
387
+ `VYDANNE_RELEASE_NAME`, `VYDANNE_STATUS`; and `VYDANNE_IPA` for the App Store half.
388
+
389
+ **An app that has never been published needs `releaseStatus: "draft"`.** Play calls it a *draft app*
390
+ and refuses a `completed` release on every track except `internal`, answering *"Only releases with
391
+ status draft may be created on draft app."* — which names neither the track nor the fix, and is why
392
+ the same bundle uploads to `internal` and fails on `alpha`. Set it in `google.releaseStatus` (or
393
+ `VYDANNE_STATUS=draft` for one run); the build then waits in Play Console for a person to start the
394
+ rollout, which is where an unpublished app's first one belongs. Remove it once the app is live.
395
+ vydanne explains this rejection rather than printing Play's version of it.
383
396
 
384
397
  **Play is dry by default on purpose.** Nothing goes live until you add `--apply`, so a
385
398
  half-finished folder can never overwrite a good listing. Play also uses its **own** language codes
package/SKILL.md CHANGED
@@ -286,6 +286,7 @@ With `--store google` it uploads an `.aab` to a **closed testing track** with re
286
286
  edit transaction. `production` is REFUSED — not flag-gated — so no argument combination ships to the
287
287
  public; promoting the tested build stays a human's job, mirroring the Apple side never submitting. Track
288
288
  comes from `google.track` / `VYDANNE_TRACK`, default `internal`; the bundle from `google.aab` /
289
+ - `VYDANNE_STATUS` — release status for `prerelease --store google`: `draft`, `inProgress`, `halted`, `completed` (default). Use `draft` for an app that has never been published: Play refuses a completed release on any track but `internal` until it is live, and says so in a message that names neither the track nor the fix. Also settable as `google.releaseStatus`.
289
290
  `VYDANNE_AAB` (a directory takes its newest `.aab`). **For a PAID app use `internal`** — it's the only
290
291
  track where testers install without buying. Notes follow supply's layout, per locale, first match wins:
291
292
  `<google.metadataDir>/<play-locale>/changelogs/<versionCode>.txt` → `next.txt` → `default.txt`, capped
@@ -296,6 +297,11 @@ than silent. The versionCode is read out of the `.aab` locally and the changelog
296
297
  BEFORE the upload; re-uploading a used code fails loudly instead of silently replacing. DRY by default;
297
298
  `--apply` publishes.
298
299
 
300
+ `inspect --store google` also reports **which build is on which track** — versionCode, version
301
+ name, status, and a staged rollout's percentage — for every track that carries a release. Tracks
302
+ with no release are omitted. That is the question after any upload, and it could not be answered
303
+ from this tool at all before 0.11.
304
+
299
305
  `--store google` routes `inspect` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
300
306
  (OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
301
307
  The AAB binary and the (YouTube-URL) promo video stay outside vydanne.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
package/src/config.mjs CHANGED
@@ -98,6 +98,12 @@ export async function loadConfig(p) {
98
98
  defaultLocale: raw.google.defaultLocale || raw.primaryLocale,
99
99
  aab: raw.google.aab || null,
100
100
  track: raw.google.track || "internal", // testing only — `prerelease` refuses production
101
+ // What state a new release is created in: draft | inProgress | halted | completed.
102
+ // Left null so `prerelease` keeps its own default ("completed"). The one case that needs
103
+ // it is an app that has never been published — Play calls that a "draft app" and refuses
104
+ // a completed release on any track but internal, so the first closed or open rollout has
105
+ // to be created as "draft" and started by a person in Console.
106
+ releaseStatus: raw.google.releaseStatus || null,
101
107
  // Play image type -> local source path. Merged over the defaults so an app overrides only the
102
108
  // slots whose layout differs; the default for `icon` points at znachok's output, which is a
103
109
  // sensible default for this portfolio and a mystery to anyone else, so it is overridable.
@@ -86,6 +86,35 @@ export class PlayClient {
86
86
 
87
87
  getTrack(editId, track) { return this.req("GET", `/edits/${editId}/tracks/${track}`); }
88
88
 
89
+ /**
90
+ * The track NAMES this app has, from inside an edit.
91
+ *
92
+ * Only the names are worth taking from here. The `releases` this returns are the track's DESIRED
93
+ * state — whatever the last edit wrote — which is not what anyone means by "what is on the track".
94
+ * Use [trackReleases] for that; see its note.
95
+ */
96
+ listTracks(editId) { return this.req("GET", `/edits/${editId}/tracks`); }
97
+
98
+ /**
99
+ * What is ACTUALLY on a track: every non-obsolete release, with its lifecycle state.
100
+ *
101
+ * Needs no edit, and it is a different answer from `edits/…/tracks`. That one reports the last
102
+ * write; this reports reality, and the two disagree exactly when it matters most. Measured on
103
+ * Niva, 2026-09-05:
104
+ *
105
+ * edits/…/tracks alpha → [131] "the upload worked"
106
+ * tracks/alpha/… alpha → 1.4 (131) IN_REVIEW
107
+ * + 1.0 (102) PUBLISHED "…and testers still have 1.0"
108
+ *
109
+ * A newly uploaded build does not reach testers until review passes, so the previously published
110
+ * release keeps serving — and the edit-based view cannot see it at all. Reporting only the edit
111
+ * view meant `inspect` said a release was live when nobody had it yet.
112
+ *
113
+ * Shape differs from the edits API too: `releaseName`, `activeArtifacts[].versionCode` and
114
+ * `releaseLifecycleState`, not `name`/`versionCodes`/`status`.
115
+ */
116
+ trackReleases(track) { return this.req("GET", `/tracks/${encodeURIComponent(track)}/releases`); }
117
+
89
118
  /**
90
119
  * Point a track at version codes. `releases` is the FULL desired state of that track — Play replaces
91
120
  * it wholesale, so send one complete release object rather than appending to what is already there.
@@ -1,7 +1,14 @@
1
1
  const IMAGE_TYPES = ["phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", "wearScreenshots", "tvScreenshots", "featureGraphic", "icon", "promoGraphic"];
2
2
 
3
- // Read-only snapshot of the Play listing — languages, contact details, image counts. Everything reads
4
- // through one throwaway edit (deleted, never committed), the same as the ASC `inspect`.
3
+ // Read-only snapshot of the Play listing — languages, contact details, image counts, and WHAT IS
4
+ // PUBLISHED WHERE. Everything reads through one throwaway edit (deleted, never committed), the same
5
+ // as the ASC `inspect`.
6
+ //
7
+ // The tracks half was missing until 2026-09-05, and its absence was the difference between "the
8
+ // upload command said it worked" and knowing. `inspect` reported the listing beautifully and could
9
+ // not answer the only question anyone asks after a release — which build is on which track, under
10
+ // what version name — so that had to be worked out by hand against the raw androidpublisher API.
11
+ // A read-only command that cannot show you the state you just changed is half a command.
5
12
  export async function run(config, client) {
6
13
  const g = config.google;
7
14
  const editId = await client.newEdit();
@@ -17,6 +24,68 @@ export async function run(config, client) {
17
24
  if (imgs.length) counts.push(`${t}=${imgs.length}`);
18
25
  }
19
26
  console.log(` images (${lang}): ${counts.join(" ") || "(none)"}`);
27
+
28
+ // What is actually ON each track, which is NOT what the edit says is on it.
29
+ //
30
+ // The edit's own `tracks` gives the desired state — the last thing written — so straight after
31
+ // an upload it happily reports the new build and nothing else. The track's `releases` endpoint
32
+ // reports reality: the build in review AND the older one still serving testers underneath it,
33
+ // because a new build does not reach anyone until review passes. Reporting the edit view alone
34
+ // said a release was live when nobody had it (measured on Niva, 2026-09-05).
35
+ //
36
+ // So the edit is used only to enumerate track NAMES; every state below comes from the
37
+ // non-edit endpoint.
38
+ const LIFECYCLE = {
39
+ RELEASE_LIFECYCLE_STATE_PUBLISHED: "published",
40
+ RELEASE_LIFECYCLE_STATE_IN_REVIEW: "in review",
41
+ RELEASE_LIFECYCLE_STATE_DRAFT: "draft",
42
+ RELEASE_LIFECYCLE_STATE_HALTED: "halted",
43
+ RELEASE_LIFECYCLE_STATE_UNSPECIFIED: "unspecified",
44
+ };
45
+ const edited = (await client.listTracks(editId)).json.tracks || [];
46
+ const rows = [];
47
+ let degraded = null;
48
+ for (const t of edited) {
49
+ const res = await client.trackReleases(t.track);
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;
53
+ if (res.status !== 200) {
54
+ // ANYTHING ELSE MUST NOT BE SWALLOWED. `req` returns a status rather than throwing, so an
55
+ // earlier version of this that caught exceptions caught nothing at all — a 403 produced
56
+ // zero rows and the command printed "nothing published" about an app with three live
57
+ // tracks. The endpoint is quota-limited ("Listing releases quota exceeded"), so this is a
58
+ // state a normal day reaches, not an exotic one. Fall back to the edit's view, which is the
59
+ // last write rather than what is serving, and label every row so it is never mistaken for
60
+ // the real thing.
61
+ degraded = res.json?.error?.message || `HTTP ${res.status}`;
62
+ for (const rel of t.releases || []) {
63
+ const codes = (rel.versionCodes || []).join(",") || "-";
64
+ if (codes === "-") continue;
65
+ rows.push(` ~ ${t.track.padEnd(12)} ${codes.padEnd(8)} ${(rel.name || "-").padEnd(12)} ${rel.status || "?"}`);
66
+ }
67
+ continue;
68
+ }
69
+ for (const rel of res.json.releases || []) {
70
+ const codes = (rel.activeArtifacts || []).map((a) => a.versionCode).join(",") || "-";
71
+ const state = LIFECYCLE[rel.releaseLifecycleState] || rel.releaseLifecycleState || "?";
72
+ const staged = rel.userFraction != null ? ` ${Math.round(rel.userFraction * 100)}% rollout` : "";
73
+ rows.push(` ${t.track.padEnd(12)} ${codes.padEnd(8)} ${(rel.releaseName || "-").padEnd(12)} ${state}${staged}`);
74
+ }
75
+ }
76
+ if (rows.length) {
77
+ console.log(` tracks (${rows.length} release(s)):`);
78
+ console.log(` ${"track".padEnd(12)} ${"code".padEnd(8)} ${"name".padEnd(12)} state`);
79
+ for (const r of rows) console.log(r);
80
+ } else if (degraded) {
81
+ console.log(" tracks: could not be read — " + degraded);
82
+ } else {
83
+ console.log(" tracks: (nothing published — no track carries a release)");
84
+ }
85
+ if (degraded && rows.length) {
86
+ console.log(` ~ rows are the EDIT's view (what was last written), not what is serving.`);
87
+ console.log(` Live state unavailable: ${degraded}`);
88
+ }
20
89
  } finally {
21
90
  await client.deleteEdit(editId);
22
91
  }
@@ -92,13 +92,26 @@ export async function run(config, client) {
92
92
  if (!notes) notes = readNotes(g.metadataDir, versionCode, g.defaultLocale);
93
93
 
94
94
  // One complete release object: Play replaces the track's releases wholesale.
95
- const release = { status: "completed", versionCodes: [String(versionCode)] };
95
+ //
96
+ // The status is settable because a DRAFT APP — one that has never been published — refuses a
97
+ // "completed" release on any track but internal, with
98
+ // "Only releases with status draft may be created on draft app."
99
+ // which names neither the track nor the fix. Set VYDANNE_STATUS=draft for the first closed
100
+ // or open rollout of an app that is not live yet; the release then waits in Play Console for
101
+ // a human to start it, which is where an unpublished app's first rollout belongs anyway.
102
+ const status = process.env.VYDANNE_STATUS || g.releaseStatus || "completed";
103
+ if (!RELEASE_STATUSES.includes(status)) {
104
+ throw new Error(
105
+ `release status "${status}" is not one Play accepts — use one of ${RELEASE_STATUSES.join(", ")}`,
106
+ );
107
+ }
108
+ const release = { status, versionCodes: [String(versionCode)] };
96
109
  if (notes.entries.length) release.releaseNotes = notes.entries;
97
110
  const name = process.env.VYDANNE_RELEASE_NAME;
98
111
  if (name) release.name = name;
99
112
 
100
113
  const put = await client.putTrack(editId, track, [release]);
101
- if (put.status >= 300) throw new Error(`tracks.update ${put.status}: ${JSON.stringify(put.json).slice(0, 300)}`);
114
+ if (put.status >= 300) throw new Error(explainPlayError("tracks.update", put, track, status));
102
115
 
103
116
  if (client.dryRun) {
104
117
  await client.deleteEdit(editId);
@@ -106,8 +119,17 @@ export async function run(config, client) {
106
119
  return true;
107
120
  }
108
121
  const res = await client.commit(editId);
109
- if (res.status >= 300) throw new Error(`edits.commit ${res.status}: ${JSON.stringify(res.json).slice(0, 300)}`);
110
- console.log(green(`\n committed versionCode ${versionCode} is live on "${track}".`));
122
+ if (res.status >= 300) throw new Error(explainPlayError("edits.commit", res, track, status));
123
+ // "live" is only true of a release that has actually started. A draft one is uploaded and
124
+ // waiting, and telling somebody it is live is how a build sits unnoticed for a week.
125
+ console.log(
126
+ status === "draft"
127
+ ? green(`\n committed — versionCode ${versionCode} is on "${track}" as a DRAFT release.`)
128
+ : green(`\n committed — versionCode ${versionCode} is live on "${track}".`),
129
+ );
130
+ if (status === "draft") {
131
+ console.log(` Nobody has it yet: open Play Console and start the rollout when you are ready.`);
132
+ }
111
133
  archiveNextNotes(notes, versionCode);
112
134
  console.log(" Production stays manual: promote it in Play Console when you're ready.");
113
135
  return true;
@@ -209,3 +231,39 @@ function archiveNextNotes(notes, versionCode) {
209
231
  }
210
232
  }
211
233
  }
234
+
235
+ /** The release statuses Play's Publishing API accepts. A typo here costs a round trip otherwise. */
236
+ const RELEASE_STATUSES = ["draft", "inProgress", "halted", "completed"];
237
+
238
+ /**
239
+ * Play's rejection, plus what to do about it.
240
+ *
241
+ * One rejection is worth translating rather than printing. An app that has never been published is
242
+ * a "draft app", and Play will not accept a `completed` release on any track except internal:
243
+ *
244
+ * Only releases with status draft may be created on draft app.
245
+ *
246
+ * That sentence names neither the track it is talking about nor the setting that fixes it, and it
247
+ * arrives identically from the track update and from the commit — so the same upload succeeds on
248
+ * `internal` and fails on `alpha` with a message that suggests nothing about tracks at all. The
249
+ * fix is one setting, and it belongs in the error rather than in somebody's memory.
250
+ */
251
+ function explainPlayError(where, res, track, status) {
252
+ const body = JSON.stringify(res.json).slice(0, 300);
253
+ const message = res.json?.error?.message || "";
254
+ if (/draft app/i.test(message)) {
255
+ return [
256
+ `${where} ${res.status}: ${message}`,
257
+ "",
258
+ ` This app has never been published, so Play calls it a draft app — and a draft app only`,
259
+ ` accepts releases whose status is "draft". You asked for "${status}" on track "${track}".`,
260
+ "",
261
+ ` Set it once in your config: google: { releaseStatus: "draft" }`,
262
+ ` Or for this run only: VYDANNE_STATUS=draft`,
263
+ "",
264
+ ` The build then waits in Play Console for a person to start the rollout, which is where an`,
265
+ ` unpublished app's first one belongs. Remove the setting once the app is live.`,
266
+ ].join("\n");
267
+ }
268
+ return `${where} ${res.status}: ${body}`;
269
+ }
package/types/index.d.ts CHANGED
@@ -139,6 +139,15 @@ export interface GoogleConfig {
139
139
  * track you created in Play Console. Only 'production' is refused — that release is a human's.
140
140
  */
141
141
  track?: string;
142
+ /**
143
+ * What state a new release is created in. Defaults to 'completed' — the rollout starts on upload.
144
+ *
145
+ * Set 'draft' for an app that has NEVER been published: Play calls that a "draft app" and refuses
146
+ * a completed release on any track but internal, with a message that names neither the track nor
147
+ * the fix. A draft release waits in Play Console for a person to start it. Remove once live.
148
+ * Override: VYDANNE_STATUS.
149
+ */
150
+ releaseStatus?: "draft" | "inProgress" | "halted" | "completed";
142
151
  /**
143
152
  * Play image type -> local source path. Merged over the defaults, so declare only what differs.
144
153
  * A type whose source does not exist is skipped; a missing local set never deletes the live one.
@@ -100,6 +100,11 @@ export default {
100
100
  // 'internal' (default), 'alpha', 'beta', or the name of any closed track you made in Play Console.
101
101
  // Only 'production' is refused.
102
102
  // track: "internal",
103
+ // What state a new release is created in: "draft" | "inProgress" | "halted" | "completed".
104
+ // Defaults to "completed" — the rollout starts on upload. An app that has NEVER been published
105
+ // is a "draft app" to Play, which refuses a completed release on any track but internal; set
106
+ // "draft" until it is live and start the rollout yourself in Console. Override: VYDANNE_STATUS.
107
+ // releaseStatus: "draft",
103
108
  // Play image type -> local source. Merged over the defaults, so override only what differs.
104
109
  // images: { icon: "brand/icons/play/icon-512.png", phoneScreenshots: "marketing/out/play-phone-plain" },
105
110
  // Play holds graphics PER LANGUAGE. Default is one set at `defaultLocale`; list locales (or "*")