apple creative-assets: place Header and Search Results assets once Apple approved them (the version page offers only approved assets); waiting files are reported per locale and placement with their upload date and the re-run command, in the run, --verify, validate and the report; iPhone Duo: the published API spec still has no Duo type, the note says the console takes Duo and the API does not yet

This commit is contained in:
CRTkafa 2026-10-20 03:04:14 +07:00
parent 47dfd372fe
commit c2f34c62f3
13 changed files with 163 additions and 78 deletions

View file

@ -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 <md5>" 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 <date>, 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<string, CreativeFile[]>): 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<void> {
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<J[]> => [
...(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<void> {
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<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)`);
// 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<void> {
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<void> {
return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary);
async function verifySlots(): Promise<void> {
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<void> {
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` : ''}`);
}
}