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

@ -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),