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
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue