apple.macos listing / review notes per platform + iOS-only claim warnings; apple upload of a .pkg: bounded pkgutil, macOS processing wait, heartbeats; release 409 pattern
This commit is contained in:
parent
53d57544bf
commit
1e47c2ee60
18 changed files with 417 additions and 63 deletions
|
|
@ -20,7 +20,7 @@ import { compactDiff } from '../lib/diff';
|
|||
import { mergeAppleListing, overLimits } from '../lib/listing-pull';
|
||||
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan';
|
||||
import { createOnce, findOrCreate, getById } from '../lib/upsert';
|
||||
import { appleName } from '../lib/config';
|
||||
import { appleName, listingPathFor } from '../lib/config';
|
||||
import { wantedRelease } from '../lib/release-type';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
|
|
@ -29,10 +29,10 @@ import { ascClient, EDITABLE, type J, noAppInfo } from './asc';
|
|||
import { stdinIsTTY } from '../lib/tty';
|
||||
import { allVersions, type AscPlatform, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
|
||||
|
||||
export function readAppleCopy(ctx: Ctx) {
|
||||
export function readAppleCopy(ctx: Ctx, platform?: string) {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const only = ctx.args.list('only');
|
||||
const all = parseAppStoreListing(readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8'));
|
||||
const all = parseAppStoreListing(readFileSync(resolve(listingPathFor(config, platform)), 'utf8'));
|
||||
return Object.fromEntries(Object.entries(all).filter(([l]) => !only.length || only.includes(l)));
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ async function pull(ctx: Ctx): Promise<void> {
|
|||
const at = l.attributes;
|
||||
remote[at.locale] = { ...remote[at.locale], description: at.description ?? undefined, keywords: at.keywords ?? undefined, promotionalText: at.promotionalText ?? undefined, whatsNew: at.whatsNew ?? undefined };
|
||||
}
|
||||
const file = resolve(config.apple?.listing ?? 'store/appstore.md');
|
||||
const file = resolve(listingPathFor(config, platform));
|
||||
const before = existsSync(file) ? readFileSync(file, 'utf8') : '# App Store listing copy\n\nPulled from App Store Connect by `storebound apple listing pull`.\n';
|
||||
const r = mergeAppleListing(before, remote, appleName(config));
|
||||
const over = overLimits(r.changes, 'apple');
|
||||
|
|
@ -104,8 +104,8 @@ type Asc = Awaited<ReturnType<typeof ascClient>>;
|
|||
async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, platform: AscPlatform): Promise<AppleListingPlan> {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const app = config.app;
|
||||
const listing = readAppleCopy(ctx);
|
||||
const md = readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8');
|
||||
const listing = readAppleCopy(ctx, platform);
|
||||
const md = readFileSync(resolve(listingPathFor(config, platform)), 'utf8');
|
||||
const drafts = draftSections(md, 'apple');
|
||||
const include = ctx.args.has('include-drafts');
|
||||
const skipped: Skipped[] = [];
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ async function setReleaseType(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>
|
|||
try {
|
||||
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(changes.map(ch => [ch.field, ch.to])) } });
|
||||
} catch (e) {
|
||||
if (/-> 409/.test((e as Error).message)) throw new Error(`${tag}: App Store Connect refuses the release type change now (409): ${(e as Error).message}
|
||||
if (/-> 409\b/.test((e as Error).message)) throw new Error(`${tag}: App Store Connect refuses the release type change now (409): ${(e as Error).message}
|
||||
It is editable until App Review approves the version; after that use apple release --manual-release.`);
|
||||
throw e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// App Review information on the editable version: contact, demo account, notes. Idempotent.
|
||||
// storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]
|
||||
// storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]
|
||||
// The contact (name, phone, email) is personal data, so it is never in the config: it comes from
|
||||
// .storebound/credentials.json "reviewContact", or is copied from another app of the same team with --contact-from.
|
||||
// Demo account (reviewAccess.signInRequired: true): "reviewDemoAccount" in .storebound/credentials.json or
|
||||
// REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD; masked in every log line. Notes: apple.info.reviewNotes plus
|
||||
// reviewAccess.instructions and accounts.inApp (lib/review-details.ts).
|
||||
// reviewAccess.instructions and accounts.inApp (lib/review-details.ts). --platform macos: apple.macos.reviewNotes
|
||||
// replaces apple.info.reviewNotes when set (a Mac app may have no Game Center, IAP or ads).
|
||||
import { refuseTemplate } from '../lib/template-answers';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
|
|
@ -12,7 +13,8 @@ import { findReviewContact, maskSecret, maskUser, noContactMessage, reviewDemoAc
|
|||
import { reviewDetailPlan } from '../lib/review-details';
|
||||
import { done, log } from '../lib/log';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
import { ascClient } from './asc';
|
||||
import { ascClient, EDITABLE, type J } from './asc';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel } from './platform';
|
||||
|
||||
type Attrs = Record<string, unknown>;
|
||||
const shown = (k: string, v: unknown): string => {
|
||||
|
|
@ -36,8 +38,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const { config, root } = ctx.loaded;
|
||||
refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)');
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const version = await c.editableVersion();
|
||||
if (!version) throw new Error('no editable App Store version (apple listing --version x.y.z creates it)');
|
||||
const appId = await c.app();
|
||||
const platform = parsePlatform(ctx.args.get('platform')) ?? pickPlatform(await allVersions(c, appId), (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), undefined, 'an editable version');
|
||||
const version = await c.editableVersion(platform);
|
||||
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (apple listing --version x.y.z creates it)`);
|
||||
|
||||
let contact: Record<string, string> | null = null;
|
||||
const from = ctx.args.get('contact-from');
|
||||
|
|
@ -57,7 +61,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
if (!contact) throw new Error(noContactMessage(root, checked));
|
||||
|
||||
const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root));
|
||||
const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root), platform);
|
||||
const read = async () => (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data ?? null;
|
||||
const cur = await read();
|
||||
if (ctx.args.has('dry')) {
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ const TAG = 'testflight';
|
|||
const USAGE = 'usage: storebound apple testflight status | groups [sync] | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D';
|
||||
|
||||
/** The state of build `number` (of `marketing` version) right now; found: false while App Store Connect does not list it. */
|
||||
async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise<BuildSeen & { version?: string }> {
|
||||
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': 'IOS', include: 'preReleaseVersion,buildBetaDetail', limit: '50' });
|
||||
async function seeBuild(c: Asc, appId: string, number: string, marketing?: string, platform = 'IOS'): Promise<BuildSeen & { version?: string }> {
|
||||
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': platform, include: 'preReleaseVersion,buildBetaDetail', limit: '50' });
|
||||
if (marketing) q.set('filter[preReleaseVersion.version]', marketing);
|
||||
const resp = await c.asc('GET', `/v1/builds?${q}`);
|
||||
const hits = buildsWithVersion(resp).filter(b => b.attributes.version === number && (!marketing || b.marketing === marketing));
|
||||
|
|
@ -54,13 +54,13 @@ export interface BuildWait { outcome: WaitOutcome; timedOut: boolean; seen: Buil
|
|||
* Polls build `number` until processing ends (VALID, or FAILED / INVALID) or `timeoutMs` passes; logs each state change.
|
||||
* `once` reads the state a single time. Shared by `testflight wait` and `apple upload` (which waits after the upload).
|
||||
*/
|
||||
export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise<BuildWait> {
|
||||
export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; platform?: 'IOS' | 'MAC_OS'; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise<BuildWait> {
|
||||
const started = Date.now();
|
||||
let delay: number | null = null, last = '';
|
||||
// polls are up to 2 minutes apart: a heartbeat in between says the wait is alive
|
||||
const beat = o.once ? null : heartbeat(ms => log(o.tag ?? TAG, `still waiting for build ${number}: ${last || 'first check'}, ${elapsed(ms)} of ${elapsed(o.timeoutMs)}`), o);
|
||||
try { for (;;) {
|
||||
const seen = await seeBuild(c, appId, number, o.marketing);
|
||||
const seen = await seeBuild(c, appId, number, o.marketing, o.platform);
|
||||
const outcome = waitOutcome(seen);
|
||||
const line = outcome === 'missing' ? 'not listed yet (App Store Connect lists it once processing starts)' : `${seen.processingState}${seen.internal ? `, internal ${seen.internal}` : ''}`;
|
||||
if (line !== last) { log(o.tag ?? TAG, `build ${number}${seen.version ? ` (${seen.version})` : ''}: ${line} [${Math.round((Date.now() - started) / 60_000)} min]`); last = line; }
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
|
||||
import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, readLastBuild, showCmd, UPLOAD_DONE, uploadProblems, uploadSearchDirs } from '../lib/xcode';
|
||||
import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, readLastBuild, remoteBuildSession, showCmd, UPLOAD_DONE, uploadProblems, uploadSearchDirs } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { BUILD_WAIT_EXIT } from '../lib/build-ops';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
|
|
@ -51,6 +51,9 @@ function stop(legacy: number, message: string): never {
|
|||
|
||||
const TAG = 'upload';
|
||||
|
||||
/** Test hook: heartbeat options (short interval, manual ticker) for the reads, the upload and the processing wait. */
|
||||
export const hooks: { heartbeat?: HeartbeatOptions } = {};
|
||||
|
||||
function md5File(path: string): string {
|
||||
const h = rt.hasher('md5');
|
||||
const fd = openSync(path, 'r');
|
||||
|
|
@ -257,9 +260,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
} else ipa = resolve(ipa);
|
||||
shipPhase('verify', `checking ${relative(root, ipa)}`);
|
||||
const isPkg = ipa.endsWith('.pkg');
|
||||
log(TAG, `${relative(root, ipa)}: reading ${isPkg ? 'the package (pkgutil)' : 'the IPA'} (${mb(statSync(ipa).size)})`);
|
||||
// over SSH (no GUI session) anything that touches the login keychain can wait for a prompt nobody sees: say so up front
|
||||
const remote = process.platform === 'darwin' ? remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()) : null;
|
||||
if (remote) warn(TAG, `${remote}: the upload itself uses the App Store Connect API key (no keychain, no prompt), but pkgutil / altool can stall on a keychain or GUI prompt nobody sees here; every step has a timeout. If it stalls, run it in the Mac's logged-in GUI session (see storebound apple build --help, --allow-ssh)`);
|
||||
const platform: 'IOS' | 'MAC_OS' = isPkg ? 'MAC_OS' : 'IOS';
|
||||
if (macos && !isPkg) throw new Error('--platform macos uploads a signed .pkg (storebound apple build --platform macos --tauri <src-tauri>), not an .ipa');
|
||||
const info: ReturnType<typeof ipaInfo> = isPkg ? pkgInfo(ipa, bundleId) : ipaInfo(ipa);
|
||||
const info: ReturnType<typeof ipaInfo> = isPkg ? pkgInfo(ipa, bundleId, { say: m => log(TAG, m) }) : ipaInfo(ipa);
|
||||
log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build})`);
|
||||
shipReport({ version: info.version, build: info.build, url: consoleUrl('asc.testflight') });
|
||||
if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`);
|
||||
if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`);
|
||||
|
|
@ -277,18 +285,21 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const wait = !a.has('no-wait');
|
||||
const timeout = parseDuration(a.str('timeout', '60m'));
|
||||
|
||||
log(TAG, `asking App Store Connect about ${bundleId}: the app, the editable version, the builds, earlier uploads of build ${info.build}`);
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
// the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0
|
||||
// from a project that ignored the intended version otherwise lands in the wrong train
|
||||
const editable = await c.editableVersion(platform).catch(() => null);
|
||||
const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version');
|
||||
if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); }
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
|
||||
// every read has its own timeout and retries; the heartbeat says the run is alive while one of them waits
|
||||
const { appId, editable, builds, already } = await withHeartbeat(ms => log(TAG, `still reading App Store Connect, ${elapsed(ms)}`), async () => {
|
||||
const appId = await c.app();
|
||||
// the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0
|
||||
// from a project that ignored the intended version otherwise lands in the wrong train
|
||||
const editable = await c.editableVersion(platform).catch(() => null);
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
|
||||
// the same IPA uploaded by an earlier run: nothing to send again, only the wait
|
||||
const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined;
|
||||
return { appId, editable, builds, already };
|
||||
}, hooks.heartbeat);
|
||||
const numbers = builds.map(b => String(b.attributes.version));
|
||||
const next = nextBuildNumber(numbers);
|
||||
// the same IPA uploaded by an earlier run: nothing to send again, only the wait
|
||||
const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined;
|
||||
const onAsc = builds.find(b => String(b.attributes.version) === info.build);
|
||||
const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next });
|
||||
// the ship-step contract: a build number App Store Connect has already is "nothing to do" (exit 7), never a second upload
|
||||
|
|
@ -300,14 +311,18 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
return;
|
||||
}
|
||||
if (decision instanceof Error) throw decision;
|
||||
log(TAG, `build ${info.build} is not on App Store Connect (next free: ${next})`);
|
||||
if (decision === 'recheck') {
|
||||
log(TAG, `build ${info.version} (${info.build}) is on App Store Connect already${already ? ` (build upload ${already.id})` : ''}; --allow-existing: not sending it again`);
|
||||
if (!wait) { if (json) printJson({ ...{ ipa: relative(root, ipa), version: info.version, build: info.build }, uploaded: true, alreadyUploaded: true }); done(TAG, `build ${info.build} already uploaded`); return; }
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: timeout, tag: TAG });
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, platform, timeoutMs: timeout, tag: TAG, ...hooks.heartbeat });
|
||||
if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: r.seen.processingState ?? null });
|
||||
if (r.outcome === 'valid') { done(TAG, waitMessage(info.build, r)); return; }
|
||||
stop(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout, waitMessage(info.build, r));
|
||||
}
|
||||
// (a build number App Store Connect has already ends above, before this: the clearer message)
|
||||
const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version');
|
||||
if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); }
|
||||
if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`);
|
||||
const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform);
|
||||
const plan = via === 'api'
|
||||
|
|
@ -327,14 +342,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
shipPhase('upload', `uploading ${mb(size)} via ${via}`, 0);
|
||||
log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build}), ${mb(size)}, via ${via}, timeout ${a.str('timeout', '60m')}`);
|
||||
if (via === 'api') {
|
||||
try { await apiUpload(c, appId, ipa, info, deadline, {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; }
|
||||
try { await apiUpload(c, appId, ipa, info, deadline, hooks.heartbeat ?? {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; }
|
||||
} else {
|
||||
try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; }
|
||||
try { await altoolUpload(root, ipa, deadline, hooks.heartbeat); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; }
|
||||
}
|
||||
if (!wait) { if (json) printJson({ ...summary, uploaded: true }); done(TAG, `build ${info.build} uploaded; storebound apple status shows when it is VALID`); return; }
|
||||
// the same poll as `storebound apple testflight wait`, within what is left of --timeout
|
||||
shipPhase('processing', `waiting for App Store Connect to process build ${info.build}`);
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG });
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, platform, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG, ...hooks.heartbeat });
|
||||
const state = r.timedOut || (r.outcome !== 'valid' && r.outcome !== 'failed') ? 'TIMEOUT' : r.seen.processingState ?? 'UNKNOWN';
|
||||
if (json) printJson({ ...summary, uploaded: true, processingState: state });
|
||||
if (r.outcome === 'valid') { done(TAG, `${waitMessage(info.build, r)}. Then: apple testflight distribute --build ${info.build} --group <name> --dry`); return; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue