apple iap-submit: DEVELOPER_REJECTED IAP / subscription / group versions (left by a canceled submission) are submitted as they are, plan says 'add (developer rejected, re-submitted as is)', --new-version only for APPROVED / REJECTED, empty draft reused; fake ASC models cancel and re-add. play build / check: warn before Gradle when defaultConfig does not read -PversionCode / -PversionName, with the exact Groovy / Kotlin line (built with AGP 8.13); android.injected.version.code does not override bundleRelease; post-build versionCode mismatch is a hard stop. play-production-release plan: 'Do you want to restart your review?' dialog, Restart review is never-press. Pitfalls 31-33

This commit is contained in:
CRTkafa 2026-10-12 11:32:05 +08:00
parent 41d2ffcc24
commit 0b1451800c
18 changed files with 399 additions and 26 deletions

View file

@ -362,6 +362,9 @@ function productionCountries(config: ShipConfig, p: PlanIds): Part {
};
}
/** The dialog Publishing overview shows when changes are submitted while a review is running (seen 2026-10-12). */
export const RESTART_DIALOG = 'Do you want to restart your review?';
function productionRelease(config: ShipConfig, p: PlanIds): Part {
const mp = managedPublishingNote(config);
return {
@ -374,18 +377,21 @@ function productionRelease(config: ShipConfig, p: PlanIds): Part {
{ text: 'Press Save (stores the release; nothing is sent yet).', click: ['Save'] },
{ text: 'Open Publishing overview (left menu). It lists "Changes not yet sent for review" and a "Submit N changes for review" button.' },
{ text: `Stop. The owner presses "Submit N changes for review" and then "Send changes for review" in the "Send N changes for review?" dialog (a real mouse click: a JS .click() did nothing on it). ${mp}`, answers: ['managedPublishing'] },
{ text: `While a review is already running, "Submit N changes for review" opens "${RESTART_DIALOG}" instead, with "Restart review": that sends the new changes and starts the running review over. It is the owner's button as well (tell the owner a review is running and what restarting means); never the agent's. Cancel closes the dialog and nothing changes.` },
{ text: 'Verify: read Publishing overview as text and pipe it to `storebound console verify play-production-release --from-text -`; `storebound play status` shows the production release.' },
]),
answers: [ans('managedPublishing', 'Managed publishing on?', config.play?.managedPublishing ?? false, config.play?.managedPublishing === undefined ? 'owner-check' : 'certain', config.play?.managedPublishing === undefined ? 'Play default (off)' : 'play.managedPublishing',
{ note: mp, where: 'Publishing overview > Managed publishing (only read; the owner switches it)' })],
final: 'Send changes for review',
neverPress: ['Submit N changes for review', 'Send changes for review', ...NEVER_PRESS],
neverPress: ['Submit N changes for review', 'Send changes for review', 'Restart review', ...NEVER_PRESS],
allowed: [
{ button: 'Releases (tab), Edit release, Next', class: 'navigation', where: 'production track: opens the draft release and its review page' },
{ button: 'Save', class: 'draft-save', where: 'the release review page: stores the draft release; nothing is sent' },
{ button: 'Cancel', class: 'navigation', where: `the "${RESTART_DIALOG}" dialog: closes it; the running review goes on unchanged` },
],
uiFacts: [
'Verified 2026-10-12 on a first production release: Releases tab > Edit release > Next (review page; a missing deobfuscation file is only a warning) > Save; then Publishing overview > "Submit N changes for review" > dialog "Send N changes for review?" > "Send changes for review" (the owner\'s final button; a JS .click() did nothing on it, it needs a real click).',
`Seen 2026-10-12: pressing "Submit N changes for review" while a review is running opens the dialog "${RESTART_DIALOG}" with "Restart review" (the owner\'s final button: the running review starts over with the new changes) and Cancel. Known dialog; agents never press Restart review.`,
mp,
PLAY_MATERIAL_FACT,
],

View file

@ -52,8 +52,11 @@ export function pickGradle(root: string, cfg: PlayBuildConfig = {}): GradleTarge
return { framework: fw, dir, module, task: `:${module}:bundle${flavor}Release`, notes };
}
/** Command line of the release bundle build. Version code / name go in as injected properties (the same ones Android
* Studio uses) and as -PversionCode / -PversionName for build files that read those. */
/** Command line of the release bundle build. Version code / name go in as -PversionCode / -PversionName (the build
* file must read them: versionSourceIssues) and as the injected properties Android Studio uses. Those do NOT
* override defaultConfig for bundleRelease: checked with AGP 8.13 (2026-10-12), `-Pandroid.injected.version.code=42`
* on a build file with `versionCode 1` gives an AAB with versionCode 1; seen live the same day with a Capacitor app.
* They stay on the command line for older plugins / IDE parity only; the AAB check after the build is the stop. */
export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionName?: string; flavor?: string; hasWrapper: boolean }): string[] {
if (t.framework === 'flutter')
return ['flutter', 'build', 'appbundle', '--release', ...(o.flavor ? ['--flavor', o.flavor] : []), ...(o.versionCode ? ['--build-number', String(o.versionCode)] : []), ...(o.versionName ? ['--build-name', o.versionName] : [])];
@ -62,6 +65,90 @@ export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionNa
...(o.versionName ? [`-Pandroid.injected.version.name=${o.versionName}`, `-PversionName=${o.versionName}`] : [])];
}
// ---- where the build file takes versionCode / versionName from ----
export interface VersionSourceIssue { field: 'versionCode' | 'versionName'; file: string; line: number | null; text: string; kind: 'literal' | 'other' | 'missing'; current: string; fix: string }
/** The application module's build file (build.gradle.kts first, then build.gradle), or null. */
export function appBuildFile(t: Pick<GradleTarget, 'dir' | 'module'>): string | null {
const base = join(t.dir, ...t.module.split(':'));
return ['build.gradle.kts', 'build.gradle'].map(f => join(base, f)).find(f => existsSync(f)) ?? null;
}
/** The line that reads the -P property storebound passes, keeping `current` as the default. Pure. */
export function versionFixLine(field: 'versionCode' | 'versionName', current: string, kotlin: boolean): string {
// Groovy: `versionCode (x) as Integer` parses as versionCode(x) as Integer, which hands Gradle a String (AGP 8.13:
// "Value is null"); the cast must sit inside the parentheses
if (kotlin) return field === 'versionCode' ? `versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: ${current}`
: `versionName = project.findProperty("versionName") as String? ?: "${current}"`;
return field === 'versionCode' ? `versionCode((project.findProperty('versionCode') ?: ${current}) as Integer)` : `versionName project.findProperty('versionName') ?: '${current}'`;
}
/** The block body after `name {` (brace matched, strings skipped), with the line number it starts on. Pure. */
function block(src: string, name: string): { body: string; line: number } | null {
const m = new RegExp(`\\b${name}\\s*\\{`).exec(src);
if (!m) return null;
let depth = 0, q = '';
const start = m.index + m[0].length;
for (let i = start; i < src.length; i++) {
const c = src[i]!;
if (q) { if (c === '\\') i++; else if (c === q) q = ''; continue; }
if (c === '"' || c === "'") q = c;
else if (c === '{') depth++;
else if (c === '}') { if (depth === 0) return { body: src.slice(start, i), line: src.slice(0, start).split('\n').length }; depth--; }
}
return null;
}
/**
* Whether defaultConfig of the build file reads versionCode / versionName from the project properties storebound
* passes (-PversionCode / -PversionName). A literal (`versionCode 1`), an environment variable or anything else keeps
* the AAB at that value whatever `play build` passes (seen live: a Capacitor app read versionCode from an env var and
* the AAB stayed at 1). Each issue carries the exact line to use. Pure apart from reading the file.
*/
export function versionSourceIssues(file: string, src = readFileSync(file, 'utf8')): VersionSourceIssue[] {
const kotlin = file.endsWith('.kts');
// comments out first (block and line; a `//` right after a colon is a URL), keeping the line numbers
const code = src.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' ')).replace(/(^|[^:])\/\/.*$/gm, '$1');
const dc = block(code, 'defaultConfig');
if (!dc) return [];
const out: VersionSourceIssue[] = [];
const lines = dc.body.split('\n');
for (const field of ['versionCode', 'versionName'] as const) {
const fallback = field === 'versionCode' ? '1' : '1.0';
let hit: { line: number; text: string; value: string } | null = null;
lines.forEach((l, i) => {
for (const stmt of l.split(';')) {
const m = new RegExp(`^\\s*(?:${field}|set${field[0]!.toUpperCase()}${field.slice(1)})\\b\\s*(?:=\\s*)?(.*)$`).exec(stmt);
if (m && !hit) hit = { line: dc.line + i, text: l.trim(), value: m[1]!.trim() };
}
});
if (!hit) {
out.push({ field, file, line: null, text: '', kind: 'missing', current: fallback, fix: versionFixLine(field, fallback, kotlin) });
continue;
}
const h = hit as { line: number; text: string; value: string };
const reads = new RegExp(`(?:findProperty|property|hasProperty|getProperty|gradleProperty)\\s*\\(\\s*['"]${field}['"]|properties\\s*\\[\\s*['"]${field}['"]`).test(h.value);
if (reads) continue;
const lit = /^\(?\s*(\d+|"[^"]*"|'[^']*')\s*\)?$/.exec(h.value);
const current = lit ? lit[1]!.replace(/^['"]|['"]$/g, '')
: field === 'versionCode' ? (/(?:\?:|\|\||,)\s*['"]?(\d+)['"]?/.exec(h.value)?.[1] ?? /\b(\d+)\b/.exec(h.value)?.[1] ?? fallback)
: (/['"](\d[\w.+-]*)['"]/.exec(h.value)?.[1] ?? fallback);
out.push({ field, file, line: h.line, text: h.text, kind: lit ? 'literal' : 'other', current, fix: versionFixLine(field, current, kotlin) });
}
return out;
}
/** One warning per issue: where, why, and the line to use. Pure. */
export function versionSourceMessage(i: VersionSourceIssue, rel: string, framework?: AndroidFramework): string {
const where = i.line ? `${rel}:${i.line}` : rel;
const flag = i.field === 'versionCode' ? '-PversionCode' : '-PversionName';
const what = i.kind === 'missing' ? `defaultConfig sets no ${i.field}`
: i.kind === 'literal' ? `${i.field} is a literal (${i.text})` : `${i.field} does not come from the project property ${i.field} (${i.text})`;
const unity = framework === 'unity' ? ' (Unity export: the file is rewritten on every export; set Bundle Version Code / Version in Player Settings or edit it after each export)' : '';
return `${where}: ${what}, so ${flag} from play build is ignored and the AAB keeps ${i.kind === 'missing' ? 'whatever the manifest says' : i.current}; android.injected.version.* does not override it for bundleRelease. Use in defaultConfig: ${i.fix}${unity}`;
}
/** Environment that hands the upload key to the Android Gradle plugin (injected signing properties). */
export function signingEnv(s: AndroidSigning | null): Record<string, string> {
if (!s) return {};

View file

@ -33,8 +33,15 @@ const APPROVED_APP = new Set(['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'PENDI
'PROCESSING_FOR_APP_STORE', 'REPLACED_WITH_NEW_VERSION', 'ACCEPTED', 'DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE']);
/** Product states (InAppPurchaseState / Subscription.state) that mean the product was approved once. */
const APPROVED_PRODUCT = new Set(['APPROVED', 'DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE']);
/** Version states that can still go into a draft submission. */
const SUBMITTABLE = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW']);
/** Version states that can still go into a draft submission. DEVELOPER_REJECTED (the owner canceled the submission
* it was in) goes in as it is: seen live (2026-10-12), POST reviewSubmissionItems with that same in-app purchase version
* answers 201 READY_FOR_REVIEW and the submit 200 WAITING_FOR_REVIEW; no new version is needed (--new-version is for
* APPROVED or REJECTED products whose metadata changed). */
const SUBMITTABLE = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', 'DEVELOPER_REJECTED']);
/** What the plan says about a DEVELOPER_REJECTED version it adds. */
export const DEV_REJECTED_NOTE = 'developer rejected, re-submitted as is';
/** Product version states for which --new-version makes a new draft version first. */
export const NEW_VERSION_FROM = new Set(['APPROVED', 'REJECTED']);
const IN_REVIEW = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW']);
export type ItemKind = 'inAppPurchaseVersion' | 'subscriptionVersion' | 'subscriptionGroupVersion' | 'appStoreVersion';
@ -111,7 +118,7 @@ export function chooseSubmission(open: OpenSub[], o: { appVersionId?: string } =
return { action: 'create', why: 'no open review submission: a new draft' };
}
export interface PlanItem { kind: ItemKind; versionId: string; label: string }
export interface PlanItem { kind: ItemKind; versionId: string; label: string; /** a DEVELOPER_REJECTED version added again as it is */ devRejected?: boolean }
export interface SubmitPlan {
items: PlanItem[];
/** Product ids that are left out, with the reason. */
@ -156,9 +163,9 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
const v = latestVersion(versions);
if (!v) { plan.skipped.push({ id, why: 'no version on App Store Connect yet (create the product first: storebound apple iap / apple subscriptions)' }); return; }
if (IN_REVIEW.has(v.state)) { plan.skipped.push({ id, why: `already ${v.state}` }); return; }
if (!SUBMITTABLE.has(v.state)) { plan.skipped.push({ id, why: `latest version is ${v.state}: change it in App Store Connect (or --new-version) to send a new version` }); return; }
if (!SUBMITTABLE.has(v.state)) { plan.skipped.push({ id, why: `latest version is ${v.state}: ${NEW_VERSION_FROM.has(v.state) ? 'add --new-version to send a new version' : 'change it in App Store Connect to send a new version'}` }); return; }
if (already.has(v.id)) { plan.skipped.push({ id, why: 'already in the draft submission' }); return; }
plan.items.push({ kind, versionId: v.id, label: `${id} (${typeLabel(type)}, version ${v.version ?? '?'})` });
plan.items.push({ kind, versionId: v.id, label: `${id} (${typeLabel(type)}, version ${v.version ?? '?'})`, ...(v.state === 'DEVELOPER_REJECTED' ? { devRejected: true } : {}) });
if (!approvedTypes.has(type)) firstTypes.add(type);
};
@ -180,7 +187,7 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
if (!v) { plan.blockers.push(`subscription group "${g.name}" has no version to submit`); continue; }
if (IN_REVIEW.has(v.state) || already.has(v.id)) continue;
if (!SUBMITTABLE.has(v.state)) { plan.blockers.push(`subscription group "${g.name}": version is ${v.state}`); continue; }
plan.items.push({ kind: 'subscriptionGroupVersion', versionId: v.id, label: `subscription group "${g.name}" (not approved yet, goes with its subscriptions)` });
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);
const forced = o.appVersionId ? s.appVersions.find(v => v.id === o.appVersionId) : undefined;
@ -192,7 +199,7 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
: 'the app has no approved version yet: in-app purchases go together with an app version';
if (!o.withVersion && !forced) plan.blockers.push(`${why}. Add --with-version (the editable version ${editable ? editable.versionString : '(none yet: storebound apple listing --version x.y.z --build N)'} goes into the same submission; submitting it submits the app)`);
else if (!editable) plan.blockers.push(`${why}, and there is no editable app version: storebound apple listing --version x.y.z --build N first`);
else if (!already.has(editable.id)) { plan.appVersion = editable; plan.items.unshift({ kind: 'appStoreVersion', versionId: editable.id, label: `app version ${editable.versionString}` }); }
else if (!already.has(editable.id)) { plan.appVersion = editable; plan.items.unshift({ kind: 'appStoreVersion', versionId: editable.id, label: `app version ${editable.versionString}`, ...(editable.state === 'DEVELOPER_REJECTED' ? { devRejected: true } : {}) }); }
}
if (plan.items.length + (s.draft?.itemVersionIds.length ?? 0) > MAX_SUBMISSION_ITEMS) plan.blockers.push(`more than ${MAX_SUBMISSION_ITEMS} items in one submission: use --only for a part`);
const sid = s.draft?.id ?? '${submission}';
@ -203,6 +210,9 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
return plan;
}
/** The plan's word for an item it adds. Pure. */
export const addAction = (it: PlanItem): string => (it.devRejected ? `add (${DEV_REJECTED_NOTE})` : 'add');
/** Whether the run ends with a submit: new items, or a submission back from App Review that only needs resubmitting. Pure. */
export const willSubmit = (s: SubmitState, plan: SubmitPlan): boolean => plan.items.length > 0 || s.draft?.state === 'UNRESOLVED_ISSUES';
@ -255,7 +265,7 @@ export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep,
for (const it of ch.sub.items) rows.push({ step: 'item', what: labelOf(s, it.versionId, it.kind), now: it.state,
action: marked.has(it) ? 'in it already; mark resolved' : it.state === 'REJECTED' ? `REJECTED, left: ${resolveHint(it.kind)}` : 'in it already' });
}
for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: '-', action: 'add' });
for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: it.devRejected ? 'DEVELOPER_REJECTED' : '-', action: addAction(it) });
if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : `left as it is: ${EMPTY_DRAFT}` });
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED' });
return rows;