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.
@@ -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,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
- 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
 
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 (process.env.VYDANNE_COMMIT !== "1") {
103
+ if (client.dryRun) {
83
104
  await client.deleteEdit(editId);
84
- console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with VYDANNE_COMMIT=1 to publish to "${track}".`));
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, 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
@@ -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. name -> { mod: <file in src/commands>, client: needs an ASC client }
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
- fill: { mod: "fill", client: true },
7
- "age-rating": { mod: "ageRating", client: true },
8
- "review-contact": { mod: "reviewContact", client: true },
9
- accessibility: { mod: "accessibility", client: true },
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
- const md5 = (buf) => crypto.createHash("md5").update(buf).digest("hex");
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) {