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

This commit is contained in:
CRTkafa 2026-10-20 02:50:09 +07:00
parent 98d3bf4410
commit 1736300784
4 changed files with 42 additions and 5 deletions

View file

@ -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: `<renders>/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

View file

@ -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<void> {
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<string>(); // dry run: MD5s already planned for upload
async function upload(f: CreativeFile, sum: string): Promise<J> {
const buf = readFileSync(f.file);
@ -231,10 +233,26 @@ export async function run(ctx: Ctx): Promise<void> {
}
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);
}

View file

@ -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.

View file

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