vydanne 0.6.0 → 0.8.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.
@@ -2,15 +2,20 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { green, yellow, red } from "../util.mjs";
4
4
  import { VALID } from "../locales.mjs";
5
+ import { md5 } from "../upload.mjs";
6
+ import { localScreenshots, remoteScreenshots, compareShots, localScreenshotLocales } from "../screenshots.mjs";
5
7
 
6
8
  // [asc attribute, local metadata filename, isLongText]
7
9
  const VERSION_FIELDS = [
8
10
  ["description", "description", true], ["keywords", "keywords", false], ["promotionalText", "promotional_text", false],
9
11
  ["whatsNew", "release_notes", true], ["marketingUrl", "marketing_url", false], ["supportUrl", "support_url", false],
10
12
  ];
11
- const INFO_FIELDS = [["name", "name", false], ["subtitle", "subtitle", false]];
12
- const IOS_DEVICE = { iphone69: "APP_IPHONE_67", iphone65: "APP_IPHONE_65", ipad13: "APP_IPAD_PRO_3GEN_129", watch: "APP_WATCH_ULTRA" };
13
- const MAC_DEVICE = { macos: "APP_DESKTOP" };
13
+ // Must list everything `fill`'s INFO_TXT writes, or `diff` reports "in sync" over a field it never
14
+ // looked at — which is not a harmless gap: `fill` PATCHes unconditionally (patchAttrs never compares),
15
+ // so `diff` is the ONLY thing that can say whether a value actually reached Apple. When
16
+ // privacyPolicyUrl was added to fill and not here, one command claimed 19 pending writes while the
17
+ // other claimed sync, and neither was evidence of anything.
18
+ const INFO_FIELDS = [["name", "name", false], ["subtitle", "subtitle", false], ["privacyPolicyUrl", "privacy_url", false]];
14
19
 
15
20
  const norm = (s) => (s == null ? null : String(s).replace(/\r/g, "").replace(/\n+$/, "").trim());
16
21
  const short = (s, n = 24) => { s = String(s).replace(/\n/g, " "); return s.length > n ? s.slice(0, n) + "…" : s; };
@@ -19,13 +24,19 @@ const short = (s, n = 24) => { s = String(s).replace(/\n/g, " "); return s.lengt
19
24
  // Connect — i.e. what `fill` / `previews` would change. Reads each localization by id (list is sparse).
20
25
  export async function run(config, client) {
21
26
  await client.findApp(config.bundleId);
22
- const info = await client.appInfo();
27
+ // allowLive for the same reason as editVersion below: comparing local name/subtitle against what is
28
+ // on sale is a legitimate read, and on an app with nothing in preparation the live record is the
29
+ // only one to compare against.
30
+ const info = await client.appInfo({ allowLive: true });
23
31
  const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
24
32
  let actionable = 0; // differences `fill`/`previews` would actually change (release_notes on a 1.0 is benign)
25
33
 
26
34
  for (const platform of config.platforms) {
27
- const v = await client.editVersion(platform);
28
- if (!v) { console.log(red(`${platform}: no editable version`)); continue; }
35
+ // allowLive: comparing local against the version ON SALE is a legitimate question and often the
36
+ // reason to run this before preparing a release. The state is printed below, so it is never unclear
37
+ // WHICH version the comparison is against — and `fill` no longer targets a live one either way.
38
+ const v = await client.editVersion(platform, { allowLive: true });
39
+ if (!v) { console.log(red(`${platform}: no version at all`)); continue; }
29
40
  console.log(`${platform} v${v.attributes.versionString} ${v.attributes.appStoreState}`);
30
41
  const verLocs = await client.versionLocalizations(v.id);
31
42
  const localDirs = fs.existsSync(config.metadataDir)
@@ -84,24 +95,62 @@ async function mediaDiff(config, client, platform, verLocs) {
84
95
  const primary = verLocs.find((l) => l.attributes.locale === config.primaryLocale);
85
96
  if (!primary) return 0;
86
97
  let diffs = 0;
87
- const dev = platform === "MAC_OS" ? MAC_DEVICE : IOS_DEVICE;
88
- const base = platform === "MAC_OS" ? "fastlane/screenshots-macos" : "fastlane/screenshots";
89
- const localDir = path.join(base, config.primaryLocale);
90
- const local = {};
91
- if (fs.existsSync(localDir)) for (const f of fs.readdirSync(localDir).filter((f) => f.endsWith(".png"))) { const dt = dev[f.split("_")[0]]; if (dt) local[dt] = (local[dt] || 0) + 1; }
92
- const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appScreenshotSets?include=appScreenshots&limit=50`);
93
- const remote = {};
94
- for (const s of sets.data || []) remote[s.attributes.screenshotDisplayType] = (s.relationships?.appScreenshots?.data || []).length;
95
- for (const dt of new Set([...Object.keys(local), ...Object.keys(remote)])) {
96
- const L = local[dt] || 0, R = remote[dt] || 0;
97
- if (L !== R) { diffs++; console.log(` ${yellow("screenshots")} ${dt.replace("APP_", "")}: local ${L} / remote ${R} (@${config.primaryLocale})`); }
98
+ // Compared by CONTENT, not by count — the machinery lives in src/screenshots.mjs and is shared with
99
+ // `preflight`, so the two commands cannot drift apart in what "in sync" means. The history is there.
100
+ // Every locale with a local set, for the reason preflight now does the same: `bridge` writes one
101
+ // folder per translated locale, and checking only the primary reported "in sync" about a listing
102
+ // whose other nineteen locales were showing last month's art.
103
+ for (const code of [...new Set([config.primaryLocale, ...localScreenshotLocales(platform, config)])]) {
104
+ const loc = verLocs.find((l) => l.attributes.locale === code);
105
+ if (!loc) continue;
106
+ const local = localScreenshots(platform, code, config);
107
+ const remote = await remoteScreenshots(client, loc.id);
108
+ for (const dt of new Set([...Object.keys(local), ...Object.keys(remote)])) {
109
+ const c = compareShots(local[dt] || new Map(), remote[dt] || new Map());
110
+ if (!c) continue;
111
+ const label = ` ${yellow("screenshots")} ${dt.replace("APP_", "")}`;
112
+ if (c.kind === "count") {
113
+ diffs++;
114
+ console.log(`${label}: local ${c.local} / remote ${c.remote} (@${code})`);
115
+ } else if (c.kind === "renamed") {
116
+ diffs++;
117
+ console.log(`${label}: ${c.names.length} not on the store by name (${c.names.slice(0, 3).join(", ")}) (@${code})`);
118
+ } else if (c.kind === "stale") {
119
+ diffs++;
120
+ console.log(`${label}: ${c.names.length} of ${c.of} differ in content (${c.names.slice(0, 3).join(", ")}) (@${code})`);
121
+ } else {
122
+ // Apple did not give a checksum back. Say so rather than reporting a match we did not establish.
123
+ console.log(`${label}: ${c.names.length} present, checksum not reported by Apple — content unverified (@${code})`);
124
+ }
125
+ }
98
126
  }
127
+ // Previews by content too — the count comparison this replaces had the screenshot bug in miniature:
128
+ // one-local-vs-one-remote reads "in sync" however different the videos are, so a re-rendered App
129
+ // Preview reported nothing to do. `uploadAsset` commits md5(bytes) as sourceFileChecksum for
130
+ // previews exactly as it does for screenshots, so the same comparison is available for free.
99
131
  const { json: psets } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appPreviewSets?include=appPreviews&limit=50`);
132
+ const prevById = new Map((psets.included || []).filter((r) => r.type === "appPreviews").map((r) => [r.id, r.attributes || {}]));
100
133
  const remotePrev = {};
101
- for (const s of psets.data || []) remotePrev[s.attributes.previewType] = (s.relationships?.appPreviews?.data || []).length;
134
+ for (const s of psets.data || []) {
135
+ remotePrev[s.attributes.previewType] = (s.relationships?.appPreviews?.data || [])
136
+ .map((ref) => prevById.get(ref.id)?.sourceFileChecksum ?? null);
137
+ }
102
138
  for (const spec of (config.previews || []).filter((s) => s.platform === platform && (s.locales || []).includes(config.primaryLocale))) {
103
- const L = fs.existsSync(path.resolve(spec.file)) ? 1 : 0, R = remotePrev[spec.type] || 0;
104
- if (L !== R) { diffs++; console.log(` ${yellow("preview")} ${spec.type}: local ${L} / remote ${R} (@${config.primaryLocale})`); }
139
+ const file = path.resolve(spec.file);
140
+ const L = fs.existsSync(file) ? 1 : 0;
141
+ const R = remotePrev[spec.type] || [];
142
+ const label = ` ${yellow("preview")} ${spec.type}`;
143
+ if (L !== R.length) {
144
+ diffs++;
145
+ console.log(`${label}: local ${L} / remote ${R.length} (@${config.primaryLocale})`);
146
+ } else if (L === 1) {
147
+ if (R.every((sum) => sum === null)) {
148
+ console.log(`${label}: present, checksum not reported by Apple — content unverified`);
149
+ } else if (!R.includes(md5(fs.readFileSync(file)))) {
150
+ diffs++;
151
+ console.log(`${label}: differs in content (${path.basename(spec.file)}) (@${config.primaryLocale})`);
152
+ }
153
+ }
105
154
  }
106
155
  return diffs;
107
156
  }
@@ -4,12 +4,17 @@ import { green, yellow, red } from "../util.mjs";
4
4
  import { VALID } from "../locales.mjs";
5
5
  import { uploadAsset } from "../upload.mjs";
6
6
  import { reportCrossStore } from "../crossStore.mjs";
7
+ import { deviceMap, screenshotBase, IMAGE_FILE } from "../screenshots.mjs";
7
8
 
8
- // Version-localization fields (attr -> metadata filename) and AppInfo fields (name/subtitle, shared).
9
+ // Version-localization fields (attr -> metadata filename) and AppInfo fields (shared across releases).
9
10
  const VERSION_TXT = { description: "description", keywords: "keywords", promotionalText: "promotional_text", whatsNew: "release_notes", marketingUrl: "marketing_url", supportUrl: "support_url" };
10
- const INFO_TXT = { name: "name", subtitle: "subtitle" };
11
- const IOS_DEVICE = { iphone69: "APP_IPHONE_67", iphone65: "APP_IPHONE_65", ipad13: "APP_IPAD_PRO_3GEN_129", watch: "APP_WATCH_ULTRA" };
12
- const MAC_DEVICE = { macos: "APP_DESKTOP" };
11
+ // `privacyPolicyUrl` belongs here rather than on the version because it is an APP-level fact, not a
12
+ // per-release one. Apple blocks Add for Review without it ("You must enter a Privacy Policy URL in
13
+ // App Privacy") — a wall you hit at the very end, once everything else is green, and one nothing in
14
+ // vydanne could clear because this map stopped at name/subtitle. It is per-LOCALE all the same, so a
15
+ // game with a single policy page writes the same line into each locale's `privacy_url.txt`; the
16
+ // filename follows fastlane's own convention, so an existing metadata tree needs no rearranging.
17
+ const INFO_TXT = { name: "name", subtitle: "subtitle", privacyPolicyUrl: "privacy_url" };
13
18
 
14
19
  /**
15
20
  * PATCH a localization, surviving attributes Apple refuses to edit RIGHT NOW.
@@ -61,25 +66,64 @@ export async function run(config, client) {
61
66
  console.error(red("fill: refusing to upload — fix the listing text, or set VYDANNE_ALLOW_CROSS_STORE=1."));
62
67
  return false;
63
68
  }
69
+ // No allowLive: name/subtitle live on the app-info record, and the live one must never take a PATCH
70
+ // (Apple would refuse each with INVALID_STATE, failing the whole locale — release notes included).
64
71
  const info = await client.appInfo();
72
+ // Said out loud, because the `if (info)` guard below otherwise skips name/subtitle for every locale
73
+ // in perfect silence — twenty locales of "written" with two fields quietly missing from each.
74
+ if (!info) console.log(yellow(" no editable app info — name/subtitle will be skipped (the live record is not writable)"));
65
75
  const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
66
76
 
67
77
  for (const platform of config.platforms) {
78
+ // No allowLive, deliberately: this is the command whose PATCHes would otherwise land on the listing
79
+ // customers are reading. Refusing is the whole point — an unwritten release note is recoverable, a
80
+ // rewritten live listing is not.
68
81
  const v = await client.editVersion(platform);
69
- if (!v) { console.error(red(`fill ${platform}: no editable version`)); continue; }
70
- console.log(green(`fill ${platform} (metadata=${!skipMeta} screenshots=${!skipShots})...`));
82
+ if (!v) {
83
+ console.error(red(`fill ${platform}: no editable version — refusing to write to the live listing.`));
84
+ console.error(" `vydanne prepare --apply` creates the version to fill, then re-run this.");
85
+ ok = false;
86
+ continue;
87
+ }
88
+ console.log(green(`fill ${platform} (metadata=${!skipMeta} screenshots=${!skipShots}) -> ${v.attributes.versionString} ${v.attributes.appStoreState}`));
71
89
  const verLocs = await client.versionLocalizations(v.id);
72
90
 
73
91
  if (!skipMeta) {
74
92
  let failed = 0;
75
93
  const dropped = new Set();
76
- const dirs = fs.readdirSync(config.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory() && VALID.has(d.name)).map((d) => d.name);
94
+ // Guarded: a metadataDir that doesn't exist used to surface as a raw ENOENT stack from readdirSync
95
+ // — the Play half of this command has always said it in a sentence, and there is no reason the
96
+ // Apple half should be the one that throws.
97
+ if (!fs.existsSync(config.metadataDir)) {
98
+ console.error(red(`fill ${platform}: no metadata directory at ${config.metadataDir}`));
99
+ console.error(" Create it (one folder per App Store locale), or point `metadataDir` at yours.");
100
+ return false;
101
+ }
102
+ const all = fs.readdirSync(config.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
103
+ const dirs = all.filter((d) => VALID.has(d));
104
+ // A folder Apple has no locale for is dropped from the upload — silently, until now. `de` instead
105
+ // of `de-DE` is the exact mistake locales.mjs calls the #1 gotcha, and the old filter made it look
106
+ // like the locale had simply been forgotten. Naming it costs one line and saves a release.
107
+ const strays = all.filter((d) => !VALID.has(d));
108
+ if (strays.length) console.log(yellow(` ${strays.length} folder(s) are not App Store locale codes, skipped: ${strays.join(", ")} (\`vydanne locales\` lists the valid ones)`));
77
109
  for (const code of dirs) {
78
110
  const folder = path.join(config.metadataDir, code);
79
111
  const read = (f) => { const p = path.join(folder, `${f}.txt`); return fs.existsSync(p) ? fs.readFileSync(p, "utf8").replace(/\n+$/, "") : null; };
80
112
  // version localization (description/keywords/promo/whatsNew/urls)
81
113
  let vl = verLocs.find((l) => l.attributes.locale === code);
82
- if (!vl) { const c = await client.post(`/v1/appStoreVersionLocalizations`, { data: { type: "appStoreVersionLocalizations", attributes: { locale: code }, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } }); vl = c.json.data; verLocs.push(vl); }
114
+ if (!vl) {
115
+ const c = await client.post(`/v1/appStoreVersionLocalizations`, { data: { type: "appStoreVersionLocalizations", attributes: { locale: code }, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } });
116
+ vl = c.json.data;
117
+ // A refused POST leaves `vl` undefined, and the PATCH below then reads `.id` off it — a raw
118
+ // TypeError that kills the whole fill on one bad locale. Report the locale Apple refused and
119
+ // carry on with the rest, the way every other per-locale failure here behaves.
120
+ if (!vl) {
121
+ console.error(red(` ✗ ${code}: could not create localization — ${JSON.stringify(c.json?.errors?.[0]?.detail || c.json).slice(0, 160)}`));
122
+ failed++;
123
+ continue;
124
+ }
125
+ verLocs.push(vl);
126
+ }
83
127
  const vattrs = {};
84
128
  for (const [k, f] of Object.entries(VERSION_TXT)) { const t = read(f); if (t != null) vattrs[k] = t; }
85
129
  if (Object.keys(vattrs).length) {
@@ -117,19 +161,42 @@ export async function run(config, client) {
117
161
  }
118
162
 
119
163
  async function uploadScreenshots(config, client, platform, verLocs) {
120
- const base = platform === "MAC_OS" ? "fastlane/screenshots-macos" : "fastlane/screenshots";
121
- const DEV = platform === "MAC_OS" ? MAC_DEVICE : IOS_DEVICE;
164
+ const base = screenshotBase(platform, config);
165
+ const DEV = deviceMap(platform);
122
166
  if (!fs.existsSync(base)) return;
123
- for (const code of fs.readdirSync(base).filter((d) => VALID.has(d))) {
167
+ const dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
168
+ const strays = dirs.filter((d) => !VALID.has(d));
169
+ if (strays.length) console.log(yellow(` ${strays.length} screenshot folder(s) are not App Store locale codes, skipped: ${strays.join(", ")}`));
170
+ for (const code of dirs.filter((d) => VALID.has(d))) {
124
171
  const loc = verLocs.find((l) => l.attributes.locale === code);
125
- if (!loc) continue;
126
- const files = fs.readdirSync(path.join(base, code)).filter((f) => f.endsWith(".png")).sort();
172
+ // Said, not skipped: with VYDANNE_SKIP_METADATA=1 (or a screenshots folder for a locale that has no
173
+ // metadata folder) no localization was created above, and a silent `continue` here reports success
174
+ // for a locale whose screenshots never left the disk.
175
+ if (!loc) { console.log(yellow(` ${code}: no App Store localization — screenshots skipped (run fill with metadata to create it)`)); continue; }
176
+ const files = fs.readdirSync(path.join(base, code)).filter((f) => IMAGE_FILE.test(f)).sort();
127
177
  const byDev = {};
128
- for (const f of files) { const dt = DEV[f.split("_")[0]]; if (dt) (byDev[dt] ||= []).push(f); }
178
+ const unknown = [];
179
+ for (const f of files) { const dt = DEV[f.split("_")[0]]; dt ? (byDev[dt] ||= []).push(f) : unknown.push(f); }
180
+ // The prefix before the first underscore selects the device slot; a file with an unknown one used to
181
+ // vanish without a word — the docs even said "if a screenshot doesn't appear, check the name first",
182
+ // which is the tool telling the user to do its job. Name the files instead.
183
+ if (unknown.length) console.log(yellow(` ${code}: ${unknown.length} file(s) with no device prefix, not uploaded: ${unknown.slice(0, 3).join(", ")}${unknown.length > 3 ? ", …" : ""} (known: ${Object.keys(DEV).join(", ")})`));
129
184
  const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?include=appScreenshots&limit=50`);
130
185
  for (const [dt, list] of Object.entries(byDev)) {
131
186
  let set = (sets.data || []).find((s) => s.attributes.screenshotDisplayType === dt);
132
- if (set && (set.relationships?.appScreenshots?.data || []).length) continue; // never duplicate
187
+ // A populated slot used to `continue` unconditionally — "never duplicate", but it also meant a
188
+ // re-render could NEVER reach a listing that already had images, and because the skip happened
189
+ // before the log below, `fill` reported success having uploaded nothing. Same shape as previews
190
+ // now, and the same VYDANNE_REPLACE flag rather than a second one for the other asset kind.
191
+ const existing = set?.relationships?.appScreenshots?.data || [];
192
+ if (existing.length && process.env.VYDANNE_REPLACE !== "1") {
193
+ console.log(yellow(` ${code}/${dt}: already has ${existing.length}, skipping (set VYDANNE_REPLACE=1 to replace)`));
194
+ continue;
195
+ }
196
+ for (const s of existing) { // VYDANNE_REPLACE: drop the old shots so the new upload takes their place
197
+ await client.del(`/v1/appScreenshots/${s.id}`);
198
+ console.log(yellow(` ${code}/${dt}: ${client.dryRun ? "would remove" : "removed"} old screenshot ${s.id}`));
199
+ }
133
200
  if (!set) { const c = await client.post(`/v1/appScreenshotSets`, { data: { type: "appScreenshotSets", attributes: { screenshotDisplayType: dt }, relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: loc.id } } } } }); set = c.json.data; }
134
201
  for (const f of list) await uploadAsset(client, { type: "appScreenshots", setType: "appScreenshotSet", setId: set.id, filePath: path.join(base, code, f) });
135
202
  console.log(green(` ${code}/${dt}: ${list.length} screenshots`));
@@ -3,8 +3,10 @@ export async function run(config, client) {
3
3
  await client.findApp(config.bundleId);
4
4
  console.log(`APP ${client.app.attributes.name} (${config.bundleId}) id=${client.appId} primary=${client.app.attributes.primaryLocale}`);
5
5
  for (const platform of config.platforms) {
6
- const v = await client.editVersion(platform);
7
- if (!v) { console.log(` ${platform}: no editable version`); continue; }
6
+ // allowLive: this command's whole job is reporting what is in the store, and on an app with nothing
7
+ // in preparation the live version is the answer — not something to withhold.
8
+ const v = await client.editVersion(platform, { allowLive: true });
9
+ if (!v) { console.log(` ${platform}: no version at all`); continue; }
8
10
  const locs = await client.versionLocalizations(v.id);
9
11
  console.log(` ${platform}: v${v.attributes.versionString} ${v.attributes.appStoreState} localizations=${locs.length}`);
10
12
  const primary = locs.find((l) => l.attributes.locale === config.primaryLocale) || locs[0];
@@ -1,5 +1,6 @@
1
1
  import { green, red, yellow, LIMITS, VERSION_FIELDS } from "../util.mjs";
2
2
  import { reportCrossStore } from "../crossStore.mjs";
3
+ import { localScreenshots, remoteScreenshots, compareShots, localScreenshotLocales, unknownScreenshotDirs } from "../screenshots.mjs";
3
4
 
4
5
  // Verify a listing is submission-complete the CORRECT way — each localization read by id (not the sparse
5
6
  // list), char limits, primary-locale coverage, per-platform — and warn on the gotchas before ASC does.
@@ -8,10 +9,33 @@ export async function run(config, client) {
8
9
  const problems = [], notes = [];
9
10
  const res = config.resolvedLocales;
10
11
  if (res.unsupported.length) notes.push(`UI locales with no App Store listing (fall back to ${config.primaryLocale}): ${res.unsupported.join(", ")}`);
12
+ if (res.invalid?.length) problems.push(`localeMap points at code(s) Apple does not have: ${res.invalid.join(", ")}`);
13
+
14
+ // name/subtitle live on the APP-INFO record, not the version, which is why they were never checked
15
+ // here — LIMITS has carried a 30-char limit for each since the beginning and nothing read it, while
16
+ // the README listed them among the limits "checked before upload". Read-only, so `allowLive` is
17
+ // right: on an app with nothing in preparation the live record is the only one to measure.
18
+ const info = await client.appInfo({ allowLive: true });
19
+ if (info) {
20
+ const infoLocs = (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [];
21
+ for (const il of infoLocs) {
22
+ const a = await client.localization(il.id, "appInfoLocalizations");
23
+ for (const f of ["name", "subtitle"]) {
24
+ const val = (a[f] || "").toString();
25
+ if (!val) { if (f === "name" && il.attributes.locale === config.primaryLocale) problems.push(`${il.attributes.locale}: name EMPTY`); continue; }
26
+ if (val.length > LIMITS[f]) problems.push(`${il.attributes.locale}: ${f} ${val.length}>${LIMITS[f]}`);
27
+ }
28
+ }
29
+ }
11
30
 
12
31
  for (const platform of config.platforms) {
32
+ // A blocker, and now reachable: this guard existed before but editVersion() handed back the LIVE
33
+ // version instead of null, so preflight validated the shipped listing and called it submittable.
13
34
  const v = await client.editVersion(platform);
14
- if (!v) { problems.push(`${platform}: no editable version`); continue; }
35
+ if (!v) {
36
+ problems.push(`${platform}: no editable version — run \`vydanne prepare --apply\` to create one`);
37
+ continue;
38
+ }
15
39
  console.log(` ${platform}: version ${v.attributes.versionString} (${v.attributes.appStoreState})`);
16
40
  const locs = await client.versionLocalizations(v.id);
17
41
  const ascLocales = [...new Set([config.primaryLocale, ...Object.values(res.supported)])];
@@ -27,11 +51,40 @@ export async function run(config, client) {
27
51
  }
28
52
  }
29
53
 
30
- const primary = locs.find((l) => l.attributes.locale === config.primaryLocale);
31
- if (primary) {
32
- const { json } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appScreenshotSets?include=appScreenshots&limit=50`);
33
- const count = (json.data || []).reduce((n, s) => n + (s.relationships?.appScreenshots?.data || []).length, 0);
34
- if (!count) problems.push(`${platform}/${config.primaryLocale}: no screenshots`);
54
+ // EVERY locale with a local screenshot folder, not just the primary one.
55
+ //
56
+ // By CONTENT, not by count — the same comparison `diff` runs (src/screenshots.mjs), so the two
57
+ // cannot disagree about what "in sync" means. The count-only check that replaced called a STALE
58
+ // set green: a full local recapture left three-local-vs-three-remote, preflight said "no blockers",
59
+ // and the submission would have shipped the old art — which is precisely the state Niva sat in with
60
+ // store screenshots still showing a three-tier selector the app no longer had.
61
+ //
62
+ // Restricting that to `primaryLocale` left the same hole open one locale over, and `bridge` widened
63
+ // it: it populates a folder per translated locale, so a de-DE set re-rendered upstream but never
64
+ // re-uploaded passed green while the primary one was checked in full.
65
+ const shotLocales = [...new Set([config.primaryLocale, ...localScreenshotLocales(platform, config)])];
66
+ const strays = unknownScreenshotDirs(platform, config);
67
+ if (strays.length) notes.push(`${platform}: screenshot folder(s) that are not App Store locale codes, never uploaded: ${strays.join(", ")}`);
68
+
69
+ for (const code of shotLocales) {
70
+ const loc = locs.find((l) => l.attributes.locale === code);
71
+ if (!loc) continue; // a missing primary localization is already a blocker above
72
+ const remote = await remoteScreenshots(client, loc.id);
73
+ const count = Object.values(remote).reduce((n, m) => n + m.size, 0);
74
+ // Only the primary listing MUST have screenshots — Apple falls back to it for any locale without.
75
+ if (!count && code === config.primaryLocale) problems.push(`${platform}/${code}: no screenshots`);
76
+ // Freshness is judged only where a local set exists: no local screenshots means there is nothing
77
+ // to compare against, not that the store's are wrong. A display type the store has and local
78
+ // lacks is likewise left alone — `fill` never deletes by omission, so this does not flag it.
79
+ for (const [dt, L] of Object.entries(localScreenshots(platform, code, config))) {
80
+ const c = compareShots(L, remote[dt] || new Map());
81
+ if (!c) continue;
82
+ const slot = dt.replace("APP_", "");
83
+ if (c.kind === "count") problems.push(`${platform}/${code}: screenshots ${slot} local ${c.local} / remote ${c.remote} — the store set is not the local one (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
84
+ else if (c.kind === "renamed") problems.push(`${platform}/${code}: screenshots ${slot} ${c.names.length} local file(s) not on the store by name (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
85
+ else if (c.kind === "stale") problems.push(`${platform}/${code}: screenshots ${slot} STALE — ${c.names.length} of ${c.of} differ in content; green would ship the old art (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
86
+ else notes.push(`${platform}/${code}: screenshots ${slot} present, checksum not reported by Apple — content unverified`);
87
+ }
35
88
  }
36
89
  }
37
90