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.
- package/GETTING_STARTED.md +69 -40
- package/README.md +91 -16
- package/SKILL.md +129 -24
- package/bin/vydanne.mjs +24 -1
- package/package.json +3 -2
- package/src/client.mjs +42 -6
- package/src/commands/accessibility.mjs +13 -0
- package/src/commands/ageRating.mjs +94 -21
- package/src/commands/bridge.mjs +328 -0
- package/src/commands/compliance.mjs +78 -9
- package/src/commands/diff.mjs +63 -19
- package/src/commands/fill.mjs +74 -13
- package/src/commands/inspect.mjs +4 -2
- package/src/commands/preflight.mjs +59 -6
- package/src/commands/prepare.mjs +220 -0
- package/src/commands/prerelease.mjs +17 -5
- package/src/commands/previews.mjs +33 -2
- package/src/commands/privacy.mjs +75 -5
- package/src/commands/push.mjs +118 -0
- package/src/commands/reviewContact.mjs +63 -13
- package/src/config.mjs +53 -4
- package/src/index.mjs +60 -3
- package/src/locales.mjs +27 -5
- package/src/play/aab.mjs +133 -0
- package/src/play/commands/diff.mjs +40 -0
- package/src/play/commands/fill.mjs +20 -19
- package/src/play/commands/prerelease.mjs +94 -20
- package/src/play/images.mjs +67 -0
- package/src/registry.mjs +11 -0
- package/src/screenshots.mjs +112 -0
- package/src/upload.mjs +3 -1
- package/types/index.d.ts +225 -10
- package/vydanne.config.example.mjs +54 -1
package/src/play/aab.mjs
ADDED
|
@@ -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
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
6
|
-
|
|
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 ===
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
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
|
|
115
|
-
*
|
|
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
|
|
119
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
170
|
+
entries.push({ language, text: text.slice(0, 500) });
|
|
131
171
|
} else {
|
|
132
|
-
|
|
172
|
+
entries.push({ language, text });
|
|
133
173
|
}
|
|
134
174
|
}
|
|
135
175
|
// Keep the default locale first purely so the log reads sensibly.
|
|
136
|
-
|
|
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
|
+
}
|