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:
parent
41d2ffcc24
commit
0b1451800c
18 changed files with 399 additions and 26 deletions
|
|
@ -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);
|
||||
|
|
|
|||
13
cli/check.ts
13
cli/check.ts
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue