vydanne 0.6.0 → 0.7.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.
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import zlib from "node:zlib";
3
+
4
+ /**
5
+ * Read the versionCode out of an `.aab`, locally, before anything is uploaded.
6
+ *
7
+ * WHY. Release notes resolve to `<metadataDir>/<locale>/changelogs/<versionCode>.txt`, but in repos
8
+ * that derive versionCode from `git rev-list --count HEAD` the number is unknowable until the build is
9
+ * cut — every commit moves it. So nobody can name the changelog file in advance, the fallback quietly
10
+ * wins, and the first time the real number appears in a log is AFTER a multi-megabyte upload. The
11
+ * bundle itself has known its own versionCode all along: it is an attribute on the `<manifest>`
12
+ * element of `base/manifest/AndroidManifest.xml`.
13
+ *
14
+ * HOW, without Java. bundletool is the official reader and is a JVM tool; shelling out to it would be
15
+ * the first Java dependency in a package whose whole pitch is native Node. But the format is shallow:
16
+ * an .aab is a ZIP, and the manifest inside is aapt2's protobuf XML. Neither needs a library —
17
+ * `zlib.inflateRawSync` decompresses the entry, and protobuf's wire format is walkable generically:
18
+ * find the submessage that looks like an XmlAttribute named "versionCode" (field 2, its `name`) and
19
+ * take its value (field 3 as a decimal string, or the first varint inside field 6, the compiled item).
20
+ * Matching by attribute NAME rather than by the exact Resources.proto field numbers for Primitive is
21
+ * deliberate — those internals have shifted between aapt2 versions; "an attribute called versionCode
22
+ * on the manifest of an Android app" has not.
23
+ *
24
+ * Returns null on anything unexpected rather than throwing: the caller has a correct-by-construction
25
+ * fallback (Play reports the versionCode after upload), so a parse failure must degrade to the old
26
+ * behaviour, never block a release.
27
+ */
28
+ export function readAabVersionCode(file) {
29
+ try {
30
+ const manifest = zipEntry(fs.readFileSync(file), "base/manifest/AndroidManifest.xml");
31
+ return manifest ? findVersionCode(manifest) : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Extract one entry from a ZIP buffer (stored or deflated), or null. */
38
+ function zipEntry(buf, wanted) {
39
+ // End-of-central-directory: scan back from the end (the record allows a trailing comment).
40
+ let eocd = -1;
41
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
42
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
43
+ }
44
+ if (eocd < 0) return null;
45
+ let off = buf.readUInt32LE(eocd + 16); // central directory offset
46
+ const count = buf.readUInt16LE(eocd + 10);
47
+ for (let n = 0; n < count && off + 46 <= buf.length; n++) {
48
+ if (buf.readUInt32LE(off) !== 0x02014b50) return null;
49
+ const method = buf.readUInt16LE(off + 10);
50
+ const csize = buf.readUInt32LE(off + 20);
51
+ const nameLen = buf.readUInt16LE(off + 28);
52
+ const extraLen = buf.readUInt16LE(off + 30);
53
+ const commentLen = buf.readUInt16LE(off + 32);
54
+ const localOff = buf.readUInt32LE(off + 42);
55
+ const name = buf.toString("utf8", off + 46, off + 46 + nameLen);
56
+ if (name === wanted) {
57
+ // The local header repeats name/extra with its OWN lengths (extra often differs) — read them.
58
+ if (buf.readUInt32LE(localOff) !== 0x04034b50) return null;
59
+ const lname = buf.readUInt16LE(localOff + 26);
60
+ const lextra = buf.readUInt16LE(localOff + 28);
61
+ const data = buf.subarray(localOff + 30 + lname + lextra, localOff + 30 + lname + lextra + csize);
62
+ if (method === 0) return data;
63
+ if (method === 8) return zlib.inflateRawSync(data);
64
+ return null;
65
+ }
66
+ off += 46 + nameLen + extraLen + commentLen;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** Parse one protobuf message into its fields. Throws on anything that isn't valid wire format. */
72
+ function protoFields(buf) {
73
+ const out = [];
74
+ let i = 0;
75
+ const varint = () => {
76
+ let v = 0, shift = 0;
77
+ for (;;) {
78
+ if (i >= buf.length || shift > 49) throw new Error("varint"); // >2^49 can't be a versionCode anyway
79
+ const b = buf[i++];
80
+ v += (b & 0x7f) * 2 ** shift;
81
+ if (!(b & 0x80)) return v;
82
+ shift += 7;
83
+ }
84
+ };
85
+ while (i < buf.length) {
86
+ const key = varint();
87
+ const no = Math.floor(key / 8), wire = key % 8;
88
+ if (wire === 0) out.push({ no, wire, val: varint() });
89
+ else if (wire === 2) { const len = varint(); if (i + len > buf.length) throw new Error("len"); out.push({ no, wire, bytes: buf.subarray(i, i + len) }); i += len; }
90
+ else if (wire === 5) { i += 4; out.push({ no, wire }); }
91
+ else if (wire === 1) { i += 8; out.push({ no, wire }); }
92
+ else throw new Error("wire");
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** Depth-first hunt for an XmlAttribute whose name (field 2) is "versionCode". */
98
+ function findVersionCode(buf) {
99
+ let fields;
100
+ try { fields = protoFields(buf); } catch { return null; } // not a message — a string that happened to be field-2
101
+ const name = fields.find((f) => f.no === 2 && f.wire === 2);
102
+ if (name && name.bytes.toString("utf8") === "versionCode") {
103
+ const value = fields.find((f) => f.no === 3 && f.wire === 2)?.bytes.toString("utf8");
104
+ if (value && /^\d+$/.test(value)) return Number(value);
105
+ const compiled = fields.find((f) => f.no === 6 && f.wire === 2);
106
+ if (compiled) {
107
+ const v = firstVarint(compiled.bytes);
108
+ if (v != null) return v;
109
+ }
110
+ return null;
111
+ }
112
+ for (const f of fields) {
113
+ if (f.wire !== 2) continue;
114
+ const found = findVersionCode(f.bytes);
115
+ if (found != null) return found;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** The first varint anywhere in a message — inside Item→Primitive that is the integer value itself
121
+ * (the attribute-level varints, like resource_id, live OUTSIDE the compiled item). */
122
+ function firstVarint(buf) {
123
+ let fields;
124
+ try { fields = protoFields(buf); } catch { return null; }
125
+ for (const f of fields) {
126
+ if (f.wire === 0) return f.val;
127
+ if (f.wire === 2) {
128
+ const v = firstVarint(f.bytes);
129
+ if (v != null) return v;
130
+ }
131
+ }
132
+ return null;
133
+ }
@@ -1,10 +1,13 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { green, red, yellow } from "../../util.mjs";
5
+ import { playImages, imageLocales } from "../images.mjs";
4
6
 
5
7
  // [Play listing attribute, local metadata filename] — supply's convention under fastlane/metadata/android.
6
8
  const FIELDS = [["title", "title"], ["shortDescription", "short_description"], ["fullDescription", "full_description"]];
7
9
  const norm = (s) => (s == null ? null : String(s).replace(/\r/g, "").replace(/\n+$/, "").trim());
10
+ const sha1 = (buf) => crypto.createHash("sha1").update(buf).digest("hex");
8
11
 
9
12
  // Show what differs between local Play sources (fastlane/metadata/android/<locale>/*.txt) and the live Play
10
13
  // listing — a dry-run of `fill --store google`.
@@ -38,6 +41,43 @@ export async function run(config, client) {
38
41
  const localSet = new Set(localLangs);
39
42
  const extra = listings.map((l) => l.language).filter((x) => !localSet.has(x));
40
43
  if (extra.length) console.log(` ${yellow("Play-only languages")} (no local folder): ${extra.join(", ")}`);
44
+
45
+ // Images, by CONTENT. This command compared nothing here at all, so "in sync" was a claim about the
46
+ // text only — a full recapture of every screenshot reported nothing to do, which is the same bug the
47
+ // Apple diff had with counts, one step worse. Play's images.list returns the sha1 of what it holds
48
+ // (the comparison supply itself uses to skip identical uploads), so local bytes can be checked
49
+ // against the store without downloading anything. Only types with a LOCAL asset are judged, and a
50
+ // remote-only type is left unflagged — mirroring fill, which never deletes by omission.
51
+ // Every locale `fill` would upload to, so the two commands agree on what "in sync" covers. With the
52
+ // default (no `google.imageLocales`) that is the one `defaultLocale` this always compared.
53
+ for (const lang of imageLocales(g, localLangs)) {
54
+ for (const [type, src, kind] of playImages(config)) {
55
+ const localized = path.join(src, lang);
56
+ const from = kind === "dir" && fs.existsSync(localized) ? localized : src;
57
+ if (!fs.existsSync(from)) continue;
58
+ const files = kind === "dir" ? fs.readdirSync(from).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort().map((f) => path.join(from, f)) : [from];
59
+ const label = ` ${yellow("images")} ${type}`;
60
+ // A dir that exists but is empty means the local set was deliberately cleared — `fill` will not
61
+ // touch the live one (never-delete-by-omission), so if the store still holds images they are
62
+ // stale and only Play Console can remove them. Reported, not counted as actionable, because no
63
+ // vydanne command would change it.
64
+ if (!files.length) {
65
+ const held = ((await client.listImages(editId, lang, type)).json.images || []).length;
66
+ if (held) console.log(`${label}: local dir empty, store holds ${held} — stale; only Play Console can remove them (@${lang})`);
67
+ continue;
68
+ }
69
+ const local = files.map((f) => sha1(fs.readFileSync(f)));
70
+ const remote = ((await client.listImages(editId, lang, type)).json.images || []).map((i) => i.sha1);
71
+ if (local.length !== remote.length) {
72
+ actionable++;
73
+ console.log(`${label}: local ${local.length} / remote ${remote.length} (@${lang})`);
74
+ } else if (JSON.stringify([...local].sort()) !== JSON.stringify([...remote].sort())) {
75
+ const changed = local.filter((s) => !remote.includes(s)).length;
76
+ actionable++;
77
+ console.log(`${label}: ${changed} of ${local.length} differ in content (@${lang})`);
78
+ }
79
+ }
80
+ }
41
81
  } finally {
42
82
  await client.deleteEdit(editId);
43
83
  }
@@ -2,18 +2,9 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { green, yellow, red } from "../../util.mjs";
4
4
  import { reportCrossStore } from "../../crossStore.mjs";
5
+ import { playImages, imageLocales } from "../images.mjs";
5
6
 
6
7
  const FIELDS = [["title", "title"], ["shortDescription", "short_description"], ["fullDescription", "full_description"]];
7
- // Play image type -> local source (a dir of PNGs = screenshots; a single file = graphic). From zdymak.
8
- // Each type is uploaded only when its local asset EXISTS, so an app that lacks (say) tablet shots or a
9
- // znachok icon simply skips that type — a missing local set never deletes the live one.
10
- const IMAGES = [
11
- ["icon", "brand/icons/play/icon-512.png", "file"],
12
- ["featureGraphic", "marketing/out/play-feature-graphic.png", "file"],
13
- ["phoneScreenshots", "marketing/out/play-phone-plain", "dir"],
14
- ["sevenInchScreenshots", "marketing/out/play-tablet7-plain", "dir"],
15
- ["tenInchScreenshots", "marketing/out/play-tablet-plain", "dir"],
16
- ];
17
8
 
18
9
  // Push the Play listing (text + images) inside one Edit, then validate and commit. iOS/Android are separate
19
10
  // stores — this is the Google half. Images only touch a type whose local asset EXISTS (so a missing local
@@ -23,12 +14,13 @@ export async function run(config, client) {
23
14
  // SAFE BY DEFAULT: validate + discard the edit unless `--apply`. A store-mutating commit must be an
24
15
  // explicit opt-in — never the default (a stale/partial local set could otherwise clobber a live one).
25
16
  const commit = !client.dryRun;
17
+ const IMAGES = playImages(config);
26
18
  const localLangs = fs.existsSync(g.metadataDir)
27
19
  ? fs.readdirSync(g.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
28
20
  : [];
29
21
  const haveImages = IMAGES.some(([, src]) => fs.existsSync(src));
30
22
  if (!localLangs.length && !haveImages) {
31
- console.log(yellow(`fill(play): no local listing folders under ${g.metadataDir} and no zdymak play assets — nothing to upload yet (populate them for Android Phase 2).`));
23
+ console.log(yellow(`fill(play): no local listing folders under ${g.metadataDir} and no local play assets — nothing to upload yet (populate them, or run \`vydanne bridge\`).`));
32
24
  return true;
33
25
  }
34
26
 
@@ -52,14 +44,23 @@ export async function run(config, client) {
52
44
  }
53
45
  }
54
46
  // Images — replace a type only when the local asset exists (delete-all then upload).
55
- const lang = g.defaultLocale;
56
- for (const [type, src, kind] of IMAGES) {
57
- if (!fs.existsSync(src)) continue;
58
- const files = kind === "dir" ? fs.readdirSync(src).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort().map((f) => path.join(src, f)) : [src];
59
- if (!files.length) continue;
60
- await client.deleteAllImages(editId, lang, type);
61
- for (const f of files) await client.uploadImage(editId, lang, type, f);
62
- console.log(green(` ${lang}/${type}: ${files.length} image(s)`));
47
+ for (const lang of imageLocales(g, localLangs)) {
48
+ for (const [type, src, kind] of IMAGES) {
49
+ // A per-locale override directory (`<src>/<lang>`) wins when it exists, so an app can localize
50
+ // some slots and leave the rest shared without listing every combination in the config.
51
+ const localized = path.join(src, lang);
52
+ const from = kind === "dir" && fs.existsSync(localized) ? localized : src;
53
+ if (!fs.existsSync(from)) continue;
54
+ const files = kind === "dir" ? fs.readdirSync(from).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort().map((f) => path.join(from, f)) : [from];
55
+ // A dir that EXISTS but is empty is different from a missing one: someone (the store-assets
56
+ // bridge, when zdymak stops producing a form factor) deliberately emptied it, expecting the live
57
+ // set to follow. It doesn't — never-delete-by-omission holds — but that must be said, because the
58
+ // silent skip is how a listing keeps showing screenshots of a UI the app no longer has.
59
+ if (!files.length) { console.log(yellow(` ${lang}/${type}: local dir is empty — live set left untouched (delete it in Play Console if it is stale)`)); continue; }
60
+ await client.deleteAllImages(editId, lang, type);
61
+ for (const f of files) await client.uploadImage(editId, lang, type, f);
62
+ console.log(green(` ${lang}/${type}: ${files.length} image(s)`));
63
+ }
63
64
  }
64
65
 
65
66
  const v = await client.validate(editId);
@@ -1,9 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { green, yellow, red } from "../../util.mjs";
4
+ import { readAabVersionCode } from "../aab.mjs";
4
5
 
5
- /** Tracks this command will write. `production` is deliberately absent — see below. */
6
- const TESTING_TRACKS = new Set(["internal", "alpha", "beta"]);
6
+ /**
7
+ * The one track this command refuses. Everything else is passed through.
8
+ *
9
+ * `internal`, `alpha` and `beta` are Play's BUILT-IN track names, not the whole vocabulary: Play
10
+ * Console encourages named closed tracks ("qa", "beta-partners"), and its API addresses them by that
11
+ * name. A closed list rejected every one of them with "unknown track" — refusing a release for a
12
+ * reason that was never true. The production refusal below is the one that matters, and it is exact.
13
+ */
14
+ const PRODUCTION = "production";
7
15
 
8
16
  /**
9
17
  * Upload an .aab to a CLOSED TESTING track, with release notes.
@@ -26,13 +34,15 @@ export async function run(config, client) {
26
34
  const g = config.google;
27
35
  const track = process.env.VYDANNE_TRACK || g.track || "internal";
28
36
 
29
- if (track === "production") {
37
+ if (track === PRODUCTION) {
30
38
  console.error(red("prerelease: refusing to write the production track — that release is a human's to make."));
31
39
  console.error(" Promote the tested build in Play Console when you're ready.");
32
40
  return false;
33
41
  }
34
- if (!TESTING_TRACKS.has(track)) {
35
- console.error(red(`prerelease: unknown track "${track}" (expected: ${[...TESTING_TRACKS].join(", ")})`));
42
+ // Any other name is handed to Play, which knows its own tracks: a typo comes back as a 404 naming
43
+ // the track, which is a better error than a list that was never authoritative.
44
+ if (!track) {
45
+ console.error(red("prerelease: no track — set `google.track` or VYDANNE_TRACK."));
36
46
  return false;
37
47
  }
38
48
 
@@ -44,6 +54,16 @@ export async function run(config, client) {
44
54
  console.log(green(`prerelease → track "${track}"`));
45
55
  console.log(` bundle: ${path.relative(process.cwd(), aab) || aab} (${(fs.statSync(aab).size / 1e6).toFixed(1)} MB)`);
46
56
 
57
+ // The bundle declares its own versionCode, so read it HERE — before the multi-megabyte upload — and
58
+ // resolve the changelogs against it while there is still time to fix them. In repos that derive the
59
+ // code from `git rev-list --count HEAD` it is unknowable in advance, so nobody can pre-name
60
+ // `<versionCode>.txt`; the fallback used to win silently and the first place the real number ever
61
+ // appeared was Play's upload response. A null here (unreadable bundle) degrades to exactly that old
62
+ // behaviour: Play's answer after upload stays the authority either way.
63
+ const declared = readAabVersionCode(aab);
64
+ if (declared != null) console.log(` versionCode ${declared} (read from the bundle's manifest)`);
65
+ let notes = declared != null ? readNotes(g.metadataDir, declared, g.defaultLocale) : null;
66
+
47
67
  const editId = await client.newEdit();
48
68
  try {
49
69
  // Upload, or REUSE. Play rejects a versionCode it already holds, which is the right answer for an
@@ -63,16 +83,17 @@ export async function run(config, client) {
63
83
  console.log(yellow(` versionCode ${versionCode} already uploaded — reusing that bundle`));
64
84
  }
65
85
 
66
- const releaseNotes = readNotes(g.metadataDir, versionCode, g.defaultLocale);
67
- if (releaseNotes.length) {
68
- console.log(` release notes: ${releaseNotes.length} locale(s) — ${releaseNotes.map((n) => n.language).join(", ")}`);
69
- } else {
70
- console.log(yellow(` no release notes found under ${g.metadataDir}/<locale>/changelogs/{${versionCode},default}.txt`));
86
+ if (declared != null && declared !== versionCode) {
87
+ // Play's answer is derived from the same manifest, so a disagreement means OUR parser misread the
88
+ // bundle — say so and re-resolve the notes against the truth rather than shipping the wrong file.
89
+ console.log(yellow(` bundle parse said ${declared} but Play says ${versionCode} — trusting Play (please report this)`));
90
+ notes = null;
71
91
  }
92
+ if (!notes) notes = readNotes(g.metadataDir, versionCode, g.defaultLocale);
72
93
 
73
94
  // One complete release object: Play replaces the track's releases wholesale.
74
95
  const release = { status: "completed", versionCodes: [String(versionCode)] };
75
- if (releaseNotes.length) release.releaseNotes = releaseNotes;
96
+ if (notes.entries.length) release.releaseNotes = notes.entries;
76
97
  const name = process.env.VYDANNE_RELEASE_NAME;
77
98
  if (name) release.name = name;
78
99
 
@@ -87,6 +108,7 @@ export async function run(config, client) {
87
108
  const res = await client.commit(editId);
88
109
  if (res.status >= 300) throw new Error(`edits.commit ${res.status}: ${JSON.stringify(res.json).slice(0, 300)}`);
89
110
  console.log(green(`\n committed — versionCode ${versionCode} is live on "${track}".`));
111
+ archiveNextNotes(notes, versionCode);
90
112
  console.log(" Production stays manual: promote it in Play Console when you're ready.");
91
113
  return true;
92
114
  } catch (e) {
@@ -111,27 +133,79 @@ function resolveAab(configured) {
111
133
  }
112
134
 
113
135
  /**
114
- * Release notes per locale, following fastlane supply's layout so an existing repo needs no migration:
115
- * `<metadataDir>/<play-locale>/changelogs/<versionCode>.txt`, falling back to `default.txt`.
136
+ * Release notes per locale — supply's layout, plus a convention that breaks the naming circularity:
137
+ *
138
+ * <metadataDir>/<play-locale>/changelogs/<versionCode>.txt exact — supply's own convention
139
+ * next.txt THIS release, named before its code exists
140
+ * default.txt evergreen fallback ("bug fixes")
141
+ *
142
+ * `next.txt` exists because `<versionCode>.txt` cannot be written in advance when the code is derived
143
+ * from the commit count: every commit moves the number, so the only file you could name ahead of time
144
+ * was `default.txt` — which then also serves every FUTURE release, silently. Write this release's
145
+ * notes as `next.txt`; after a real commit they are archived as `<versionCode>.txt` (the code is known
146
+ * by then), so the next release cannot inherit them by accident.
147
+ *
148
+ * Which file won is reported per source, and the default.txt fallback is a WARNING — it used to be
149
+ * indistinguishable from an exact match, which is how a release ships with last release's notes.
116
150
  */
117
151
  function readNotes(metadataDir, versionCode, defaultLocale) {
118
- const out = [];
119
- if (!metadataDir || !fs.existsSync(metadataDir)) return out;
152
+ const entries = [];
153
+ const bySource = { [`${versionCode}.txt`]: 0, "next.txt": 0, "default.txt": 0 };
154
+ const nextFiles = [];
155
+ const empty = [];
156
+ if (!metadataDir || !fs.existsSync(metadataDir)) return { entries, nextFiles };
120
157
  for (const language of fs.readdirSync(metadataDir)) {
121
158
  const dir = path.join(metadataDir, language, "changelogs");
122
159
  if (!fs.existsSync(dir)) continue;
123
- const file = [path.join(dir, `${versionCode}.txt`), path.join(dir, "default.txt")].find((f) => fs.existsSync(f));
160
+ const file = [`${versionCode}.txt`, "next.txt", "default.txt"].map((f) => path.join(dir, f)).find((f) => fs.existsSync(f));
124
161
  if (!file) continue;
125
162
  const text = fs.readFileSync(file, "utf8").trim();
126
- if (!text) continue;
163
+ // An empty file would otherwise drop the locale without a word — name it below instead.
164
+ if (!text) { empty.push(`${language}/${path.basename(file)}`); continue; }
165
+ bySource[path.basename(file)]++;
166
+ if (path.basename(file) === "next.txt") nextFiles.push(file);
127
167
  // Play caps release notes at 500 chars and rejects the whole edit if any locale is over.
128
168
  if (text.length > 500) {
129
169
  console.log(yellow(` ${language}: release notes ${text.length}/500 chars — truncated`));
130
- out.push({ language, text: text.slice(0, 500) });
170
+ entries.push({ language, text: text.slice(0, 500) });
131
171
  } else {
132
- out.push({ language, text });
172
+ entries.push({ language, text });
133
173
  }
134
174
  }
135
175
  // Keep the default locale first purely so the log reads sensibly.
136
- return out.sort((a, b) => (a.language === defaultLocale ? -1 : b.language === defaultLocale ? 1 : 0));
176
+ entries.sort((a, b) => (a.language === defaultLocale ? -1 : b.language === defaultLocale ? 1 : 0));
177
+
178
+ if (!entries.length) {
179
+ console.log(yellow(` no release notes found under ${notesPattern(metadataDir, versionCode)}`));
180
+ } else {
181
+ const parts = Object.entries(bySource).filter(([, n]) => n).map(([f, n]) => `${n} from ${f}`);
182
+ console.log(` release notes for versionCode ${versionCode}: ${entries.length} locale(s) — ${parts.join(" · ")}`);
183
+ if (bySource["default.txt"]) {
184
+ console.log(yellow(` ${bySource["default.txt"]} locale(s) fell back to default.txt — no ${versionCode}.txt or next.txt.`));
185
+ console.log(yellow(" If those notes describe an older release, write this one's as changelogs/next.txt."));
186
+ }
187
+ }
188
+ if (empty.length) console.log(yellow(` empty changelog file(s), locale dropped: ${empty.join(", ")}`));
189
+ return { entries, nextFiles };
190
+ }
191
+
192
+ const notesPattern = (dir, code) => `${dir}/<locale>/changelogs/{${code},next,default}.txt`;
193
+
194
+ /**
195
+ * After a REAL commit, park each next.txt under the versionCode it just shipped as. Renaming (not
196
+ * copying) is the point: a `next.txt` that lingered would be picked up by the NEXT release too, and
197
+ * "this release's notes" quietly becoming "every release's notes" is the exact failure default.txt
198
+ * already has. The rename also lands on supply's own `<versionCode>.txt` convention, so the history
199
+ * of what shipped with what stays greppable.
200
+ */
201
+ function archiveNextNotes(notes, versionCode) {
202
+ for (const file of notes.nextFiles) {
203
+ const to = path.join(path.dirname(file), `${versionCode}.txt`);
204
+ try {
205
+ fs.renameSync(file, to);
206
+ console.log(` archived ${path.relative(process.cwd(), file)} -> ${versionCode}.txt`);
207
+ } catch (e) {
208
+ console.log(yellow(` could not archive ${file}: ${e.message} — rename it to ${versionCode}.txt yourself, or the next release reuses it`));
209
+ }
210
+ }
137
211
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Play listing images: the type vocabulary, and where each one is read from by default.
3
+ *
4
+ * ONE table, imported by `fill --store google` (which uploads them), `diff --store google` (which
5
+ * compares them) and `bridge` (which fills the folders they read). A second copy would let the three
6
+ * disagree about which files are even part of the listing — the same drift src/screenshots.mjs exists
7
+ * to prevent on the Apple side.
8
+ *
9
+ * The DEFAULTS are this portfolio's layout and are meant to be overridden: `google.images` in
10
+ * vydanne.config.mjs is merged over them, slot by slot. The icon default in particular points at
11
+ * znachok's output directory, which is an obvious path if you use znachok and a mystery otherwise.
12
+ *
13
+ * `wearScreenshots` and the TV slots have no historical default because nothing here shipped them yet;
14
+ * they are listed so a Wear OS or Android TV release is a config line rather than a code change. A type
15
+ * whose local source does not exist is skipped, so listing them costs nothing.
16
+ */
17
+
18
+ /** Play image type -> whether its local source is a single file or a directory of images. */
19
+ export const PLAY_IMAGE_KIND = {
20
+ icon: "file",
21
+ featureGraphic: "file",
22
+ tvBanner: "file",
23
+ phoneScreenshots: "dir",
24
+ sevenInchScreenshots: "dir",
25
+ tenInchScreenshots: "dir",
26
+ wearScreenshots: "dir",
27
+ tvScreenshots: "dir",
28
+ };
29
+
30
+ /** Play image type -> default local source path. */
31
+ export const DEFAULT_PLAY_IMAGES = {
32
+ icon: "brand/icons/play/icon-512.png",
33
+ featureGraphic: "marketing/out/play-feature-graphic.png",
34
+ phoneScreenshots: "marketing/out/play-phone-plain",
35
+ sevenInchScreenshots: "marketing/out/play-tablet7-plain",
36
+ tenInchScreenshots: "marketing/out/play-tablet-plain",
37
+ wearScreenshots: "marketing/out/play-wear",
38
+ };
39
+
40
+ /**
41
+ * The resolved table for one app: [type, localSource, kind][].
42
+ *
43
+ * Shaped as tuples because that is what the callers iterate; an unknown type defaults to "dir", which
44
+ * is the only guess that cannot lose data — a directory source that is really a file simply won't exist.
45
+ */
46
+ export function playImages(config) {
47
+ const table = config?.google?.images ?? DEFAULT_PLAY_IMAGES;
48
+ return Object.entries(table).map(([type, src]) => [type, src, PLAY_IMAGE_KIND[type] ?? "dir"]);
49
+ }
50
+
51
+ /**
52
+ * Which locales get graphics.
53
+ *
54
+ * Play holds images PER LANGUAGE; uploading only to `defaultLocale` was this portfolio's choice (one
55
+ * untranslated set for every market), not a platform limit, and the code asserted it as though it were
56
+ * one. `google.imageLocales` opts into localized art: a list of language codes, or "*" for every local
57
+ * listing folder. The default stays one set at `defaultLocale`, so nothing changes for an app that never
58
+ * asks. Shared by `fill` and `diff` so they cannot disagree about which locales are even being compared.
59
+ */
60
+ export function imageLocales(g, localLangs = []) {
61
+ const want = g?.imageLocales;
62
+ if (!want) return [g.defaultLocale];
63
+ if (want === "*" || (Array.isArray(want) && want.includes("*"))) {
64
+ return localLangs.length ? localLangs : [g.defaultLocale];
65
+ }
66
+ return Array.isArray(want) ? want : [want];
67
+ }
package/src/registry.mjs CHANGED
@@ -10,6 +10,14 @@
10
10
  // the STORE specifically — `privacy` and `compliance` write local files (a record, a PDF) and are not
11
11
  // marked, because a dry run that refused to produce a local artefact would just be broken.
12
12
  export const COMMANDS = {
13
+ // Creates the editable version everything below writes INTO, so it comes first in more than
14
+ // listing order: on an app with a version already on sale, `fill` has nothing valid to target
15
+ // until this has run once.
16
+ prepare: { mod: "prepare", client: true, writes: true },
17
+ // The pipeline in its one working order — prepare → fill → previews → age-rating → review-contact →
18
+ // accessibility → preflight — because that order lived in nobody's head, and a release nearly got
19
+ // written to a live listing while everyone re-derived it. Stops at the first failure; never submits.
20
+ push: { mod: "push", client: true, writes: true },
13
21
  fill: { mod: "fill", client: true, writes: true },
14
22
  "age-rating": { mod: "ageRating", client: true, writes: true },
15
23
  "review-contact": { mod: "reviewContact", client: true, writes: true },
@@ -18,6 +26,9 @@ export const COMMANDS = {
18
26
  previews: { mod: "previews", client: true, writes: true },
19
27
  iap: { mod: "iap", client: false },
20
28
  compliance: { mod: "compliance", client: false },
29
+ // Maps zdymak's output layout onto the folders `fill` reads. Local files only (like privacy and
30
+ // compliance above), so it is not marked `writes` — it has its own `--dry-run` instead.
31
+ bridge: { mod: "bridge", client: false },
21
32
  inspect: { mod: "inspect", client: true },
22
33
  diff: { mod: "diff", client: true },
23
34
  preflight: { mod: "preflight", client: true },
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { md5 } from "./upload.mjs";
4
+ import { VALID } from "./locales.mjs";
5
+
6
+ // Screenshot filename prefix -> ASC display type, plus the base paths. ONE copy, used by three commands:
7
+ // `fill` uploads by these, `diff` and `preflight` judge freshness by them. They used to be pasted into
8
+ // fill.mjs and diff.mjs separately, which is one edit away from `fill` uploading a set that `diff` then
9
+ // can't see — the same drift this module's callers exist to catch in other people.
10
+ // Apple accepts JPEG as well as PNG for screenshots. The `.png`-only filter this replaces made a `.jpg`
11
+ // set invisible to fill, diff AND preflight at once: nothing uploaded, nothing compared, preflight green.
12
+ export const IMAGE_FILE = /\.(png|jpe?g)$/i;
13
+
14
+ export const IOS_DEVICE = { iphone69: "APP_IPHONE_67", iphone65: "APP_IPHONE_65", ipad13: "APP_IPAD_PRO_3GEN_129", watch: "APP_WATCH_ULTRA" };
15
+ export const MAC_DEVICE = { macos: "APP_DESKTOP" };
16
+ export const deviceMap = (platform) => (platform === "MAC_OS" ? MAC_DEVICE : IOS_DEVICE);
17
+
18
+ // fastlane's supply convention, which is what most repos already have — but a DEFAULT now, not a law.
19
+ // These two paths were hardcoded, and the docs said so out loud ("symlink them if your layout differs"),
20
+ // which is an honest way to describe a tool that cannot be pointed at your repo. `metadataDir` was always
21
+ // configurable; there was never a reason for its sibling not to be.
22
+ export const DEFAULT_SCREENSHOT_BASE = { IOS: "fastlane/screenshots", MAC_OS: "fastlane/screenshots-macos" };
23
+
24
+ /** Where this platform's screenshots live: `screenshots` in the config, else the supply convention. */
25
+ export const screenshotBase = (platform, config) =>
26
+ config?.screenshots?.[platform] ?? DEFAULT_SCREENSHOT_BASE[platform] ?? DEFAULT_SCREENSHOT_BASE.IOS;
27
+
28
+ /**
29
+ * Locales with a local screenshot folder for this platform.
30
+ *
31
+ * Filtered through `VALID` for the same reason `fill` filters its upload loop through it: a folder named
32
+ * `de` instead of `de-DE` is not a locale Apple knows, and treating it as one would compare a real store
33
+ * localization against nothing. The caller reports what was dropped — see `unknownScreenshotDirs`.
34
+ */
35
+ export function localScreenshotLocales(platform, config) {
36
+ const base = screenshotBase(platform, config);
37
+ if (!fs.existsSync(base)) return [];
38
+ return fs.readdirSync(base, { withFileTypes: true })
39
+ .filter((d) => d.isDirectory() && VALID.has(d.name))
40
+ .map((d) => d.name)
41
+ .sort();
42
+ }
43
+
44
+ /** Screenshot folders that are NOT App Store locale codes — reported, never silently ignored. */
45
+ export function unknownScreenshotDirs(platform, config) {
46
+ const base = screenshotBase(platform, config);
47
+ if (!fs.existsSync(base)) return [];
48
+ return fs.readdirSync(base, { withFileTypes: true })
49
+ .filter((d) => d.isDirectory() && !VALID.has(d.name))
50
+ .map((d) => d.name)
51
+ .sort();
52
+ }
53
+
54
+ /** The local set for one locale: displayType -> Map(fileName -> md5 of the bytes). */
55
+ export function localScreenshots(platform, locale, config) {
56
+ const dir = path.join(screenshotBase(platform, config), locale);
57
+ const dev = deviceMap(platform);
58
+ const local = {};
59
+ if (!fs.existsSync(dir)) return local;
60
+ for (const f of fs.readdirSync(dir).filter((f) => IMAGE_FILE.test(f)).sort()) {
61
+ const dt = dev[f.split("_")[0]];
62
+ if (dt) (local[dt] ||= new Map()).set(f, md5(fs.readFileSync(path.join(dir, f))));
63
+ }
64
+ return local;
65
+ }
66
+
67
+ /**
68
+ * What the store holds for one localization: displayType -> Map(fileName -> sourceFileChecksum|null).
69
+ *
70
+ * `include=appScreenshots` returns the shots as full resources in `included`; the set's relationships
71
+ * carry ids only, so the attributes (fileName, sourceFileChecksum) have to be picked up from there. A
72
+ * null checksum means Apple reported the file but not its content — present, and unverifiable.
73
+ */
74
+ export async function remoteScreenshots(client, locId) {
75
+ const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${locId}/appScreenshotSets?include=appScreenshots&limit=50`);
76
+ const shotsById = new Map((sets.included || []).filter((r) => r.type === "appScreenshots").map((r) => [r.id, r.attributes || {}]));
77
+ const remote = {};
78
+ for (const s of sets.data || []) {
79
+ const m = new Map();
80
+ for (const ref of s.relationships?.appScreenshots?.data || []) {
81
+ const a = shotsById.get(ref.id);
82
+ if (a) m.set(a.fileName, a.sourceFileChecksum ?? null);
83
+ else m.set(ref.id, null); // not included — treat as present but unverifiable
84
+ }
85
+ remote[s.attributes.screenshotDisplayType] = m;
86
+ }
87
+ return remote;
88
+ }
89
+
90
+ /**
91
+ * One display type, local vs store — by CONTENT, not by count. Counting was actively misleading:
92
+ * re-rendering every screenshot leaves three-local-vs-three-remote, so a count check says "in sync"
93
+ * about a listing showing the old images. The checksum is there to compare against: `upload.mjs`
94
+ * commits md5(bytes) as `sourceFileChecksum`.
95
+ *
96
+ * Returns null on a verified match, else one finding — the kinds need different actions, so they are
97
+ * kept apart rather than collapsed into a boolean:
98
+ * count the sets aren't even the same size
99
+ * renamed same size, but local names the store doesn't have
100
+ * stale same names, different bytes — the store is showing old art
101
+ * unverified Apple returned no checksum, so a match was never established (say so, don't claim it)
102
+ */
103
+ export function compareShots(L, R) {
104
+ if (L.size !== R.size) return { kind: "count", local: L.size, remote: R.size };
105
+ const renamed = [...L.keys()].filter((name) => !R.has(name));
106
+ if (renamed.length) return { kind: "renamed", names: renamed };
107
+ const stale = [...L].filter(([name, sum]) => R.get(name) !== null && R.get(name) !== sum).map(([name]) => name);
108
+ if (stale.length) return { kind: "stale", names: stale, of: L.size };
109
+ const unverified = [...L.keys()].filter((name) => R.get(name) === null);
110
+ if (unverified.length) return { kind: "unverified", names: unverified };
111
+ return null;
112
+ }