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
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

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. */
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. */
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 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<J[]> => [
...(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; }
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<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)`);
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<void> {
}
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>();
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<void> {
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);

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
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.

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
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 <md5>"), reuse (or unarchive) a library
asset with the same MD5, select it once processed, never re-upload a rejected file or one that failed processing
twice. Then `--add-for-review` puts the unreviewed assets into the draft submission (with the first version, or alone
once the app has an approved version) and `--verify` reads every slot back. 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

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
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.

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');
});
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;