Screenshot guardrails: name every screenshot not COMPLETE on 409 ASSET_IN_POST_PROCESSING (submit, validate; stuck after 5 min in UPLOAD_COMPLETE), apple screenshots --repair-stuck replaces only those in place, part uploads without Expect: 100-continue (failed part deletes the reservation, one retry, never committed), storebound api --confirm <operationId> for agents' plain writes (never owner-only)

This commit is contained in:
CRTkafa 2026-10-21 12:40:26 +07:00
parent 164182012f
commit 00756e6908
19 changed files with 475 additions and 45 deletions

View file

@ -1,6 +1,7 @@
// Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match
// by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]
// storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)
// Source per language: <renders>/<language id>/ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67),
// ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 ->
// APP_IPAD_PRO_3GEN_129: the API has no "13 inch" value, the 13" display set keeps the 3rd gen 12.9" name). With
@ -21,6 +22,7 @@ import { pulledChecksum } from '../lib/art-pull';
import { consoleLink } from '../lib/console-links';
import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
import { type Asc, ascClient, EDITABLE, keptPrefix, md5, setUpToDate, type J } from './asc';
import { pendingScreenshots, repairPlan, repairSet, setRows, updateLedger } from './screenshot-health';
import { allVersions, parsePlatform, pickPlatform, platformLabel, stateOf, versionLabel } from './platform';
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
@ -104,6 +106,8 @@ export async function run(ctx: Ctx): Promise<void> {
const syncSet = (locId: string, sets: J[], type: string, files: string[], tag: string) =>
syncScreenshotSet(c, { rel: 'appStoreVersionLocalization', type: 'appStoreVersionLocalizations', id: locId }, sets, type, files, tag, ctx.args.has('dry'));
if (ctx.args.has('repair-stuck')) { await repairStuck(ctx, c, root, locs, src, platform, only); return; }
const tasks: (() => Promise<void>)[] = [];
for (const loc of locs) {
const locale: string = loc.attributes.locale;
@ -132,3 +136,35 @@ export async function run(ctx: Ctx): Promise<void> {
}
done('apple', bad ? `${bad} screenshots failed processing (re-run to retry)` : 'screenshots done');
}
/** --repair-stuck: only the screenshots that are not COMPLETE and will not finish on their own (UPLOAD_COMPLETE for
* over 5 min, FAILED, or a stranded AWAITING_UPLOAD reservation) are replaced at their own positions with the same
* local file (same name in the renders). Everything else stays; still-processing ones are left to finish. --dry lists
* the plan. Re-runnable: a run cut midway leaves the rest for the next run, which skips what is already COMPLETE. */
async function repairStuck(ctx: Ctx, c: Asc, root: string, locs: J[], src: string, platform: string, only: string[]): Promise<void> {
const dry = ctx.args.has('dry');
const langOf = byAppleLocale(ctx.loaded.config.languages);
const dirOf = Object.fromEntries(Object.entries(displaySets(ctx.loaded.config.apple, platform)).map(([d, t]) => [t, d] as const));
let total = 0, held = 0;
for (const loc of locs) {
const locale: string = loc.attributes.locale;
if (only.length && !only.includes(locale)) continue;
for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?limit=50`)) {
const type: string = set.attributes.screenshotDisplayType;
const rows = setRows(await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`), locale, type);
const pending = pendingScreenshots(rows, updateLedger(root, rows), Date.now());
held += pending.filter(p => p.kind === 'processing').length;
const lang = langOf.get(locale);
const dir = dirOf[type];
const files = lang && dir ? numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png')) : [];
const steps = repairPlan(rows, pending, files);
if (!steps.length) continue;
total += steps.length;
const tag = `${locale} ${type}`;
if (dry) { for (const st of steps) log('apple', `${tag}: would replace #${st.position} ${st.fileName} (${st.kind}) with ${st.file ?? 'NO LOCAL FILE'}`); continue; }
await repairSet(c, set.id, steps, m => log('apple', `${tag} ${m}`));
}
}
if (held) log('apple', `${held} screenshot(s) still processing (under 5 min): left alone; run validate again in a few minutes`);
done('apple', total ? `${dry ? 'dry run: would replace' : 'replaced'} ${total} stuck screenshot(s)` : 'no stuck screenshots');
}