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

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