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 ## 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 ## 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) - **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_appPricePoints_getToManyRelated': 'apple/appinfo',
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate', 'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo', '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_assetLibrary_getToOneRelated': 'apple/creative-assets',
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight', 'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
'apps_betaAppReviewDetail_getToOneRelated': '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: // 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), // 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 // 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. // 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 // Flow, as App Store Connect works (version page > Product Page Information > Header and Search Results > Header /
// uploaded once into the app's Asset Library (POST appAssetLibraryImages / Videos, category CREATIVE_ASSETS, its MD5 // Search Results > Browse Assets > Save; Help "Manage your App Store assets", "App Store asset statuses",
// in referenceName "storebound <md5>" because library assets carry no checksum), Apple reviews it, and only an // "Submit assets from Asset Library", checked 19 Oct 2026):
// APPROVED asset is selected for a slot (POST appAssetLibraryPlacements with placementType PRODUCT_PAGE_HEADER_ASSET or // 1. a file is uploaded once into the app's Asset Library (POST appAssetLibraryImages / Videos, category
// APP_STORE_SEARCH_RESULTS_ASSET and the version localization). Upsert: a slot that already shows the same MD5 is left // CREATIVE_ASSETS, its MD5 in referenceName "storebound <md5>" because library assets carry no checksum);
// alone; a new file replaces the slot only once it is approved (the old placement is removed, then the new one is // 2. after processing it is "Prepare for Submission", and in that state it can already be selected for a slot
// created); an asset with the same MD5 already in the library is reused, never uploaded twice. Read back at the end. // (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. // 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 { readFileSync } from 'node:fs';
import { basename } from 'node:path'; import { basename } from 'node:path';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
import { consoleLink } from '../lib/console-links';
import { done, log, warn } from '../lib/log'; import { done, log, warn } from '../lib/log';
import { elapsed } from '../lib/progress';
import * as rt from '../lib/rt';
import { ffprobe } from '../shots/report'; import { ffprobe } from '../shots/report';
import { imageInfo } from '../shots/spec'; import { imageInfo } from '../shots/spec';
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative'; import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
import { ascClient, md5, type J } from './asc'; import { ascClient, md5, type J } from './asc';
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
const TAG = 'apple';
export const REF = (sum: string): string => `storebound ${sum}`; 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]; 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. */ /** 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[]> } { 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); fs = fs.filter(f => ca[f.placement] !== false);
for (const f of fs) { 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); 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 (issues.some(i => i.level === 'error')) bad++;
} }
if (fs.length) local.set(l.apple, fs); 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> { export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded; const { config, root } = ctx.loaded;
const dry = ctx.args.has('dry'); 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 bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const { src, local } = localCreative(ctx); 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; } 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('apple', `${[...local.values()].flat().length} creative assets for ${local.size} locales pass the checks`); log(TAG, `${[...local.values()].flat().length} creative assets for ${local.size} locales pass the checks`);
let c: Awaited<ReturnType<typeof ascClient>>; let c: Awaited<ReturnType<typeof ascClient>>;
try { c = await ascClient(root, bundleId, config.apple?.appId); } try { c = await ascClient(root, bundleId, config.apple?.appId); }
catch (e) { catch (e) {
if (!dry) throw 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`); 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('apple', `creative assets planned (dry run, no App Store Connect comparison: ${(e as Error).message.split('\n')[0]})`); 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 appId = await c.app();
const version = await c.editableVersion(); const lib = (await c.asc('GET', `/v1/apps/${appId}/assetLibrary`)).data as J;
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)'); 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 locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
const library = async (): Promise<J[]> => [ 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}/images?filter[category]=CREATIVE_ASSETS&limit=200`)),
...(await c.all(`/v1/appAssetLibraries/${lib.id}/videos?filter[category]=CREATIVE_ASSETS&limit=200`)), ...(await c.all(`/v1/appAssetLibraries/${lib.id}/videos?filter[category]=CREATIVE_ASSETS&limit=200`)),
]; ];
let assets = await library(); 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 placedIn = async (locId: string, ptype: string) => c.all(`/v1/appStoreVersionLocalizations/${locId}/placements?filter[placementType]=${ptype}&include=image,video`);
const rows: Row[] = []; 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> { async function upload(f: CreativeFile, sum: string): Promise<J> {
const buf = readFileSync(f.file); const buf = readFileSync(f.file);
const type = f.kind === 'image' ? 'appAssetLibraryImages' : 'appAssetLibraryVideos'; 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[]) { for (const op of (res.attributes.uploadOperations ?? []) as J[]) {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
for (const h of op.requestHeaders ?? []) headers[h.name] = h.value; 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}`); 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; 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) { for (const loc of locs) {
const locale: string = loc.attributes.locale; 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 ptype = PLACEMENT_TYPE[f.placement];
const tag = `${locale} ${ptype}`; const tag = `${locale} ${ptype}`;
const sum = md5(readFileSync(f.file)); const sum = md5(readFileSync(f.file));
const placed = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/placements?filter[placementType]=${ptype}&include=image,video`); const row = (action: Row['action'], asset?: J) => rows.push({ locale, placement: f.placement, file: f.file, action, state: asset?.attributes?.state, asset });
const shownIds = placed.map(p => p.relationships?.image?.data?.id ?? p.relationships?.video?.data?.id).filter(Boolean); const placed = await placedIn(loc.id, ptype);
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; } const shownIds = placed.map(shownOf).filter(Boolean);
let asset = bySum(sum); 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 (!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; } if (dry) { log(TAG, `${tag}: would upload ${basename(f.file)} to the Asset Library and select it once processed`); row('upload'); continue; }
log('apple', `${tag}: uploading ${basename(f.file)} to the Asset Library`); log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`);
asset = await upload(f, sum); asset = await upload(f, sum);
assets = await library(); 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'; 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}`); for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
const media = f.kind === 'image' ? 'image' : 'video'; const media = f.kind === 'image' ? 'image' : 'video';
await c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: ptype }, 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 } } } } }); relationships: { [media]: { data: { type: asset.type, id: asset.id } }, appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: loc.id } } } } });
log('apple', `${tag}: selected ${basename(f.file)}`); log(TAG, `${tag}: selected ${basename(f.file)} (${asset.attributes?.state})`);
rows.push({ locale, placement: f.placement, file: f.file, action }); row(action, asset);
} }
} }
if (dry) return done('apple', 'creative assets checked (dry run, nothing sent)');
// read back // read back
let off = 0; 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 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]}`); 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 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 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 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 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 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'], '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" "write"
], ],
"apple creative-assets": [ "apple creative-assets": [
"src" "add-for-review",
"src",
"verify"
], ],
"apple encryption": [ "apple encryption": [
"build" "build"
@ -890,6 +892,7 @@
"ws": [ "ws": [
"aab", "aab",
"accounts", "accounts",
"add-for-review",
"adgroup", "adgroup",
"against", "against",
"agent", "agent",
@ -1111,6 +1114,7 @@
"variant", "variant",
"variants", "variants",
"vendor", "vendor",
"verify",
"version", "version",
"version-code", "version-code",
"version-name", "version-name",
@ -1179,7 +1183,9 @@
"storebound apple cpp visible --name \"...\" [--off] [--dry] [--yes] show / hide an approved page (asks)" "storebound apple cpp visible --name \"...\" [--off] [--dry] [--yes] show / hide an approved page (asks)"
], ],
"apple creative-assets": [ "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": [ "apple encryption": [
"storebound apple encryption [--build 42] [--dry]" "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 } }, 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'] : [])] }, 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, { 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) }, 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, { 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.', 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_appPriceSchedule_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/appPriceSchedule` | read | |
| `apps_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersionExperimentsV2` | read | apple/ppo | | `apps_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersionExperimentsV2` | read | apple/ppo |
| `apps_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersionExperimentsV2` | read | | | `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_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | |
| `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | | | `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | |
| `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/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. 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 - **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. 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 - **Upload and select:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset
(API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`); Apple reviews each asset, and Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`), and once processed
only an approved one is selected for the slot (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / ("Prepare for Submission", a bounded wait of 2 minutes with a heartbeat) the run selects them for the slots of the
`APP_STORE_SEARCH_RESULTS_ASSET`). Until then the run says "waiting for Apple's approval": re-run later. Same file editable version (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`), as the
(MD5) = nothing sent. MCP: `apple_creative_assets` (dry by default). 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. - **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 - **Play:** no such slot. The feature graphic (1024 x 500, `play images`) and the promo video (YouTube URL) are the
closest equivalents. 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`. `screenshots-duo` note in check / validate. `tests/ppo.test.ts`.
## 76. Header and Search Results assets are not uploaded like screenshots ## 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 - **Symptom:** a header or search results image was "uploaded" but the version page shows no header, a run kept saying
assets to select a header asset", or a re-run uploaded the same file again. "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 - **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 their own: a slot is an `appAssetLibraryPlacements` row (`PRODUCT_PAGE_HEADER_ASSET` or
`appAssetLibraryPlacements` row with `PRODUCT_PAGE_HEADER_ASSET` or `APP_STORE_SEARCH_RESULTS_ASSET` and the version `APP_STORE_SEARCH_RESULTS_ASSET`, the version localization). An asset is reviewed only once it is in a review
localization). Library assets carry no `sourceFileChecksum`, and the write-ups' name for the search slot is the submission (its own item, `reviewSubmissionItems` with `appAssetLibraryImage` / `appAssetLibraryVideo`; the console's
API's `APP_STORE_SEARCH_RESULTS_ASSET` (`SEARCH_RESULTS_ADS_ASSET` is the Apple Ads one). "Add for Review"). Apple's "App Store asset statuses": a processed asset is "Prepare for Submission" and can already
- **Do:** `storebound apple creative-assets`: it keeps the file's MD5 in `referenceName` ("storebound <md5>"), reuses be selected for a version. So an earlier storebound that selected only APPROVED assets waited forever: nothing ever
a library asset with the same MD5, selects only approved ones and says "waiting for Apple's approval" otherwise. sent the asset to review. Library assets carry no `sourceFileChecksum`, a rejected asset stays rejected (uploading
Uploading and selecting are normal steps, not owner buttons; the version's Submit for Review stays the owner's. the same bytes again only adds a copy), and the search slot is the API's `APP_STORE_SEARCH_RESULTS_ASSET`
- **Guard:** `cli/apple/creative-assets.ts`, `tests/creative-assets.test.ts` (fake App Store Connect: dry run, upload, (`SEARCH_RESULTS_ADS_ASSET` is the Apple Ads one).
waiting, select, replace on a changed file, nothing on the same file). - **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. 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 - **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. 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 - **Upload and select:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset
(API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`); Apple reviews each asset, and Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`), and once processed
only an approved one is selected for the slot (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / ("Prepare for Submission", a bounded wait of 2 minutes with a heartbeat) the run selects them for the slots of the
`APP_STORE_SEARCH_RESULTS_ASSET`). Until then the run says "waiting for Apple's approval": re-run later. Same file editable version (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`), as the
(MD5) = nothing sent. MCP: `apple_creative_assets` (dry by default). 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. - **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 - **Play:** no such slot. The feature graphic (1024 x 500, `play images`) and the promo video (YouTube URL) are the
closest equivalents. closest equivalents.

View file

@ -54,7 +54,8 @@ describe('apple.name', () => {
expect((j.hints as string[]).join('\n')).toContain('already being used'); expect((j.hints as string[]).join('\n')).toContain('already being used');
p = project({ ...NAMED, play: undefined }, files); p = project({ ...NAMED, play: undefined }, files);
j = jsonOut((await capture(() => check(p.ctx(['--json'], ['json'])))).out); 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); 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']))); const r = await capture(() => check(p.ctx(['--json'], ['json'])));
j = jsonOut(r.out); 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, // 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 // and `apple creative-assets` against the fake App Store Connect (Asset Library upload, select once processed (no
// on a changed file, nothing on the same file). // 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { md5 } from '../cli/apple/asc'; 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 { checkCreativeImage, checkCreativeVideo, CREATIVE_NOTE, creativeFiles, creativeNote } from '../cli/shots/creative';
import { imageInfo } from '../cli/shots/spec'; import { imageInfo } from '../cli/shots/spec';
import { upsertTiming } from '../cli/lib/upsert'; 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', () => { describe('apple creative-assets against the fake App Store Connect', () => {
let s: Stores; let s: Stores;
beforeAll(ascEnv); beforeAll(ascEnv);
afterAll(restore); afterAll(restore);
beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); }); beforeEach(() => { upsertTiming.waitMs = 0; processingTiming.timeoutMs = 0; processingTiming.pollMs = 0; s = installStores(); });
afterEach(uninstallStores); afterEach(uninstallStores);
const APP = '6400000101'; const APP = '6400000101';
const CFG = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] }; 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' } }); s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-en', attributes: { locale: 'en-US' }, rels: { appStoreVersion: 'ver-1' } });
return project(CFG, files); 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 a = s.calls.length;
const r = await capture(() => run(p.ctx(argv))); const r = await capture(() => run(p.ctx(argv)));
if (r.exit) throw new Error(`exit ${r.exit}: ${r.out}\n${r.err}`); if ((r.exit ?? 0) !== 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/')) }; 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'; }; 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, 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) });
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']); const dry = await go(p, ['--dry']);
expect(dry.w).toEqual([]); expect(dry.w).toEqual([]);
expect(dry.out).toContain('would upload header.png'); expect(dry.out).toContain('would upload header.png');
const first = await go(p); 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.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements',
expect(first.out).toContain("waiting for Apple's approval"); 'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryImages').map(x => x.attributes.referenceName)).toContain(REF(md5(header))); expect(lib().map(x => x.attributes.referenceName)).toContain(REF(md5(files()['store/renders/en/creative/header.png'])));
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(s.asc.all('appAssetLibraryPlacements').map(x => x.attributes.placementType).sort()).toEqual(['APP_STORE_SEARCH_RESULTS_ASSET', 'PRODUCT_PAGE_HEADER_ASSET']); 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([]); expect((await go(p)).w).toEqual([]);
// a changed header: uploaded, selected only after approval, then the old placement is replaced const q = project(CFG, files(9));
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 rep = await go(q); 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); 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 () => { 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 p = setup({ 'store/renders/en/creative/header.png': png(3840, 1600, true) });
const a = s.calls.length; 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(), betaTesters: r => String(r.attributes.email).toLowerCase(),
}; };
const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$|^appAssetLibrary(Images|Videos)$/; 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)$/; const LIBRARY = /^appAssetLibrary(Images|Videos)$/;
/** Attributes a new resource of a type starts with. */ /** Attributes a new resource of a type starts with. */
const DEFAULTS: Record<string, Record<string, unknown>> = { 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])) } }); 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)) { if (attrs.uploaded === true && ASSETS.test(self.type)) {
const { uploaded: _u, ...rest } = attrs; 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' } }); else Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
delete self.attributes.uploadOperations; 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)) { } 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 // 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 // empty draft cannot be canceled