diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f68fa..9f7d48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `/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 diff --git a/cli/apple/creative-assets.ts b/cli/apple/creative-assets.ts index c932487..18ce31e 100644 --- a/cli/apple/creative-assets.ts +++ b/cli/apple/creative-assets.ts @@ -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 => (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 } { @@ -158,6 +158,7 @@ export async function run(ctx: Ctx): Promise { if (verify) return verifySlots(); const rows: Row[] = []; + const planned = new Set(); // dry run: MD5s already planned for upload async function upload(f: CreativeFile, sum: string): Promise { const buf = readFileSync(f.file); const type = f.kind === 'image' ? 'appAssetLibraryImages' : 'appAssetLibraryVideos'; @@ -202,7 +203,12 @@ export async function run(ctx: Ctx): Promise { 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 { 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')}` diff --git a/tests/creative-assets.test.ts b/tests/creative-assets.test.ts index 07b5daa..6a24321 100644 --- a/tests/creative-assets.test.ts +++ b/tests/creative-assets.test.ts @@ -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;