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

@ -4,7 +4,8 @@
// says which version of each goes in, which items are first of their type (those need an app version in the same
// submission: --with-version adds the editable one, and then submitting submits the app too), and what is left out.
// --new-version: approved or rejected products get a new draft version first (POST inAppPurchaseVersions /
// subscriptionVersions) so changed metadata can go to review. --no-submit: only fill the draft submission; the owner
// subscriptionVersions) so changed metadata can go to review. A DEVELOPER_REJECTED version (its submission was
// canceled) needs none: it goes into the submission again as it is (seen live, 2026-10-12). --no-submit: only fill the draft submission; the owner
// presses "Submit for Review" in App Store Connect. Without --no-submit the last step asks first (never under MCP).
// The submission is the app's open one when there is one (chooseSubmission): a draft, or one back from App Review with
// UNRESOLVED_ISSUES, which gets only the missing items and is resubmitted; while one waits for or is in review nothing
@ -17,7 +18,7 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { allSubscriptions } from '../lib/subscriptions';
import { bindIds, chooseSubmission, EMPTY_DRAFT, labelOf, type ResolveScope, resolveHint, toResolve, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf, willSubmit } from '../lib/review-submit';
import { addAction, bindIds, chooseSubmission, EMPTY_DRAFT, labelOf, type ResolveScope, resolveHint, toResolve, latestVersion, NEW_VERSION_FROM, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf, willSubmit } from '../lib/review-submit';
import { readJournal } from '../lib/upsert';
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute } from './jsonapi';
@ -71,8 +72,8 @@ export async function run(ctx: Ctx): Promise<void> {
if (a.has('new-version')) {
const reqs = [
...state.iaps.filter(p => ids.includes(p.productId) && ['APPROVED', 'REJECTED', 'DEVELOPER_REJECTED'].includes(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('iap', p.id, p.productId)),
...state.subs.filter(p => ids.includes(p.productId) && ['APPROVED', 'REJECTED', 'DEVELOPER_REJECTED'].includes(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('sub', p.id, p.productId)),
...state.iaps.filter(p => ids.includes(p.productId) && NEW_VERSION_FROM.has(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('iap', p.id, p.productId)),
...state.subs.filter(p => ids.includes(p.productId) && NEW_VERSION_FROM.has(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('sub', p.id, p.productId)),
];
if (reqs.length) {
await execute(c.asc, reqs, { dry, tag: TAG });
@ -126,7 +127,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
if (dry || plan.blockers.length || busy) { if (plan.blockers.length) process.exitCode = 1; return; }
} else if (!o.quiet) {
for (const l of choiceLines(state)) log(tag, l);
for (const it of plan.items) log(tag, `submit: ${it.label}`);
for (const it of plan.items) log(tag, `${addAction(it)}: ${it.label}`);
for (const sk of plan.skipped) log(tag, `skip ${sk.id}: ${sk.why}`);
if (plan.firstOfType.length) log(tag, `first ${plan.firstOfType.join(' / ')} of the app: needs an app version in the same submission`);
for (const b of plan.blockers) warn(tag, b);

View file

@ -12,6 +12,7 @@ import { trackingIssue } from './lib/privacy-manifest';
import { type AsoFinding, auditAso } from './lib/aso';
import { draftLocales } from './lib/aso-intel';
import { appleName, playCodes } from './lib/config';
import { appBuildFile, pickGradle, versionSourceIssues, versionSourceMessage } from './lib/gradle';
import { playConsoleReminders } from './lib/console-plan';
import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy';
import type { Ctx } from './lib/ctx';
@ -160,6 +161,18 @@ export async function run(ctx: Ctx): Promise<void> {
const st = webState(ctx.loaded.root, store, cap);
if (st.staleness.stale) note(`${store}: ${staleMessage(st.path, st.staleness, st.copied)}. Set build.prebuild (e.g. "npm run build && npx cap sync") so apple build / play build refresh them`);
}
// play build passes -PversionCode / -PversionName: the app module's build file must read them (seen live: a
// Capacitor app read versionCode from an env var and the AAB stayed at 1)
if (config.play) {
const b = config.play.build ?? {};
try {
const cap = capacitorProject(ctx.loaded.root, b.dir);
const sub = !b.dir && cap?.dir ? cap.dir : '';
const t = pickGradle(sub ? join(ctx.loaded.root, sub) : ctx.loaded.root, { dir: b.dir, module: b.module, flavor: b.flavor });
const file = t.framework === 'flutter' ? null : appBuildFile(t);
if (file) for (const i of versionSourceIssues(file)) note(`play: ${versionSourceMessage(i, relative(ctx.loaded.root, file), t.framework)}`);
} catch { /* no Gradle project here (yet): play build says so */ }
}
for (const i of profileIssues(config,{ demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) {
if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg);
}

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;

View file

@ -10,6 +10,9 @@
// .storebound/credentials.json (ANDROID_KEYSTORE, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD)
// goes to Gradle as injected signing properties through the environment, never on the command line; without it the
// project's own signingConfig signs. Local only: Play is only read (version codes).
// Before Gradle runs, the app module's build file must read -PversionCode / -PversionName in defaultConfig (a literal
// or an env var keeps the AAB at that value; android.injected.version.* does not override it for bundleRelease): a
// warning with the exact line to use. After the build, an AAB whose versionCode is not the planned one is a hard stop.
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { androidSigning } from '../lib/credentials';
@ -17,7 +20,7 @@ import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { pump } from '../lib/runner';
import { aabInfo, findBundles, gradleArgs, pickGradle, signingEnv } from '../lib/gradle';
import { aabInfo, appBuildFile, findBundles, gradleArgs, pickGradle, signingEnv, versionSourceIssues, versionSourceMessage } from '../lib/gradle';
import { nextVersionCode } from '../lib/release';
import { capacitorProject, webStep } from '../lib/web-assets';
import { androidSdk, androidSdkEnv, androidSdkMissing } from '../lib/tools';
@ -61,9 +64,12 @@ export async function run(ctx: Ctx): Promise<void> {
// the SDK as Android Studio finds it (ANDROID_HOME is often unset in non-interactive shells and local.properties is
// gitignored): handed to Gradle through the environment, local.properties is never written
const sdk = androidSdk([t.dir]);
// the build file must read -PversionCode / -PversionName (Flutter passes --build-number / --build-name instead)
const buildFile = t.framework === 'flutter' ? null : appBuildFile(t);
const versionWarnings = buildFile ? versionSourceIssues(buildFile).map(i => versionSourceMessage(i, relative(root, buildFile), t.framework)) : [];
const plan = { framework: t.framework, dir: relative(root, t.dir) || '.', module: t.module, task: t.framework === 'flutter' ? 'flutter build appbundle' : t.task,
versionCode: code || null, versionName: versionName ?? null, signing: signing ? `upload key from ${process.env.ANDROID_KEYSTORE ? 'env' : '.storebound/credentials.json'} (alias ${signing.keyAlias})` : 'the project\'s own signingConfig',
androidSdk: sdk.path ? `${sdk.path} (${sdk.source})` : null, command: argv.join(' '), out: relative(root, out) || '.', notes: t.notes };
androidSdk: sdk.path ? `${sdk.path} (${sdk.source})` : null, command: argv.join(' '), out: relative(root, out) || '.', notes: t.notes, versionWarnings };
const webOpts = { config, root, cap, store: 'play' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
if (dry) {
const web = await webStep({ ...webOpts, dry: true, quiet: json });
@ -72,6 +78,7 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `signing: ${plan.signing}`);
if (sdk.path) log(TAG, `Android SDK: ${sdk.path} (from ${sdk.source})`); else warn(TAG, androidSdkMissing(sdk));
for (const n of t.notes) log(TAG, n);
for (const w of versionWarnings) warn(TAG, w);
log(TAG, `would run (in ${relative(root, cwd) || '.'}): ${plan.command}`);
done(TAG, 'build planned (dry run)');
return;
@ -82,6 +89,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (signing && !existsSync(signing.storeFile)) throw new Error('ANDROID_KEYSTORE: the keystore file does not exist');
if (!signing) warn(TAG, 'no ANDROID_KEYSTORE: the project\'s own release signingConfig must sign, else Play refuses the bundle');
for (const n of t.notes) log(TAG, n);
for (const w of versionWarnings) warn(TAG, w);
log(TAG, `$ ${plan.command}`);
const started = Date.now();
mkdirSync(out, { recursive: true });
@ -100,7 +108,11 @@ export async function run(ctx: Ctx): Promise<void> {
if (!aab) throw new Error(`build finished but no new .aab under ${relative(root, t.dir)}/${t.module}/build/outputs/bundle`);
const info = aabInfo(aab);
if (info.packageName !== pkg) warn(TAG, `the AAB's package is ${info.packageName}, the config says ${pkg}`);
if (code && info.versionCode !== code) warn(TAG, `the AAB has versionCode ${info.versionCode}, not ${code}: the build file sets it itself; read -PversionCode there or pass that number to play upload knowingly`);
// the hard stop: an AAB with another versionCode than planned is never copied to the output folder
if (code && info.versionCode !== code) {
const fix = buildFile ? versionSourceIssues(buildFile).filter(i => i.field === 'versionCode').map(i => ` Use in defaultConfig of ${relative(root, buildFile)}: ${i.fix}`).join('') : '';
throw new Error(`the AAB has versionCode ${info.versionCode}, not ${code}: the build file sets it itself and ignores -PversionCode (android.injected.version.code does not override it).${fix || ' Read -PversionCode in defaultConfig.'} Then build again. (Built: ${relative(root, aab)}, not copied.)`);
}
const dest = join(out, `${info.packageName}-${info.versionCode}.aab`);
copyFileSync(aab, dest);
const result = { aab: dest, packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, web: web.bundle };