First in-app purchases left out of a review are caught (pitfall 70): firstProductGaps in validate / release plan (blocker) and the apple submit plan (refuses to leave a never-approved first product out); apple status (WARN + firstProductGaps in --json), release status and the desktop checks warn while a version waits for or is in review without it; iap-review says 'ready, not submitted yet' instead of 'complete'
This commit is contained in:
parent
1c35a625e1
commit
b830a99a5f
16 changed files with 288 additions and 18 deletions
33
cli/apple/first-products.ts
Normal file
33
cli/apple/first-products.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Reads what `firstProductGaps` (cli/lib/review-submit.ts) needs: every in-app purchase and subscription, the app's
|
||||
// open review submission with its items, and the versions of the never-approved products (to see whether they are in
|
||||
// it). Used by apple status, validate and the release status / plan built on them (pitfall 70). Read-only.
|
||||
import { type FirstProductGap, firstProductGaps, type FirstProductInput, NEVER_SUBMITTED } from '../lib/review-submit';
|
||||
import type { Asc, J } from './asc';
|
||||
import { openSubmissions } from './submission';
|
||||
|
||||
export async function readFirstProducts(c: Asc, appId: string, o: { iaps?: J[]; only?: string[]; versionState?: string } = {}): Promise<FirstProductInput> {
|
||||
const ids = (vs: J[]): string[] => vs.map(v => v.id as string);
|
||||
const iaps: FirstProductInput['iaps'] = [];
|
||||
for (const p of o.iaps ?? await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
||||
const state = p.attributes.state as string;
|
||||
iaps.push({ productId: p.attributes.productId, type: p.attributes.inAppPurchaseType, state,
|
||||
versionIds: NEVER_SUBMITTED.has(state) ? ids(await c.all(`/v2/inAppPurchases/${p.id}/versions?limit=200`)) : [] });
|
||||
}
|
||||
const subs: FirstProductInput['subs'] = [];
|
||||
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
|
||||
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
|
||||
const state = s.attributes.state as string;
|
||||
subs.push({ productId: s.attributes.productId, groupId: g.id, state,
|
||||
versionIds: NEVER_SUBMITTED.has(state) ? ids(await c.all(`/v1/subscriptions/${s.id}/versions?limit=200`)) : [] });
|
||||
}
|
||||
}
|
||||
const open = await openSubmissions(c, appId);
|
||||
// the submission that matters: one in review, else one back from review, else a draft holding items
|
||||
const pick = open.find(x => x.state === 'WAITING_FOR_REVIEW' || x.state === 'IN_REVIEW') ?? open.find(x => x.state === 'UNRESOLVED_ISSUES') ?? open.find(x => x.items.length);
|
||||
const submission = pick ? { id: pick.id, state: pick.state, itemVersionIds: pick.items.map(i => i.versionId) } : null;
|
||||
return { iaps, subs, submission, versionState: o.versionState, only: o.only };
|
||||
}
|
||||
|
||||
export async function firstProductReport(c: Asc, appId: string, o: Parameters<typeof readFirstProducts>[2] = {}): Promise<FirstProductGap[]> {
|
||||
return firstProductGaps(await readFirstProducts(c, appId, o));
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { done, log, warn } from '../lib/log';
|
|||
import { reportMissing } from '../lib/store-lag';
|
||||
import { IMAGE_SPECS, imageInfo } from '../shots/spec';
|
||||
import { ascClient, md5 } from './asc';
|
||||
import { productStateText } from '../lib/review-submit';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
/** Every iPhone / iPad screenshot size App Store Connect accepts (portrait and landscape). */
|
||||
|
|
@ -79,9 +80,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
else log('apple', `${sku}: screenshot uploaded (${state})`);
|
||||
}
|
||||
// what keeps a product in MISSING_METADATA (Apple's rule: a localization, a price, availability and the screenshot)
|
||||
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
||||
const all = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
||||
const approvedTypes = new Set(all.filter((x: { attributes: { state: string } }) => /APPROVED|REMOVED_FROM_SALE/.test(x.attributes.state)).map((x: { attributes: { inAppPurchaseType: string } }) => x.attributes.inAppPurchaseType));
|
||||
for (const p of all) {
|
||||
const state: string = p.attributes.state;
|
||||
if (state !== 'MISSING_METADATA') { log('apple', `${p.attributes.productId}: ${state}`); continue; }
|
||||
if (state !== 'MISSING_METADATA') { log('apple', `${p.attributes.productId}: ${productStateText(state, approvedTypes.has(p.attributes.inAppPurchaseType))}`); continue; }
|
||||
const lack: string[] = [];
|
||||
const locs = await c.maybe(`/v2/inAppPurchases/${p.id}/inAppPurchaseLocalizations?limit=50`);
|
||||
if (!locs?.data?.length) lack.push('a localization (name + description)');
|
||||
|
|
@ -97,5 +100,5 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
if (dry) console.log(dryPlan('apple iap-review', rows));
|
||||
if (problems) process.exitCode = 1;
|
||||
done('apple', dry ? 'iap-review dry run (nothing uploaded)' : 'iap-review done (READY_TO_SUBMIT = complete; first IAPs go to review with a version, from the IAP page)');
|
||||
done('apple', dry ? 'iap-review dry run (nothing uploaded)' : 'iap-review done (READY_TO_SUBMIT = ready, not submitted yet; the first in-app purchase of a type goes to review with the next app version: storebound apple submit adds it)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@
|
|||
// naming what is missing where.
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { log, printJson } from '../lib/log';
|
||||
import { log, printJson, warn } from '../lib/log';
|
||||
import type { FirstProductGap } from '../lib/review-submit';
|
||||
import { firstProductReport } from './first-products';
|
||||
import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
|
||||
import { EMPTY_DRAFT } from '../lib/review-submit';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
|
|
@ -79,6 +81,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
// an empty draft: App Store Connect keeps it and refuses to cancel it; harmless
|
||||
const empty = new Set<string>();
|
||||
for (const s of subs.filter((x: J) => x.attributes?.state === 'READY_FOR_REVIEW')) if (!(await readItems(c, s.id)).length) empty.add(s.id);
|
||||
// first products of a type that are not in the review (pitfall 70): not sellable after the version is approved
|
||||
const vstate = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
const reviewed = versions.find((v: J) => /WAITING_FOR_REVIEW|IN_REVIEW/.test(vstate(v))) ?? versions[0];
|
||||
let gaps: FirstProductGap[] = [];
|
||||
try { gaps = await firstProductReport(c, appId, { iaps, versionState: reviewed ? vstate(reviewed) : undefined }); } catch (e) { log('apple', `first in-app purchases not compared with the review: ${(e as Error).message.slice(0, 200)}`); }
|
||||
const itemLabel = (kind: string, id: string): string => {
|
||||
const v = versions.find((x: J) => x.id === id);
|
||||
return v ? `app version ${v.attributes.versionString}` : `${kind} ${id}`;
|
||||
|
|
@ -102,7 +109,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? '');
|
||||
if (v) buildVersions[b.id] = v;
|
||||
}
|
||||
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], capabilityCheck: caps });
|
||||
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], firstProductGaps: gaps, capabilityCheck: caps });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +125,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const newest = builds.data[0]?.attributes?.version;
|
||||
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
|
||||
}
|
||||
for (const g of gaps) warn('apple', `${g.msg}; ${g.fix}`);
|
||||
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
|
||||
else printCapabilityReport(caps, 'apple');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue