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
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -2,6 +2,21 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Fix (`apple iap-submit`, `apple resubmit`): after the owner canceled a submission, every IAP version was
|
||||
DEVELOPER_REJECTED and the plan skipped them all ("change it in App Store Connect (or --new-version)", then
|
||||
"nothing to submit"). Apple accepts the same version again (201 READY_FOR_REVIEW, then the submit 200), so
|
||||
DEVELOPER_REJECTED in-app purchase, subscription and subscription group versions now go in as they are, listed as
|
||||
"add (developer rejected, re-submitted as is)"; an empty draft is reused. `--new-version` is only for APPROVED or
|
||||
REJECTED products (pitfall 31).
|
||||
- `play build` and `check`: warn before Gradle runs when the app module's `defaultConfig` does not read
|
||||
`-PversionCode` / `-PversionName` (a literal, an env var, ...), naming the file and line and the exact Groovy or
|
||||
Kotlin line to use (both built with AGP 8.13). `android.injected.version.code` does not override `defaultConfig`
|
||||
for `bundleRelease` (checked with AGP 8.13); the docs no longer suggest it. An AAB with another versionCode than
|
||||
planned is now a hard stop (it was a warning) and is not copied (pitfall 32).
|
||||
- Console plan `play-production-release`: the "Do you want to restart your review?" dialog that "Submit N changes
|
||||
for review" opens while a review is running is a known step; "Restart review" is the owner's button and in
|
||||
neverPress (pitfall 33).
|
||||
|
||||
- Safety (`apple testflight groups`): run bare, it created the configured beta groups right away, with no plan. Now it only lists the groups and prints `would create "Team", "Friends"`. Creating is
|
||||
`groups sync` (or `groups --yes`). Flows treat bare `groups` as a read and `groups sync` as a write, and the MCP tool
|
||||
runs `groups sync` (dry by default).
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 33 | 2026-10 | Play Console: pressing "Submit N changes for review" in Publishing overview while a review was running opened a dialog "Do you want to restart your review?" with "Restart review". Pressing it restarts the running review with the new changes; the plan did not know the dialog. | `play-production-release` plan: the dialog is a known step and UI fact, "Restart review" is in neverPress (the owner's button), Cancel is allowed (closes it, nothing changes); play-first-release and release-management skills explain it. `tests/console-play-plans.test.ts`. |
|
||||
| 32 | 2026-10 | `play build` passed `-PversionCode` / `-PversionName` and `android.injected.version.*`, but a Capacitor `app/build.gradle` read versionCode from an env var, so the AAB stayed at versionCode 1 (the post-build check caught it). Checked with AGP 8.13: `-Pandroid.injected.version.code=42` on `versionCode 1` gives an AAB with versionCode 1, so the injected property does not override `defaultConfig` for `bundleRelease`. The Groovy line `versionCode (project.findProperty('versionCode') ?: 1) as Integer` fails ("Value is null": it parses as `versionCode(...) as Integer`); the cast belongs inside the parentheses. | `versionSourceIssues` (cli/lib/gradle.ts) reads `defaultConfig` of the app module's build.gradle(.kts): a literal, an env var or anything but the project property is a warning in `play build` (before Gradle, also in the dry run and `--json` as `versionWarnings`) and in `check`, with the exact line (Groovy `versionCode((project.findProperty('versionCode') ?: <current>) as Integer)`, `versionName project.findProperty('versionName') ?: '<current>'`; Kotlin `versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: <current>`, `versionName = project.findProperty("versionName") as String? ?: "<current>"`), both lines built with AGP 8.13; the post-build versionCode check is now a hard stop (the AAB is not copied). `tests/build-sign.test.ts`, `tests/web-assets.test.ts`. |
|
||||
| 31 | 2026-10 | After the owner canceled a WAITING_FOR_REVIEW submission, the app version went DEVELOPER_REJECTED, then PREPARE_FOR_SUBMISSION, and every IAP version stayed DEVELOPER_REJECTED. `apple iap-submit --with-version --dry` skipped all of them ("latest version is DEVELOPER_REJECTED: change it in App Store Connect (or --new-version)") and said "nothing to submit". Apple takes the same version again: `POST /v1/reviewSubmissionItems` with that inAppPurchaseVersion → 201 READY_FOR_REVIEW (all 7), then `PATCH submitted: true` → 200 WAITING_FOR_REVIEW; an empty READY_FOR_REVIEW draft was reused for it. | DEVELOPER_REJECTED IAP, subscription and subscription group versions are submittable as they are (`planIapSubmission`; the app version already was): the plan says "add (developer rejected, re-submitted as is)" and `apple resubmit`'s table the same; `--new-version` only for APPROVED / REJECTED; an empty draft is reused (`chooseSubmission`). The fake ASC leaves the versions of a canceled submission DEVELOPER_REJECTED, accepts them as items and refuses APPROVED ones. `tests/resubmit.test.ts`. |
|
||||
| 30 | 2026-10 | Resubmitting after an Invalid Binary rejection: `apple iap-submit --with-version --yes` created a second, empty draft next to the rejected submission (UNRESOLVED_ISSUES, holding the REJECTED version and 7 IAP versions) and stopped with "the store says it already exists but does not list it yet (lists lag)". The answer was really 409 `ITEM_PART_OF_ANOTHER_SUBMISSION` naming the rejected submission; and right after a new build is attached Apple answers the submit with 409 "Version is not ready to be submitted yet". That 409 lasted 25 minutes with a VALID, APP_STORE_ELIGIBLE build attached and the console's Resubmit / Add for Review disabled: the appStoreVersion item was still REJECTED. `PATCH /v1/reviewSubmissionItems/{id}` `{"data":{"type":"reviewSubmissionItems","id":...,"attributes":{"resolved":true}}}` → 200, item READY_FOR_REVIEW, then the submit passed. An empty READY_FOR_REVIEW draft cannot be canceled (409 "Resource is not in cancellable state"). | `chooseSubmission` lists the open submissions (READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW, IN_REVIEW) and reuses one, never creating a second; `isConflict` excludes the code and `addItem` names the other submission, a draft the run created is left as is (not cancellable, harmless) and the named one used; REJECTED items are marked resolved before the submit (`resolveRejected`: the version item in `apple resubmit`, IAP / subscription items with `--resolve-iaps`); `submitWithRetry` re-reads the items on "not ready", stops at once naming a REJECTED item out of scope and how to resolve it, and backs off (15 s, 30 s, 60 s, 2 min, heartbeat, 10 min limit) only when none is left; `apple status` labels an empty draft harmless and treats its cancel 409 as such; new `apple resubmit --build <n>` (build by number, plan table, `--yes`); `apple status` shows the UNRESOLVED_ISSUES items and the command. `tests/resubmit.test.ts`. |
|
||||
| 29 | 2026-10 | Apple refused build 1 as Invalid Binary one minute after submission: "ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid." The app's own manifest had NSPrivacyTracking true with an EMPTY NSPrivacyTrackingDomains, as storebound recommended from `appPrivacy.tracking: true` although the tracking came from an ad SDK (LevelPlay) whose own manifest declared it. Apple's wording names the other direction (domains listed, tracking false); both are invalid. After the fix (app manifest false, App Privacy still "used for tracking") `check` wrongly demanded the app manifest say true. | Rule (`trackingProblem` / `trackingIssue` / `itms91064` in `cli/lib/privacy-manifest.ts`): a manifest's NSPrivacyTracking and NSPrivacyTrackingDomains agree, in the app's and every SDK's; `check`, `scan` (blocker) and `privacy-manifest` (blocker) fail on either mismatch with the exact ITMS text and never suggest true without domains. App Privacy tracking no longer needs the app manifest's true: `check` looks for SDK manifests (Pods, frameworks, SwiftPM checkouts, Carthage, Capacitor plugins, the last built IPA; `cli/lib/privacy-files.ts`) that declare tracking (info), known tracking SDKs in the dependencies (info), else warns. `privacy-manifest --propose` and the scan proposal write true only with `appPrivacy.trackingDomains` (the app's own domains), else false. `apple upload` reads every PrivacyInfo.xcprivacy in the IPA (app root, Frameworks, bundles; XML or binary) and stops before any network call; `apple build` checks the exported IPA. `tests/privacy-tracking.test.ts`. |
|
||||
| 28 | 2026-10 | First App Store release with IAPs: the console route failed an agent (the IAP page's "Add for Review" menu ignored scripted clicks; the version page no longer has an "In-App Purchases and Subscriptions" section), while `apple iap-submit --with-version --yes` added the IAPs to the draft submission that held 1.0.0 and submitted them first try. App Privacy's Publish opens a "Publish Your App Privacy Responses?" dialog and leaves a "Published … ago by …" line. | appstore-first-release and store-iap-setup point to `apple iap-submit --with-version` (the owner's yes: it submits), never the console. `console verify apple-app-privacy` reads the "Published …" line as the published signal; the plan names the dialog. `tests/console-play-plans.test.ts`. |
|
||||
|
|
|
|||
|
|
@ -81,8 +81,16 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
|
|||
Flutter, the root for native, a Unity export with `launcher/` + `unityLibrary/`; or `play.build.dir` / `module` /
|
||||
`flavor`), the task (`:app:bundleRelease`; Flutter: `flutter build appbundle`), the next free versionCode from Play
|
||||
(or `--version-code N`) and where the key comes from. Then without `--dry`. The AAB lands in
|
||||
`build/android/<package>-<versionCode>.aab`; storebound reads the versionCode back from the bundle and warns when
|
||||
the build file ignored the one it passed (read `-PversionCode` there, or `android.injected.version.code`).
|
||||
`build/android/<package>-<versionCode>.aab`. The app module's `defaultConfig` must read the project properties
|
||||
storebound passes; `play build` (and `check`) warn before Gradle runs when `versionCode` / `versionName` is a
|
||||
literal or comes from an env var, with the line to use:
|
||||
Groovy `versionCode((project.findProperty('versionCode') ?: 1) as Integer)` and
|
||||
`versionName project.findProperty('versionName') ?: '1.0'`; Kotlin
|
||||
`versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1` and
|
||||
`versionName = project.findProperty("versionName") as String? ?: "1.0"` (keep your current values as the
|
||||
defaults). `android.injected.version.code` is passed too but does NOT override `defaultConfig` for
|
||||
`bundleRelease` (checked with AGP 8.13); don't count on it. After the build storebound reads the versionCode back
|
||||
from the bundle; another number than planned is a hard stop (the AAB is not copied).
|
||||
The Android SDK is found the way Android Studio finds it: `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `sdk.dir` in the
|
||||
Gradle project's `local.properties`, then `~/Library/Android/sdk` (macOS) / `~/Android/Sdk` (Linux) /
|
||||
`%LOCALAPPDATA%\Android\Sdk` (Windows), and handed to Gradle through `ANDROID_HOME` / `ANDROID_SDK_ROOT`;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ products) and answers the compliance facts no file proves, all in one batch at t
|
|||
deobfuscation file is only a warning) > Save.
|
||||
c) Publishing overview > "Submit N changes for review" > dialog "Send N changes for review?" > **Send changes for
|
||||
review**: the owner's final button, and it needs a real click (a JS `.click()` did nothing on it).
|
||||
While a review is already running, "Submit N changes for review" opens **"Do you want to restart your
|
||||
review?"** with **Restart review** instead: that restarts the running review with the new changes. It is the
|
||||
owner's button too (never the agent's); Cancel leaves the running review alone.
|
||||
Managed publishing is off by default: the app goes live automatically once Google approves it. If the owner wants
|
||||
to pick the moment, they turn on Publishing overview > Managed publishing first; record it in
|
||||
`play.managedPublishing` (`play release` and the plan warn while it is off).
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately
|
|||
1. `storebound play next-version-code` before building; the versionCode must be higher than every uploaded one.
|
||||
2. Upload and start the rollout (owner confirms): `storebound play release app.aab --production inProgress --fraction 0.05`.
|
||||
Draft apps cannot do this until the first release is reviewed (`play-first-release`).
|
||||
Changes made in the Console (listing, App content) are sent from Publishing overview > "Submit N changes for
|
||||
review". While a review is running that button opens "Do you want to restart your review?" with **Restart
|
||||
review**: the running review starts over with the new changes. The owner's button, never the agent's; to keep the
|
||||
running review, Cancel and send the changes after it is done.
|
||||
3. Increase: `storebound play rollout --fraction 0.2` (then 0.5, ...). Play never lowers a fraction.
|
||||
4. Problem: `storebound play rollout --halt` (no confirmation needed; stops new installs of the update). Like Apple's
|
||||
pause it is **not a rollback**: updated users keep it. Ship a fixed build with a higher versionCode; resume with
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ Auto-renewable subscriptions are a separate list (`subscriptions`, `store-subscr
|
|||
**submits the app too**.
|
||||
- The real run asks before adding the items and again before submitting; `--no-submit` stops at the filled draft so
|
||||
the owner presses "Submit for Review" in App Store Connect. `--new-version` makes a new draft version of an
|
||||
approved or rejected product first (changed name, description or screenshot). Never an MCP tool; MCP has the
|
||||
approved or rejected product first (changed name, description or screenshot). After the owner canceled a
|
||||
submission the versions are DEVELOPER_REJECTED: they go into the next submission as they are (the plan says
|
||||
"add (developer rejected, re-submitted as is)"); no new version is needed. Never an MCP tool; MCP has the
|
||||
read-only plan `apple_iap_review_plan`. `--json` prints the plan for scripts.
|
||||
4. Offer codes (free or discounted) for one-time purchases: see the app-store-promotion skill (`storebound apple offer-codes`).
|
||||
5. Test: Sandbox testers (Users and Access > Sandbox) on device; or a StoreKit configuration file in Xcode for local
|
||||
|
|
|
|||
|
|
@ -421,3 +421,41 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
it) when one out of scope is left, backing off only when none is; `apple status` labels an empty draft "empty draft
|
||||
(harmless; App Store Connect keeps it, not cancellable)" and treats the cancel 409 as harmless; the fake ASC blocks
|
||||
the submit while an item is REJECTED and refuses to cancel an empty draft; `tests/resubmit.test.ts`.
|
||||
|
||||
## 31. After a canceled submission: DEVELOPER_REJECTED versions go in as they are
|
||||
- **Symptom:** the owner canceled a WAITING_FOR_REVIEW submission; `apple iap-submit --with-version --dry` then
|
||||
skipped every IAP ("latest version is DEVELOPER_REJECTED: change it in App Store Connect (or --new-version)") and
|
||||
said "nothing to submit".
|
||||
- **Cause:** canceling leaves the app version DEVELOPER_REJECTED (then PREPARE_FOR_SUBMISSION) and every IAP version
|
||||
DEVELOPER_REJECTED. Apple accepts the same IAP version again: `POST /v1/reviewSubmissionItems` → 201
|
||||
READY_FOR_REVIEW, then the submit → 200 WAITING_FOR_REVIEW. storebound treated it like an approved product.
|
||||
- **Do:** `storebound apple iap-submit --with-version --dry`: the plan lists them as "add (developer rejected,
|
||||
re-submitted as is)" into the open draft (an empty one is reused); then after the owner's yes `--yes`. Use
|
||||
`--new-version` only for APPROVED or REJECTED products whose metadata changed.
|
||||
- **Guard:** `planIapSubmission` (cli/lib/review-submit.ts) takes DEVELOPER_REJECTED IAP, subscription and group
|
||||
versions; the fake ASC models the cancel and the re-add; `tests/resubmit.test.ts`.
|
||||
|
||||
## 32. `play build`: the AAB keeps the build file's versionCode
|
||||
- **Symptom:** `play build` passed versionCode 3, the AAB had versionCode 1 (the post-build check stopped it). The
|
||||
Capacitor `app/build.gradle` read versionCode from an env var.
|
||||
- **Cause:** storebound passes `-PversionCode` / `-PversionName` (and `android.injected.version.*`); a build file
|
||||
that does not read the project property ignores them. `android.injected.version.code` does NOT override
|
||||
`defaultConfig` for `bundleRelease` (checked with AGP 8.13).
|
||||
- **Do:** in `defaultConfig`, Groovy: `versionCode((project.findProperty('versionCode') ?: 1) as Integer)` and
|
||||
`versionName project.findProperty('versionName') ?: '1.0'`; Kotlin:
|
||||
`versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1` and
|
||||
`versionName = project.findProperty("versionName") as String? ?: "1.0"` (your current values as defaults). Not
|
||||
`versionCode (project.findProperty('versionCode') ?: 1) as Integer`: Groovy reads it as `versionCode(...) as
|
||||
Integer` and Gradle fails with "Value is null".
|
||||
- **Guard:** `play build` (also `--dry`) and `check` warn before Gradle with the file, the line and the line to use;
|
||||
the post-build versionCode check is a hard stop; `tests/build-sign.test.ts`, `tests/web-assets.test.ts`.
|
||||
|
||||
## 33. Play "Do you want to restart your review?"
|
||||
- **Symptom:** in Publishing overview, "Submit N changes for review" while a review is running opens "Do you want
|
||||
to restart your review?" with "Restart review".
|
||||
- **Cause:** Play reviews one set of changes at a time; restarting sends the new changes and starts the running
|
||||
review over.
|
||||
- **Do:** never press "Restart review" as an agent: tell the owner a review is running and what restarting means;
|
||||
the owner decides (Cancel keeps the running review).
|
||||
- **Guard:** the `play-production-release` console plan lists the dialog, "Restart review" is in neverPress;
|
||||
`tests/console-play-plans.test.ts`.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|||
import { installedProfiles } from '../cli/apple/build';
|
||||
import { androidSigning, ascKeyFile } from '../cli/lib/credentials';
|
||||
import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../cli/lib/csr';
|
||||
import { aabInfo, findBundles, gradleArgs, pickGradle, signingEnv, validatePlayBuild } from '../cli/lib/gradle';
|
||||
import { aabInfo, appBuildFile, findBundles, gradleArgs, pickGradle, signingEnv, validatePlayBuild, versionSourceIssues, versionSourceMessage } from '../cli/lib/gradle';
|
||||
import { parseBinaryPlist, parseMobileprovision, parsePlist, parseXmlPlist, toXmlPlist } from '../cli/lib/plist';
|
||||
import {
|
||||
type ApiCert, type ApiProfile, certSha1, certSpki, certificateReq, explainCertError, exportOptions, parseIdentities, pickCertificate, planCapabilities,
|
||||
|
|
@ -301,6 +301,46 @@ describe('gradle projects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// play build passes -PversionCode / -PversionName; seen live (2026-10-12): a Capacitor app/build.gradle read versionCode
|
||||
// from an env var, so the AAB stayed at 1. Checked with AGP 8.13 the same day: android.injected.version.code does not
|
||||
// override a literal for bundleRelease; the Groovy and Kotlin lines below give the passed values (and the default without).
|
||||
describe('versionCode / versionName source in the build file', () => {
|
||||
const gv = (body: string) => `plugins { id 'com.android.application' }\nandroid {\n namespace 'com.example.game'\n defaultConfig {\n applicationId 'com.example.game' // don't touch\n${body}\n }\n}\n`;
|
||||
const kv = (body: string) => `plugins { id("com.android.application") }\nandroid {\n namespace = "com.example.game"\n defaultConfig {\n applicationId = "com.example.game"\n${body}\n }\n}\n`;
|
||||
const kinds = (file: string, src: string) => versionSourceIssues(file, src).map(i => [i.field, i.kind, i.line, i.current, i.fix]);
|
||||
test('Groovy: literal and env var warn with the exact line; findProperty does not', () => {
|
||||
expect(kinds('app/build.gradle', gv(" versionCode 3\n versionName '1.4.0'"))).toEqual([
|
||||
['versionCode', 'literal', 6, '3', "versionCode((project.findProperty('versionCode') ?: 3) as Integer)"],
|
||||
['versionName', 'literal', 7, '1.4.0', "versionName project.findProperty('versionName') ?: '1.4.0'"]]);
|
||||
expect(kinds('app/build.gradle', gv(" versionCode System.getenv('VERSION_CODE') ? System.getenv('VERSION_CODE').toInteger() : 1\n versionName System.getenv('VERSION_NAME') ?: \"1.0.2\"")).map(k => k.slice(0, 4))).toEqual([
|
||||
['versionCode', 'other', 6, '1'], ['versionName', 'other', 7, '1.0.2']]);
|
||||
expect(kinds('app/build.gradle', gv(" versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'"))).toEqual([]);
|
||||
expect(kinds('app/build.gradle', gv(" versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 1\n versionName project.findProperty('versionName') ?: '1.0'"))).toEqual([]);
|
||||
// commented out lines do not count; nothing set at all is said too
|
||||
expect(kinds('app/build.gradle', gv(" // versionCode 1\n /* versionName '1.0' */")).map(k => [k[0], k[1]])).toEqual([['versionCode', 'missing'], ['versionName', 'missing']]);
|
||||
});
|
||||
test('Kotlin: literal and env var warn with the exact line; findProperty does not', () => {
|
||||
expect(kinds('app/build.gradle.kts', kv(' versionCode = 5\n versionName = "2.0"'))).toEqual([
|
||||
['versionCode', 'literal', 6, '5', 'versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 5'],
|
||||
['versionName', 'literal', 7, '2.0', 'versionName = project.findProperty("versionName") as String? ?: "2.0"']]);
|
||||
expect(kinds('app/build.gradle.kts', kv(' versionCode = System.getenv("VERSION_CODE")?.toInt() ?: 1\n versionName = System.getenv("VERSION_NAME") ?: "1.0"')).map(k => k.slice(0, 4))).toEqual([
|
||||
['versionCode', 'other', 6, '1'], ['versionName', 'other', 7, '1.0']]);
|
||||
expect(kinds('app/build.gradle.kts', kv(' versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1\n versionName = project.findProperty("versionName") as String? ?: "1.0"'))).toEqual([]);
|
||||
});
|
||||
test('the message names the file and line, says the injected property does not help, and gives the line', () => {
|
||||
const [i] = versionSourceIssues('app/build.gradle', gv(" versionCode System.getenv('VERSION_CODE') as Integer\n versionName project.findProperty('versionName') ?: '1.0'"));
|
||||
const m = versionSourceMessage(i!, 'android/app/build.gradle', 'capacitor');
|
||||
expect(m).toContain('android/app/build.gradle:6: versionCode does not come from the project property versionCode');
|
||||
expect(m).toContain('-PversionCode from play build is ignored');
|
||||
expect(m).toContain('android.injected.version.* does not override it for bundleRelease');
|
||||
expect(m).toContain("Use in defaultConfig: versionCode((project.findProperty('versionCode') ?: 1) as Integer)");
|
||||
const d = dir('gradle-kts');
|
||||
touch(d, 'app/build.gradle.kts', kv(' versionCode = 1'));
|
||||
expect(appBuildFile({ dir: d, module: 'app' })).toBe(join(d, 'app/build.gradle.kts'));
|
||||
expect(appBuildFile({ dir: d, module: 'other' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentials for builds', () => {
|
||||
test('android upload key from .storebound/credentials.json; inline ASC key goes to a private temp file', () => {
|
||||
const d = dir('creds');
|
||||
|
|
@ -372,6 +412,38 @@ describe('CLI dry runs (no store access)', () => {
|
|||
expect(readFileSync(marker, 'utf8').trim()).toBe(`${sdk}|${sdk}`);
|
||||
expect(readFileSync(join(d, 'android/local.properties'), 'utf8')).toBe(props);
|
||||
});
|
||||
test('play build --dry: a build file that does not read -PversionCode is warned about before Gradle, with the line to use', () => {
|
||||
const d = project('cli-play-vc');
|
||||
touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode System.getenv('VERSION_CODE') ? System.getenv('VERSION_CODE').toInteger() : 1\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n");
|
||||
let r = cli(d, ['play', 'build', '--dry', '--json', '--version-code', '12']);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const j = JSON.parse(r.stdout.toString());
|
||||
expect(j.versionWarnings).toHaveLength(1);
|
||||
expect(j.versionWarnings[0]).toContain("android/app/build.gradle:3: versionCode does not come from the project property versionCode");
|
||||
expect(j.versionWarnings[0]).toContain("versionCode((project.findProperty('versionCode') ?: 1) as Integer)");
|
||||
r = cli(d, ['play', 'build', '--dry', '--version-code', '12']);
|
||||
expect(r.stderr.toString()).toContain('-PversionCode from play build is ignored');
|
||||
touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n");
|
||||
r = cli(d, ['play', 'build', '--dry', '--json', '--version-code', '12']);
|
||||
expect(JSON.parse(r.stdout.toString()).versionWarnings).toEqual([]);
|
||||
});
|
||||
test('play build: an AAB with another versionCode than planned is a hard stop, not copied', () => {
|
||||
const d = project('cli-play-stop');
|
||||
touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode 1\n versionName '1.0'\n }\n}\n");
|
||||
const built = join(d, 'prebuilt.aab');
|
||||
writeFileSync(built, makeAab('com.example.game', 1, '1.0'));
|
||||
touch(d, 'android/gradlew', `#!/bin/sh\nmkdir -p app/build/outputs/bundle/release && cp "${built}" app/build/outputs/bundle/release/app-release.aab\n`);
|
||||
chmodSync(join(d, 'android/gradlew'), 0o755);
|
||||
const sdk = dir('sdk-folder-stop');
|
||||
mkdirSync(join(sdk, 'platform-tools'), { recursive: true });
|
||||
const r = Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'play', 'build', '--version-code', '12', '--no-prebuild', '--allow-stale-web'], { cwd: d, stdout: 'pipe', stderr: 'pipe',
|
||||
env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', HOME: dir('clihome-stop'), ANDROID_HOME: sdk, ANDROID_SDK_ROOT: '', ANDROID_KEYSTORE: '' } });
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr.toString()).toContain('versionCode is a literal (versionCode 1)');
|
||||
expect(r.stderr.toString()).toContain('the AAB has versionCode 1, not 12');
|
||||
expect(r.stderr.toString()).toContain("Use in defaultConfig of android/app/build.gradle: versionCode((project.findProperty('versionCode') ?: 1) as Integer)");
|
||||
expect(existsSync(join(d, 'build/android/com.example.game-1.aab'))).toBe(false);
|
||||
});
|
||||
test('play upload refuses production', () => {
|
||||
const d = project('cli-up');
|
||||
writeFileSync(join(d, 'app.aab'), makeAab('com.example.game', 3));
|
||||
|
|
|
|||
|
|
@ -220,7 +220,11 @@ describe('the first production release: countries, managed publishing, the owner
|
|||
|
||||
test('release: Edit release, Next, Save; Publishing overview\'s Submit and Send changes for review are the owner\'s (a real click); the managed publishing warning', () => {
|
||||
const p = consolePlan('play-production-release', base);
|
||||
for (const b of ['Submit N changes for review', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b);
|
||||
for (const b of ['Submit N changes for review', 'Send changes for review', 'Restart review']) expect(p.buttons.neverPress).toContain(b);
|
||||
// seen live: Submit while a review runs opens "Do you want to restart your review?" with "Restart review" (the owner's)
|
||||
expect(p.steps.map(s => s.text).join('\n')).toContain('opens "Do you want to restart your review?" instead, with "Restart review"');
|
||||
expect(p.uiFacts.some(f => f.includes('Do you want to restart your review?') && f.includes('agents never press Restart review'))).toBe(true);
|
||||
expect(p.buttons.allowed.some(b => b.button === 'Cancel' && b.where.includes('restart your review'))).toBe(true);
|
||||
expect(p.buttons.final).toBe('Send changes for review');
|
||||
const text = p.steps.map(s => s.text).join('\n');
|
||||
for (const f of ['Edit release', 'deobfuscation file is only a warning', 'Send N changes for review?', 'a JS .click() did nothing', 'goes live automatically once Google approves it']) expect(text).toContain(f);
|
||||
|
|
|
|||
|
|
@ -163,6 +163,13 @@ export class FakeAsc {
|
|||
&& !['COMPLETE', 'CANCELING'].includes(String(this.res.get(String(r.rels.reviewSubmission))?.attributes.state)));
|
||||
if (holder) return json({ errors: [{ status: '409', code: 'STATE_ERROR.ITEM_PART_OF_ANOTHER_SUBMISSION', title: 'The item is already part of another submission',
|
||||
detail: `${this.res.get(String(vid))?.type ?? kind} with id ${vid} was already added to another reviewSubmission with id ${holder.rels.reviewSubmission}` }] }, 409);
|
||||
// seen live (2026-10-12): a product version left DEVELOPER_REJECTED by a canceled submission is accepted again as
|
||||
// it is (201, item READY_FOR_REVIEW); an APPROVED one is not (it needs a new version first)
|
||||
const v = this.res.get(String(vid));
|
||||
if (v && /^(inAppPurchase|subscription|subscriptionGroup)Versions$/.test(v.type) && v.attributes.state === 'APPROVED')
|
||||
return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: `${v.type} with id ${v.id} is not in a valid state to be submitted` }] }, 409);
|
||||
if (v && v.attributes.state === 'DEVELOPER_REJECTED' && v.type !== 'appStoreVersions') v.attributes.state = 'READY_FOR_REVIEW';
|
||||
main.attributes.state ??= 'READY_FOR_REVIEW';
|
||||
}
|
||||
const u = UNIQUE[d.type];
|
||||
if (u && this.all(d.type).some(r => u(r) === u(main as R)))
|
||||
|
|
@ -269,9 +276,15 @@ export class FakeAsc {
|
|||
return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Version is not ready to be submitted yet, please try again later' }] }, 409);
|
||||
if (attrs.canceled && self.attributes.state === 'READY_FOR_REVIEW' && !items.length)
|
||||
return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Resource is not in cancellable state' }] }, 409);
|
||||
// submitting moves the submission (and its items) on; canceling ends it
|
||||
// submitting moves the submission (and its items) on; canceling ends it, and (seen live) the versions in a canceled
|
||||
// submission are left DEVELOPER_REJECTED; the app version is editable again (PREPARE_FOR_SUBMISSION)
|
||||
const wasSubmitted = ['WAITING_FOR_REVIEW', 'IN_REVIEW'].includes(String(self.attributes.state));
|
||||
self.attributes.state = attrs.submitted ? 'WAITING_FOR_REVIEW' : 'CANCELING';
|
||||
if (attrs.submitted) for (const it of this.children(self.id, 'reviewSubmissionItems')) it.attributes.state = 'READY_FOR_REVIEW';
|
||||
if (attrs.canceled && wasSubmitted) for (const it of items) for (const [k, vid] of Object.entries(it.rels)) {
|
||||
const v = k === 'reviewSubmission' ? undefined : this.res.get(String(vid));
|
||||
if (v) v.attributes[v.type === 'appStoreVersions' ? 'appVersionState' : 'state'] = v.type === 'appStoreVersions' ? 'PREPARE_FOR_SUBMISSION' : 'DEVELOPER_REJECTED';
|
||||
}
|
||||
} else if (self.type === 'reviewSubmissionItems' && attrs.resolved === true) {
|
||||
// marking a REJECTED item resolved puts it back to READY_FOR_REVIEW (seen live)
|
||||
if (self.attributes.state === 'REJECTED') self.attributes.state = 'READY_FOR_REVIEW';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// (PATCH reviewSubmissionItems resolved: true), and an empty draft cannot be canceled ("not in cancellable state").
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { ShipConfig } from '../cli/lib/config';
|
||||
import { chooseSubmission, formatRows, type OpenSub } from '../cli/lib/review-submit';
|
||||
import { chooseSubmission, formatRows, type OpenSub, planIapSubmission, resubmitRows, type SubmitState } from '../cli/lib/review-submit';
|
||||
import { isConflict, upsertTiming } from '../cli/lib/upsert';
|
||||
import { notReadyYet, otherSubmissionOf, submitTiming } from '../cli/apple/submission';
|
||||
import { run as appleIapSubmit } from '../cli/apple/iap-submit';
|
||||
|
|
@ -248,6 +248,77 @@ describe('apple iap-submit with a submission back from App Review', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Seen live (2026-10-12): the owner canceled a WAITING_FOR_REVIEW submission; the app version went DEVELOPER_REJECTED,
|
||||
// then PREPARE_FOR_SUBMISSION, every in-app purchase version stayed DEVELOPER_REJECTED. iap-submit skipped them all
|
||||
// ("change it in App Store Connect (or --new-version)"), but Apple takes the same versions again as items (201
|
||||
// READY_FOR_REVIEW) and the submit (200 WAITING_FOR_REVIEW); an empty draft that was open next to it is reused.
|
||||
describe('after the owner canceled a submission (DEVELOPER_REJECTED versions)', () => {
|
||||
/** rs-sub WAITING_FOR_REVIEW with the version and both IAPs, an empty draft next to it, then canceled in the fake. */
|
||||
function canceled(): void {
|
||||
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'WAITING_FOR_REVIEW', platform: 'IOS' }, rels: { app: APP } });
|
||||
for (const [k, t] of [['gems', 'CONSUMABLE'], ['pro', 'NON_CONSUMABLE']] as const) {
|
||||
s.asc.put({ type: 'inAppPurchases', id: `iap-${k}`, attributes: { productId: `com.example.notes.${k}`, inAppPurchaseType: t, state: 'WAITING_FOR_REVIEW' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'inAppPurchaseVersions', id: `iapv-${k}`, attributes: { version: 1, state: 'WAITING_FOR_REVIEW' }, rels: { inAppPurchase: `iap-${k}` } });
|
||||
}
|
||||
s.asc.put({ type: 'reviewSubmissions', id: 'rs-sub', 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-sub', appStoreVersion: 'ver-1' } });
|
||||
for (const k of ['gems', 'pro']) s.asc.put({ type: 'reviewSubmissionItems', id: `it-${k}`, attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-sub', inAppPurchaseVersion: `iapv-${k}` } });
|
||||
s.asc.put({ type: 'reviewSubmissions', id: 'rs-empty', attributes: { platform: 'IOS', state: 'READY_FOR_REVIEW' }, rels: { app: APP } });
|
||||
expect(s.asc.handle('PATCH', '/v1/reviewSubmissions/rs-sub', { data: { attributes: { canceled: true } } }).status).toBe(200);
|
||||
}
|
||||
|
||||
test('the fake: a canceled submission leaves DEVELOPER_REJECTED versions, which are accepted again as items; APPROVED ones are not', () => {
|
||||
canceled();
|
||||
expect(s.asc.res.get('iapv-gems')!.attributes.state).toBe('DEVELOPER_REJECTED');
|
||||
expect(s.asc.res.get('ver-1')!.attributes.appVersionState).toBe('PREPARE_FOR_SUBMISSION');
|
||||
const add = (vid: string): Response => s.asc.handle('POST', '/v1/reviewSubmissionItems', { data: { type: 'reviewSubmissionItems',
|
||||
relationships: { reviewSubmission: { data: { type: 'reviewSubmissions', id: 'rs-empty' } }, inAppPurchaseVersion: { data: { type: 'inAppPurchaseVersions', id: vid } } } } });
|
||||
expect(add('iapv-gems').status).toBe(201);
|
||||
s.asc.res.get('iapv-pro')!.attributes.state = 'APPROVED';
|
||||
expect(add('iapv-pro').status).toBe(409);
|
||||
});
|
||||
|
||||
test('plan: DEVELOPER_REJECTED IAP, subscription and group versions go in as they are; --new-version stays for APPROVED / REJECTED', () => {
|
||||
const st: SubmitState = {
|
||||
appVersions: [{ id: 'v-1', versionString: '1.0.0', state: 'DEVELOPER_REJECTED' }],
|
||||
iaps: [{ id: 'i-1', productId: 'com.example.game.gems', type: 'CONSUMABLE', state: 'READY_TO_SUBMIT', versions: [{ id: 'iv-1', version: 1, state: 'DEVELOPER_REJECTED' }] },
|
||||
{ id: 'i-2', productId: 'com.example.game.old', type: 'CONSUMABLE', state: 'APPROVED', versions: [{ id: 'iv-2', version: 1, state: 'APPROVED' }] }],
|
||||
subs: [{ id: 's-1', productId: 'com.example.game.plus', groupId: 'g-1', state: 'READY_TO_SUBMIT', versions: [{ id: 'sv-1', version: 1, state: 'DEVELOPER_REJECTED' }] }],
|
||||
groups: [{ id: 'g-1', name: 'Plus', versions: [{ id: 'gv-1', version: 1, state: 'DEVELOPER_REJECTED' }] }],
|
||||
draft: { id: 'rs-empty', itemVersionIds: [], state: 'READY_FOR_REVIEW' },
|
||||
};
|
||||
const plan = planIapSubmission(st, ['com.example.game.gems', 'com.example.game.plus', 'com.example.game.old'], { withVersion: true });
|
||||
expect(plan.blockers).toEqual([]);
|
||||
expect(plan.items.map(i => [i.kind, i.versionId, !!i.devRejected])).toEqual([['appStoreVersion', 'v-1', true], ['inAppPurchaseVersion', 'iv-1', true],
|
||||
['subscriptionVersion', 'sv-1', true], ['subscriptionGroupVersion', 'gv-1', true]]);
|
||||
expect(plan.skipped).toEqual([{ id: 'com.example.game.old', why: 'latest version is APPROVED: add --new-version to send a new version' }]);
|
||||
expect(plan.prepare.map(r => r.path)).not.toContain('/v1/reviewSubmissions');
|
||||
const rows = resubmitRows(st, plan, { number: '8', id: 'b-8', current: 'b-8', action: 'same', reason: 'attached already' });
|
||||
expect(rows.filter(r => r.step === 'item').map(r => r.action)).toEqual(Array(4).fill('add (developer rejected, re-submitted as is)'));
|
||||
});
|
||||
|
||||
test('iap-submit --with-version: the dry plan adds them as they are; a real run fills the empty draft and submits it, no new version, no new draft', async () => {
|
||||
canceled();
|
||||
const p = project(CONFIG);
|
||||
const a = s.calls.length;
|
||||
const dry = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--new-version', '--dry'])));
|
||||
expect([dry.exit ?? 0, dry.err]).toEqual([0, '']);
|
||||
expect(shown(a)).toEqual([]);
|
||||
expect(dry.out).toContain('draft review submission rs-empty reused');
|
||||
expect(dry.out).toContain('add (developer rejected, re-submitted as is): com.example.notes.gems (consumable, version 1)');
|
||||
expect(dry.out).toContain('add (developer rejected, re-submitted as is): com.example.notes.pro (non-consumable, version 1)');
|
||||
expect(dry.out).not.toContain('skip');
|
||||
expect(dry.out).not.toContain('nothing to submit');
|
||||
expect(dry.out).not.toContain('/v1/inAppPurchaseVersions');
|
||||
const b = s.calls.length;
|
||||
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
||||
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
||||
expect(shown(b)).toEqual(['POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-empty']);
|
||||
expect(s.asc.all('reviewSubmissions').map(x => [x.id, x.attributes.state])).toEqual([['rs-sub', 'CANCELING'], ['rs-empty', 'WAITING_FOR_REVIEW']]);
|
||||
expect(s.asc.children('rs-empty', 'reviewSubmissionItems').map(i => Object.values(i.rels).find(v => v !== 'rs-empty'))).toEqual(['ver-1', 'iapv-gems', 'iapv-pro']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple status', () => {
|
||||
test('an UNRESOLVED_ISSUES submission with its items and the resubmit command', async () => {
|
||||
rejected();
|
||||
|
|
|
|||
|
|
@ -215,6 +215,17 @@ describe('check', () => {
|
|||
const withPre = project('check-pre', { build: { prebuild: 'npx cap sync' } });
|
||||
expect(checkJson(sb(withPre, 'check', '--json')).warnings.join()).not.toContain('web assets');
|
||||
});
|
||||
test('warns when android/app/build.gradle does not read -PversionCode / -PversionName (literal or env var), with the line to use', () => {
|
||||
const d = project('check-vc', { build: { prebuild: 'npx cap sync' } });
|
||||
touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode System.getenv('VERSION_CODE') as Integer ?: 1\n versionName '1.0'\n }\n}\n");
|
||||
const w = checkJson(sb(d, 'check', '--json')).warnings.filter(x => x.startsWith('play: android/app/build.gradle'));
|
||||
expect(w).toHaveLength(2);
|
||||
expect(w[0]).toContain('android/app/build.gradle:3: versionCode does not come from the project property versionCode');
|
||||
expect(w[0]).toContain("versionCode((project.findProperty('versionCode') ?: 1) as Integer)");
|
||||
expect(w[1]).toContain("versionName project.findProperty('versionName') ?: '1.0'");
|
||||
touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n");
|
||||
expect(checkJson(sb(d, 'check', '--json')).warnings.join()).not.toContain('build.gradle');
|
||||
});
|
||||
});
|
||||
|
||||
/** check --json prints its log lines first, then the result object. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue