apple creative-assets: a live app without an editable version uses the live version (only approved assets are selected there; new ones upload and wait for a review of their own via --add-for-review); pitfall 76: placements read back ACTIVE live, a state the 4.5.1 spec does not list

This commit is contained in:
CRTkafa 2026-10-19 05:45:47 +07:00
parent 183579449c
commit c03291e7f7
6 changed files with 49 additions and 12 deletions

View file

@ -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<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'wait' | 'select' | 'replace' | 'refuse'; state?: string; asset?: J }
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | '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<string, CreativeFile[]> } {
@ -138,9 +138,14 @@ export async function run(ctx: Ctx): Promise<void> {
const lib = (await c.asc('GET', `/v1/apps/${appId}/assetLibrary`)).data as J;
const live = async () => (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`))
.find(v => (v.attributes.appVersionState ?? v.attributes.appStoreState) === 'READY_FOR_DISTRIBUTION' || v.attributes.appStoreState === 'READY_FOR_SALE') ?? null;
// --verify falls back to the live version (read-only); writes need the editable one
const version = await c.editableVersion() ?? (verify ? await live() : null);
if (!version) throw new Error(`no editable App Store version: the header and search results slots belong to a version page (run apple listing --version x.y.z first). ${consoleLink('asc.version')}`);
// No editable version: the live one. Apple lets an approved asset be swapped onto the live version without a new
// build, and an app with an approved version can send assets to review alone; so there only APPROVED assets are
// selected, and the others wait for their review (--add-for-review puts them into a submission of their own).
const editable = await c.editableVersion();
const version = editable ?? await live();
const onLive = !editable;
if (!version) throw new Error(`no editable or live App Store version: the header and search results slots belong to a version page (run apple listing --version x.y.z first). ${consoleLink('asc.version')}`);
if (onLive && !verify) log(TAG, `no editable version: using the live version ${version.attributes.versionString}, where only approved assets can be selected`);
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
const library = async (): Promise<J[]> => [
...(await c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=CREATIVE_ASSETS&limit=200`)),
@ -197,7 +202,7 @@ export async function run(ctx: Ctx): Promise<void> {
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 once processed`); row('upload'); continue; }
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; }
log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`);
asset = await upload(f, sum);
assets = await library();
@ -214,6 +219,10 @@ export async function run(ctx: Ctx): Promise<void> {
if (cls === 'processing') { row('wait', asset); continue; }
warn(TAG, `${tag}: ${basename(f.file)} is ${asset.attributes?.state}${reason(asset)}; not selected`); row('refuse', asset); continue;
}
if (onLive && asset.attributes?.state !== 'APPROVED') {
log(TAG, `${tag}: ${basename(f.file)} is ${asset.attributes?.state} in the Asset Library; the live version takes it once App Review approved it (re-run then)`);
row('approve', asset); continue;
}
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}`);
@ -234,9 +243,11 @@ export async function run(ctx: Ctx): Promise<void> {
}
if (off) process.exitCode = 1;
// review: one item per distinct unreviewed asset in a slot
const n = (a: Row['action'][]) => rows.filter(r => a.includes(r.action)).length;
// review: one item per distinct unreviewed asset (in a slot, or waiting for approval to go onto the live version)
const unreviewed = new Map<string, J>();
for (const r of rows) if (r.asset && r.action !== 'refuse' && r.action !== 'wait' && classify(r.asset.attributes?.state) === 'unreviewed') unreviewed.set(r.asset.id, r.asset);
const approve = n(['approve']);
let added = 0;
if (unreviewed.size && forReview) {
const kindOf = (a: J) => (a.type === 'appAssetLibraryVideos' ? 'appAssetLibraryVideo' : 'appAssetLibraryImage');
@ -250,12 +261,11 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${added} creative asset(s) added to draft review submission ${sid}; nothing was submitted`);
}
}
const n = (a: Row['action'][]) => rows.filter(r => a.includes(r.action)).length;
const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} to upload, ${n(['wait'])} still processing, ${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'])} to upload, ${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')}`
: `${unreviewed.size} selected asset(s) are not reviewed yet ("Prepare for Submission"): run with --add-for-review to put them into the draft review submission (the version's, or alone once the app has an approved version); Submit for Review stays the owner's: ${consoleLink('asc.app-review')}`);
: `${unreviewed.size} asset(s) are not reviewed yet ("Prepare for Submission"): run with --add-for-review to put them into the draft review submission (the version's, or alone once the app has an approved version); Submit for Review stays the owner's: ${consoleLink('asc.app-review')}`);
}
if (n(['refuse'])) process.exitCode = 1;
return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary);