From 1e47c2ee60ae66f1e5b64266e8c6385e2a76a022 Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 3 Nov 2026 23:30:59 +0700 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + cli/apple/listing.ts | 12 +-- cli/apple/release.ts | 2 +- cli/apple/review-details.ts | 16 ++-- cli/apple/testflight.ts | 8 +- cli/apple/upload.ts | 45 ++++++---- cli/check.ts | 8 +- cli/commands.ts | 2 +- cli/data/flags.json | 5 +- cli/lib/config.ts | 34 ++++++++ cli/lib/listing-mentions.ts | 3 +- cli/lib/mas.ts | 56 ++++++++---- cli/lib/review-details.ts | 13 +-- cli/lib/validate-rules.ts | 35 +++++++- cli/validate.ts | 4 +- skills/appstore-first-release/SKILL.md | 7 +- tests/macos-overrides.test.ts | 112 ++++++++++++++++++++++++ tests/upload-pkg-hang.test.ts | 114 +++++++++++++++++++++++++ 18 files changed, 417 insertions(+), 63 deletions(-) create mode 100644 tests/macos-overrides.test.ts create mode 100644 tests/upload-pkg-hang.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2219b..b444832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased +- `apple.macos: { listing, reviewNotes, gameCenter, noIap }`: an app with iOS and macOS versions gets its own Mac listing file and review notes (`apple listing --platform macos`, `apple review-details --platform macos`, new there); `validate` warns about iOS-only claims in a macOS version's texts (Game Center, tap / swipe, iPhone / iPad, in-app purchases with `noIap`). +- `apple upload `: reading the package is bounded (pkgutil signature check 90 s, expand 5 min, then the `apple build` .json) and logged; the processing wait finds macOS builds (it looked only at iOS and sat silently until --timeout); every slow read prints a heartbeat; an SSH / non-GUI session is warned about up front. +- `apple release`: a 409 on a release type change is recognized again (a stray control character had broken the pattern). + ## 0.8.65 · 2026-11-03 - `validate`: in-app purchases removed from sale (DEVELOPER_REMOVED_FROM_SALE, REMOVED_FROM_SALE) are no blockers; a warning only when the config still sells one. diff --git a/cli/apple/listing.ts b/cli/apple/listing.ts index 9649aa9..8ccb325 100644 --- a/cli/apple/listing.ts +++ b/cli/apple/listing.ts @@ -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 { 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>; async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, platform: AscPlatform): Promise { 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[] = []; diff --git a/cli/apple/release.ts b/cli/apple/release.ts index aad0498..5913c62 100644 --- a/cli/apple/release.ts +++ b/cli/apple/release.ts @@ -150,7 +150,7 @@ async function setReleaseType(ctx: Ctx, c: Awaited> 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; } diff --git a/cli/apple/review-details.ts b/cli/apple/review-details.ts index 123cc50..8a0a566 100644 --- a/cli/apple/review-details.ts +++ b/cli/apple/review-details.ts @@ -1,10 +1,11 @@ // App Review information on the editable version: contact, demo account, notes. Idempotent. -// storebound apple review-details [--contact-from ] [--dry] +// storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from ] [--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; const shown = (k: string, v: unknown): string => { @@ -36,8 +38,10 @@ export async function run(ctx: Ctx): Promise { 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 | null = null; const from = ctx.args.get('contact-from'); @@ -57,7 +61,7 @@ export async function run(ctx: Ctx): Promise { } 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')) { diff --git a/cli/apple/testflight.ts b/cli/apple/testflight.ts index 24f35d4..35c7628 100644 --- a/cli/apple/testflight.ts +++ b/cli/apple/testflight.ts @@ -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 | 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 { - 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 { + 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 { +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 { 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; } diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index a31574f..688bc56 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -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 { } 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 ), not an .ipa'); - const info: ReturnType = isPkg ? pkgInfo(ipa, bundleId) : ipaInfo(ipa); + const info: ReturnType = 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 { 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 { 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 { 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 --dry`); return; } diff --git a/cli/check.ts b/cli/check.ts index 692967e..5311034 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -16,7 +16,7 @@ import { type BundleManifest, bundleTrackingIssues, ipaManifests, projectSdkMani 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 { appleName, listingPathFor, playCodes } from './lib/config'; import { fieldsMentions } from './lib/cross-store'; import { appBuildFile, pickGradle, versionSourceIssues, versionSourceMessage } from './lib/gradle'; import { playConsoleReminders } from './lib/console-plan'; @@ -107,6 +107,12 @@ export async function run(ctx: Ctx): Promise { try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(traceMessage(t)); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); } // other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77) try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); } + if (config.apple?.macos?.listing) tryDo('appstore listing (macOS)', () => { + const copy = parseAppStoreListing(readFileSync(resolve(listingPathFor(config, 'macos')), 'utf8')); + const missing = config.languages.filter(l => l.apple && !copy[l.apple]).map(l => l.apple); + if (missing.length) note(`macOS appstore listing has no section for ${missing.join(', ')}`); + return `${Object.keys(copy).length} locales`; + }); // listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed for (const f of [config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : '', config.play ? resolve(config.play.listing ?? 'store/listing.md') : ''].filter(Boolean)) { if (!existsSync(f)) continue; diff --git a/cli/commands.ts b/cli/commands.ts index f644811..3cba163 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -85,7 +85,7 @@ export const COMMANDS: Record Promise, string]> = { 'apple listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale; pull [--write]: live texts back into the listing file (diff first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant ; --repair-stuck [--dry] (replace only screenshots not COMPLETE, at their positions); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'], - 'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'], + 'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes; --platform ios|macos|tvos|visionos (macos: apple.macos.reviewNotes when set)'], 'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission ); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and places it once Apple approved it, reporting waiting ones per locale (MD5 upsert, safe to re-run); --add-for-review, --verify (read-only), --dry'], 'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'], diff --git a/cli/data/flags.json b/cli/data/flags.json index d8952e6..44ebcb1 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -264,7 +264,8 @@ "with-version" ], "apple review-details": [ - "contact-from" + "contact-from", + "platform" ], "apple screenshots": [ "include-drafts", @@ -1580,7 +1581,7 @@ "storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]" ], "apple review-details": [ - "storebound apple review-details [--contact-from ] [--dry]" + "storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from ] [--dry]" ], "apple screenshots": [ "storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]", diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 5e48f4c..05b9fc0 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -707,6 +707,15 @@ export interface ShipConfig { * 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from //landscape/ (shots.landscape). Uploaded * as APP_DESKTOP to the macOS version: `apple screenshots --platform macos`. */ mac?: boolean; + /** + * macOS App Store overrides (not `mac`, which only switches Mac screenshots on). An app with an iOS and a macOS + * version often needs different version texts and review notes (the Mac app may have no Game Center, in-app + * purchases or ads). `listing`: an App Store listing file used instead of apple.listing for `apple listing + * --platform macos`; `reviewNotes`: used instead of apple.info.reviewNotes by `apple review-details --platform macos`. + * `gameCenter: true`: the Mac app has Game Center (silences the Game Center check of `validate --platform macos`). + * `noIap: true`: the Mac app sells nothing (validate then flags in-app purchase lines in the Mac texts). + */ + macos?: { listing?: string; reviewNotes?: string; gameCenter?: boolean; noIap?: boolean }; /** "Header and Search Results" creative assets (`storebound apple creative-assets`, iOS / iPadOS 27+). Files under * //creative/: header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9), search.{...} (3:2, 1920x1280 to * 3840x2560) or universal.png (5244x2950) for both. `header` / `search` false skip that slot; `universal` is a @@ -1036,6 +1045,14 @@ export function validateExtras(cfg: Partial): string[] { e.push('play.developerAccount: the developer account name as Play Console shows it (1-100 characters)'); if (cfg.play?.name !== undefined && !(typeof cfg.play.name === 'string' && cfg.play.name.trim() && [...cfg.play.name].length <= 30)) e.push('play.name: 1-30 characters (Play app name)'); if (cfg.play?.free !== undefined && typeof cfg.play.free !== 'boolean') e.push('play.free: true (free) | false (paid)'); + const mo = cfg.apple?.macos; + if (mo !== undefined) { + if (typeof mo !== 'object' || mo === null || Array.isArray(mo)) e.push('apple.macos: { listing?: "store/appstore-macos.md", reviewNotes?: "...", gameCenter?: true, noIap?: true }'); + else { + for (const k of ['listing', 'reviewNotes'] as const) if (mo[k] !== undefined && (typeof mo[k] !== 'string' || !mo[k]!.trim())) e.push(`apple.macos.${k}: a non-empty string${k === 'listing' ? ' (path of the macOS App Store listing file)' : ''}`); + for (const k of ['gameCenter', 'noIap'] as const) if (mo[k] !== undefined && typeof mo[k] !== 'boolean') e.push(`apple.macos.${k}: true | false`); + } + } const ad = cfg.apple?.adhoc; if (ad?.method !== undefined && ad.method !== 'release-testing' && ad.method !== 'ad-hoc') e.push('apple.adhoc.method: release-testing | ad-hoc'); const ppp = cfg.prices?.ppp; @@ -1114,6 +1131,23 @@ function validateIarcDetails(d: IarcDetails | undefined): string[] { } /** Platforms of the app (app identity): app.platforms, else from the store sections. */ +/** True for the macOS platform in any spelling: "macos", "mac_os" (API name MAC_OS), "mac". Pure. */ +export const isMacosPlatform = (p: string | undefined | null): boolean => !!p && /^mac(_?os)?$/i.test(p.replace(/[\s-]/g, '')); + +/** The App Store listing file (as written in the config, relative) for a platform: apple.macos.listing for macOS when + * set, else apple.listing, else store/appstore.md. Pure. */ +export function listingPathFor(config: Pick, platform?: string | null): string { + const mac = isMacosPlatform(platform) ? config.apple?.macos?.listing?.trim() : ''; + return mac || config.apple?.listing || 'store/appstore.md'; +} + +/** The App Review notes text of the config for a platform: apple.macos.reviewNotes for macOS when set, else + * apple.info.reviewNotes. Pure; reviewAccess and account deletion lines are added by reviewNotes() in lib/review-details. */ +export function reviewNotesFor(config: Pick, platform?: string | null): string | undefined { + const mac = isMacosPlatform(platform) ? config.apple?.macos?.reviewNotes : undefined; + return mac?.trim() ? mac : config.apple?.info?.reviewNotes; +} + /** App Store name limit (characters). */ export const APPLE_NAME_MAX = 30; diff --git a/cli/lib/listing-mentions.ts b/cli/lib/listing-mentions.ts index f7f9fb5..b8aaac3 100644 --- a/cli/lib/listing-mentions.ts +++ b/cli/lib/listing-mentions.ts @@ -2,7 +2,7 @@ // report. Reads store/appstore.md and store/listing.md (or the config's listing paths). import { existsSync, readFileSync } from 'node:fs'; import { relative } from 'node:path'; -import type { Loaded } from './config'; +import { listingPathFor, type Loaded } from './config'; import { fileMentions, type PlatformHit } from './cross-store'; export function listingFileMentions(loaded: Pick, stores: { apple: boolean; play: boolean } = { apple: true, play: true }): PlatformHit[] { @@ -11,6 +11,7 @@ export function listingFileMentions(loaded: Pick { return parsePlist(readFileSync(p)) as Record; } +/** What bounds and reports the .pkg reading (tests inject a fake spawnSync and short limits). */ +export interface PkgInfoOptions { say?: (msg: string) => void; spawnSync?: typeof rt.spawnSync; checkMs?: number; expandMs?: number } +/** pkgutil --check-signature evaluates the certificate chain (revocation lookups over the network): never unbounded. */ +export const PKG_CHECK_MS = 90_000; +/** pkgutil --expand copies the whole archive to a temp folder: minutes for a big app, never unbounded. */ +export const PKG_EXPAND_MS = 5 * 60_000; + /** bundle id, version and build of a .pkg: pkgutil --expand on a Mac (PackageInfo), else the .json next to it that - * `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. */ -export function pkgInfo(pkg: string, expectBundleId?: string): { bundleId: string; version: string; build: string } { - if (rt.which('pkgutil')) { - const sig = parsePkgSignature(rt.spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe' }).stdout.toString()); - const bad = pkgProblems(sig); - if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`); - const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`); - try { - if (rt.spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe' }).exitCode === 0) { - for (const d of readdirSync(tmp)) { - const p = join(tmp, d, 'PackageInfo'); - const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null; - if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info; - } - } - } finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } } - } + * `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. Both pkgutil + * calls have a timeout (no stdin, so no prompt) and say what they do first; one that times out falls back to the + * .json file, else it fails with the reason (a silent hang over SSH, 10 min, was this read). */ +export function pkgInfo(pkg: string, expectBundleId?: string, o: PkgInfoOptions = {}): { bundleId: string; version: string; build: string } { + const say = o.say ?? (() => {}); + const spawnSync = o.spawnSync ?? rt.spawnSync; const side = `${pkg}.json`; + let timedOut = ''; + if (rt.which('pkgutil') || o.spawnSync) { + const checkMs = o.checkMs ?? PKG_CHECK_MS, expandMs = o.expandMs ?? PKG_EXPAND_MS; + say(`checking the signature of ${basename(pkg)} (pkgutil --check-signature, up to ${Math.round(checkMs / 1000)} s)`); + const chk = spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe', timeout: checkMs }); + if (chk.exitCode === null && chk.signalCode) timedOut = `pkgutil --check-signature gave no answer in ${Math.round(checkMs / 1000)} s (it checks the certificate chain over the network)`; + else { + const bad = pkgProblems(parsePkgSignature(chk.stdout.toString())); + if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`); + const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`); + say(`reading bundle id, version and build (pkgutil --expand, up to ${Math.round(expandMs / 1000)} s; a big package takes a while)`); + try { + const ex = spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe', timeout: expandMs }); + if (ex.exitCode === null && ex.signalCode) timedOut = `pkgutil --expand gave no answer in ${Math.round(expandMs / 1000)} s`; + else if (ex.exitCode === 0) { + for (const d of readdirSync(tmp)) { + const p = join(tmp, d, 'PackageInfo'); + const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null; + if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info; + } + } + } finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } } + } + } + if (timedOut) say(`${timedOut}: using ${basename(side)} (written by apple build) instead`); if (existsSync(side)) { const j = JSON.parse(readFileSync(side, 'utf8')) as { bundleId?: string; version?: string; build?: string; unsigned?: boolean }; if (j.unsigned) throw new Error(`${basename(pkg)} was built with --unsigned: the Mac App Store refuses it`); if (j.bundleId && j.version && j.build) return { bundleId: j.bundleId, version: j.version, build: j.build }; } + if (timedOut) throw new Error(`${basename(pkg)}: ${timedOut}, and there is no ${basename(side)} file from apple build to read instead: run it in the Mac's logged-in GUI session, or check the Mac's network`); throw new Error(`${basename(pkg)}: cannot read bundle id / version / build (needs pkgutil on a Mac, or the ${basename(side)} file apple build wrote)`); } diff --git a/cli/lib/review-details.ts b/cli/lib/review-details.ts index f397e3c..26153b8 100644 --- a/cli/lib/review-details.ts +++ b/cli/lib/review-details.ts @@ -3,14 +3,15 @@ // field takes up to 4000 bytes; a demo account must keep working during the whole review (Guideline 2.1). // https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information // https://developer.apple.com/documentation/appstoreconnectapi/appstorereviewdetail/attributes-data.dictionary -import type { ShipConfig } from './config'; +import { reviewNotesFor, type ShipConfig } from './config'; import { type DemoAccount, maskSecret, maskUser } from './credentials'; export const NOTES_MAX_BYTES = 4000; -/** Notes for the reviewer: apple.info.reviewNotes, then reviewAccess.instructions, then where account deletion is. */ -export function reviewNotes(config: Pick): string { - const parts = [config.apple?.info?.reviewNotes?.trim() ?? '']; +/** Notes for the reviewer: apple.info.reviewNotes (macOS: apple.macos.reviewNotes when set), then reviewAccess.instructions, + * then where account deletion is. */ +export function reviewNotes(config: Pick, platform?: string): string { + const parts = [reviewNotesFor(config, platform)?.trim() ?? '']; const ins = config.reviewAccess?.instructions?.trim(); if (ins && !parts[0]!.includes(ins)) parts.push(`Sign-in: ${ins}`); const del = config.accounts?.enabled ? config.accounts.inApp?.trim() : ''; @@ -29,8 +30,8 @@ export interface ReviewDetailPlan { * (throws when it is missing, so a submission never goes out with "sign-in required" and no account); without it the * demo fields are cleared. */ -export function reviewDetailPlan(config: Pick, contact: Record, demo: DemoAccount | null): ReviewDetailPlan { - const notes = reviewNotes(config); +export function reviewDetailPlan(config: Pick, contact: Record, demo: DemoAccount | null, platform?: string): ReviewDetailPlan { + const notes = reviewNotes(config, platform); const bytes = new TextEncoder().encode(notes).length; if (bytes > NOTES_MAX_BYTES) throw new Error(`review notes are ${bytes} bytes; App Store Connect takes ${NOTES_MAX_BYTES}`); const required = !!config.reviewAccess?.signInRequired; diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index c1e5491..ad3593b 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -72,7 +72,10 @@ export interface AppleSnapshot { build: { number: string; processingState: string; usesNonExemptEncryption: boolean | null; declarationState: string | null } | null; /** Locales the config expects (apple: entries of `languages`). */ expectedLocales: string[]; - locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; screenshots: Record }[]; + locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; promotionalText?: string; whatsNew?: string; screenshots: Record }[]; + /** apple.macos facts for the macOS claim check (validate fills it): the config's macOS review notes (what `apple review-details + * --platform macos` would send) and the gameCenter / noIap switches. */ + macos?: { notes?: string; gameCenter?: boolean; noIap?: boolean }; requiredDisplayTypes: string[]; /** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */ iphoneDuo?: boolean; @@ -208,6 +211,35 @@ export function copyrightRules(s: Pick = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' }; +const MACOS_CLAIMS: { kind: string; re: RegExp; needs?: 'noGameCenter' | 'noIap'; what: string }[] = [ + { kind: 'game-center', re: /game\s*center/i, needs: 'noGameCenter', what: 'Game Center (set apple.macos.gameCenter: true when the Mac app has it)' }, + { kind: 'touch', re: /\b(tap|taps|tapping|swipe|swipes|swiping|pinch|pinches)\b|touch[\s-]?screen/i, what: 'touch words (tap, swipe, pinch, touch screen)' }, + { kind: 'ios-device', re: /\b(iphone|ipad)s?\b/i, what: 'an iPhone / iPad mention' }, + { kind: 'iap', re: /in[\s-]app[\s-]purchases?|\biaps?\b|restore purchases/i, needs: 'noIap', what: 'an in-app purchase line (apple.macos.noIap is set)' }, +]; + +/** + * A macOS version's texts (description, promotional text, what's new, keywords, subtitle) and review notes (live, and + * the config's apple.macos.reviewNotes / apple.info.reviewNotes) that carry iOS-only claims. Warnings only. Game Center + * is flagged unless apple.macos.gameCenter is true; in-app purchase lines only when apple.macos.noIap is set. Pure. + */ +export function macosClaimRules(s: Pick): Finding[] { + if (s.version?.platform !== 'MAC_OS') return []; + const texts: { where: string; text: string }[] = []; + for (const l of s.locales) for (const k of ['subtitle', 'description', 'promotionalText', 'whatsNew', 'keywords'] as const) if (l[k]) texts.push({ where: `${l.locale} ${k}`, text: l[k]! }); + if (s.reviewDetail?.notes) texts.push({ where: 'review notes (on the store)', text: s.reviewDetail.notes }); + if (s.macos?.notes) texts.push({ where: 'review notes (config)', text: s.macos.notes }); + const out: Finding[] = []; + for (const c of MACOS_CLAIMS) { + if (c.needs === 'noGameCenter' && s.macos?.gameCenter) continue; + if (c.needs === 'noIap' && !s.macos?.noIap) continue; + const hits = [...new Set(texts.filter(t => c.re.test(t.text)).map(t => t.where))]; + if (hits.length) out.push({ store: 'apple', level: 'warn', code: 'macos-claims', msg: `macOS version ${s.version.versionString}: ${c.what} in ${hits.slice(0, 6).join(', ')}${hits.length > 6 ? ` (+${hits.length - 6} more)` : ''}`, + fix: 'a separate macOS listing file (apple.macos.listing, then storebound apple listing --platform macos) and apple.macos.reviewNotes (storebound apple review-details --platform macos)' }); + } + return out; +} + export function appleRules(s: AppleSnapshot): Finding[] { const f: Finding[] = []; const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction, url?: string) => f.push({ store: 'apple', level, code, msg, fix, ...(action ? { action } : {}), ...(url ? { url } : {}) }); @@ -232,6 +264,7 @@ export function appleRules(s: AppleSnapshot): Finding[] { add('warn', 'version-platform', `version ${o.versionString} (${name}) is ${o.state} too: this report covers ${vl || 'iOS'} only`, `storebound validate --platform ${name.toLowerCase()}; storebound apple status lists every platform`); } f.push(...copyrightRules(s)); + f.push(...macosClaimRules(s)); if (!s.build) add('blocker', 'build', 'no build attached to the version', 'storebound apple listing --version x.y.z --build N (pick the build number you just uploaded)'); else { diff --git a/cli/validate.ts b/cli/validate.ts index b95f7a2..773359b 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -22,6 +22,7 @@ import { firstProductReport } from './apple/first-products'; import { requiredDisplayTypes } from './apple/screenshots'; import { pendingScreenshots, setRows, type ShotRow, updateLedger } from './apple/screenshot-health'; import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config'; +import { reviewNotes } from './lib/review-details'; import { reviewDemoAccount } from './lib/credentials'; import { allSubscriptions, basePlanId } from './lib/subscriptions'; import type { Ctx } from './lib/ctx'; @@ -143,7 +144,7 @@ export async function collectApple(ctx: Ctx): Promise { shotRows.push(...setRows(shots, l.attributes.locale, s.attributes.screenshotDisplayType)); } const ia = infoLocs.get(l.attributes.locale) ?? {}; - locales.push({ locale: l.attributes.locale, name: ia.name, subtitle: ia.subtitle, description: l.attributes.description, keywords: l.attributes.keywords, screenshots }); + locales.push({ locale: l.attributes.locale, name: ia.name, subtitle: ia.subtitle, description: l.attributes.description, keywords: l.attributes.keywords, promotionalText: l.attributes.promotionalText ?? undefined, whatsNew: l.attributes.whatsNew ?? undefined, screenshots }); } reviewDetail = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null; } @@ -188,6 +189,7 @@ export async function collectApple(ctx: Ctx): Promise { expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x), requiredDisplayTypes: requiredDisplayTypes(config.apple, platform), iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play, + macos: platform === 'MAC_OS' ? { notes: reviewNotes(config, 'macos'), gameCenter: config.apple?.macos?.gameCenter, noIap: config.apple?.macos?.noIap } : undefined, macVersion: every.some(x => platformOf(x) === 'MAC_OS'), mac: !!config.apple?.mac, priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'), availability: !!avail?.data, diff --git a/skills/appstore-first-release/SKILL.md b/skills/appstore-first-release/SKILL.md index 4f90081..e41c8c5 100644 --- a/skills/appstore-first-release/SKILL.md +++ b/skills/appstore-first-release/SKILL.md @@ -63,7 +63,12 @@ answers the compliance facts no file proves, all in one batch at the start. 10. **API · Review details.** `storebound apple review-details` (contact from `.storebound/credentials.json` `reviewContact`, or `--contact-from `; notes from the config). Apps behind a sign-in: the demo account from `.storebound/` (`review-access` skill). Apps with accounts: in-app deletion - (`account-deletion` skill). + (`account-deletion` skill). An app with an iOS **and** a macOS version: `--platform macos` (`apple listing`, `apple + review-details`) uses `apple.macos.listing` (its own appstore file) and `apple.macos.reviewNotes` when set, else + the iOS ones; the Mac app often has no Game Center, IAP or ads, so say so there. `storebound validate --platform + macos` warns about iOS-only claims in the Mac texts and notes (Game Center unless `apple.macos.gameCenter: true`, + tap / swipe / pinch / touch screen, iPhone / iPad, and IAP lines when `apple.macos.noIap: true`). Not `apple.mac` + (that only turns Mac screenshots on). 11. **API · In-app purchases and subscriptions.** `store-iap-setup` and `store-subscriptions` skills. First IAPs and subscriptions go to review **with** this version: `storebound apple iap-submit --with-version --dry`, then (after the owner's yes, since it submits) `storebound apple iap-submit --with-version --yes`. On a real first diff --git a/tests/macos-overrides.test.ts b/tests/macos-overrides.test.ts new file mode 100644 index 0000000..ac9c9f2 --- /dev/null +++ b/tests/macos-overrides.test.ts @@ -0,0 +1,112 @@ +// apple.macos: per-platform listing file and review notes for an app with an iOS and a macOS App Store version, the +// config check, and the validate warning for iOS-only claims in a macOS version's texts. +import { describe, expect, test } from 'bun:test'; +import { isMacosPlatform, listingPathFor, reviewNotesFor, validateExtras, type ShipConfig } from '../cli/lib/config'; +import { reviewDetailPlan, reviewNotes } from '../cli/lib/review-details'; +import { appleRules, macosClaimRules, type AppleSnapshot } from '../cli/lib/validate-rules'; + +const cfg = (apple: Record) => ({ apple: { bundleId: 'com.example.app', ...apple } }) as unknown as Pick; +const contact = { contactFirstName: 'A', contactLastName: 'B', contactPhone: '+10000000000', contactEmail: 'a@example.com' }; + +describe('platform helpers', () => { + test('spellings of macOS', () => { + for (const p of ['macos', 'MAC_OS', 'macOS', 'mac']) expect(isMacosPlatform(p)).toBe(true); + for (const p of ['ios', 'IOS', 'tvos', '', undefined]) expect(isMacosPlatform(p)).toBe(false); + }); + test('listingPathFor: macOS override, else apple.listing, else the default', () => { + const c = cfg({ listing: 'store/ios.md', macos: { listing: 'store/mac.md' } }); + expect(listingPathFor(c, 'macos')).toBe('store/mac.md'); + expect(listingPathFor(c, 'MAC_OS')).toBe('store/mac.md'); + expect(listingPathFor(c, 'ios')).toBe('store/ios.md'); + expect(listingPathFor(c)).toBe('store/ios.md'); + expect(listingPathFor(cfg({ macos: {} }), 'macos')).toBe('store/appstore.md'); + expect(listingPathFor(cfg({ listing: 'store/ios.md', macos: { reviewNotes: 'x' } }), 'macos')).toBe('store/ios.md'); + }); + test('reviewNotesFor: macOS override, else apple.info.reviewNotes', () => { + const c = cfg({ info: { reviewNotes: 'iOS notes' }, macos: { reviewNotes: 'Mac notes' } }); + expect(reviewNotesFor(c, 'macos')).toBe('Mac notes'); + expect(reviewNotesFor(c, 'ios')).toBe('iOS notes'); + expect(reviewNotesFor(cfg({ info: { reviewNotes: 'iOS notes' }, macos: {} }), 'macos')).toBe('iOS notes'); + }); +}); + +describe('review details plan', () => { + const c = { ...cfg({ info: { reviewNotes: 'iOS notes: Game Center, IAP' }, macos: { reviewNotes: 'Mac notes: no purchases' } }), reviewAccess: { signInRequired: false, instructions: 'No sign-in.' } } as Pick; + test('macos picks the Mac notes, ios and default keep the iOS notes; the shared sign-in line stays', () => { + expect(reviewDetailPlan(c, contact, null, 'macos').attributes.notes).toBe('Mac notes: no purchases\n\nSign-in: No sign-in.'); + expect(reviewDetailPlan(c, contact, null, 'ios').attributes.notes).toBe('iOS notes: Game Center, IAP\n\nSign-in: No sign-in.'); + expect(reviewDetailPlan(c, contact, null).attributes.notes).toBe(reviewNotes(c)); + }); +}); + +describe('config check', () => { + test('apple.macos shape', () => { + expect(validateExtras({ apple: { bundleId: 'com.example.app', macos: { listing: 'a.md', reviewNotes: 'x', gameCenter: true, noIap: true } } } as never)).toEqual([]); + const bad = validateExtras({ apple: { bundleId: 'com.example.app', macos: { listing: '', gameCenter: 'yes', noIap: 1 } } } as never); + expect(bad.some(m => m.startsWith('apple.macos.listing'))).toBe(true); + expect(bad.some(m => m.startsWith('apple.macos.gameCenter'))).toBe(true); + expect(bad.some(m => m.startsWith('apple.macos.noIap'))).toBe(true); + expect(validateExtras({ apple: { bundleId: 'com.example.app', macos: 'x' } } as never).some(m => m.startsWith('apple.macos:'))).toBe(true); + }); +}); + +describe('validate: iOS-only claims on a macOS version', () => { + const loc = (o: Record) => [{ locale: 'en-US', screenshots: {}, ...o }]; + const snap = (over: Partial): Pick => + ({ version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform: 'MAC_OS' }, locales: [], reviewDetail: null, ...over }); + const codes = (f: { msg: string }[]) => f.map(x => x.msg); + + test('Game Center, touch words and iPhone mentions warn; nothing blocks', () => { + const f = macosClaimRules(snap({ locales: loc({ description: 'Compete on Game Center. Tap and swipe to play on your iPhone.' }) })); + expect(f.length).toBe(3); + expect(f.every(x => x.level === 'warn' && x.code === 'macos-claims')).toBe(true); + expect(codes(f).join('\n')).toContain('en-US description'); + }); + test('promotional text, what\'s new, keywords and live review notes are read', () => { + const f = macosClaimRules(snap({ locales: loc({ promotionalText: 'Use the touch screen', whatsNew: 'Pinch to zoom', keywords: 'ipad' }), reviewDetail: { notes: 'Sign in with Game Center' } })); + const all = codes(f).join('\n'); + expect(all).toContain('en-US promotionalText'); + expect(all).toContain('en-US whatsNew'); + expect(all).toContain('en-US keywords'); + expect(all).toContain('review notes (on the store)'); + }); + test('the config review notes the Mac would use are checked too', () => { + expect(codes(macosClaimRules(snap({ macos: { notes: 'Game Center leaderboards are in the menu' } }))).join()).toContain('review notes (config)'); + }); + test('apple.macos.gameCenter silences Game Center; IAP lines only with noIap', () => { + const text = loc({ description: 'Game Center scores. Remove ads with an in-app purchase.' }); + expect(macosClaimRules(snap({ locales: text, macos: { gameCenter: true } }))).toEqual([]); + const f = macosClaimRules(snap({ locales: text, macos: { gameCenter: true, noIap: true } })); + expect(f.length).toBe(1); + expect(f[0]!.msg).toContain('in-app purchase'); + }); + test('words that only look similar do not match; iOS versions are never checked', () => { + expect(macosClaimRules(snap({ locales: loc({ description: 'Contact the support team; a stapler, a tapestry, an iPhoneless desk.' }) }))).toEqual([]); + expect(macosClaimRules(snap({ version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, locales: loc({ description: 'Game Center, tap' }) }))).toEqual([]); + }); + test('appleRules includes the warning', () => { + const base = { expectedLocales: [], requiredDisplayTypes: [], iaps: [], expectedProducts: [], ageRating: null, priceSchedule: false, availability: false, build: null, privacyUrl: { url: 'https://example.com', status: 200 } }; + const f = appleRules({ ...base, ...snap({ locales: loc({ description: 'Tap here', screenshots: {} as never }) }) } as AppleSnapshot); + expect(f.some(x => x.code === 'macos-claims' && x.level === 'warn')).toBe(true); + }); +}); + +describe('apple listing reads the platform file', () => { + test('readAppleCopy(ctx, "macos") reads apple.macos.listing, ios reads apple.listing', async () => { + const { mkdtempSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join, resolve } = await import('node:path'); + const { readAppleCopy } = await import('../cli/apple/listing'); + const { parseArgs } = await import('../cli/lib/args'); + const fence = '`'.repeat(3); + const sec = (d: string) => ['## en-US', '', '### subtitle', fence, 'Sub', fence, '', '### promotionalText', fence, 'Promo', fence, '', '### keywords', fence, 'run,time', fence, '', '### description', fence, d, fence, ''].join('\n'); + const dir = mkdtempSync(join(tmpdir(), 'sb-macos-')); + writeFileSync(join(dir, 'ios.md'), sec('Play on iPhone with Game Center.')); + writeFileSync(join(dir, 'mac.md'), sec('Play on your Mac.')); + const loaded = { config: cfg({ listing: 'ios.md', macos: { listing: 'mac.md' } }), resolve: (p: string) => resolve(dir, p), root: dir }; + const ctx = { loaded, args: parseArgs([]) } as never; + expect(readAppleCopy(ctx, 'macos')['en-US']?.description).toContain('Mac'); + expect(readAppleCopy(ctx, 'ios')['en-US']?.description).toContain('iPhone'); + expect(readAppleCopy(ctx)['en-US']?.description).toContain('iPhone'); + }); +}); diff --git a/tests/upload-pkg-hang.test.ts b/tests/upload-pkg-hang.test.ts new file mode 100644 index 0000000..f4e1890 --- /dev/null +++ b/tests/upload-pkg-hang.test.ts @@ -0,0 +1,114 @@ +// `apple upload --platform macos` once printed nothing for 10 minutes over SSH although the build number was +// VALID on App Store Connect. Causes: pkgutil --check-signature / --expand ran unbounded and silent, and the processing +// wait polled the IOS platform only, so a Mac build was "not listed yet" for the whole --timeout. These tests keep +// every slow step logged and bounded, and an already-VALID build number ends at once (no upload, no wait). +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { hooks, run as appleUpload } from '../cli/apple/upload'; +import { PKG_CHECK_MS, PKG_EXPAND_MS, pkgInfo } from '../cli/lib/mas'; +import { HEARTBEAT_MS } from '../cli/lib/progress'; +import type { SyncResult } from '../cli/lib/rt'; +import { ascEnv, BASE, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores'; + +const SIDE = JSON.stringify({ bundleId: 'com.example.notes', version: '1.2.0', build: '7' }); +const files = (): Record => ({ 'build/macos/Example.pkg': 'pkg', 'build/macos/Example.pkg.json': SIDE }); +const result = (o: Partial): SyncResult => ({ exitCode: 0, signalCode: null, success: true, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), ...o }); +const timedOut = result({ exitCode: null, signalCode: 'SIGTERM', success: false }); +const SIGNED = 'Package "Bare.pkg":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. 3rd Party Mac Developer Installer: Example (ABCDE12345)\n Expires: 2030-01-01\n 2. Apple Worldwide Developer Relations Certification Authority\n 3. Apple Root CA\n'; + +type Seen = { argv: string[]; timeout?: number }; +const fake = (seen: Seen[], answer: (argv: string[]) => SyncResult) => ((argv: string[], o: { timeout?: number }) => { seen.push({ argv, timeout: o.timeout }); return answer(argv); }) as never; + +/** Runs fn with log output captured (log.ts writes through console). */ +async function logged(fn: () => Promise): Promise<{ text: string; error: Error | null }> { + const out: string[] = []; + const log = console.log, err = console.error, warn = console.warn; + console.log = console.error = console.warn = (...a: unknown[]) => { out.push(a.map(String).join(' ')); }; + let error: Error | null = null; + try { await fn(); } catch (e) { error = e as Error; } finally { console.log = log; console.error = err; console.warn = warn; } + return { text: out.join('\n'), error }; +} + +describe('pkgInfo: bounded and never silent', () => { + test('the signature check has a timeout and a log line; when it times out the .json apple build wrote is used', () => { + const p = project(BASE, files()); + const said: string[] = [], seen: Seen[] = []; + const info = pkgInfo(join(p.root, 'build/macos/Example.pkg'), 'com.example.notes', { say: m => said.push(m), spawnSync: fake(seen, () => timedOut) }); + expect(info).toEqual({ bundleId: 'com.example.notes', version: '1.2.0', build: '7' }); + expect(seen).toHaveLength(1); + expect(seen[0]!.timeout).toBe(PKG_CHECK_MS); + expect(said[0]).toContain('pkgutil --check-signature'); + expect(said.at(-1)).toContain('gave no answer'); + expect(said.at(-1)).toContain('Example.pkg.json'); + }); + + test('the expand is bounded too; a timeout without the .json file is an error that names the cause', () => { + const p = project(BASE, { 'build/macos/Bare.pkg': 'pkg' }); + const said: string[] = [], seen: Seen[] = []; + const spawnSync = fake(seen, argv => (argv[1] === '--check-signature' ? result({ stdout: Buffer.from(SIGNED) }) : timedOut)); + let error: Error | null = null; + try { pkgInfo(join(p.root, 'build/macos/Bare.pkg'), undefined, { say: m => said.push(m), spawnSync, expandMs: 5000 }); } catch (e) { error = e as Error; } + expect(error?.message).toContain('pkgutil --expand gave no answer'); + expect(error?.message).toContain('Bare.pkg.json'); + expect(seen.map(x => x.argv[1])).toEqual(['--check-signature', '--expand']); + expect(seen[1]!.timeout).toBe(5000); + expect(said.some(m => m.includes('pkgutil --expand'))).toBe(true); + expect(PKG_EXPAND_MS).toBeGreaterThan(PKG_CHECK_MS); + }); +}); + +describe('apple upload of a .pkg (fake stores)', () => { + setDefaultTimeout(30_000); + let s: Stores; + const path = process.env.PATH; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); process.env.PATH = mkdtempSync(join(tmpdir(), 'sb-nopkgutil-')); }); // no pkgutil: the .json is read + afterEach(() => { uninstallStores(); process.env.PATH = path; hooks.heartbeat = undefined; }); + + test('an already VALID build number ends at once: clear message, no upload, no wait, every slow step logged first', async () => { + s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } }); + const p = project(BASE, files()); + const started = Date.now(); + const r = await logged(() => appleUpload(p.ctx(['build/macos/Example.pkg', '--platform', 'macos', '--yes']))); + expect(Date.now() - started).toBeLessThan(10_000); + expect(r.error?.message).toContain('already on App Store Connect (VALID)'); + expect(writes(s.calls)).toEqual([]); + expect(s.calls.some(c => decodeURIComponent(c.path).includes('preReleaseVersion.platform'))).toBe(false); // no processing poll + expect(s.asc.parts).toEqual([]); + expect(r.text).toContain('reading the package'); + expect(r.text).toContain('asking App Store Connect'); + expect(r.text.indexOf('reading the package')).toBeLessThan(r.text.indexOf('asking App Store Connect')); + }); + + test('--allow-existing on a Mac build: the wait polls MAC_OS and prints a heartbeat while a poll is in flight', async () => { + s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } }); + const p = project(BASE, files()); + let beat: (() => void) | null = null; + hooks.heartbeat = { everyMs: 1000, ticker: f => { beat = f; return () => { beat = null; }; } }; + const inner = globalThis.fetch, polls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + if (decodeURIComponent(String(input)).includes('preReleaseVersion.platform')) { + (beat as (() => void) | null)?.(); // the poll is in flight: time passes + polls.push(decodeURIComponent(String(input))); + // the fake does not implement include=: answer the way App Store Connect does + return new Response(JSON.stringify({ data: [{ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'pv-1' } } } }], included: [{ type: 'preReleaseVersions', id: 'pv-1', attributes: { version: '1.2.0' } }] }), { status: 200 }); + } + return inner(input as never, init); + }) as typeof fetch; + const r = await logged(() => appleUpload(p.ctx(['build/macos/Example.pkg', '--platform', 'macos', '--yes', '--allow-existing']))); + expect(r.error).toBeNull(); + expect(r.text).toMatch(/still waiting for build 7: first check/); + expect(r.text).toContain('build 7 is VALID'); + expect(polls).toHaveLength(1); + expect(polls[0]).toContain('filter[preReleaseVersion.platform]=MAC_OS'); + expect(writes(s.calls)).toEqual([]); + }); + + test('the default heartbeat is at least once a minute', () => { + expect(HEARTBEAT_MS).toBeLessThanOrEqual(60_000); + }); +});