From c03291e7f7429dab8a2ee37558d123d2d4bbe4d0 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 19 Oct 2026 05:45:47 +0700 Subject: [PATCH] apple creative-assets: a live app without an editable version uses the live version (only approved assets are selected there; new ones upload and wait for a review of their own via --add-for-review); pitfall 76: placements read back ACTIVE live, a state the 4.5.1 spec does not list --- CHANGELOG.md | 4 +++- cli/apple/creative-assets.ts | 28 +++++++++++++++++++--------- docs/store-art.md | 4 ++++ skills/store-pitfalls/SKILL.md | 7 +++++-- skills/store-screenshots/SKILL.md | 4 ++++ tests/creative-assets.test.ts | 14 ++++++++++++++ 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e62234e..6790cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ a heartbeat). `--add-for-review` adds the selected, unreviewed assets to the app's draft review submission (never submits; Submit for Review stays the owner's); `--verify` (MCP `apple_creative_assets_verify`) reads every slot back read-only and fails when one differs from the local file. A file App Review rejected, or one that failed processing - twice, is not uploaded again; an archived copy of the same file is unarchived instead of uploaded. Pitfall 76 rewritten. + 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. Pitfall 76 rewritten. ## 0.8.13 ยท 2026-10-18 diff --git a/cli/apple/creative-assets.ts b/cli/apple/creative-assets.ts index 37f1b0d..2c09d4a 100644 --- a/cli/apple/creative-assets.ts +++ b/cli/apple/creative-assets.ts @@ -90,7 +90,7 @@ const reason = (a: J): string => { /** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */ export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) }; -export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'wait' | 'select' | 'replace' | 'refuse'; state?: string; asset?: J } +export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J } /** Local files per App Store locale, checked; throws on any error before a network call. */ export function localCreative(ctx: Ctx): { src: string; local: Map } { @@ -138,9 +138,14 @@ export async function run(ctx: Ctx): Promise { const lib = (await c.asc('GET', `/v1/apps/${appId}/assetLibrary`)).data as J; const live = async () => (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`)) .find(v => (v.attributes.appVersionState ?? v.attributes.appStoreState) === 'READY_FOR_DISTRIBUTION' || v.attributes.appStoreState === 'READY_FOR_SALE') ?? null; - // --verify falls back to the live version (read-only); writes need the editable one - const version = await c.editableVersion() ?? (verify ? await live() : null); - if (!version) throw new Error(`no editable App Store version: the header and search results slots belong to a version page (run apple listing --version x.y.z first). ${consoleLink('asc.version')}`); + // No editable version: the live one. Apple lets an approved asset be swapped onto the live version without a new + // build, and an app with an approved version can send assets to review alone; so there only APPROVED assets are + // selected, and the others wait for their review (--add-for-review puts them into a submission of their own). + const editable = await c.editableVersion(); + 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`); 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`)), @@ -197,7 +202,7 @@ export async function run(ctx: Ctx): Promise { if (pick.refuse) { warn(TAG, `${tag}: ${basename(f.file)} ${pick.refuse}`); row('refuse'); continue; } let asset = pick.asset; if (!asset) { - if (dry) { log(TAG, `${tag}: would upload ${basename(f.file)} to the Asset Library and select it once processed`); row('upload'); continue; } + if (dry) { log(TAG, `${tag}: would upload ${basename(f.file)} to the Asset Library and select it ${onLive ? 'on the live version once approved' : 'once processed'}`); row('upload'); continue; } log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`); asset = await upload(f, sum); assets = await library(); @@ -214,6 +219,10 @@ 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)`); + row('approve', asset); continue; + } const action = placed.length ? 'replace' : 'select'; if (dry) { log(TAG, `${tag}: would ${action === 'replace' ? `replace ${placed.length} selected asset(s) with` : 'select'} ${basename(f.file)} (${asset.attributes?.state})`); row(action, asset); continue; } for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`); @@ -234,9 +243,11 @@ export async function run(ctx: Ctx): Promise { } if (off) process.exitCode = 1; - // review: one item per distinct unreviewed asset in a slot + const n = (a: Row['action'][]) => rows.filter(r => a.includes(r.action)).length; + // review: one item per distinct unreviewed asset (in a slot, or waiting for approval to go onto the live version) const unreviewed = new Map(); for (const r of rows) if (r.asset && r.action !== 'refuse' && r.action !== 'wait' && classify(r.asset.attributes?.state) === 'unreviewed') unreviewed.set(r.asset.id, r.asset); + const approve = n(['approve']); let added = 0; if (unreviewed.size && forReview) { const kindOf = (a: J) => (a.type === 'appAssetLibraryVideos' ? 'appAssetLibraryVideo' : 'appAssetLibraryImage'); @@ -250,12 +261,11 @@ export async function run(ctx: Ctx): Promise { log(TAG, `${added} creative asset(s) added to draft review submission ${sid}; nothing was submitted`); } } - const n = (a: Row['action'][]) => rows.filter(r => a.includes(r.action)).length; - const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} to upload, ${n(['wait'])} still processing, ${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`; + const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} to upload, ${n(['wait'])} still processing, ${approve ? `${approve} waiting for App Review (live version), ` : ''}${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`; if (unreviewed.size) { log(TAG, forReview ? `owner step: Submit for Review with the version (or alone once the app has an approved version): ${consoleLink('asc.app-review')}` - : `${unreviewed.size} selected asset(s) are not reviewed yet ("Prepare for Submission"): run with --add-for-review to put them into the draft review submission (the version's, or alone once the app has an approved version); Submit for Review stays the owner's: ${consoleLink('asc.app-review')}`); + : `${unreviewed.size} asset(s) are not reviewed yet ("Prepare for Submission"): run with --add-for-review to put them into the draft review submission (the version's, or alone once the app has an approved version); Submit for Review stays the owner's: ${consoleLink('asc.app-review')}`); } if (n(['refuse'])) process.exitCode = 1; return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary); diff --git a/docs/store-art.md b/docs/store-art.md index 65bf0d0..77ab175 100644 --- a/docs/store-art.md +++ b/docs/store-art.md @@ -109,6 +109,10 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a `appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the selected, unreviewed assets to the app's draft submission, once: before the first approved version it is the same submission as that version, later they can go alone. Pressing Submit for Review stays the owner's. +- **Live app, no editable version:** the run uses the live version, where only APPROVED assets are selected (Apple + swaps approved assets onto a live version without a new build). New files are uploaded and wait for review: + `--add-for-review` sends them as a submission of their own (the app has an approved version), the owner presses + Submit for Review, and a re-run after approval selects them. - **Verify:** `storebound apple creative-assets --verify` (MCP `apple_creative_assets_verify`) is read-only: every slot of the editable version (else the live one) shows the local file, with its review state; exit 1 when one differs. - **Checks:** `storebound check` and `validate` print an info note when no locale has one. diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index fb68278..174df6a 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -923,11 +923,14 @@ that fails without the fix (see CONTRIBUTING.md). 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). + (`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. Uploading, selecting and adding to the + 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. 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 diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index 02c9a1f..de05ca4 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -218,6 +218,10 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a `appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the selected, unreviewed assets to the app's draft submission, once: before the first approved version it is the same submission as that version, later they can go alone. Pressing Submit for Review stays the owner's. +- **Live app, no editable version:** the run uses the live version, where only APPROVED assets are selected (Apple + swaps approved assets onto a live version without a new build). New files are uploaded and wait for review: + `--add-for-review` sends them as a submission of their own (the app has an approved version), the owner presses + Submit for Review, and a re-run after approval selects them. - **Verify:** `storebound apple creative-assets --verify` (MCP `apple_creative_assets_verify`) is read-only: every slot of the editable version (else the live one) shows the local file, with its review state; exit 1 when one differs. - **Checks:** `storebound check` and `validate` print an info note when no locale has one. diff --git a/tests/creative-assets.test.ts b/tests/creative-assets.test.ts index fa7b49a..4c4da94 100644 --- a/tests/creative-assets.test.ts +++ b/tests/creative-assets.test.ts @@ -175,6 +175,20 @@ describe('apple creative-assets against the fake App Store Connect', () => { expect(ok.out).toContain('every slot shows the local file'); }); + test('live version only (no editable one): upload, add for review alone, select once approved', async () => { + const p = setup(files()); + s.asc.all('appStoreVersions')[0]!.attributes.appVersionState = 'READY_FOR_DISTRIBUTION'; + const up = await go(p, ['--add-for-review']); + 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(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']); + expect((await go(p, ['--verify'])).out).toContain('every slot shows the local file'); + }); + test('a bad file stops the run before any call', async () => { const p = setup({ 'store/renders/en/creative/header.png': png(3840, 1600, true) }); const a = s.calls.length;