From 1736300784f734cd23dbc3c8686d1b084cf702dc Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 20 Oct 2026 02:50:09 +0700 Subject: [PATCH] apple creative-assets: a placement App Store Connect refuses on the live version is reported with the live version page's deep link, the live page stays unchanged (new placement first, old one removed after) and the other live slots are not retried; pitfall 76 --- CHANGELOG.md | 4 +++- cli/apple/creative-assets.ts | 22 ++++++++++++++++++++-- skills/store-pitfalls/SKILL.md | 7 +++++-- tests/creative-assets.test.ts | 14 ++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7d48b..0ee5763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ 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, uploaded once and placed per locale (the dry run counts files, not slots). - Pitfall 76 rewritten. + If App Store Connect refuses a placement on the live version, the run says so, leaves the live page unchanged (the + new placement goes first, the old one is removed only after Apple accepted it), links the live version page and does + not retry the other live 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 18ce31e..295aa9d 100644 --- a/cli/apple/creative-assets.ts +++ b/cli/apple/creative-assets.ts @@ -34,6 +34,7 @@ import { ffprobe } from '../shots/report'; import { imageInfo } from '../shots/spec'; import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative'; import { ascClient, md5, type J } from './asc'; +import { AmbiguousWriteError } from '../lib/upsert'; import { addItem, ensureDraft, OtherSubmissionError } from './submission'; const TAG = 'apple'; @@ -158,6 +159,7 @@ export async function run(ctx: Ctx): Promise { if (verify) return verifySlots(); const rows: Row[] = []; + let liveRefused = ''; // the first 4xx on a live-version placement: later live slots are not tried const planned = new Set(); // dry run: MD5s already planned for upload async function upload(f: CreativeFile, sum: string): Promise { const buf = readFileSync(f.file); @@ -231,10 +233,26 @@ export async function run(ctx: Ctx): Promise { } const action = placed.length ? 'replace' : 'select'; if (dry) { log(TAG, `${tag}: would ${action === 'replace' ? `replace ${placed.length} selected asset(s) with` : 'select'} ${basename(f.file)} (${asset.attributes?.state})`); row(action, asset); continue; } - for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`); + if (onLive && liveRefused) { warn(TAG, `${tag}: not tried (App Store Connect refused a placement on the live version above; no blind retries)`); row('refuse', asset); continue; } const media = f.kind === 'image' ? 'image' : 'video'; - await c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: ptype }, + const place = () => c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: ptype }, relationships: { [media]: { data: { type: asset.type, id: asset.id } }, appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: loc.id } } } } }); + if (onLive) { + // Live version: place the new asset first, remove the old one only after Apple accepted it, so a refusal + // leaves the live page as it was. Whether the API takes placements on a live version is not documented. + try { await place(); } + catch (e) { + const status = /-> (4\d\d)/.exec(String((e as Error).message))?.[1]; + if (!status || e instanceof AmbiguousWriteError) throw e; + liveRefused = String(e instanceof Error ? e.message : e).split('\n')[0]!; + warn(TAG, `${tag}: App Store Connect refused to select ${basename(f.file)} on the live version ${version.attributes.versionString} (${status}): ${liveRefused}. The live page is unchanged. Select it in the console instead (Header and Search Results on the live version page): ${consoleLink('asc.version', '', { appleAppId: appId }).replace('/version/inflight', '/version/deliverable')}, or create a new version and re-run; the remaining live slots are not tried`); + row('refuse', asset); continue; + } + for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`); + } else { + for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`); + await place(); + } log(TAG, `${tag}: selected ${basename(f.file)} (${asset.attributes?.state})`); row(action, asset); } diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 174df6a..be166fc 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -930,8 +930,11 @@ that fails without the fix (see CONTRIBUTING.md). asset with the same MD5, select it once processed, never re-upload a rejected file or one that failed processing twice. Then `--add-for-review` puts the unreviewed assets into the draft submission (with the first version, or alone once the app has an approved version) and `--verify` reads every slot back. With no editable version the live version - is used and only APPROVED assets go onto it; the rest wait for their own review. Uploading, selecting and adding to the + is used and only APPROVED assets go onto it; the rest wait for their own review. Whether the API accepts a new + placement on a live version is not documented: storebound places the new asset first and removes the old one only + after Apple accepted it, and on a 4xx it says so, leaves the live page unchanged, gives the live version page's deep + link (select it there, or create a version and re-run) and does not try the remaining live slots. Uploading, selecting and adding to the draft are normal steps, not owner buttons; Submit for Review stays the owner's. - **Guard:** `cli/apple/creative-assets.ts` (`classify`, `pickAsset`, `processingTiming`), `tests/creative-assets.test.ts` (fake App Store Connect: upload and select in one run, still processing, rejected / archived files, add-for-review - without submitting, verify read-only); the fake commits library assets to PREPARE_FOR_SUBMISSION, as Apple does. + without submitting, verify read-only, a refused live placement); the fake commits library assets to PREPARE_FOR_SUBMISSION, as Apple does. diff --git a/tests/creative-assets.test.ts b/tests/creative-assets.test.ts index 6a24321..8073ba5 100644 --- a/tests/creative-assets.test.ts +++ b/tests/creative-assets.test.ts @@ -194,6 +194,20 @@ describe('apple creative-assets against the fake App Store Connect', () => { expect((await go(p, ['--verify'])).out).toContain('every slot shows the local file'); }); + test('live version refuses a placement: clear message with the console deep link, live page unchanged, no blind retry', async () => { + const p = setup(files()); + s.asc.all('appStoreVersions')[0]!.attributes.appVersionState = 'READY_FOR_DISTRIBUTION'; + await go(p); + for (const x of lib()) x.attributes.state = 'APPROVED'; + s.faults.push({ method: 'POST', match: /appAssetLibraryPlacements/, when: 'before', status: 409, code: 'STATE_ERROR', detail: 'version not editable', times: 5 }); + const r = await go(p, [], 1); + expect(r.out).toContain('refused to select header.png on the live version 1.0.0 (409)'); + expect(r.out).toContain('https://appstoreconnect.apple.com/apps/'); + expect(r.out).toContain('not tried (App Store Connect refused a placement on the live version above'); + expect(r.w).toEqual(['POST /v1/appAssetLibraryPlacements']); + expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0); + }); + 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 } });