diff --git a/CHANGELOG.md b/CHANGELOG.md index dd98a4e..7ce1a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `/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 , + 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: `/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 diff --git a/cli/apple/creative-assets.ts b/cli/apple/creative-assets.ts index 295aa9d..ac65f1c 100644 --- a/cli/apple/creative-assets.ts +++ b/cli/apple/creative-assets.ts @@ -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 " 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 , 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): 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 { 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 => [ ...(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 { 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 { 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 { 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 { return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary); async function verifySlots(): Promise { - 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 { 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` : ''}`); } } diff --git a/cli/commands.ts b/cli/commands.ts index 3bb9ee0..17cf3b3 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -80,7 +80,7 @@ export const COMMANDS: Record Promise, 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 ); --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'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 1565902..3787556 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -847,6 +847,7 @@ "play" ], "validate": [ + "add-for-review", "allow-price-changes", "apple", "capacitor", @@ -877,6 +878,7 @@ "unity", "variant", "variants", + "verify", "weekly", "write" ], diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 61bd7c2..3c9e93a 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -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`); diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 16c72d0..70aa50b 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -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 //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 //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, diff --git a/cli/shots/spec.ts b/cli/shots/spec.ts index e0663ec..89813d9 100644 --- a/cli/shots/spec.ts +++ b/cli/shots/spec.ts @@ -56,10 +56,10 @@ export const IMAGE_SPECS: Record = { }; /** 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 = { '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. */ diff --git a/cli/validate.ts b/cli/validate.ts index 0383635..bdf936f 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -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 { @@ -51,6 +52,15 @@ export async function collectProfile(loaded: Loaded): Promise { }; } +/** 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>, appId: string): Promise { + try { + const { local } = localCreative(ctx); + return local.size ? await creativeWaiting(c, appId, local) : []; + } catch { return []; } +} + export async function collectApple(ctx: Ctx): Promise { 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 { 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), diff --git a/docs/store-art.md b/docs/store-art.md index 93ca1b2..c1ded30 100644 --- a/docs/store-art.md +++ b/docs/store-art.md @@ -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 , 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 diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 8a34ceb..032859d 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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 "), 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 , 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 diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index b07203c..95e9b9c 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -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 , 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 diff --git a/tests/creative-assets.test.ts b/tests/creative-assets.test.ts index 8073ba5..8bd7ee4 100644 --- a/tests/creative-assets.test.ts +++ b/tests/creative-assets.test.ts @@ -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 () => { diff --git a/tests/ppo.test.ts b/tests/ppo.test.ts index f130eab..f4c22c2 100644 --- a/tests/ppo.test.ts +++ b/tests/ppo.test.ts @@ -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[0]).find(x => x.code === 'screenshots-duo')!;