vydanne 0.5.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 +161 -23
- package/SKILL.md +158 -26
- package/bin/vydanne.mjs +67 -13
- package/package.json +4 -3
- package/src/client.mjs +78 -7
- 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 +85 -13
- package/src/commands/inspect.mjs +4 -2
- package/src/commands/preflight.mjs +65 -6
- package/src/commands/prepare.mjs +220 -0
- package/src/commands/prerelease.mjs +25 -5
- package/src/commands/previews.mjs +43 -5
- package/src/commands/privacy.mjs +75 -5
- package/src/commands/push.mjs +118 -0
- package/src/commands/reviewContact.mjs +65 -14
- package/src/config.mjs +55 -4
- package/src/crossStore.mjs +151 -0
- package/src/index.mjs +60 -3
- package/src/locales.mjs +27 -5
- package/src/play/aab.mjs +133 -0
- package/src/play/client.mjs +6 -3
- package/src/play/commands/diff.mjs +40 -0
- package/src/play/commands/fill.mjs +32 -24
- package/src/play/commands/preflight.mjs +5 -0
- package/src/play/commands/prerelease.mjs +96 -22
- package/src/play/images.mjs +67 -0
- package/src/registry.mjs +27 -9
- package/src/screenshots.mjs +112 -0
- package/src/upload.mjs +6 -1
- package/types/index.d.ts +240 -10
- package/vydanne.config.example.mjs +54 -1
|
@@ -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,30 +83,32 @@ 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
|
|
|
79
100
|
const put = await client.putTrack(editId, track, [release]);
|
|
80
101
|
if (put.status >= 300) throw new Error(`tracks.update ${put.status}: ${JSON.stringify(put.json).slice(0, 300)}`);
|
|
81
102
|
|
|
82
|
-
if (
|
|
103
|
+
if (client.dryRun) {
|
|
83
104
|
await client.deleteEdit(editId);
|
|
84
|
-
console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with
|
|
105
|
+
console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with --apply to publish to "${track}".`));
|
|
85
106
|
return true;
|
|
86
107
|
}
|
|
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
|
@@ -1,22 +1,40 @@
|
|
|
1
1
|
// The canonical command registry — the single source of vydanne's public commands. bin/ dispatches from
|
|
2
2
|
// this, and the drift guards (scripts/check-docs.mjs, scripts/check-types.mjs) assert every command is
|
|
3
3
|
// documented in README/SKILL and typed in types/index.d.ts. Add a command here → the guards force it into
|
|
4
|
-
// the docs + types before publish.
|
|
4
|
+
// the docs + types before publish.
|
|
5
|
+
//
|
|
6
|
+
// name -> { mod: <file in src/commands>, client: needs an ASC client, writes: mutates the STORE }
|
|
7
|
+
//
|
|
8
|
+
// `writes` is what makes a command dry-run unless `--apply` is passed, so it is a safety declaration, not
|
|
9
|
+
// a label: mark a new command `writes: true` the moment it can change anything on the store side. It means
|
|
10
|
+
// the STORE specifically — `privacy` and `compliance` write local files (a record, a PDF) and are not
|
|
11
|
+
// marked, because a dry run that refused to produce a local artefact would just be broken.
|
|
5
12
|
export const COMMANDS = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 },
|
|
21
|
+
fill: { mod: "fill", client: true, writes: true },
|
|
22
|
+
"age-rating": { mod: "ageRating", client: true, writes: true },
|
|
23
|
+
"review-contact": { mod: "reviewContact", client: true, writes: true },
|
|
24
|
+
accessibility: { mod: "accessibility", client: true, writes: true },
|
|
10
25
|
privacy: { mod: "privacy", client: false },
|
|
11
|
-
previews: { mod: "previews", client: true },
|
|
26
|
+
previews: { mod: "previews", client: true, writes: true },
|
|
12
27
|
iap: { mod: "iap", client: false },
|
|
13
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 },
|
|
14
32
|
inspect: { mod: "inspect", client: true },
|
|
15
33
|
diff: { mod: "diff", client: true },
|
|
16
34
|
preflight: { mod: "preflight", client: true },
|
|
17
35
|
// Uploads the .ipa to TestFlight. Needs the credentials as well as the client: the REST API
|
|
18
36
|
// cannot carry a binary, so this one shells out to `xcrun altool`, which authenticates itself.
|
|
19
|
-
prerelease: { mod: "prerelease", client: true, credentials: true },
|
|
37
|
+
prerelease: { mod: "prerelease", client: true, credentials: true, writes: true },
|
|
20
38
|
};
|
|
21
39
|
|
|
22
40
|
// Commands available for `--store google` (Google Play). Same names as the Apple ones, different backend
|
|
@@ -25,8 +43,8 @@ export const PLAY_COMMANDS = {
|
|
|
25
43
|
inspect: { mod: "inspect" },
|
|
26
44
|
preflight: { mod: "preflight" },
|
|
27
45
|
diff: { mod: "diff" },
|
|
28
|
-
fill: { mod: "fill" },
|
|
29
|
-
prerelease: { mod: "prerelease" },
|
|
46
|
+
fill: { mod: "fill", writes: true },
|
|
47
|
+
prerelease: { mod: "prerelease", writes: true },
|
|
30
48
|
};
|
|
31
49
|
|
|
32
50
|
// Full public command surface (the module-dispatched ones above + the three handled inline in bin/).
|
|
@@ -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
|
+
}
|
package/src/upload.mjs
CHANGED
|
@@ -2,7 +2,9 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Exported because `diff` compares against it: the checksum committed here is the only thing that lets
|
|
6
|
+
// a local file be compared to what Apple actually holds, rather than to how MANY things it holds.
|
|
7
|
+
export const md5 = (buf) => crypto.createHash("md5").update(buf).digest("hex");
|
|
6
8
|
|
|
7
9
|
// Native ASC asset upload (what spaceship does internally): reserve the asset (returns pre-signed
|
|
8
10
|
// uploadOperations) -> PUT each chunk -> commit with uploaded:true + the MD5 checksum.
|
|
@@ -33,6 +35,9 @@ export async function uploadAsset(client, { type, setType, setId, filePath }) {
|
|
|
33
35
|
|
|
34
36
|
// Previews process asynchronously — poll until Apple exposes videoUrl, then set the poster frame.
|
|
35
37
|
export async function setPreviewPoster(client, previewId, frameTimeCode, { tries = 30, delayMs = 15000 } = {}) {
|
|
38
|
+
// In a dry run the preview was never created, so `previewId` is a synthetic `dry-run-<n>` and this would
|
|
39
|
+
// poll a 404 for seven and a half minutes before giving up.
|
|
40
|
+
if (client.dryRun) return true;
|
|
36
41
|
for (let i = 0; i < tries; i++) {
|
|
37
42
|
const { json } = await client.get(`/v1/appPreviews/${previewId}`);
|
|
38
43
|
if (json.data?.attributes?.videoUrl) {
|