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:
parent
183579449c
commit
c03291e7f7
6 changed files with 49 additions and 12 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue