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 } });