apple creative-assets: place Header and Search Results assets once Apple approved them (the version page offers only approved assets); waiting files are reported per locale and placement with their upload date and the re-run command, in the run, --verify, validate and the report; iPhone Duo: the published API spec still has no Duo type, the note says the console takes Duo and the API does not yet

This commit is contained in:
CRTkafa 2026-10-20 03:04:14 +07:00
parent 47dfd372fe
commit c2f34c62f3
13 changed files with 163 additions and 78 deletions

View file

@ -2,20 +2,22 @@
## 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. A live app without
an editable version uses the live version: approved assets are selected there, new ones are uploaded and wait for a
review of their own. Art without words needs no copy per locale: `<renders>/creative/header.png` / `search.*`
fill every language without its own file, uploaded once and placed per locale (the dry run counts files, not slots).
If App Store Connect refuses a placement on the live version, the run says so, leaves the live page unchanged (the
new placement goes first, the old one is removed only after Apple accepted it), links the live version page and does
not retry the other live slots. Pitfall 76 rewritten.
- **Header and Search Results: upload, add for review, place once approved.** `storebound apple creative-assets`
uploads each file once into the Asset Library (MD5 upsert: a rejected file or one that failed processing twice is
not uploaded again, an archived copy is unarchived), and places it on the version as soon as Apple approved it: the
version page's slots offer only approved assets. Until then every locale and placement says "uploaded <date>,
waiting for Apple's approval; run `storebound apple creative-assets` again later", and a re-run places it (an
approved old asset stays shown meanwhile). `--add-for-review` adds the 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 with the same waiting state, and `validate` / the report list waiting files. A live
app without an editable version uses the live version; if App Store Connect refuses a placement there, the run says
so, leaves the live page unchanged (the new placement goes first, the old one is removed only after Apple accepted
it), links the live version page and does not retry the other live slots. Art without words needs no copy per
locale: `<renders>/creative/header.png` / `search.*` fill every language without its own file, uploaded once and
placed per locale (the dry run counts files, not slots). Pitfall 76 rewritten.
- **iPhone Duo.** Apple's published App Store Connect API spec (re-checked: still the 2026-10-06 file) has no Duo
screenshot or preview type, so `apple screenshots` keeps skipping the Duo sets with the console link; the check /
validate note now says the console takes Duo and the API does not yet.
- **macOS (tvOS, visionOS) versions are no longer invisible.** `apple status`, `listing`, `listing pull`, `resubmit`,
`submit`, `release` and `screenshots` take `--platform ios|macos|tvos|visionos` (MCP tools and the desktop's owner

View file

@ -10,14 +10,14 @@
// "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.
// 2. after processing it is "Prepare for Submission"; App Review reviews it as a review submission item of its own
// (reviewSubmissionItems appAssetLibraryImage / appAssetLibraryVideo; console "Add for Review"): before the app's
// first approved version in the same submission as that version, afterwards alone. --add-for-review adds it to
// the draft submission; pressing Submit for Review stays the owner's;
// 3. the version page's slots offer only approved assets ("Browse approved assets", seen in a real release, 20 Oct
// 2026): an APPROVED asset is selected (POST appAssetLibraryPlacements, placementType PRODUCT_PAGE_HEADER_ASSET /
// APP_STORE_SEARCH_RESULTS_ASSET, the version localization); any other one is reported per locale and placement
// as "uploaded <date>, waiting for Apple's approval", and a later run places it (pitfall 76).
// 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.
@ -33,7 +33,7 @@ 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 { type Asc, ascClient, md5, type J } from './asc';
import { AmbiguousWriteError } from '../lib/upsert';
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
@ -83,6 +83,29 @@ export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: bo
return {};
}
/** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later".
* The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that
* is not APPROVED waits; a later run places it. Pure. */
export function waitingText(a: J): string {
const d = String(a?.attributes?.createdDate ?? '').slice(0, 10);
return `${d ? `uploaded ${d}, ` : ''}waiting for Apple's approval (${a?.attributes?.state ?? '?'}); run \`storebound apple creative-assets\` again later`;
}
/** Local creative files whose library asset (same MD5) is processed but not APPROVED yet, per locale and placement:
* for validate and the report (one library read, no placements). */
export async function creativeWaiting(c: Asc, appId: string, local: Map<string, CreativeFile[]>): Promise<{ locale: string; placement: string; file: string; text: string }[]> {
const lib = (await c.maybe(`/v1/apps/${appId}/assetLibrary`))?.data as J | undefined;
if (!lib) return [];
const assets = [...(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`))];
const out: { locale: string; placement: string; file: string; text: string }[] = [];
for (const [locale, fs] of local) for (const f of fs) {
const a = pickAsset(assets, md5(readFileSync(f.file))).asset;
if (a && selectable(classify(a.attributes?.state)) && a.attributes?.state !== 'APPROVED') out.push({ locale, placement: PLACEMENT_TYPE[f.placement], file: basename(f.file), text: waitingText(a) });
}
return out;
}
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(', ')})` : '';
@ -146,7 +169,7 @@ export async function run(ctx: Ctx): Promise<void> {
const version = editable ?? await live();
const onLive = !editable;
if (!version) throw new Error(`no editable or live App Store version: the header and search results slots belong to a version page (run apple listing --version x.y.z first). ${consoleLink('asc.version')}`);
if (onLive && !verify) log(TAG, `no editable version: using the live version ${version.attributes.versionString}, where only approved assets can be selected`);
if (onLive && !verify) log(TAG, `no editable version: using the live version ${version.attributes.versionString}`);
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`)),
@ -208,7 +231,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (dry) {
// one upload per file: a later locale with the same file reuses it (the real run finds it by MD5)
const first = !planned.has(sum); planned.add(sum);
log(TAG, `${tag}: would ${first ? 'upload' : 'reuse'} ${basename(f.file)}${first ? ' to the Asset Library' : ' (uploaded once for an earlier locale)'} and select it ${onLive ? 'on the live version once approved' : 'once processed'}`);
log(TAG, `${tag}: would ${first ? 'upload' : 'reuse'} ${basename(f.file)}${first ? ' to the Asset Library' : ' (uploaded once for an earlier locale)'} and select it once Apple approved it`);
row(first ? 'upload' : 'reuse'); continue;
}
log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`);
@ -227,8 +250,9 @@ export async function run(ctx: Ctx): Promise<void> {
if (cls === 'processing') { row('wait', asset); continue; }
warn(TAG, `${tag}: ${basename(f.file)} is ${asset.attributes?.state}${reason(asset)}; not selected`); row('refuse', asset); continue;
}
if (onLive && asset.attributes?.state !== 'APPROVED') {
log(TAG, `${tag}: ${basename(f.file)} is ${asset.attributes?.state} in the Asset Library; the live version takes it once App Review approved it (re-run then)`);
// the version page's slots take only approved assets ("Browse approved assets"): the rest wait, a re-run places them
if (asset.attributes?.state !== 'APPROVED') {
log(TAG, `${tag}: ${basename(f.file)} ${waitingText(asset)}`);
row('approve', asset); continue;
}
const action = placed.length ? 'replace' : 'select';
@ -285,7 +309,7 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${added} creative asset(s) added to draft review submission ${sid}; nothing was submitted`);
}
}
const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} file(s) to upload${n(['reuse']) ? ` (used by ${n(['upload', 'reuse'])} slots)` : ''}, ${n(['wait'])} still processing, ${approve ? `${approve} waiting for App Review (live version), ` : ''}${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`;
const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} file(s) to upload${n(['reuse']) ? ` (used by ${n(['upload', 'reuse'])} slots)` : ''}, ${n(['wait'])} still processing, ${approve ? `${approve} waiting for Apple's approval, ` : ''}${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')}`
@ -295,7 +319,7 @@ export async function run(ctx: Ctx): Promise<void> {
return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary);
async function verifySlots(): Promise<void> {
let bad = 0;
let bad = 0, waiting = 0;
for (const loc of locs) {
const locale: string = loc.attributes.locale;
for (const f of local.get(locale) ?? []) {
@ -305,11 +329,13 @@ export async function run(ctx: Ctx): Promise<void> {
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';
const pending = ok ? undefined : pickAsset(assets, sum).asset;
if (ok) log(TAG, `ok ${locale} ${ptype}: ${what}`);
else if (pending && selectable(classify(pending.attributes?.state)) && pending.attributes?.state !== 'APPROVED') { waiting++; log(TAG, `waiting ${locale} ${ptype}: ${basename(f.file)} ${waitingText(pending)} (shows ${what} until then)`); }
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'}`);
done(TAG, `creative assets verified on version ${version!.attributes.versionString} (read-only): ${bad ? `${bad} slot(s) differ from the local files` : waiting ? 'every other slot shows the local file' : 'every slot shows the local file'}${waiting ? `, ${waiting} waiting for Apple's approval` : ''}`);
}
}

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>); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'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 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 places it once Apple approved it, reporting waiting ones per locale (MD5 upsert, safe to re-run); --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); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'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

@ -847,6 +847,7 @@
"play"
],
"validate": [
"add-for-review",
"allow-price-changes",
"apple",
"capacitor",
@ -877,6 +878,7 @@
"unity",
"variant",
"variants",
"verify",
"weekly",
"write"
],

View file

@ -57,6 +57,8 @@ export function rendersFindings(store: 'apple' | 'play', r: RendersCheck | undef
export interface AppleSnapshot {
version: { versionString: string; state: string; copyright?: string | null; platform?: string } | null;
/** Header and Search Results files uploaded but not approved yet, per locale and placement (apple creative-assets). */
creativeWaiting?: { locale: string; placement: string; file: string; text: string }[];
/** Editable or rejected versions of the app's other platforms (an app's macOS version has its own state). */
otherPlatformVersions?: { versionString: string; state: string; platform: string }[];
/** app.copyright from the config (undefined: not set). */
@ -206,6 +208,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${vl}: ${s.version.state}`);
else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${vl} is ${s.version.state} (nothing to prepare)`);
else add('warn', 'version', `version ${vl}: ${s.version.state}`);
for (const w of s.creativeWaiting ?? []) add('warn', 'creative-waiting', `${w.locale} ${w.placement}: ${w.file} ${w.text}`, 'storebound apple creative-assets (a re-run places it once approved; --add-for-review sends it to review)');
for (const o of s.otherPlatformVersions ?? []) {
const name = PLATFORM_NAMES[o.platform] ?? o.platform;
add('warn', 'version-platform', `version ${o.versionString} (${name}) is ${o.state} too: this report covers ${vl || 'iOS'} only`, `storebound validate --platform ${name.toLowerCase()}; storebound apple status lists every platform`);

View file

@ -248,7 +248,7 @@ 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 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.',
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 Apple approved them (the slots take only approved assets; until then each locale and placement is reported as waiting, and a re-run places them; 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,

View file

@ -56,10 +56,10 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
};
/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store
* Connect API 4.5.1 (spec checked 2026-10-18) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
* Connect API 4.5.1 (spec re-checked 2026-10-20: the published zip is still the 2026-10-06 one, same sha256) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
* When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
export const IPHONE_DUO_NOT_IN_API = 'App Store Connect does not take iPhone Duo screenshots through the API yet; storebound keeps them in store/renders';
export const IPHONE_DUO_NOT_IN_API = 'the App Store Connect console takes iPhone Duo screenshots, the API does not yet; storebound keeps them in store/renders';
/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */
export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01';
const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)';
@ -69,7 +69,7 @@ const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" /
export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null {
if (!o.apple || o.duo) return null;
const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM;
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; the App Store Connect console takes Duo screenshots and previews, the API does not yet (apple screenshots skips them with the console link)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
}
/** Play screenshots in general (any size the store accepts): 320..3840 px per side, long side at most 2x the short. */

View file

@ -35,6 +35,7 @@ import type { ScanFinding } from './lib/scan/rules';
import { playClient } from './play/client';
import { listOneTimeProducts } from './play/products';
import { creativeNote } from './shots/creative';
import { creativeWaiting, localCreative } from './apple/creative-assets';
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
@ -51,6 +52,15 @@ export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
};
}
/** Header and Search Results files uploaded but not approved yet (the slots take only approved assets). Never fails
* validate: a local file problem or an unreadable library is a note for creative-assets itself. */
async function creativeWaitingOf(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string): Promise<AppleSnapshot['creativeWaiting']> {
try {
const { local } = localCreative(ctx);
return local.size ? await creativeWaiting(c, appId, local) : [];
} catch { return []; }
}
export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
@ -118,6 +128,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
profile: await collectProfile(ctx.loaded),
version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState, copyright: v.attributes.copyright ?? null, platform } : null,
otherPlatformVersions,
creativeWaiting: await creativeWaitingOf(ctx, c, appId),
configCopyright: config.app.copyright,
build, locales, reviewDetail, ageRating,
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),

View file

@ -100,14 +100,16 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a
- **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 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
Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`; processing is a bounded
wait of 2 minutes with a heartbeat). The version page's slots offer only approved assets ("Browse approved assets"),
so an asset is placed (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`)
once Apple approved it; until then each locale and placement says "uploaded <date>, waiting for Apple's approval; run
`storebound apple creative-assets` again later", and a later run places it. `--verify`, `validate` and the report
show the same waiting state. 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
`appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the uploaded, 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.
- **Live app, no editable version:** the run uses the live version, where only APPROVED assets are selected (Apple

View file

@ -913,31 +913,33 @@ 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 shows no header, a run kept saying
"waiting for Apple's approval" for days, 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 waited on
an asset nobody had sent to review, 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: 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). Seen live (19 Oct 2026): placements of a live version read back
with state `ACTIVE`, which the 4.5.1 spec's `AppAssetLibraryPlacementState` does not list; never validate placement
states against the spec enum.
"Add for Review"); after processing it is "Prepare for Submission". The version page's slots offer only approved
assets ("Browse approved assets", seen in a real release, 20 Oct 2026), so placing waits for Apple's approval.
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). Seen live (19 Oct 2026): placements of a live version read back with state `ACTIVE`, which the 4.5.1
spec's `AppAssetLibraryPlacementState` does not list; never validate placement states against the spec enum.
- **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. With no editable version the live version
is used and only APPROVED assets go onto it; the rest wait for their own review. Whether the API accepts a new
placement on a live version is not documented: storebound places the new asset first and removes the old one only
after Apple accepted it, and on a 4xx it says so, leaves the live page unchanged, gives the live version page's deep
link (select it there, or create a version and re-run) and does not try the remaining live slots. Uploading, selecting and adding to the
draft are normal steps, not owner buttons; Submit for Review stays the owner's.
- **Guard:** `cli/apple/creative-assets.ts` (`classify`, `pickAsset`, `processingTiming`), `tests/creative-assets.test.ts`
(fake App Store Connect: upload and select in one run, still processing, rejected / archived files, add-for-review
without submitting, verify read-only, a refused live placement); the fake commits library assets to PREPARE_FOR_SUBMISSION, as Apple does.
asset with the same MD5, never re-upload a rejected file or one that failed processing twice. `--add-for-review`
puts the unreviewed assets into the draft submission (with the first version, or alone once the app has an approved
version; Submit for Review stays the owner's). An asset that is not APPROVED is reported per locale and placement as
"uploaded <date>, waiting for Apple's approval; run `storebound apple creative-assets` again later"; the run is an
upsert, so a later run places it as soon as it is approved (an approved old asset stays shown meanwhile). `--verify`
and `validate` / the report show the same waiting state. With no editable version the live version is used.
Whether the API accepts a new placement on a live version is not documented: storebound places the new asset first
and removes the old one only after Apple accepted it, and on a 4xx it says so, leaves the live page unchanged, gives
the live version page's deep link (select it there, or create a version and re-run) and does not try the remaining
live slots.
- **Guard:** `cli/apple/creative-assets.ts` (`classify`, `pickAsset`, `waitingText`, `creativeWaiting`,
`processingTiming`), `tests/creative-assets.test.ts` (fake App Store Connect: upload, wait for approval, place on a
re-run, still processing, rejected / archived files, add-for-review without submitting, verify read-only with the
waiting state, a refused live placement, the validate finding).
## 77. A macOS (tvOS, visionOS) version was invisible next to the iOS one
- **Symptom:** `apple resubmit` said "nothing to resubmit" while a macOS version was REJECTED with an

View file

@ -209,14 +209,16 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a
- **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 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
Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`; processing is a bounded
wait of 2 minutes with a heartbeat). The version page's slots offer only approved assets ("Browse approved assets"),
so an asset is placed (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`)
once Apple approved it; until then each locale and placement says "uploaded <date>, waiting for Apple's approval; run
`storebound apple creative-assets` again later", and a later run places it. `--verify`, `validate` and the report
show the same waiting state. 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
`appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the uploaded, 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.
- **Live app, no editable version:** the run uses the live version, where only APPROVED assets are selected (Apple

View file

@ -4,7 +4,10 @@
// --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 { classify, MAX_FAILED, pickAsset, processingTiming, REF, run, selectable } from '../cli/apple/creative-assets';
import { classify, creativeWaiting, MAX_FAILED, pickAsset, processingTiming, REF, run, selectable, waitingText } from '../cli/apple/creative-assets';
import { ascClient } from '../cli/apple/asc';
import { appleRules } from '../cli/lib/validate-rules';
import { creativeFiles as filesOf } from '../cli/shots/creative';
import { checkCreativeImage, checkCreativeVideo, CREATIVE_NOTE, creativeFiles, creativeNote } from '../cli/shots/creative';
import { imageInfo } from '../cli/shots/spec';
import { upsertTiming } from '../cli/lib/upsert';
@ -111,29 +114,35 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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 () => {
test('dry run, upload, wait for approval per locale and placement, a re-run places them; same file = nothing; changed file = replace once approved', 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/appAssetLibraryPlacements',
'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(first.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
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(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0);
expect(first.out).toMatch(/en-US PRODUCT_PAGE_HEADER_ASSET: header\.png (uploaded \d{4}-\d\d-\d\d, )?waiting for Apple's approval \(PREPARE_FOR_SUBMISSION\); run `storebound apple creative-assets` again later/);
expect(first.out).toContain('2 waiting for Apple\'s approval');
expect(first.out).toContain('--add-for-review');
expect(first.out).not.toContain("waiting for Apple's approval");
expect((await go(p)).w).toEqual([]); // still waiting: nothing sent again
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(p)).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((await go(p)).w).toEqual([]);
const q = project(CFG, files(9));
const rep = await go(q);
expect(rep.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect((await go(q)).w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(2); // the approved old one stays shown meanwhile
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(q)).w).toEqual(['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 () => {
test('processing that outlasts the bounded wait: not selected, a re-run once approved 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)
@ -143,6 +152,8 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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([]);
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(p)).w).toEqual(['POST /v1/appAssetLibraryPlacements', 'POST /v1/appAssetLibraryPlacements']);
});
@ -153,8 +164,8 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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']);
expect(r.w).toEqual(['PATCH /v1/appAssetLibraryImages/{id}']);
expect(lib().find(x => x.id === 'img-arc')!.attributes.state).not.toBe('ARCHIVED');
});
test('--add-for-review: unreviewed assets go into the draft once, nothing is submitted', async () => {
@ -175,6 +186,12 @@ describe('apple creative-assets against the fake App Store Connect', () => {
expect(before.w).toEqual([]);
expect(before.out).toContain('shows nothing, wanted header.png');
await go(p);
const wait = await go(p, ['--verify']);
expect(wait.w).toEqual([]);
expect(wait.out).toMatch(/waiting en-US PRODUCT_PAGE_HEADER_ASSET: header\.png .*waiting for Apple's approval/);
expect(wait.out).toContain("2 waiting for Apple's approval");
for (const x of lib()) x.attributes.state = 'APPROVED';
await go(p);
const ok = await go(p, ['--verify']);
expect(ok.w).toEqual([]);
expect(ok.out).toContain('every slot shows the local file');
@ -187,7 +204,7 @@ describe('apple creative-assets against the fake App Store Connect', () => {
expect(up.out).toContain('using the live version 1.0.0');
expect(up.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}',
'POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems']);
expect(up.out).toContain('2 waiting for App Review (live version)');
expect(up.out).toContain("2 waiting for Apple's approval");
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0);
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(p)).w).toEqual(['POST /v1/appAssetLibraryPlacements', 'POST /v1/appAssetLibraryPlacements']);
@ -218,7 +235,24 @@ describe('apple creative-assets against the fake App Store Connect', () => {
expect(dry.out).toContain('1 file(s) to upload (used by 2 slots)');
const r = await go(p);
expect(r.w.filter(x => x === 'POST /v1/appAssetLibraryImages')).toHaveLength(1);
expect(r.w.filter(x => x === 'POST /v1/appAssetLibraryPlacements')).toHaveLength(2);
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(p)).w.filter(x => x === 'POST /v1/appAssetLibraryPlacements')).toHaveLength(2);
});
test('validate / report: a file waiting for approval is named per locale and placement', async () => {
const p = setup(files());
await go(p);
const c = await ascClient(p.ctx([]).loaded.root, 'com.example.notes', APP);
const local = new Map([['en-US', filesOf(`${p.ctx([]).loaded.root}/store/renders`, 'en')]]);
const w = await creativeWaiting(c, APP, local);
expect(w.map(x => [x.locale, x.placement, x.file])).toEqual([['en-US', 'PRODUCT_PAGE_HEADER_ASSET', 'header.png'], ['en-US', 'APP_STORE_SEARCH_RESULTS_ASSET', 'search.png']]);
expect(waitingText({ attributes: { state: 'IN_REVIEW', createdDate: '2026-10-19T10:00:00Z' } })).toBe("uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later");
const f = appleRules({ version: null, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true,
iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, creativeWaiting: w }).filter(x => x.code === 'creative-waiting');
expect(f).toHaveLength(2);
expect(f[0]!.msg).toContain("en-US PRODUCT_PAGE_HEADER_ASSET: header.png");
for (const x of lib()) x.attributes.state = 'APPROVED';
expect(await creativeWaiting(c, APP, local)).toEqual([]);
});
test('a bad file stops the run before any call', async () => {

View file

@ -106,6 +106,7 @@ describe('iPhone Duo (foldable) sizes', () => {
expect(n.level).toBe('info');
expect(n.msg).toContain('optional now, required from April 2027 for iOS 27.1 SDK builds');
expect(n.msg).toContain('tablet screenshots');
expect(n.msg).toContain('the App Store Connect console takes Duo screenshots and previews, the API does not yet');
expect(iphoneDuoNote({ apple: true, duo: false, today: after })!.level).toBe('warn');
const base = { version: null, build: null, locales: [], expectedLocales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } };
const f = appleRules({ ...base, today: before } as unknown as Parameters<typeof appleRules>[0]).find(x => x.code === 'screenshots-duo')!;