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:
CRTkafa 2026-10-15 19:31:26 +08:00
parent 1c35a625e1
commit b830a99a5f
16 changed files with 288 additions and 18 deletions

View file

@ -2,6 +2,13 @@
## Unreleased
- **First in-app purchases left out of a review are caught.** Apple reviews the first in-app purchase of a type (and
the first subscription of a group) only together with an app version. `validate` and `release plan` now block when
one that never passed review is not in the open review submission, and `apple submit` refuses to leave one out;
`apple status`, `release status` and the desktop warn when a version waits for or is in review without it ("not
sellable after approval; add it with the next version, or withdraw the submission and resubmit with it").
`apple iap-review` says "ready, not submitted yet" instead of calling READY_TO_SUBMIT complete.
## 0.8.6 · 2026-10-14
- **Desktop: Fix with AI** on scan findings that need a code change (NSAllowsArbitraryLoads in Info.plist, a missing

View file

@ -83,12 +83,12 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'apps_customerReviews_getToManyRelated': 'reviews',
'apps_getCollection': 'apple/asc apple/bundle apps console/app-id console/verify lib/store-apps',
'apps_getInstance': 'apple/appinfo apple/status apps onboard',
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
'apps_inAppPurchasesV2_getToManyRelated': 'apple/first-products apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
'apps_perfPowerMetrics_getToManyRelated': 'health',
'apps_promotedPurchases_getToManyRelated': 'apple/promoted',
'apps_promotedPurchases_replaceToManyRelationship': 'lib/offers',
'apps_reviewSubmissions_getToManyRelated': 'apple/submission',
'apps_subscriptionGroups_getToManyRelated': 'apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
'apps_subscriptionGroups_getToManyRelated': 'apple/first-products apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
'apps_updateInstance': 'apple/appinfo',
'betaAppLocalizations_createInstance': 'lib/testflight',
'betaAppLocalizations_updateInstance': 'lib/testflight',
@ -157,7 +157,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'inAppPurchasesV2_inAppPurchaseLocalizations_getToManyRelated': 'apple/iap apple/iap-review catalog',
'inAppPurchasesV2_offerCodes_getToManyRelated': 'apple/offer-codes',
'inAppPurchasesV2_pricePoints_getToManyRelated': 'apple/iap apple/offer-codes prices prices-ppp',
'inAppPurchasesV2_versions_getToManyRelated': 'apple/iap-submit',
'inAppPurchasesV2_versions_getToManyRelated': 'apple/first-products apple/iap-submit',
'profiles_certificates_getToManyRelationship': 'lib/signing',
'profiles_createInstance': 'apple/adhoc lib/signing',
'profiles_devices_getToManyRelated': 'apple/adhoc',
@ -180,7 +180,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'subscriptionGroups_createInstance': 'apple/subscriptions',
'subscriptionGroups_getInstance': 'apple/subscriptions',
'subscriptionGroups_subscriptionGroupLocalizations_getToManyRelated': 'apple/subscriptions validate',
'subscriptionGroups_subscriptions_getToManyRelated': 'apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
'subscriptionGroups_subscriptions_getToManyRelated': 'apple/first-products apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
'subscriptionGroups_versions_getToManyRelated': 'apple/iap-submit',
'subscriptionLocalizations_createInstance': 'apple/subscriptions',
'subscriptionOfferCodeCustomCodes_createInstance': 'apple/subscriptions',
@ -201,7 +201,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'subscriptions_subscriptionAvailability_getToOneRelated': 'apple/subscriptions',
'subscriptions_subscriptionLocalizations_getToManyRelated': 'apple/subscriptions catalog validate',
'subscriptions_updateInstance': 'apple/subscriptions',
'subscriptions_versions_getToManyRelated': 'apple/iap-submit',
'subscriptions_versions_getToManyRelated': 'apple/first-products apple/iap-submit',
'subscriptions_winBackOffers_getToManyRelated': 'apple/win-back',
'territories_getCollection': 'apple/appinfo apple/events apple/iap apple/offer-codes apple/subscriptions',
'territoryAvailabilities_updateInstance': 'lib/offers',

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@
// workspace (`storebound ws status`, `storebound release status`), so all of them read an app the same way.
// Every read is a storebound child command; `exec` runs it (serve tracks it for cancellation, the CLI just awaits it).
import type { AppStatus, ProjectSummary } from './desktop-protocol';
import { appIdentity, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './portfolio';
import { appIdentity, appleFirstProductWarnings, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './portfolio';
import type { RunResult } from './runner';
export type Exec = (argv: string[], cwd: string) => Promise<RunResult>;
@ -45,6 +45,10 @@ export async function fillAppStatus(e: AppStatus, dir: string, o: { live: boolea
if (play?.error) e.storeErrors.android = play.error;
const ios = apple?.data ? appleReleases(apple.data) : [], android = play?.data ? playReleases(play.data) : [];
e.release = { source: 'live', ...releaseTrains(ios, android), all: [...ios, ...android] };
// first in-app purchases left out of the review: a warning on the release status and in the desktop's checks
const firstWarn = apple?.data ? appleFirstProductWarnings(apple.data) : [];
if (firstWarn.length) e.warnings = firstWarn;
if (e.check) for (const w of firstWarn) if (!e.check.warnings.includes(w)) e.check.warnings.push(w);
e.catalog = catalogParity(summary, apple?.data ? appleProductIds(apple.data) : undefined, play?.data ? playProductIds(play.data) : undefined);
}
e.ok = e.check.ok && !e.storeErrors.ios && !e.storeErrors.android && (e.catalog?.ok ?? true);

View file

@ -225,6 +225,8 @@ export interface AppStatus {
/** A store that could not be read this time, shown from the last good read (its time): couldn't refresh. */
staleStores?: { ios?: string; android?: string };
errors: string[];
/** live only: release warnings read from the stores (a first in-app purchase left out of the review, pitfall 70). */
warnings?: string[];
}
export interface PortfolioResult { apps: AppStatus[]; at: string }

View file

@ -208,3 +208,7 @@ export function catalogParity(s: ProjectSummary, apple?: string[], play?: string
ok: !onlyIos.length && !onlyAndroid.length && !missingIos.length && !missingAndroid.length,
};
}
/** The warnings of `apple status --json` about first in-app purchases left out of the review (pitfall 70). Pure. */
export const appleFirstProductWarnings = (data: unknown): string[] => (((data as J)?.firstProductGaps ?? []) as J[])
.filter(g => typeof g?.msg === 'string').map(g => `App Store: ${g.msg}; ${g.fix ?? ''}`.replace(/; $/, ''));

View file

@ -158,6 +158,14 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
const groupsNeeded = new Set<string>();
const pick = (id: string, productState: string, versions: PVersion[], kind: ItemKind, type: string): void => {
const before = plan.skipped.length;
pickOne(id, productState, versions, kind, type);
// a never-approved first product left out while the app version goes to review: not sellable after approval (pitfall 70)
const firstOfType = kind === 'subscriptionVersion' ? !s.subs.some(x => x.groupId === s.subs.find(y => y.productId === id)?.groupId && APPROVED_PRODUCT.has(x.state)) : !approvedTypes.has(type);
if (plan.skipped.length > before && firstOfType && NEVER_SUBMITTED.has(productState) && plan.skipped.at(-1)?.why !== 'already in the draft submission') leftOut.push(`${id} (${plan.skipped.at(-1)?.why})`);
};
const leftOut: string[] = [];
const pickOne = (id: string, productState: string, versions: PVersion[], kind: ItemKind, type: string): void => {
if (productState === 'MISSING_METADATA') { plan.skipped.push({ id, why: 'missing metadata (price, localization or review screenshot): storebound apple iap / apple subscriptions / apple iap-review' }); return; }
if (productState === 'WAITING_FOR_UPLOAD' || productState === 'PROCESSING_CONTENT') { plan.skipped.push({ id, why: `hosted content ${productState}: content-hosted purchases can no longer be submitted` }); return; }
const v = latestVersion(versions);
@ -190,6 +198,7 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
plan.items.push({ kind: 'subscriptionGroupVersion', versionId: v.id, label: `subscription group "${g.name}" (not approved yet, goes with its subscriptions)`, ...(v.state === 'DEVELOPER_REJECTED' ? { devRejected: true } : {}) });
}
plan.firstOfType = [...firstTypes].map(typeLabel);
if (o.appVersionId && leftOut.length) plan.blockers.push(`never-approved first in-app purchases would be left out of this review, and are not sellable after the version is approved (Apple reviews them only with an app version): ${leftOut.join('; ')}. Fix them first, or take them out of the config's App Store products`);
const forced = o.appVersionId ? s.appVersions.find(v => v.id === o.appVersionId) : undefined;
plan.needsAppVersion = !!forced || (plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion));
if (plan.needsAppVersion) {
@ -278,3 +287,68 @@ export function formatRows(rows: PlanRow[]): string {
const w = (k: keyof PlanRow): number => Math.max(...all.map(r => r[k].length));
return all.map(r => `${r.step.padEnd(w('step'))} ${r.what.padEnd(w('what'))} ${r.now.padEnd(w('now'))} ${r.action}`).join('\n');
}
// ------------------------------------------------------------------------------- first products left out of a review
/** States of a product that never went through App Review and is not in one now (pitfall 70). */
export const NEVER_SUBMITTED = new Set(['READY_TO_SUBMIT', 'MISSING_METADATA', 'DEVELOPER_ACTION_NEEDED']);
const REVIEW_APP = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW']);
export interface FirstProductInput {
iaps: { productId: string; type: string; state: string; versionIds?: string[] }[];
subs: { productId: string; groupId?: string; state: string; versionIds?: string[] }[];
/** The open review submission (draft, back from review, waiting or in review) and the version ids of its items. */
submission?: { id: string; state: string; itemVersionIds: string[] } | null;
/** State of the app version that is (or will be) submitted. */
versionState?: string;
/** Only these product ids count (the config's); all when absent. */
only?: string[];
}
export interface FirstProductGap { productId: string; state: string; kind: 'iap' | 'subscription'; level: 'error' | 'warn'; msg: string; fix: string }
/**
* First in-app purchases of a type (and first subscriptions of a group) that never passed App Review and are not in
* the app's open review submission. Apple only reviews them together with an app version; a version approved without
* them leaves them unsellable (seen live: a starter pack stayed READY_TO_SUBMIT for two days next to a version
* WAITING_FOR_REVIEW). error: an open submission (or one in review) lacks them, or one cannot go in (missing
* metadata); warn: nothing is open yet, the product must go in with the version. Pure.
*/
export function firstProductGaps(s: FirstProductInput): FirstProductGap[] {
const approvedTypes = new Set(s.iaps.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.type));
const approvedGroups = new Set(s.subs.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.groupId ?? '?'));
const items = new Set(s.submission?.itemVersionIds ?? []);
const inReview = REVIEW_APP.has(s.versionState ?? '') || REVIEW_APP.has(s.submission?.state ?? '');
const out: FirstProductGap[] = [];
const consider = (productId: string, state: string, kind: FirstProductGap['kind'], versionIds: string[] = []): void => {
if (s.only && !s.only.includes(productId)) return;
if (!NEVER_SUBMITTED.has(state)) return;
if (versionIds.some(v => items.has(v))) return;
const what = kind === 'iap' ? 'first in-app purchase of its type' : 'first subscription of its group';
if (inReview) {
out.push({ productId, state, kind, level: 'warn',
msg: `${productId} is not in this review (${what}, never approved, ${state}): it will not be sellable after approval`,
fix: 'add it with the next version, or withdraw the submission (storebound apple status --cancel-submission <id>) and resubmit with it (storebound apple submit --build <n>)' });
} else if (state !== 'READY_TO_SUBMIT') {
out.push({ productId, state, kind, level: 'error',
msg: `${productId} (${what}, never approved) is ${state} and cannot go into the review with the app version: approved without it, it is not sellable`,
fix: 'complete it first (storebound apple iap-review / apple subscriptions), then storebound apple submit --build <n> adds it to the submission' });
} else {
out.push({ productId, state, kind, level: s.submission ? 'error' : 'warn',
msg: s.submission
? `${productId} (${what}, never approved, READY_TO_SUBMIT) is not in review submission ${s.submission.id}: approved without it, it is not sellable`
: `${productId} (${what}, never approved, READY_TO_SUBMIT) must go into the same review submission as the app version`,
fix: 'storebound apple submit --build <n> adds it (or apple iap-submit --with-version); submitting from the App Store Connect page leaves it out unless you add it on the version page' });
}
};
for (const p of s.iaps) if (!approvedTypes.has(p.type)) consider(p.productId, p.state, 'iap', p.versionIds);
for (const p of s.subs) if (!approvedGroups.has(p.groupId ?? '?')) consider(p.productId, p.state, 'subscription', p.versionIds);
return out;
}
/** How iap-review names a product's state: READY_TO_SUBMIT is never "complete" for a product that was not approved
* (pitfall 70). `typeApproved`: another product of its type passed review already. Pure. */
export function productStateText(state: string, typeApproved: boolean): string {
if (state !== 'READY_TO_SUBMIT') return state;
return typeApproved ? 'READY_TO_SUBMIT: ready, not submitted yet (storebound apple iap-submit)'
: 'READY_TO_SUBMIT: ready, not submitted yet (goes in with the next app version: storebound apple submit --build <n> adds it)';
}

View file

@ -62,6 +62,9 @@ export interface AppleSnapshot {
availability: boolean;
iaps: { productId: string; state: string; reviewScreenshot: boolean }[];
expectedProducts: string[];
/** First in-app purchases / subscriptions that never passed review and are not in the app's review submission
* (firstProductGaps, cli/lib/review-submit.ts; pitfall 70). */
firstProductGaps?: { level: 'error' | 'warn'; msg: string; fix: string }[];
privacyUrl: UrlCheck;
/** App ID capabilities vs apple.signing.capabilities vs the iOS project's entitlements (cli/lib/signing.ts compareCapabilities). */
capabilityMismatches?: { msg: string; fix: string }[];
@ -239,6 +242,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
if (!IAP_OK.includes(i.state)) add('blocker', 'iap', `${i.productId}: ${i.state}`, i.state === 'MISSING_METADATA' ? 'storebound apple iap + apple iap-review' : 'see the review-rejections skill');
else if (!i.reviewScreenshot && i.state !== 'APPROVED') add('blocker', 'iap', `${i.productId}: no review screenshot`, 'storebound apple iap-review');
}
for (const g of s.firstProductGaps ?? []) add(g.level === 'error' ? 'blocker' : 'warn', 'iap-first', g.msg, g.fix);
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
f.push(...appleSubscriptionRules(s));

View file

@ -94,7 +94,7 @@ async function status(args: Args): Promise<void> {
const read = await pool(list, concurrency, async t => ({ t, s: await readLive(t) }));
if (args.has('json')) {
printJson({ apps: read.map(({ t, s }) => ({
app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], storeErrors: s.storeErrors,
app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: s.warnings ?? [], storeErrors: s.storeErrors,
trains: trainsOf(s, all).map(({ which, train }) => ({ which, version: train.version, ios: train.ios, android: train.android,
lanes: { ios: laneOf('ios', train.ios), android: laneOf('android', train.android) } })),
})), at: new Date().toISOString() });
@ -105,6 +105,7 @@ async function status(args: Args): Promise<void> {
const unread = Object.keys(s.storeErrors).length > 0;
if (!trains.length) rows.push([t.name, '', '', s.errors[0] ? `error: ${s.errors[0]}` : unread ? 'not read (see the warnings)' : 'no version on either store yet', '']);
for (const { which, train } of trains) rows.push([t.name, train.version, which === 'other' ? '' : which, cell('ios', train, s), cell('android', train, s)]);
for (const w of s.warnings ?? []) warn('release', `${t.name}: ${w}`);
for (const p of [...t.problems, ...s.errors.slice(trains.length ? 0 : 1), ...Object.entries(s.storeErrors).map(([k, v]) => `${k}: ${v}`)]) warn('release', `${t.name}: ${p}`);
}
console.log(textTable(['app', 'version', 'train', 'App Store', 'Google Play'], rows));

View file

@ -16,6 +16,7 @@
import { templateBlockers } from './lib/template-answers';
import { ascClient, type J } from './apple/asc';
import { capabilityReport } from './apple/signing';
import { firstProductReport } from './apple/first-products';
import { requiredDisplayTypes } from './apple/screenshots';
import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config';
import { reviewDemoAccount } from './lib/credentials';
@ -82,7 +83,10 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`);
const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`);
const iaps: AppleSnapshot['iaps'] = [];
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
const iapList = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
const configIds = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)];
const firstProductGaps = configIds.length ? await firstProductReport(c, appId, { iaps: iapList, only: configIds, versionState: v ? (v.attributes.appVersionState ?? v.attributes.appStoreState) : undefined }) : [];
for (const p of iapList) {
const shot = (await c.maybe(`/v2/inAppPurchases/${p.id}/appStoreReviewScreenshot`))?.data;
iaps.push({ productId: p.attributes.productId, state: p.attributes.state, reviewScreenshot: !!shot });
}
@ -111,6 +115,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
requiredDisplayTypes: requiredDisplayTypes(config.apple),
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
availability: !!avail?.data,
firstProductGaps,
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
capabilityMismatches: await capabilityReport(c, config, root).then(r => r.mismatches.map(m => ({ msg: m.msg, fix: m.fix })), e => [{ msg: `capabilities not compared: ${(e as Error).message.slice(0, 200)}`, fix: 'storebound apple signing status' }]),

View file

@ -460,7 +460,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `apps_gameCenterEnabledVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterEnabledVersions` | read | |
| `apps_getCollection` | GET | `/v1/apps` | read | apple/asc, apple/bundle, apps, console/app-id, console/verify, lib/store-apps |
| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status, apps, onboard |
| `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate |
| `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/first-products, apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate |
| `apps_inAppPurchasesV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchasesV2` | read | |
| `apps_inAppPurchases_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchases` | read | |
| `apps_inAppPurchases_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchases` | read | |
@ -479,7 +479,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `apps_searchKeywords_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/searchKeywords` | read | |
| `apps_subscriptionGracePeriod_getToOneRelated` | GET | `/v1/apps/{id}/subscriptionGracePeriod` | read | |
| `apps_subscriptionGracePeriod_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/subscriptionGracePeriod` | read | |
| `apps_subscriptionGroups_getToManyRelated` | GET | `/v1/apps/{id}/subscriptionGroups` | read | apple/iap-submit, apple/promoted, apple/subscriptions, apple/win-back, catalog, prices, validate |
| `apps_subscriptionGroups_getToManyRelated` | GET | `/v1/apps/{id}/subscriptionGroups` | read | apple/first-products, apple/iap-submit, apple/promoted, apple/subscriptions, apple/win-back, catalog, prices, validate |
| `apps_subscriptionGroups_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/subscriptionGroups` | read | |
| `apps_updateInstance` | PATCH | `/v1/apps/{id}` | write (dry by default) | apple/appinfo |
| `apps_webhooks_getToManyRelated` | GET | `/v1/apps/{id}/webhooks` | read | |
@ -1246,7 +1246,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `inAppPurchasesV2_promotedPurchase_getToOneRelated` | GET | `/v2/inAppPurchases/{id}/promotedPurchase` | read | |
| `inAppPurchasesV2_promotedPurchase_getToOneRelationship` | GET | `/v2/inAppPurchases/{id}/relationships/promotedPurchase` | read | |
| `inAppPurchasesV2_updateInstance` | PATCH | `/v2/inAppPurchases/{id}` | write (dry by default) | |
| `inAppPurchasesV2_versions_getToManyRelated` | GET | `/v2/inAppPurchases/{id}/versions` | read | apple/iap-submit |
| `inAppPurchasesV2_versions_getToManyRelated` | GET | `/v2/inAppPurchases/{id}/versions` | read | apple/first-products, apple/iap-submit |
| `inAppPurchasesV2_versions_getToManyRelationship` | GET | `/v2/inAppPurchases/{id}/relationships/versions` | read | |
| `inAppPurchases_getInstance` | GET | `/v1/inAppPurchases/{id}` | read | |
| **marketplaceSearchDetails** (3) | | | | |
@ -1377,7 +1377,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `subscriptionGroups_getInstance` | GET | `/v1/subscriptionGroups/{id}` | read | apple/subscriptions |
| `subscriptionGroups_subscriptionGroupLocalizations_getToManyRelated` | GET | `/v1/subscriptionGroups/{id}/subscriptionGroupLocalizations` | read | apple/subscriptions, validate |
| `subscriptionGroups_subscriptionGroupLocalizations_getToManyRelationship` | GET | `/v1/subscriptionGroups/{id}/relationships/subscriptionGroupLocalizations` | read | |
| `subscriptionGroups_subscriptions_getToManyRelated` | GET | `/v1/subscriptionGroups/{id}/subscriptions` | read | apple/iap-submit, apple/promoted, apple/subscriptions, apple/win-back, catalog, prices, validate |
| `subscriptionGroups_subscriptions_getToManyRelated` | GET | `/v1/subscriptionGroups/{id}/subscriptions` | read | apple/first-products, apple/iap-submit, apple/promoted, apple/subscriptions, apple/win-back, catalog, prices, validate |
| `subscriptionGroups_subscriptions_getToManyRelationship` | GET | `/v1/subscriptionGroups/{id}/relationships/subscriptions` | read | |
| `subscriptionGroups_updateInstance` | PATCH | `/v1/subscriptionGroups/{id}` | write (dry by default) | |
| `subscriptionGroups_versions_getToManyRelated` | GET | `/v1/subscriptionGroups/{id}/versions` | read | apple/iap-submit |
@ -1485,7 +1485,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `subscriptions_subscriptionLocalizations_getToManyRelated` | GET | `/v1/subscriptions/{id}/subscriptionLocalizations` | read | apple/subscriptions, catalog, validate |
| `subscriptions_subscriptionLocalizations_getToManyRelationship` | GET | `/v1/subscriptions/{id}/relationships/subscriptionLocalizations` | read | |
| `subscriptions_updateInstance` | PATCH | `/v1/subscriptions/{id}` | write (dry by default) | apple/subscriptions |
| `subscriptions_versions_getToManyRelated` | GET | `/v1/subscriptions/{id}/versions` | read | apple/iap-submit |
| `subscriptions_versions_getToManyRelated` | GET | `/v1/subscriptions/{id}/versions` | read | apple/first-products, apple/iap-submit |
| `subscriptions_versions_getToManyRelationship` | GET | `/v1/subscriptions/{id}/relationships/versions` | read | |
| `subscriptions_winBackOffers_getToManyRelated` | GET | `/v1/subscriptions/{id}/winBackOffers` | read | apple/win-back |
| `subscriptions_winBackOffers_getToManyRelationship` | GET | `/v1/subscriptions/{id}/relationships/winBackOffers` | read | |

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 70 | 2026-10 | A game: the app's first in-app purchase (a starter pack) was not in the 1.0.0 review submission (its items held only the appStoreVersion); it stayed READY_TO_SUBMIT and was noticed after two days in WAITING_FOR_REVIEW. Apple reviews the first product of a type only with an app version; approved without it, it is not sellable. | `firstProductGaps` (`cli/lib/review-submit.ts`, read by `cli/apple/first-products.ts`): WARN in `apple status` (text and --json), `release status` and the desktop checks; blocker in `validate` / `release plan` and the `apple submit` plan; `iap-review` says "ready, not submitted yet". `tests/first-products.test.ts`. |
| 69 | 2026-10 | A web app: `bun add -d storebound` plus the scaffolded `storebound.config.ts` broke `next build`: the package exported only its TypeScript source and shipped no declarations; Bun imports .ts, so every storebound test passed, but tsc never takes a package's .ts source as its types. | The package ships declarations for ES module (`dist/types`) and CommonJS projects (`dist/types-cjs`) and a Node entry (`dist/config.js`); Bun keeps the source (`buildEntry`, `pruneDeclarations` in `tools/build-node.ts`, run by prepack). `tests/package-types.test.ts` type-checks the template against the packed entry under bundler / node16 / nodenext / node10 in both project types with no @types/node and skipLibCheck off, and loads the package with `import()` and `require()` under Node. `check` fails on the template's own placeholders (Example Studio, bare example.com). |
| 68 | 2026-10 | A headless Chrome started by storebound's console automation (a test run's `--user-data-dir=$TMPDIR/storebound-profile-apple-…`, `--remote-debugging-port=0 --headless=new`) outlived its parent for ~20 hours as an orphan (parent PID 1). macOS LaunchServices took it for the running "Google Chrome", so the Dock's Chrome only activated the windowless instance and the owner's own Chrome would not open. A Chrome started with a DevTools port does not notice its parent dying. | `cli/console/chrome-guard.ts`: every Chrome storebound starts itself is ended on process exit, closed gracefully on SIGINT / SIGTERM / SIGHUP, and ended by a detached watchdog when the parent dies (SIGKILL included; a reused pid is never killed); every start first ends headless orphans on storebound's profiles and names orphaned windows; headless runs on macOS prefer Chromium / Chrome for Testing over Google Chrome.app; `console fill` closes the window on an error too (`tests/chrome-guard.test.ts`). |
| 67 | 2026-10 | A checkout without `.entitlements` was reported as "builds ship without push / Game Center" while the uploaded build had both (native changes lived on a release branch). | The comparison warns only about this checkout ("built from another branch?") and names the branches / worktrees that have an `.entitlements` file; with a built IPA it reads that build's real entitlements ("uploaded build N"); without one it says the App Store Connect API does not expose them. `checkoutInfo`, `builtIpaEntitlements` (cli/apple/signing.ts), `machoEntitlements`, `compareCapabilities` (cli/lib/signing.ts), tests/signing-capabilities.test.ts. |

View file

@ -806,3 +806,19 @@ that fails without the fix (see CONTRIBUTING.md).
the template config type-checks against the packed entry in ES module and CommonJS projects under bundler,
node16, nodenext and node10 with no @types/node and skipLibCheck off; `import()` and `require()` of the package
load under Node.
## 70. The first in-app purchase was not in the version's review submission
- **Symptom:** the app version has been WAITING_FOR_REVIEW for days while its first in-app purchase (here a starter
pack) still says READY_TO_SUBMIT; the review submission's items hold only the appStoreVersion.
- **Cause:** Apple reviews the first in-app purchase of each type (and the first subscription of a group) only together
with an app version. A submission made without it (from the App Store Connect version page, or before the product
was ready) leaves it out, and the version is approved without it: the product is not sellable. storebound <= 0.8.6
printed the IAP and the submission states separately and called READY_TO_SUBMIT "complete".
- **Do:** before submitting, `storebound validate` (blocker `iap-first`) and `storebound release plan`; submit with
`storebound apple submit --build <n>`, which adds every ready product to the same submission and refuses to leave a
never-approved first product out. Already in review: withdraw the submission (`storebound apple status
--cancel-submission <id>`) and resubmit with the product, or add it with the next version.
- **Guard:** `firstProductGaps` (cli/lib/review-submit.ts) read by `cli/apple/first-products.ts`: `apple status` (WARN
and `firstProductGaps` in --json), `release status` and the desktop checks (`appleFirstProductWarnings`), `validate`
/ `release plan` (blocker), the `apple submit` plan (blocker); `iap-review` says "ready, not submitted yet";
`tests/first-products.test.ts`.

View file

@ -0,0 +1,108 @@
// Pitfall 70 (seen live): the app's first in-app purchase (a starter pack) was not in the 1.0.0 review submission;
// its items held only the appStoreVersion, the IAP stayed READY_TO_SUBMIT and nothing warned for two days in
// WAITING_FOR_REVIEW. Against the stateful App Store Connect double: apple status warns (text and --json), validate's
// rules raise a blocker before submitting, apple submit refuses to leave a never-approved first product out, and
// iap-review never calls READY_TO_SUBMIT "complete".
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import type { ShipConfig } from '../cli/lib/config';
import { firstProductGaps, planIapSubmission, productStateText, type SubmitState } from '../cli/lib/review-submit';
import { appleFirstProductWarnings } from '../cli/lib/portfolio';
import { type AppleSnapshot, appleRules } from '../cli/lib/validate-rules';
import { run as appleStatus } from '../cli/apple/status';
import { upsertTiming } from '../cli/lib/upsert';
import { ascEnv, BASE, capture, jsonOut, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores } from './helpers/fake-stores';
const APP = '6400000101';
const PACK = 'com.example.game.starter_pack';
const CONFIG: ShipConfig = { ...BASE, products: [{ id: PACK, type: 'non_consumable', usd: 1.99, listings: { en: { title: 'Starter pack', description: 'A head start.' } } }] };
let s: Stores;
beforeAll(ascEnv);
afterAll(restore);
beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); });
afterEach(uninstallStores);
/** 1.0.0 WAITING_FOR_REVIEW in rs-1; the starter pack READY_TO_SUBMIT (never approved). */
function waiting(o: { packInItems?: boolean; packState?: string } = {}): void {
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'WAITING_FOR_REVIEW', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchases', id: 'iap-pack', attributes: { productId: PACK, inAppPurchaseType: 'NON_CONSUMABLE', state: o.packState ?? 'READY_TO_SUBMIT' }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-pack', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-pack' } });
s.asc.put({ type: 'reviewSubmissions', id: 'rs-1', attributes: { platform: 'IOS', state: 'WAITING_FOR_REVIEW' }, rels: { app: APP } });
s.asc.put({ type: 'reviewSubmissionItems', id: 'it-ver', attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-1', appStoreVersion: 'ver-1' } });
if (o.packInItems) s.asc.put({ type: 'reviewSubmissionItems', id: 'it-pack', attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-1', inAppPurchaseVersion: 'iapv-pack' } });
}
describe('apple status (pitfall 70)', () => {
test('only the version in the submission, the first IAP READY_TO_SUBMIT: WARN in text and --json', async () => {
waiting();
const p = project({ ...CONFIG, apple: { ...BASE.apple!, appId: APP } });
const r = await capture(() => appleStatus(p.ctx([])));
expect(r.err).toContain(`WARN ${PACK} is not in this review`);
expect(r.err).toContain('it will not be sellable after approval');
expect(r.err).toContain('add it with the next version, or withdraw the submission');
const j = await capture(() => appleStatus(p.ctx(['--json'], ['json'])));
const gaps = jsonOut(j.out).firstProductGaps as { productId: string; level: string }[];
expect(gaps.map(g => [g.productId, g.level])).toEqual([[PACK, 'warn']]);
// release status / the desktop read the same JSON
expect(appleFirstProductWarnings(jsonOut(j.out))[0]).toStartWith(`App Store: ${PACK} is not in this review`);
});
test('the IAP in the submission items: no warning', async () => {
waiting({ packInItems: true });
const p = project({ ...CONFIG, apple: { ...BASE.apple!, appId: APP } });
const r = await capture(() => appleStatus(p.ctx([])));
expect(r.err).not.toContain('is not in this review');
});
test('an approved IAP: no warning', async () => {
waiting({ packState: 'APPROVED' });
const p = project({ ...CONFIG, apple: { ...BASE.apple!, appId: APP } });
const r = await capture(() => appleStatus(p.ctx([])));
expect(r.err).not.toContain('is not in this review');
});
});
describe('firstProductGaps (pure)', () => {
const iap = (state: string, type = 'NON_CONSUMABLE', productId = PACK) => ({ productId, type, state, versionIds: [`v-${productId}`] });
test('an open draft without the never-approved first IAP: error; with it: nothing', () => {
const sub = { id: 'rs-1', state: 'READY_FOR_REVIEW', itemVersionIds: ['ver-1'] };
expect(firstProductGaps({ iaps: [iap('READY_TO_SUBMIT')], subs: [], submission: sub, versionState: 'PREPARE_FOR_SUBMISSION' }).map(g => g.level)).toEqual(['error']);
expect(firstProductGaps({ iaps: [iap('READY_TO_SUBMIT')], subs: [], submission: { ...sub, itemVersionIds: ['ver-1', `v-${PACK}`] } })).toEqual([]);
});
test('another product of the type approved, or the product approved: nothing', () => {
expect(firstProductGaps({ iaps: [iap('READY_TO_SUBMIT'), iap('APPROVED', 'NON_CONSUMABLE', 'com.example.game.old')], subs: [], versionState: 'WAITING_FOR_REVIEW' })).toEqual([]);
expect(firstProductGaps({ iaps: [iap('APPROVED')], subs: [], versionState: 'WAITING_FOR_REVIEW' })).toEqual([]);
});
test('missing metadata before submitting: error; a first subscription of a group counts too; only config ids', () => {
expect(firstProductGaps({ iaps: [iap('MISSING_METADATA')], subs: [] })[0]!.level).toBe('error');
const g = firstProductGaps({ iaps: [], subs: [{ productId: 'com.example.game.vip', groupId: 'g1', state: 'READY_TO_SUBMIT' }], versionState: 'IN_REVIEW' });
expect(g.map(x => [x.kind, x.level])).toEqual([['subscription', 'warn']]);
expect(firstProductGaps({ iaps: [iap('READY_TO_SUBMIT')], subs: [], versionState: 'IN_REVIEW', only: ['com.example.other'] })).toEqual([]);
});
test('validate rules: error is a blocker, warn a warning', () => {
const snap: AppleSnapshot = {
version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null,
priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 },
firstProductGaps: [{ level: 'error', msg: 'm1', fix: 'f' }, { level: 'warn', msg: 'm2', fix: 'f' }],
};
const f = appleRules(snap).filter(x => x.code === 'iap-first');
expect(f.map(x => [x.level, x.msg])).toEqual([['blocker', 'm1'], ['warn', 'm2']]);
});
});
test('apple submit plan: a never-approved first IAP left out (missing metadata) blocks the version submission', () => {
const st: SubmitState = { appVersions: [{ id: 'ver-1', versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }], subs: [], groups: [], draft: null,
iaps: [{ id: 'iap-pack', productId: PACK, type: 'NON_CONSUMABLE', state: 'MISSING_METADATA', versions: [{ id: 'iapv-pack', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] }] };
const plan = planIapSubmission(st, [PACK], { withVersion: true, appVersionId: 'ver-1' });
expect(plan.blockers.join(' ')).toContain(`never-approved first in-app purchases would be left out of this review`);
const ok = planIapSubmission({ ...st, iaps: [{ ...st.iaps[0]!, state: 'READY_TO_SUBMIT' }] }, [PACK], { withVersion: true, appVersionId: 'ver-1' });
expect(ok.blockers).toEqual([]);
expect(ok.items.map(i => i.kind)).toEqual(['appStoreVersion', 'inAppPurchaseVersion']);
});
test('iap-review wording: READY_TO_SUBMIT is never "complete"', () => {
expect(productStateText('READY_TO_SUBMIT', false)).toBe('READY_TO_SUBMIT: ready, not submitted yet (goes in with the next app version: storebound apple submit --build <n> adds it)');
expect(productStateText('READY_TO_SUBMIT', true)).not.toContain('complete');
expect(productStateText('APPROVED', true)).toBe('APPROVED');
});