validate: macOS versions need Mac screenshots, Play reads survive an ended edit; macOS build numbers across platforms; Mac build prebuild + apple.macAppCheck; signing wording off the Mac; dry flows plan uploads
This commit is contained in:
parent
b1f1b206d3
commit
f5da7c9f7a
21 changed files with 519 additions and 57 deletions
|
|
@ -1,7 +1,7 @@
|
|||
// Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg.
|
||||
// storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--target universal-apple-darwin]
|
||||
// [--version x.y.z] [--build-number N] [--profile <file.provisionprofile>] [--out build/macos] [--unsigned] [--dry] [--json]
|
||||
// Steps: Tauri project -> version / build number (next free one on App Store Connect, platform MAC_OS) -> signing
|
||||
// Steps: Tauri project -> version / build number (next free one on App Store Connect, every platform) -> signing
|
||||
// identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer
|
||||
// Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or
|
||||
// from the API) -> entitlements (app sandbox + application / team identifier, the project's own keys kept) -> a
|
||||
|
|
@ -19,10 +19,13 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { appNextBuild } from './next-build';
|
||||
import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing';
|
||||
import { sshBuildRefusal } from '../lib/keychain';
|
||||
import { remoteBuildSession, showCmd } from '../lib/xcode';
|
||||
import { prebuildFor, prebuildsDone, runPrebuild } from '../lib/web-assets';
|
||||
import { preCheckCommand } from '../lib/pre-check';
|
||||
import { pump } from '../lib/runner';
|
||||
import {
|
||||
appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities,
|
||||
pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId,
|
||||
|
|
@ -41,6 +44,16 @@ const run1 = (argv: string[], timeout = 120_000): { ok: boolean; out: string } =
|
|||
};
|
||||
|
||||
/** The Tauri CLI: the project's node_modules/.bin/tauri (walking up), `cargo tauri`, else bunx. */
|
||||
/** One sh -c command in root, its output streamed; the exit code and the last lines. */
|
||||
async function runCheck(cmd: string, root: string): Promise<{ ok: boolean; code: number; tail: string }> {
|
||||
const p = rt.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
|
||||
const tail: string[] = [];
|
||||
const each = (line: string, kind: 'stdout' | 'stderr'): void => { tail.push(line); if (tail.length > 10) tail.shift(); (kind === 'stderr' ? console.error : console.log)(` ${line}`); };
|
||||
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
||||
const code = await p.exited;
|
||||
return { ok: code === 0, code, tail: tail.join('\n') };
|
||||
}
|
||||
|
||||
export function tauriCommand(cwd: string, exists: (p: string) => boolean = existsSync, which: (c: string) => string | null = rt.which): string[] {
|
||||
for (let d = cwd, prev = ''; d !== prev; prev = d, d = dirname(d)) { const p = join(d, 'node_modules', '.bin', 'tauri'); if (exists(p)) return [p]; }
|
||||
if (which('cargo-tauri')) return ['cargo', 'tauri'];
|
||||
|
|
@ -72,7 +85,13 @@ async function fetchMasProfile(c: Asc, bundleId: string, dest: string): Promise<
|
|||
return dest;
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
/** The dry-run line about signing identities: on a machine that is not a Mac, say so instead of "none found". */
|
||||
export function identityLine(o: { platform: string; appIdentity: string | null; installerIdentity: string | null; profile: string | null }): string {
|
||||
if (o.platform !== 'darwin') return `this is not a build machine: Mac App Store builds run on a Mac (the signing identities live in its keychain), so none were looked up here; profile: ${o.profile ?? '(none)'}`;
|
||||
return `app identity: ${o.appIdentity ?? '(none found)'}; installer identity: ${o.installerIdentity ?? '(none found)'}; profile: ${o.profile ?? '(none)'}`;
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx, platform: string = process.platform): Promise<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
|
||||
|
|
@ -92,9 +111,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try {
|
||||
client = await ascClient(root, bundleId, config.apple?.appId);
|
||||
if (!build) {
|
||||
const builds = await client.all(`/v1/builds?filter[app]=${await client.app()}&filter[preReleaseVersion.platform]=MAC_OS&limit=200&fields[builds]=version`);
|
||||
build = nextBuildNumber(builds.map(b => String(b.attributes.version)));
|
||||
log(TAG, `${builds.length} macOS builds on App Store Connect: next build number ${build} (CFBundleVersion must increase)`);
|
||||
// every platform of the app record, not only macOS: CFBundleVersion must be unique across all its builds
|
||||
const found = await appNextBuild(client);
|
||||
build = found.next;
|
||||
log(TAG, `${found.builds} builds (all platforms) on App Store Connect: next build number ${build} (CFBundleVersion must increase)`);
|
||||
}
|
||||
if (!teamId) teamId = (await findBundleId(client, bundleId))?.attributes?.seedId ?? null;
|
||||
} catch (e) {
|
||||
|
|
@ -107,7 +127,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const team = teamId ?? '<TEAM_ID>';
|
||||
|
||||
// identities and profile (a real signed run needs them; a dry run shows placeholders)
|
||||
const mac = process.platform === 'darwin';
|
||||
const mac = platform === 'darwin';
|
||||
let appIdentity: string | null = null, installerIdentity: string | null = null;
|
||||
if (!unsigned && mac) {
|
||||
const picked = pickMasIdentities(parseIdentities(run1(['security', 'find-identity', '-v']).out), teamId);
|
||||
|
|
@ -142,18 +162,26 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const overlay = tauriOverlay({ appIdentity, entitlementsFile: entFile, profile, version, build: buildNo, unsigned, minOs: tauri.minOs, category });
|
||||
const input = { tauri, tauriCmd, target, out, configFile, entitlementsFile: entFile, profile, appIdentity, installerIdentity: installerIdentity ?? '<Mac Installer Distribution identity>', version, build: buildNo, unsigned, pathFree, pkg };
|
||||
const steps = masSteps(input);
|
||||
// the same hooks as apple build: build.prebuild / apple.prebuild first (a project computes its build-time env there),
|
||||
// apple.macAppCheck on the built .app before productbuild (e.g. a project's own check that a value was stamped in)
|
||||
const prebuild = prebuildFor(config, 'apple') ?? null;
|
||||
const prebuildSkip = !prebuild ? undefined : a.has('no-prebuild') ? '--no-prebuild' : prebuildsDone().has(prebuild) ? 'already ran in this flow run' : undefined;
|
||||
const appCheck = config.apple?.macAppCheck?.trim() || null;
|
||||
const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record<string, PlistValue>) : undefined;
|
||||
const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt });
|
||||
const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity,
|
||||
profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])),
|
||||
prebuild, ...(prebuildSkip ? { prebuildSkip } : {}), appCheck,
|
||||
steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) };
|
||||
if (dry) {
|
||||
if (json) { printJson(plan); return; }
|
||||
log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`);
|
||||
log(TAG, `app identity: ${appIdentity ?? '(none found)'}; installer identity: ${installerIdentity ?? '(none found)'}; profile: ${plan.profile ?? '(none)'}`);
|
||||
log(TAG, identityLine({ platform, appIdentity, installerIdentity, profile: plan.profile }));
|
||||
log(TAG, `entitlements: ${plan.entitlements.join(', ')}`);
|
||||
if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`);
|
||||
if (prebuild) log(TAG, prebuildSkip ? `prebuild skipped (${prebuildSkip}): ${prebuild}` : `would run the prebuild: ${prebuild}`);
|
||||
for (const s of plan.steps) log(TAG, `would run: ${s}`);
|
||||
if (appCheck) log(TAG, `would check the built .app: ${appCheck}`);
|
||||
done(TAG, 'macOS build planned (dry run)');
|
||||
return;
|
||||
}
|
||||
|
|
@ -164,6 +192,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(entFile, entitlementsXml(ent));
|
||||
writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`);
|
||||
if (prebuild && !prebuildSkip) await runPrebuild(prebuild, root, TAG);
|
||||
shipPhase('build', `tauri build ${target}, build ${buildNo}`);
|
||||
await runLogged(steps.tauri, tauri.cwd, join(out, 'tauri-build.log'), showCmd(steps.tauri, null), env);
|
||||
const appDir = findBuiltApp(tauri.dir, root, target, tauri.productName);
|
||||
|
|
@ -179,6 +208,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
{ bundleId, version, build: buildNo, signed: !unsigned, pathFree });
|
||||
for (const w of checks.warnings) warn(TAG, w);
|
||||
if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`);
|
||||
if (appCheck) {
|
||||
const cmd = preCheckCommand(appCheck, appDir);
|
||||
log(TAG, `apple.macAppCheck: $ ${cmd}`);
|
||||
const r = await runCheck(cmd, root);
|
||||
if (!r.ok) throw new Error(`apple.macAppCheck failed (exit ${r.code}) on ${basenameOf(appDir)}; nothing packaged:\n${r.tail}`);
|
||||
log(TAG, 'apple.macAppCheck: ok');
|
||||
}
|
||||
shipPhase('sign', 'productbuild');
|
||||
rmSync(exportPath, { recursive: true, force: true });
|
||||
mkdirSync(exportPath, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { done, log, printJson, warn } from '../lib/log';
|
|||
import { derivedDataFor, archiveScanDirs, leakNeedles, leakProblem, outsideHome, pathFreeSettings, scanForPaths } from '../lib/path-free';
|
||||
import { pump } from '../lib/runner';
|
||||
import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { appNextBuild } from './next-build';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { type FamilyVerdict, familyVerdicts, projectFamilies } from '../lib/device-families';
|
||||
import { liveDeviceFamilies } from './device-families';
|
||||
|
|
@ -177,9 +177,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try {
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
if (!build) {
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`);
|
||||
build = nextBuildNumber(builds.map(b => String(b.attributes.version)));
|
||||
log(TAG, `${builds.length} builds on App Store Connect: next build number ${build}`);
|
||||
const found = await appNextBuild(c);
|
||||
build = found.next;
|
||||
log(TAG, `${found.builds} builds on App Store Connect: next build number ${build}`);
|
||||
}
|
||||
if (!teamId) teamId = (await findBundleId(c, bundleId))?.attributes?.seedId ?? null;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -7,15 +7,25 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { need } from '../lib/ctx';
|
||||
import { printJson } from '../lib/log';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { ascClient } from './asc';
|
||||
import { ascClient, type Asc } from './asc';
|
||||
|
||||
/**
|
||||
* The one place that computes the next CFBundleVersion: the highest build of the app over EVERY platform (iOS, macOS,
|
||||
* tvOS, visionOS), every processing state and every page, plus one. CFBundleVersion must be unique across the app
|
||||
* record's builds, so a platform filter (a macOS build once got a number an uploaded macOS build already had) or a
|
||||
* missed page gives a number the store refuses. `apple next-build`, `apple build` and `apple build --platform macos`
|
||||
* (also their dry runs) all call this, so they agree.
|
||||
*/
|
||||
export async function appNextBuild(c: Pick<Asc, 'all' | 'app'>): Promise<{ next: string; builds: number }> {
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`);
|
||||
return { next: nextBuildNumber(builds.map(b => String(b.attributes.version))), builds: builds.length };
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version`);
|
||||
const next = nextBuildNumber(builds.map(b => String(b.attributes.version)));
|
||||
if (ctx.args.has('json')) { printJson({ next, builds: builds.length }); return; }
|
||||
console.error(`[apple] ${builds.length} builds on App Store Connect; next build number:`);
|
||||
const { next, builds } = await appNextBuild(c);
|
||||
if (ctx.args.has('json')) { printJson({ next, builds }); return; }
|
||||
console.error(`[apple] ${builds} builds on App Store Connect; next build number:`);
|
||||
console.log(next);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,8 +52,10 @@ export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } |
|
|||
/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's
|
||||
* "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by
|
||||
* scaling a 6.9" or 6.5" set down (ACCEPTED_INSTEAD), plus iPad 13" display with ipad. iPhone Duo is optional. */
|
||||
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
|
||||
apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||
export const requiredDisplayTypes = (apple: { ipad?: boolean; mac?: boolean; screenshotTypes?: string[] } | undefined, platform: string = 'IOS'): string[] =>
|
||||
platform === 'MAC_OS' ? (apple?.mac ? Object.values(MAC_DISPLAY) : []) // a macOS version takes the Mac set only (apple.screenshotTypes is the iOS list)
|
||||
: platform !== 'IOS' ? []
|
||||
: apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||
|
||||
/** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page
|
||||
* Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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}${isPkg ? '&filter[preReleaseVersion.platform]=MAC_OS' : ''}&limit=200&fields[builds]=version,processingState`);
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue