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

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

View file

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

View file

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

View file

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

View file

@ -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();

View file

@ -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. */