creative-assets: the dry run plans one upload per file, not per slot (a shared file used by 13 locales read as 26 uploads)

This commit is contained in:
CRTkafa 2026-10-19 06:01:56 +07:00
parent 7e69883dfc
commit 98d3bf4410
3 changed files with 24 additions and 4 deletions

View file

@ -12,7 +12,8 @@
twice, is not uploaded again; an archived copy of the same file is unarchived instead of uploaded. A live app without
an editable version uses the live version: approved assets are selected there, new ones are uploaded and wait for a
review of their own. Art without words needs no copy per locale: `<renders>/creative/header.png` / `search.*`
fill every language without its own file. Pitfall 76 rewritten.
fill every language without its own file, uploaded once and placed per locale (the dry run counts files, not slots).
Pitfall 76 rewritten.
## 0.8.13 · 2026-10-18

View file

@ -90,7 +90,7 @@ const reason = (a: J): string => {
/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J }
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J }
/** Local files per App Store locale, checked; throws on any error before a network call. */
export function localCreative(ctx: Ctx): { src: string; local: Map<string, CreativeFile[]> } {
@ -158,6 +158,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (verify) return verifySlots();
const rows: Row[] = [];
const planned = new Set<string>(); // dry run: MD5s already planned for upload
async function upload(f: CreativeFile, sum: string): Promise<J> {
const buf = readFileSync(f.file);
const type = f.kind === 'image' ? 'appAssetLibraryImages' : 'appAssetLibraryVideos';
@ -202,7 +203,12 @@ export async function run(ctx: Ctx): Promise<void> {
if (pick.refuse) { warn(TAG, `${tag}: ${basename(f.file)} ${pick.refuse}`); row('refuse'); continue; }
let asset = pick.asset;
if (!asset) {
if (dry) { log(TAG, `${tag}: would upload ${basename(f.file)} to the Asset Library and select it ${onLive ? 'on the live version once approved' : 'once processed'}`); row('upload'); continue; }
if (dry) {
// one upload per file: a later locale with the same file reuses it (the real run finds it by MD5)
const first = !planned.has(sum); planned.add(sum);
log(TAG, `${tag}: would ${first ? 'upload' : 'reuse'} ${basename(f.file)}${first ? ' to the Asset Library' : ' (uploaded once for an earlier locale)'} and select it ${onLive ? 'on the live version once approved' : 'once processed'}`);
row(first ? 'upload' : 'reuse'); continue;
}
log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`);
asset = await upload(f, sum);
assets = await library();
@ -261,7 +267,7 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${added} creative asset(s) added to draft review submission ${sid}; nothing was submitted`);
}
}
const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} to upload, ${n(['wait'])} still processing, ${approve ? `${approve} waiting for App Review (live version), ` : ''}${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`;
const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} file(s) to upload${n(['reuse']) ? ` (used by ${n(['upload', 'reuse'])} slots)` : ''}, ${n(['wait'])} still processing, ${approve ? `${approve} waiting for App Review (live version), ` : ''}${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`;
if (unreviewed.size) {
log(TAG, forReview
? `owner step: Submit for Review with the version (or alone once the app has an approved version): ${consoleLink('asc.app-review')}`

View file

@ -194,6 +194,19 @@ describe('apple creative-assets against the fake App Store Connect', () => {
expect((await go(p, ['--verify'])).out).toContain('every slot shows the local file');
});
test('one shared file for many locales: the dry run plans one upload, the run uploads once and places it per locale', async () => {
const two = { ...CFG, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'tr', apple: 'tr', play: 'tr-TR' }] };
s.asc.put({ type: 'appAssetLibraries', id: 'lib-1', attributes: {}, rels: { app: APP } });
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
for (const l of ['en-US', 'tr']) s.asc.put({ type: 'appStoreVersionLocalizations', id: `loc-${l}`, attributes: { locale: l }, rels: { appStoreVersion: 'ver-1' } });
const p = project(two, { 'store/renders/creative/header.png': png(3840, 1646, false, 4) });
const dry = await go(p, ['--dry']);
expect(dry.out).toContain('1 file(s) to upload (used by 2 slots)');
const r = await go(p);
expect(r.w.filter(x => x === 'POST /v1/appAssetLibraryImages')).toHaveLength(1);
expect(r.w.filter(x => x === 'POST /v1/appAssetLibraryPlacements')).toHaveLength(2);
});
test('a bad file stops the run before any call', async () => {
const p = setup({ 'store/renders/en/creative/header.png': png(3840, 1600, true) });
const a = s.calls.length;