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:
CRTkafa 2026-11-03 23:30:59 +07:00
parent 53d57544bf
commit 1e47c2ee60
18 changed files with 417 additions and 63 deletions

View file

@ -707,6 +707,15 @@ export interface ShipConfig {
* 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from <frames>/<lang>/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
* <renders>/<lang>/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<ShipConfig>): 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<ShipConfig, 'apple'>, 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<ShipConfig, 'apple'>, 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;

View file

@ -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<Loaded, 'config' | 'resolve' | 'root'>, stores: { apple: boolean; play: boolean } = { apple: true, play: true }): PlatformHit[] {
@ -11,6 +11,7 @@ export function listingFileMentions(loaded: Pick<Loaded, 'config' | 'resolve' |
const out: PlatformHit[] = [];
const files: ['apple' | 'play', string | null][] = [
['apple', stores.apple && config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : null],
['apple', stores.apple && config.apple?.macos?.listing ? resolve(listingPathFor(config, 'macos')) : null],
['play', stores.play && config.play ? resolve(config.play.listing ?? 'store/listing.md') : null],
];
for (const [store, f] of files) if (f && existsSync(f)) out.push(...fileMentions(store, relative(root, f) || f, readFileSync(f, 'utf8'), allow));

View file

@ -271,29 +271,51 @@ export function readAppInfo(appDir: string): Record<string, PlistValue> {
return parsePlist(readFileSync(p)) as Record<string, PlistValue>;
}
/** 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 <pkg>.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)`);
}

View file

@ -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<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>): 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<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>, 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<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>, contact: Record<string, string>, demo: DemoAccount | null): ReviewDetailPlan {
const notes = reviewNotes(config);
export function reviewDetailPlan(config: Pick<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>, contact: Record<string, string>, 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;

View file

@ -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<string, number> }[];
locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; promotionalText?: string; whatsNew?: string; screenshots: Record<string, number> }[];
/** 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<AppleSnapshot, 'version' | 'configCopyrig
const PLATFORM_NAMES: Record<string, string> = { 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<AppleSnapshot, 'version' | 'locales' | 'reviewDetail' | 'macos'>): 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 {