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

This commit is contained in:
CRTkafa 2026-10-19 05:45:47 +07:00
parent 183579449c
commit c03291e7f7
6 changed files with 49 additions and 12 deletions

View file

@ -9,7 +9,9 @@
a heartbeat). `--add-for-review` adds the selected, unreviewed assets to the app's draft review submission (never 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 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 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 ## 0.8.13 · 2026-10-18

View file

@ -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. */ /** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) }; export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'wait' | 'select' | 'replace' | 'refuse'; state?: string; asset?: J } 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. */ /** Local files per App Store locale, checked; throws on any error before a network call. */
export function localCreative(ctx: Ctx): { src: string; local: Map<string, CreativeFile[]> } { export function localCreative(ctx: Ctx): { src: string; local: Map<string, CreativeFile[]> } {
@ -138,9 +138,14 @@ export async function run(ctx: Ctx): Promise<void> {
const lib = (await c.asc('GET', `/v1/apps/${appId}/assetLibrary`)).data as J; 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`)) 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; .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 // No editable version: the live one. Apple lets an approved asset be swapped onto the live version without a new
const version = await c.editableVersion() ?? (verify ? await live() : null); // build, and an app with an approved version can send assets to review alone; so there only APPROVED assets are
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')}`); // 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 locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
const library = async (): Promise<J[]> => [ const library = async (): Promise<J[]> => [
...(await c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=CREATIVE_ASSETS&limit=200`)), ...(await c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=CREATIVE_ASSETS&limit=200`)),
@ -197,7 +202,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (pick.refuse) { warn(TAG, `${tag}: ${basename(f.file)} ${pick.refuse}`); row('refuse'); continue; } if (pick.refuse) { warn(TAG, `${tag}: ${basename(f.file)} ${pick.refuse}`); row('refuse'); continue; }
let asset = pick.asset; let asset = pick.asset;
if (!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`); log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`);
asset = await upload(f, sum); asset = await upload(f, sum);
assets = await library(); assets = await library();
@ -214,6 +219,10 @@ export async function run(ctx: Ctx): Promise<void> {
if (cls === 'processing') { row('wait', asset); continue; } if (cls === 'processing') { row('wait', asset); continue; }
warn(TAG, `${tag}: ${basename(f.file)} is ${asset.attributes?.state}${reason(asset)}; not selected`); row('refuse', asset); continue; 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'; 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; } if (dry) { log(TAG, `${tag}: would ${action === 'replace' ? `replace ${placed.length} selected asset(s) with` : 'select'} ${basename(f.file)} (${asset.attributes?.state})`); row(action, asset); continue; }
for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`); for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
@ -234,9 +243,11 @@ export async function run(ctx: Ctx): Promise<void> {
} }
if (off) process.exitCode = 1; 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<string, J>(); const unreviewed = new Map<string, J>();
for (const r of rows) if (r.asset && r.action !== 'refuse' && r.action !== 'wait' && classify(r.asset.attributes?.state) === 'unreviewed') unreviewed.set(r.asset.id, r.asset); 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; let added = 0;
if (unreviewed.size && forReview) { if (unreviewed.size && forReview) {
const kindOf = (a: J) => (a.type === 'appAssetLibraryVideos' ? 'appAssetLibraryVideo' : 'appAssetLibraryImage'); const kindOf = (a: J) => (a.type === 'appAssetLibraryVideos' ? 'appAssetLibraryVideo' : 'appAssetLibraryImage');
@ -250,12 +261,11 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${added} creative asset(s) added to draft review submission ${sid}; nothing was submitted`); 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, ${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'])} to upload, ${n(['wait'])} still processing, ${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`;
if (unreviewed.size) { if (unreviewed.size) {
log(TAG, forReview log(TAG, forReview
? `owner step: Submit for Review with the version (or alone once the app has an approved version): ${consoleLink('asc.app-review')}` ? `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; if (n(['refuse'])) process.exitCode = 1;
return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary); return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary);

View file

@ -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 `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 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. 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 - **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. of the editable version (else the live one) shows the local file, with its review state; exit 1 when one differs.
- **Checks:** `storebound check` and `validate` print an info note when no locale has one. - **Checks:** `storebound check` and `validate` print an info note when no locale has one.

View file

@ -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 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 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` 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 <md5>"), reuse (or unarchive) a library - **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 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 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. 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` - **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 (fake App Store Connect: upload and select in one run, still processing, rejected / archived files, add-for-review

View file

@ -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 `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 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. 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 - **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. of the editable version (else the live one) shows the local file, with its review state; exit 1 when one differs.
- **Checks:** `storebound check` and `validate` print an info note when no locale has one. - **Checks:** `storebound check` and `validate` print an info note when no locale has one.

View file

@ -175,6 +175,20 @@ describe('apple creative-assets against the fake App Store Connect', () => {
expect(ok.out).toContain('every slot shows the local file'); 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 () => { test('a bad file stops the run before any call', async () => {
const p = setup({ 'store/renders/en/creative/header.png': png(3840, 1600, true) }); const p = setup({ 'store/renders/en/creative/header.png': png(3840, 1600, true) });
const a = s.calls.length; const a = s.calls.length;