apple creative-assets: select Header and Search Results assets once processed (Prepare for Submission), not once approved: Apple reviews an asset only in a review submission, so 0.8.13 waited forever; upload and select in one run with a bounded processing wait; --add-for-review puts unreviewed assets into the draft review submission (never submits); --verify reads every slot back read-only (MCP apple_creative_assets_verify); a rejected file or one that failed processing twice is not re-uploaded, an archived copy is unarchived; fake commits library assets to PREPARE_FOR_SUBMISSION; pitfall 76 rewritten; apple-name check test only looks at name hints

This commit is contained in:
CRTkafa 2026-10-19 05:42:53 +07:00
parent 3e4015b686
commit 183579449c
14 changed files with 378 additions and 95 deletions

View file

@ -2,6 +2,15 @@
## Unreleased
- **Header and Search Results: select once processed, then add for review.** `storebound apple creative-assets` now
selects an asset for the product page header or search results slot as soon as it is processed ("Prepare for
Submission"), as the console's Browse Assets does: Apple reviews an asset only once it is in a review submission, so
0.8.13's "select once approved" never finished. Upload and select happen in one run (processing wait up to 2 min with
a heartbeat). `--add-for-review` adds the selected, unreviewed assets to the app's draft review submission (never
submits; Submit for Review stays the owner's); `--verify` (MCP `apple_creative_assets_verify`) reads every slot back
read-only and fails when one differs from the local file. A file App Review rejected, or one that failed processing
twice, is not uploaded again; an archived copy of the same file is unarchived instead of uploaded. Pitfall 76 rewritten.
## 0.8.13 · 2026-10-18
- **iPhone Duo screenshots.** `apple.iphoneDuo: true` adds the foldable's outer (1398 x 2034) and inner (2007 x 2853)

View file

@ -79,7 +79,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'apps_appPricePoints_getToManyRelated': 'apple/appinfo',
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status art lib/store-apps listing-store onboard validate',
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/creative-assets apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status art lib/store-apps listing-store onboard validate',
'apps_assetLibrary_getToOneRelated': 'apple/creative-assets',
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',

View file

@ -1,30 +1,96 @@
// App Store "Header and Search Results" creative assets per locale of the editable App Store version:
// storebound apple creative-assets [--src store/renders] [--only en-US,tr] [--dry]
// storebound apple creative-assets [--src store/renders] [--only en-US,tr] [--dry] upload + select (upsert)
// storebound apple creative-assets --add-for-review [--dry] also put unreviewed assets into the draft submission
// storebound apple creative-assets --verify read-only: does every slot show the local file?
// Files (cli/shots/creative.ts): <renders>/<lang>/creative/header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9),
// search.{...} (3:2, 1920x1280 to 3840x2560), or one universal.png (5244x2950) for both slots. apple.creativeAssets
// can turn a placement off (header: false / search: false) or point at another universal file.
// Flow, as App Store Connect works (Header and Search Results tab > Header / Search Results > Browse Assets): a file is
// uploaded once into the app's Asset Library (POST appAssetLibraryImages / Videos, category CREATIVE_ASSETS, its MD5
// in referenceName "storebound <md5>" because library assets carry no checksum), Apple reviews it, and only an
// APPROVED asset is selected for a slot (POST appAssetLibraryPlacements with placementType PRODUCT_PAGE_HEADER_ASSET or
// APP_STORE_SEARCH_RESULTS_ASSET and the version localization). Upsert: a slot that already shows the same MD5 is left
// alone; a new file replaces the slot only once it is approved (the old placement is removed, then the new one is
// created); an asset with the same MD5 already in the library is reused, never uploaded twice. Read back at the end.
// Flow, as App Store Connect works (version page > Product Page Information > Header and Search Results > Header /
// Search Results > Browse Assets > Save; Help "Manage your App Store assets", "App Store asset statuses",
// "Submit assets from Asset Library", checked 19 Oct 2026):
// 1. a file is uploaded once into the app's Asset Library (POST appAssetLibraryImages / Videos, category
// CREATIVE_ASSETS, its MD5 in referenceName "storebound <md5>" because library assets carry no checksum);
// 2. after processing it is "Prepare for Submission", and in that state it can already be selected for a slot
// (POST appAssetLibraryPlacements, placementType PRODUCT_PAGE_HEADER_ASSET / APP_STORE_SEARCH_RESULTS_ASSET, the
// version localization). Approval is NOT needed first: an asset is only reviewed once it is in a review
// submission, so "select only approved assets" waits forever (pitfall 76);
// 3. App Review: the asset is a review submission item of its own (reviewSubmissionItems appAssetLibraryImage /
// appAssetLibraryVideo; console "Add for Review"). Before the app's first approved version it goes in the same
// submission as that version; afterwards it can go alone. --add-for-review adds it to the draft submission;
// pressing Submit for Review stays the owner's.
// Upsert: a slot that already shows the same MD5 is left alone; an asset with the same MD5 in the library is reused
// (an archived one is unarchived), never uploaded twice; a REJECTED file is not uploaded again (change the file); a
// changed file replaces the slot (the old placement is removed, then the new one is created). Read back at the end.
// Play has no such slot: the feature graphic (play images) and the promo video URL are its closest equivalents.
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { consoleLink } from '../lib/console-links';
import { done, log, warn } from '../lib/log';
import { elapsed } from '../lib/progress';
import * as rt from '../lib/rt';
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 { addItem, ensureDraft, OtherSubmissionError } from './submission';
const TAG = 'apple';
export const REF = (sum: string): string => `storebound ${sum}`;
const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'wait' | 'select' | 'replace'; state?: string }
/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */
export const ASSET_STATES = {
/** still uploading / processing: wait (bounded), then select */
processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'],
/** processed and usable in a slot; not reviewed yet: goes into a review submission */
unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'],
/** in a submission or reviewed: usable in a slot, nothing to add */
reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'],
} as const;
export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown';
/** What a library asset's state means for storebound. Pure. */
export function classify(state: string | undefined): AssetClass {
const s = String(state ?? '');
for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass;
if (s === 'FAILED') return 'failed';
if (s === 'REJECTED') return 'rejected';
if (s === 'ARCHIVED') return 'archived';
return s ? 'unknown' : 'processing';
}
/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */
export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing';
/** Stop re-uploading a file whose earlier uploads failed processing this many times. */
export const MAX_FAILED = 2;
/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing),
* else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */
export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } {
const mine = assets.filter(x => sumOf(x) === sum);
const rank: Record<AssetClass, number> = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 };
const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c);
const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]);
if (live[0]) return { asset: live[0] };
if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true };
const rej = by('rejected')[0];
if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` };
if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` };
return {};
}
const reason = (a: J): string => {
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
return d.length ? ` (${d.join(', ')})` : '';
};
/** 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 }
/** 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[]> } {
@ -42,7 +108,7 @@ export function localCreative(ctx: Ctx): { src: string; local: Map<string, Creat
fs = fs.filter(f => ca[f.placement] !== false);
for (const f of fs) {
const issues = f.kind === 'image' ? checkCreativeImage(imageInfo(readFileSync(f.file)), f.placement, f.universal) : checkCreativeVideo(f.file, ffprobe(f.file), f.placement);
for (const i of issues) (i.level === 'error' ? warn : log)('apple', `${l.apple} ${f.placement} ${basename(f.file)}: ${i.msg}`);
for (const i of issues) (i.level === 'error' ? warn : log)(TAG, `${l.apple} ${f.placement} ${basename(f.file)}: ${i.msg}`);
if (issues.some(i => i.level === 'error')) bad++;
}
if (fs.length) local.set(l.apple, fs);
@ -54,30 +120,39 @@ export function localCreative(ctx: Ctx): { src: string; local: Map<string, Creat
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const dry = ctx.args.has('dry');
const verify = ctx.args.has('verify');
const forReview = ctx.args.has('add-for-review');
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const { src, local } = localCreative(ctx);
if (!local.size) { warn('apple', `no creative assets in ${src}/<lang>/creative/ (header.png 3840x1646, search.png 3:2, or universal.png 5244x2950): optional; without them the App Store uses events, previews and screenshots`); return; }
log('apple', `${[...local.values()].flat().length} creative assets for ${local.size} locales pass the checks`);
if (!local.size) { warn(TAG, `no creative assets in ${src}/<lang>/creative/ (header.png 3840x1646, search.png 3:2, or universal.png 5244x2950): optional; without them the App Store uses events, previews and screenshots`); return; }
log(TAG, `${[...local.values()].flat().length} creative assets for ${local.size} locales pass the checks`);
let c: Awaited<ReturnType<typeof ascClient>>;
try { c = await ascClient(root, bundleId, config.apple?.appId); }
catch (e) {
if (!dry) throw e;
for (const [loc, fs] of local) for (const f of fs) log('apple', `${loc} ${PLACEMENT_TYPE[f.placement]}: would upload ${basename(f.file)} to the Asset Library and select it once approved`);
return done('apple', `creative assets planned (dry run, no App Store Connect comparison: ${(e as Error).message.split('\n')[0]})`);
for (const [loc, fs] of local) for (const f of fs) log(TAG, `${loc} ${PLACEMENT_TYPE[f.placement]}: would upload ${basename(f.file)} to the Asset Library and select it`);
return done(TAG, `creative assets planned (dry run, no App Store Connect comparison: ${(e as Error).message.split('\n')[0]})`);
}
const lib = (await c.asc('GET', `/v1/apps/${await c.app()}/assetLibrary`)).data as J;
const version = await c.editableVersion();
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
const appId = await c.app();
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')}`);
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`)),
...(await c.all(`/v1/appAssetLibraries/${lib.id}/videos?filter[category]=CREATIVE_ASSETS&limit=200`)),
];
let assets = await library();
const bySum = (s: string) => assets.filter(x => sumOf(x) === s && x.attributes?.state !== 'ARCHIVED' && x.attributes?.state !== 'FAILED' && x.attributes?.state !== 'REJECTED')[0];
const rows: Row[] = [];
const placedIn = async (locId: string, ptype: string) => c.all(`/v1/appStoreVersionLocalizations/${locId}/placements?filter[placementType]=${ptype}&include=image,video`);
const shownOf = (p: J) => p.relationships?.image?.data?.id ?? p.relationships?.video?.data?.id;
if (verify) return verifySlots();
const rows: Row[] = [];
async function upload(f: CreativeFile, sum: string): Promise<J> {
const buf = readFileSync(f.file);
const type = f.kind === 'image' ? 'appAssetLibraryImages' : 'appAssetLibraryVideos';
@ -86,11 +161,26 @@ export async function run(ctx: Ctx): Promise<void> {
for (const op of (res.attributes.uploadOperations ?? []) as J[]) {
const headers: Record<string, string> = {};
for (const h of op.requestHeaders ?? []) headers[h.name] = h.value;
const r = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) });
const r = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(300_000) });
if (!r.ok) throw new Error(`upload ${basename(f.file)} part ${op.offset} -> ${r.status}`);
}
return (await c.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true } } })).data as J;
}
/** Polls one asset until it leaves processing, at most processingTiming.timeoutMs, with a heartbeat line. */
async function processed(a: J, label: string): Promise<J> {
const t0 = Date.now();
let beat = t0, cur = a;
while (classify(cur.attributes?.state) === 'processing') {
if (Date.now() - t0 >= processingTiming.timeoutMs) {
warn(TAG, `${label}: still ${cur.attributes?.state ?? 'processing'} after ${elapsed(Date.now() - t0)}${reason(cur)} (asset ${cur.id}); re-run later to select it`);
return cur;
}
await processingTiming.sleep(processingTiming.pollMs);
cur = ((await c.maybe(`/v1/${cur.type}/${cur.id}`))?.data as J) ?? cur;
if (Date.now() - beat >= processingTiming.heartbeatMs) { beat = Date.now(); log(TAG, `${label}: processing (${cur.attributes?.state}, ${elapsed(Date.now() - t0)})`); }
}
return cur;
}
for (const loc of locs) {
const locale: string = loc.attributes.locale;
@ -98,37 +188,94 @@ export async function run(ctx: Ctx): Promise<void> {
const ptype = PLACEMENT_TYPE[f.placement];
const tag = `${locale} ${ptype}`;
const sum = md5(readFileSync(f.file));
const placed = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/placements?filter[placementType]=${ptype}&include=image,video`);
const shownIds = placed.map(p => p.relationships?.image?.data?.id ?? p.relationships?.video?.data?.id).filter(Boolean);
if (placed.length === 1 && assets.some(x => shownIds.includes(x.id) && sumOf(x) === sum)) { log('apple', `${tag}: ${basename(f.file)} already selected`); rows.push({ locale, placement: f.placement, file: f.file, action: 'keep' }); continue; }
let asset = bySum(sum);
const row = (action: Row['action'], asset?: J) => rows.push({ locale, placement: f.placement, file: f.file, action, state: asset?.attributes?.state, asset });
const placed = await placedIn(loc.id, ptype);
const shownIds = placed.map(shownOf).filter(Boolean);
const shown = assets.find(x => shownIds.includes(x.id) && sumOf(x) === sum);
if (placed.length === 1 && shown) { log(TAG, `${tag}: ${basename(f.file)} already selected (${shown.attributes?.state})`); row('keep', shown); continue; }
const pick = pickAsset(assets, sum);
if (pick.refuse) { warn(TAG, `${tag}: ${basename(f.file)} ${pick.refuse}`); row('refuse'); continue; }
let asset = pick.asset;
if (!asset) {
if (dry) { log('apple', `${tag}: would upload ${basename(f.file)} to the Asset Library (Apple approves it, then it is selected)`); rows.push({ locale, placement: f.placement, file: f.file, action: 'upload' }); continue; }
log('apple', `${tag}: uploading ${basename(f.file)} to the Asset Library`);
if (dry) { log(TAG, `${tag}: would upload ${basename(f.file)} to the Asset Library and select it once processed`); row('upload'); continue; }
log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`);
asset = await upload(f, sum);
assets = await library();
asset = bySum(sum) ?? asset;
asset = pickAsset(assets, sum).asset ?? asset;
} else if (pick.unarchive) {
if (dry) { log(TAG, `${tag}: would unarchive ${basename(f.file)} (same file, archived) and select it`); row(placed.length ? 'replace' : 'select', asset); continue; }
asset = (await c.asc('PATCH', `/v1/${asset.type}/${asset.id}`, { data: { type: asset.type, id: asset.id, attributes: { archived: false } } })).data as J;
log(TAG, `${tag}: unarchived ${basename(f.file)} (asset ${asset.id})`);
}
if (!dry) asset = await processed(asset, `${tag} ${basename(f.file)}`);
const cls = classify(asset.attributes?.state);
if (!selectable(cls)) {
if (cls === 'processing' && dry) { log(TAG, `${tag}: ${basename(f.file)} is processing in the Asset Library; would select it once processed`); row('wait', asset); continue; }
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;
}
const state = String(asset.attributes?.state ?? '');
if (state !== 'APPROVED') { log('apple', `${tag}: ${basename(f.file)} waiting for Apple's approval (${state || 'processing'}); re-run once it is approved to select it`); rows.push({ locale, placement: f.placement, file: f.file, action: 'wait', state }); continue; }
const action = placed.length ? 'replace' : 'select';
if (dry) { log('apple', `${tag}: would ${action === 'replace' ? `replace ${placed.length} selected asset(s) with` : 'select'} ${basename(f.file)}`); rows.push({ locale, placement: f.placement, file: f.file, action }); continue; }
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}`);
const media = f.kind === 'image' ? 'image' : 'video';
await 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 } } } } });
log('apple', `${tag}: selected ${basename(f.file)}`);
rows.push({ locale, placement: f.placement, file: f.file, action });
log(TAG, `${tag}: selected ${basename(f.file)} (${asset.attributes?.state})`);
row(action, asset);
}
}
if (dry) return done('apple', 'creative assets checked (dry run, nothing sent)');
// read back
let off = 0;
for (const r of rows.filter(r => r.action === 'select' || r.action === 'replace')) {
if (!dry) for (const r of rows.filter(r => r.action === 'select' || r.action === 'replace')) {
const loc = locs.find(l => l.attributes.locale === r.locale)!;
const now = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/placements?filter[placementType]=${PLACEMENT_TYPE[r.placement]}`);
if (now.length !== 1) { off++; warn('apple', `${r.locale} ${PLACEMENT_TYPE[r.placement]}: read back ${now.length} selected assets, wanted 1`); }
if (now.length !== 1 || shownOf(now[0]!) !== r.asset?.id) { off++; warn(TAG, `${r.locale} ${PLACEMENT_TYPE[r.placement]}: read back ${now.length} selected assets, wanted ${basename(r.file)}`); }
}
if (off) process.exitCode = 1;
// review: one item per distinct unreviewed asset in a slot
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);
let added = 0;
if (unreviewed.size && forReview) {
const kindOf = (a: J) => (a.type === 'appAssetLibraryVideos' ? 'appAssetLibraryVideo' : 'appAssetLibraryImage');
if (dry) for (const a of unreviewed.values()) log(TAG, `would add ${a.attributes?.fileName ?? a.id} to the draft review submission (${kindOf(a)})`);
else {
const { id: sid } = await ensureDraft(c, appId, root, TAG);
for (const a of unreviewed.values()) {
try { if (await addItem(c, sid, kindOf(a), a.id, `creative asset ${a.attributes?.fileName ?? a.id}`, TAG)) added++; }
catch (e) { if (e instanceof OtherSubmissionError) warn(TAG, `${a.attributes?.fileName ?? a.id}: already in review submission ${e.otherId}; nothing added`); else throw e; }
}
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` : ''}`;
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')}`);
}
if (n(['refuse'])) process.exitCode = 1;
return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary);
async function verifySlots(): Promise<void> {
let bad = 0;
for (const loc of locs) {
const locale: string = loc.attributes.locale;
for (const f of local.get(locale) ?? []) {
const ptype = PLACEMENT_TYPE[f.placement];
const sum = md5(readFileSync(f.file));
const placed = await placedIn(loc.id, ptype);
const shown = placed.map(p => assets.find(x => x.id === shownOf(p))).filter(Boolean) as J[];
const ok = placed.length === 1 && shown[0] && sumOf(shown[0]) === sum;
const what = shown.length ? shown.map(a => `${a.attributes?.fileName ?? a.id} (${a.attributes?.state})`).join(', ') : 'nothing';
if (ok) log(TAG, `ok ${locale} ${ptype}: ${what}`);
else { bad++; warn(TAG, `${locale} ${ptype}: shows ${what}, wanted ${basename(f.file)}${placed.length > 1 ? ` (${placed.length} selected)` : ''}`); }
}
}
if (bad) process.exitCode = 1;
done(TAG, `creative assets verified on version ${version!.attributes.versionString} (read-only): ${bad ? `${bad} slot(s) differ from the local files` : 'every slot shows the local file'}`);
}
const waiting = rows.filter(r => r.action === 'wait').length;
done('apple', `creative assets: ${rows.filter(r => r.action === 'keep').length} up to date, ${rows.filter(r => r.action === 'select' || r.action === 'replace').length} selected, ${waiting} waiting for approval${off ? `, ${off} did not read back` : ''}`);
}

View file

@ -80,7 +80,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'],
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>)'],
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library, selects once Apple approved it (MD5 upsert); --dry'],
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and selects it once processed (MD5 upsert); --add-for-review, --verify (read-only), --dry'],
'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'],
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first)'],
'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'],

View file

@ -88,7 +88,9 @@
"write"
],
"apple creative-assets": [
"src"
"add-for-review",
"src",
"verify"
],
"apple encryption": [
"build"
@ -890,6 +892,7 @@
"ws": [
"aab",
"accounts",
"add-for-review",
"adgroup",
"against",
"agent",
@ -1111,6 +1114,7 @@
"variant",
"variants",
"vendor",
"verify",
"version",
"version-code",
"version-name",
@ -1179,7 +1183,9 @@
"storebound apple cpp visible --name \"...\" [--off] [--dry] [--yes] show / hide an approved page (asks)"
],
"apple creative-assets": [
"storebound apple creative-assets [--src store/renders] [--only en-US,tr] [--dry]"
"storebound apple creative-assets [--src store/renders] [--only en-US,tr] [--dry] upload + select (upsert)",
"storebound apple creative-assets --add-for-review [--dry] also put unreviewed assets into the draft submission",
"storebound apple creative-assets --verify read-only: does every slot show the local file?"
],
"apple encryption": [
"storebound apple encryption [--build 42] [--dry]"

View file

@ -246,7 +246,11 @@ export const TOOLS: ToolDef[] = [
seconds: { type: 'integer', minimum: 15, maximum: 30 }, captions: { type: 'boolean', default: true } },
args: i => [...opt('lang', i.lang), ...opt('target', i.target), ...opt('seconds', i.seconds), ...(i.captions === false ? ['--no-captions'] : [])] },
{ name: 'apple_creative_assets', title: 'App Store header and search results assets', command: ['apple', 'creative-assets'], write: true, destructive: true, network: true, idempotent: true,
description: 'Header and Search Results (iOS / iPadOS 27+): checks <renders>/<lang>/creative/ (header 3840x1646, search 3:2, universal 5244x2950 PNG; no alpha; videos 5-30 s, 30/60 fps), uploads new files to the App Asset Library, and selects an asset for PRODUCT_PAGE_HEADER_ASSET / APP_STORE_SEARCH_RESULTS_ASSET only once Apple approved it (MD5 upsert, same file = nothing sent). Play counterpart: feature graphic + promo video.',
description: 'Header and Search Results (iOS / iPadOS 27+): checks <renders>/<lang>/creative/ (header 3840x1646, search 3:2, universal 5244x2950 PNG; no alpha; videos 5-30 s, 30/60 fps), uploads new files to the App Asset Library and selects them for PRODUCT_PAGE_HEADER_ASSET / APP_STORE_SEARCH_RESULTS_ASSET on the editable version once processed (Prepare for Submission is enough; MD5 upsert, same file = nothing sent, a rejected file is never re-uploaded). add_for_review puts unreviewed assets into the draft review submission (never submits). Play counterpart: feature graphic + promo video.',
props: { only, add_for_review: { type: 'boolean', default: false, description: 'Also add the selected, unreviewed assets to the draft review submission (Submit for Review stays the owner\'s)' } },
args: i => [...opt('only', i.only), ...(i.add_for_review === true ? ['--add-for-review'] : [])] },
{ name: 'apple_creative_assets_verify', title: 'Verify App Store header and search results', command: ['apple', 'creative-assets', '--verify'], write: false, network: true,
description: 'Read-only: does every Header and Search Results slot of the editable (else the live) App Store version show the local file (MD5), and in which review state? Fails on a slot that differs.',
props: { only }, args: i => opt('only', i.only) },
{ name: 'apple_previews', title: 'Upload App Store app previews', command: ['apple', 'previews'], write: true, destructive: true, network: true, idempotent: true,
description: 'Sync app preview videos per locale to the editable App Store version (checked locally first; MD5-idempotent, changed sets are replaced; poster frame time code). Not public until the version is submitted and released. Slow: prefer the terminal with a log.',

View file

@ -421,7 +421,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `apps_appPriceSchedule_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/appPriceSchedule` | read | |
| `apps_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersionExperimentsV2` | read | apple/ppo |
| `apps_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersionExperimentsV2` | read | |
| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, art, lib/store-apps, listing-store, onboard, validate |
| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/creative-assets, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, art, lib/store-apps, listing-store, onboard, validate |
| `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | |
| `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | |
| `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | |

View file

@ -0,0 +1,29 @@
# Draft: Header and Search Results support (not posted)
## Release tweet
storebound now fills App Store Connect's new Header and Search Results: a 21:9 product page header and a 3:2 search
results asset per language, image or video, checked locally, uploaded once, selected on the version and added to the
draft review submission. You still press Submit. `storebound apple creative-assets --dry`
## Announcement
**App Store Header and Search Results, end to end**
Since October 2026, App Store Connect has a Header and Search Results tab next to App Previews and Screenshots. On
iOS and iPadOS 27 these assets show at the top of the product page and in search results.
`storebound apple creative-assets` handles the whole flow through the App Store Connect API:
- **Checks before upload.** Header 3840 x 1646 (21:9), search results 3:2 (1920 x 1280 up to 3840 x 2560), or one
universal 5244 x 2950 PNG for both. No alpha. Videos run 5-30 s at 30 or 60 fps, and search videos play muted.
- **Upserts into the Asset Library.** Each file goes up once, matched by MD5. Re-runs send nothing. Archived copies are
restored instead of uploaded again, and a file App Review rejected is never re-sent.
- **Selects the assets on the version** once they are processed, as the console's Browse Assets does. No waiting on an
approval that only a submission can start.
- **`--add-for-review`** puts the new assets into the draft review submission, along with your first version or on
their own after that. storebound never submits; Submit for Review stays your button.
- **`--verify`** reads every slot back without writing and fails if a slot does not show your local file.
Dry run by default over MCP (`apple_creative_assets`, `apple_creative_assets_verify`). Google Play has no such slot.
The feature graphic and promo video are the closest match there.

View file

@ -98,11 +98,19 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a
play muted. `apple.creativeAssets.header: false` / `search: false` skips a slot.
- **Not rendered by `shots render`**: the scene templates are portrait device shots; make the wide art yourself (or from
the feature graphic artwork at the larger size) and drop it in.
- **Upload:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset Library
(API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`); Apple reviews each asset, and
only an approved one is selected for the slot (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` /
`APP_STORE_SEARCH_RESULTS_ASSET`). Until then the run says "waiting for Apple's approval": re-run later. Same file
(MD5) = nothing sent. MCP: `apple_creative_assets` (dry by default).
- **Upload and select:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset
Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`), and once processed
("Prepare for Submission", a bounded wait of 2 minutes with a heartbeat) the run selects them for the slots of the
editable version (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`), as the
console's Browse Assets > Save does. Approval is not needed first. Same file (MD5) = nothing sent; an archived copy is
unarchived; a file App Review rejected is never uploaded again (change the file). MCP: `apple_creative_assets` (dry by
default).
- **Review:** an asset is reviewed only once it is in a review submission (its own item, `appAssetLibraryImage` /
`appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the selected, unreviewed assets to the
app's draft submission, once: before the first approved version it is the same submission as that version, later
they can go alone. Pressing Submit for Review stays the owner's.
- **Verify:** `storebound apple creative-assets --verify` (MCP `apple_creative_assets_verify`) is read-only: every slot
of the editable version (else the live one) shows the local file, with its review state; exit 1 when one differs.
- **Checks:** `storebound check` and `validate` print an info note when no locale has one.
- **Play:** no such slot. The feature graphic (1024 x 500, `play images`) and the promo video (YouTube URL) are the
closest equivalents.

View file

@ -913,15 +913,22 @@ that fails without the fix (see CONTRIBUTING.md).
`screenshots-duo` note in check / validate. `tests/ppo.test.ts`.
## 76. Header and Search Results assets are not uploaded like screenshots
- **Symptom:** a header or search results image was "uploaded" but the version page still says "Browse approved
assets to select a header asset", or a re-run uploaded the same file again.
- **Symptom:** a header or search results image was "uploaded" but the version page shows no header, a run kept saying
"waiting for Apple's approval" for days, or a re-run uploaded the same file again.
- **Cause:** these slots take assets from the app's Asset Library (`GET /v1/apps/{id}/assetLibrary`), not a set of
their own. Each asset is reviewed by Apple first; only an APPROVED asset can be selected (an
`appAssetLibraryPlacements` row with `PRODUCT_PAGE_HEADER_ASSET` or `APP_STORE_SEARCH_RESULTS_ASSET` and the version
localization). Library assets carry no `sourceFileChecksum`, and the write-ups' name for the search slot is the
API's `APP_STORE_SEARCH_RESULTS_ASSET` (`SEARCH_RESULTS_ADS_ASSET` is the Apple Ads one).
- **Do:** `storebound apple creative-assets`: it keeps the file's MD5 in `referenceName` ("storebound <md5>"), reuses
a library asset with the same MD5, selects only approved ones and says "waiting for Apple's approval" otherwise.
Uploading and selecting are normal steps, not owner buttons; the version's Submit for Review stays the owner's.
- **Guard:** `cli/apple/creative-assets.ts`, `tests/creative-assets.test.ts` (fake App Store Connect: dry run, upload,
waiting, select, replace on a changed file, nothing on the same file).
their own: a slot is an `appAssetLibraryPlacements` row (`PRODUCT_PAGE_HEADER_ASSET` or
`APP_STORE_SEARCH_RESULTS_ASSET`, the version localization). An asset is reviewed only once it is in a review
submission (its own item, `reviewSubmissionItems` with `appAssetLibraryImage` / `appAssetLibraryVideo`; the console's
"Add for Review"). Apple's "App Store asset statuses": a processed asset is "Prepare for Submission" and can already
be selected for a version. So an earlier storebound that selected only APPROVED assets waited forever: nothing ever
sent the asset to review. Library assets carry no `sourceFileChecksum`, a rejected asset stays rejected (uploading
the same bytes again only adds a copy), and the search slot is the API's `APP_STORE_SEARCH_RESULTS_ASSET`
(`SEARCH_RESULTS_ADS_ASSET` is the Apple Ads one).
- **Do:** `storebound apple creative-assets`: MD5 in `referenceName` ("storebound <md5>"), reuse (or unarchive) a library
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. 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.

View file

@ -207,11 +207,19 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a
play muted. `apple.creativeAssets.header: false` / `search: false` skips a slot.
- **Not rendered by `shots render`**: the scene templates are portrait device shots; make the wide art yourself (or from
the feature graphic artwork at the larger size) and drop it in.
- **Upload:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset Library
(API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`); Apple reviews each asset, and
only an approved one is selected for the slot (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` /
`APP_STORE_SEARCH_RESULTS_ASSET`). Until then the run says "waiting for Apple's approval": re-run later. Same file
(MD5) = nothing sent. MCP: `apple_creative_assets` (dry by default).
- **Upload and select:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset
Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`), and once processed
("Prepare for Submission", a bounded wait of 2 minutes with a heartbeat) the run selects them for the slots of the
editable version (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`), as the
console's Browse Assets > Save does. Approval is not needed first. Same file (MD5) = nothing sent; an archived copy is
unarchived; a file App Review rejected is never uploaded again (change the file). MCP: `apple_creative_assets` (dry by
default).
- **Review:** an asset is reviewed only once it is in a review submission (its own item, `appAssetLibraryImage` /
`appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the selected, unreviewed assets to the
app's draft submission, once: before the first approved version it is the same submission as that version, later
they can go alone. Pressing Submit for Review stays the owner's.
- **Verify:** `storebound apple creative-assets --verify` (MCP `apple_creative_assets_verify`) is read-only: every slot
of the editable version (else the live one) shows the local file, with its review state; exit 1 when one differs.
- **Checks:** `storebound check` and `validate` print an info note when no locale has one.
- **Play:** no such slot. The feature graphic (1024 x 500, `play images`) and the promo video (YouTube URL) are the
closest equivalents.

View file

@ -54,7 +54,8 @@ describe('apple.name', () => {
expect((j.hints as string[]).join('\n')).toContain('already being used');
p = project({ ...NAMED, play: undefined }, files);
j = jsonOut((await capture(() => check(p.ctx(['--json'], ['json'])))).out);
expect(j.hints).toEqual([]);
// only the name hint is under test (the Duo and Header and Search Results notes are hints of their own)
expect((j.hints as string[]).filter(h => /App Store name|already being used/.test(h))).toEqual([]);
p = project({ ...PLAIN, app: { ...PLAIN.app, name: 'STAR DASH Stone Age to the Year 2100' }, play: undefined }, files);
const r = await capture(() => check(p.ctx(['--json'], ['json'])));
j = jsonOut(r.out);

View file

@ -1,9 +1,10 @@
// App Store "Header and Search Results" creative assets: local size / alpha / video checks, the check / validate note,
// and `apple creative-assets` against the fake App Store Connect (Asset Library upload, approval wait, select, replace
// on a changed file, nothing on the same file).
// and `apple creative-assets` against the fake App Store Connect (Asset Library upload, select once processed (no
// approval wait: pitfall 76), replace on a changed file, nothing on the same file, rejected / failed / archived files,
// --add-for-review into the draft submission without submitting, --verify read-only).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { md5 } from '../cli/apple/asc';
import { REF, run } from '../cli/apple/creative-assets';
import { classify, MAX_FAILED, pickAsset, processingTiming, REF, run, selectable } from '../cli/apple/creative-assets';
import { checkCreativeImage, checkCreativeVideo, CREATIVE_NOTE, creativeFiles, creativeNote } from '../cli/shots/creative';
import { imageInfo } from '../cli/shots/spec';
import { upsertTiming } from '../cli/lib/upsert';
@ -62,11 +63,31 @@ describe('creative asset checks', () => {
});
});
describe('asset states (pitfall 76: Prepare for Submission is selectable)', () => {
test('classify and selectable', () => {
expect(['AWAITING_UPLOAD', 'UPLOAD_COMPLETE', undefined].map(classify)).toEqual(['processing', 'processing', 'processing']);
expect(selectable(classify('PREPARE_FOR_SUBMISSION'))).toBe(true);
expect(selectable(classify('APPROVED'))).toBe(true);
expect(selectable(classify('IN_REVIEW'))).toBe(true);
for (const s of ['FAILED', 'REJECTED', 'ARCHIVED', 'UPLOAD_COMPLETE']) expect(selectable(classify(s))).toBe(false);
});
const a = (id: string, sum: string, state: string, stateDetails?: unknown[]) => ({ id, type: 'appAssetLibraryImages', attributes: { referenceName: REF(sum), state, stateDetails } });
const S = 'a'.repeat(32);
test('pickAsset: reviewed beats unreviewed, archived is unarchived, rejected and repeated failures refuse', () => {
expect(pickAsset([a('1', S, 'PREPARE_FOR_SUBMISSION'), a('2', S, 'APPROVED')], S).asset!.id).toBe('2');
expect(pickAsset([a('1', S, 'ARCHIVED')], S)).toMatchObject({ unarchive: true });
expect(pickAsset([a('1', S, 'REJECTED', [{ code: 'GUIDELINE_2_3' }])], S).refuse).toContain('GUIDELINE_2_3');
expect(pickAsset([a('1', S, 'FAILED')], S)).toEqual({});
expect(pickAsset(Array.from({ length: MAX_FAILED }, (_, i) => a(String(i), S, 'FAILED')), S).refuse).toContain('failed processing');
expect(pickAsset([a('1', 'b'.repeat(32), 'APPROVED')], S)).toEqual({});
});
});
describe('apple creative-assets against the fake App Store Connect', () => {
let s: Stores;
beforeAll(ascEnv);
afterAll(restore);
beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); });
beforeEach(() => { upsertTiming.waitMs = 0; processingTiming.timeoutMs = 0; processingTiming.pollMs = 0; s = installStores(); });
afterEach(uninstallStores);
const APP = '6400000101';
const CFG = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] };
@ -76,47 +97,84 @@ describe('apple creative-assets against the fake App Store Connect', () => {
s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-en', attributes: { locale: 'en-US' }, rels: { appStoreVersion: 'ver-1' } });
return project(CFG, files);
};
const go = async (p: ReturnType<typeof project>, argv: string[] = []) => {
const go = async (p: ReturnType<typeof project>, argv: string[] = [], exit = 0) => {
const a = s.calls.length;
const r = await capture(() => run(p.ctx(argv)));
if (r.exit) throw new Error(`exit ${r.exit}: ${r.out}\n${r.err}`);
return { out: r.out, w: writes(s.calls.slice(a)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '').replace(/\/v1\/(\w+)\/[^/]+$/, '/v1/$1/{id}')}`).filter(x => x.includes(' /v1/')) };
if ((r.exit ?? 0) !== exit) throw new Error(`exit ${r.exit}: ${r.out}\n${r.err}`);
return { out: r.out + r.err, w: writes(s.calls.slice(a)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '').replace(/\/v1\/(\w+)\/[^/]+$/, '/v1/$1/{id}')}`).filter(x => x.includes(' /v1/')) };
};
const approve = () => { for (const t of ['appAssetLibraryImages', 'appAssetLibraryVideos']) for (const x of s.asc.all(t)) x.attributes.state = 'APPROVED'; };
test('dry run, upload, wait for approval, select, same file = nothing, changed file = replace', async () => {
const header = png(3840, 1646, false, 1);
const p = setup({ 'store/renders/en/creative/header.png': header, 'store/renders/en/creative/search.png': png(1920, 1280, false, 2) });
const lib = () => [...s.asc.all('appAssetLibraryImages'), ...s.asc.all('appAssetLibraryVideos')];
const files = (h = 1, q = 2) => ({ 'store/renders/en/creative/header.png': png(3840, 1646, false, h), 'store/renders/en/creative/search.png': png(1920, 1280, false, q) });
test('dry run, upload + select in one run (no approval wait), same file = nothing, changed file = replace', async () => {
const p = setup(files());
const dry = await go(p, ['--dry']);
expect(dry.w).toEqual([]);
expect(dry.out).toContain('would upload header.png');
const first = await go(p);
expect(first.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
expect(first.out).toContain("waiting for Apple's approval");
expect(s.asc.all('appAssetLibraryImages').map(x => x.attributes.referenceName)).toContain(REF(md5(header)));
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0);
const again = await go(p);
expect(again.w).toEqual([]); // still waiting: no second upload
approve();
const sel = await go(p);
expect(sel.w).toEqual(['POST /v1/appAssetLibraryPlacements', 'POST /v1/appAssetLibraryPlacements']);
expect(first.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements',
'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(lib().map(x => x.attributes.referenceName)).toContain(REF(md5(files()['store/renders/en/creative/header.png'])));
expect(s.asc.all('appAssetLibraryPlacements').map(x => x.attributes.placementType).sort()).toEqual(['APP_STORE_SEARCH_RESULTS_ASSET', 'PRODUCT_PAGE_HEADER_ASSET']);
expect(first.out).toContain('--add-for-review');
expect(first.out).not.toContain("waiting for Apple's approval");
expect((await go(p)).w).toEqual([]);
// a changed header: uploaded, selected only after approval, then the old placement is replaced
const q = project(CFG, { 'store/renders/en/creative/header.png': png(3840, 1646, false, 9), 'store/renders/en/creative/search.png': png(1920, 1280, false, 2) });
expect((await go(q)).w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
approve();
const q = project(CFG, files(9));
const rep = await go(q);
expect(rep.w).toEqual(['DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(rep.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(2);
});
test('processing that outlasts the bounded wait: not selected, re-run selects without a second upload', async () => {
const f = files();
const p = setup(f);
for (const [k, id] of [['header', 'img-h'], ['search', 'img-s']] as const)
s.asc.put({ type: 'appAssetLibraryImages', id, attributes: { category: 'CREATIVE_ASSETS', referenceName: REF(md5(f[`store/renders/en/creative/${k}.png`])), state: 'UPLOAD_COMPLETE', fileName: `${k}.png` }, rels: { assetLibrary: 'lib-1' } });
const r1 = await go(p);
expect(r1.w).toEqual([]);
expect(r1.out).toContain('still UPLOAD_COMPLETE');
expect(r1.out).toContain('2 still processing');
for (const x of lib()) x.attributes.state = 'PREPARE_FOR_SUBMISSION';
expect((await go(p)).w).toEqual(['POST /v1/appAssetLibraryPlacements', 'POST /v1/appAssetLibraryPlacements']);
});
test('a rejected file is not uploaded again; an archived one is unarchived, not re-uploaded', async () => {
const f = files();
const p = setup(f);
s.asc.put({ type: 'appAssetLibraryImages', id: 'img-rej', attributes: { category: 'CREATIVE_ASSETS', referenceName: REF(md5(f['store/renders/en/creative/header.png'])), state: 'REJECTED', fileName: 'header.png' }, rels: { assetLibrary: 'lib-1' } });
s.asc.put({ type: 'appAssetLibraryImages', id: 'img-arc', attributes: { category: 'CREATIVE_ASSETS', referenceName: REF(md5(f['store/renders/en/creative/search.png'])), state: 'ARCHIVED', fileName: 'search.png' }, rels: { assetLibrary: 'lib-1' } });
const r = await go(p, [], 1);
expect(r.out).toContain('rejected by App Review');
expect(r.w).toEqual(['PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements').map(x => x.attributes.placementType)).toEqual(['APP_STORE_SEARCH_RESULTS_ASSET']);
});
test('--add-for-review: unreviewed assets go into the draft once, nothing is submitted', async () => {
const p = setup(files());
await go(p);
const dry = await go(p, ['--add-for-review', '--dry']);
expect(dry.w).toEqual([]);
const add = await go(p, ['--add-for-review']);
expect(add.w).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems']);
expect(add.out).toContain('nothing was submitted');
expect(add.w.some(x => x.startsWith('PATCH /v1/reviewSubmissions'))).toBe(false);
expect((await go(p, ['--add-for-review'])).w).toEqual([]);
});
test('--verify is read-only and fails on a slot that differs', async () => {
const p = setup(files());
const before = await go(p, ['--verify'], 1);
expect(before.w).toEqual([]);
expect(before.out).toContain('shows nothing, wanted header.png');
await go(p);
const ok = await go(p, ['--verify']);
expect(ok.w).toEqual([]);
expect(ok.out).toContain('every slot shows the local file');
});
test('a bad file stops the run before any call', async () => {
const p = setup({ 'store/renders/en/creative/header.png': png(3840, 1600, true) });
const a = s.calls.length;

View file

@ -102,7 +102,8 @@ const UNIQUE: Record<string, (r: R) => string> = {
betaTesters: r => String(r.attributes.email).toLowerCase(),
};
const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$|^appAssetLibrary(Images|Videos)$/;
/** App Asset Library assets: a state of their own (AWAITING_UPLOAD -> WAITING_FOR_REVIEW after the commit; a test sets APPROVED). */
/** App Asset Library assets: a state of their own (AWAITING_UPLOAD -> PREPARE_FOR_SUBMISSION after the commit, as Apple's
* "App Store asset statuses" describe it: processed, not in a submission yet; a test sets the review states). */
const LIBRARY = /^appAssetLibrary(Images|Videos)$/;
/** Attributes a new resource of a type starts with. */
const DEFAULTS: Record<string, Record<string, unknown>> = {
@ -310,9 +311,14 @@ export class FakeAsc {
rels: { [singular(self.type)]: self.id, ...Object.fromEntries(Object.entries(inc.relationships ?? {}).map(([k, v]) => [k, v.data.id])) } });
if (attrs.uploaded === true && ASSETS.test(self.type)) {
const { uploaded: _u, ...rest } = attrs;
if (LIBRARY.test(self.type)) Object.assign(self.attributes, rest, { state: this.parts.includes(self.id) ? 'WAITING_FOR_REVIEW' : 'FAILED' });
if (LIBRARY.test(self.type)) Object.assign(self.attributes, rest, { state: this.parts.includes(self.id) ? 'PREPARE_FOR_SUBMISSION' : 'FAILED' });
else Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
delete self.attributes.uploadOperations;
} else if (LIBRARY.test(self.type) && typeof attrs.archived === 'boolean') {
// archive / unarchive (model: an unarchived asset is back in Prepare for Submission)
const { archived, ...rest } = attrs;
Object.assign(self.attributes, rest, { state: archived ? 'ARCHIVED' : 'PREPARE_FOR_SUBMISSION' });
delete self.attributes.archived;
} else if (self.type === 'reviewSubmissions' && (attrs.submitted === true || attrs.canceled === true)) {
// seen live (2026-10-12): a REJECTED item that is not marked resolved blocks every submit with this 409, and an
// empty draft cannot be canceled