Files
Ncurator/scripts/release/macos.mjs
T

468 lines
16 KiB
JavaScript

import { execFile, spawn } from 'node:child_process'
import { access, cp, mkdir, readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { createReleaseBundle } from './artifact-bundle.mjs'
import {
assertCandidatePublishable,
cleanupSupersededReleaseCandidates,
markCandidatePublished,
materializeReleaseCandidate,
readReleaseCandidate,
recordCandidateE2E
} from './candidate.mjs'
import { loadReleaseEnvironment } from './shared/environment.mjs'
import { cleanupLocalTestLogs } from './shared/test-logs.mjs'
import { createReleaseStageDeadline } from './shared/stage-deadline.mjs'
import { prepareReleaseWorkspace } from './workspace.mjs'
import { parseReleaseTag } from './tag.mjs'
import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs'
const execFileAsync = promisify(execFile)
const ROOT = process.cwd()
const MACOS_PLATFORMS = ['macos-arm64', 'macos-x64']
const DEFAULT_E2E_PLATFORMS = ['macos-arm64']
const X64_E2E_DISABLED_REASON =
'macOS x64 package E2E is disabled by default because Rosetta execution is too slow; artifacts are still built, verified, signed, and published.'
const stageDeadline = createReleaseStageDeadline('macOS release stage')
await loadReleaseEnvironment(ROOT)
const args = process.argv.slice(2)
const valueAfter = (name) => {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
const releaseTag = valueAfter('--tag') || ''
const includeX64E2E = args.includes('--include-x64-e2e')
const regression = readRegressionSelection(args)
const modes = [
['prepare', args.includes('--prepare-only')],
['test', args.includes('--test-only')],
['publish', args.includes('--publish-only')]
].filter(([, enabled]) => enabled)
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
throw new Error('local macOS release must run on an Apple Silicon Mac')
}
let releaseVersion = ''
try {
releaseVersion = parseReleaseTag(releaseTag, 'mac').version
} catch {
// Report the complete command contract below.
}
if (!releaseVersion || modes.length !== 1) {
throw new Error(
'usage: node scripts/release/macos.mjs --tag <mac-v1.2.3> (--prepare-only|--test-only|--publish-only) [--include-x64-e2e] [--domain <name>] [--suite <name>] [--group <name>] [--case <caseId> ...] [--fresh-knowledge-cache]'
)
}
const mode = modes[0][0]
if (includeX64E2E && mode !== 'test') {
throw new Error('--include-x64-e2e is only valid with --test-only')
}
if (mode !== 'test' && hasRegressionSelectionArgs(args)) {
throw new Error('regression selection options are only valid with --test-only')
}
const run = (command, commandArgs, options = {}) =>
new Promise((resolve, reject) => {
const child = stageDeadline.track(
spawn(command, commandArgs, {
cwd: ROOT,
env: process.env,
stdio: 'inherit',
...options
})
)
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with ${code ?? signal}`))
})
})
const assertExecutable = async (filePath, label) => {
if (!filePath) throw new Error(`${label} is not configured`)
await access(filePath).catch(() => {
throw new Error(`${label} does not exist: ${filePath}`)
})
}
const validateTag = async () => {
const { stdout: tagType } = await execFileAsync('git', ['cat-file', '-t', releaseTag], {
cwd: ROOT
})
if (tagType.trim() !== 'tag') throw new Error(`${releaseTag} must be an annotated tag`)
const { stdout: packageJson } = await execFileAsync('git', ['show', `${releaseTag}:package.json`])
const version = JSON.parse(packageJson).version
if (releaseVersion !== version) {
throw new Error(`${releaseTag} does not match package version ${version}`)
}
return (
await execFileAsync('git', ['rev-list', '-n', '1', releaseTag], { cwd: ROOT })
).stdout.trim()
}
const validateEnvironment = async () => {
const defaultToolRoot = path.join(process.env.HOME || '', '.ncurator', 'release-tools')
const { stdout: pnpmPathOutput } = await execFileAsync('/usr/bin/which', ['pnpm'])
const result = {
pnpmPath: pnpmPathOutput.trim(),
sidecarVenvRoot: path.join(defaultToolRoot, 'sidecar-venv'),
sidecarBrowserCacheRoot: path.join(defaultToolRoot, 'sidecar-browser-cache'),
sidecarPipCacheRoot: path.join(defaultToolRoot, 'sidecar-pip-cache')
}
if (mode === 'publish') {
for (const name of [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'R2_ACCOUNT_ID',
'R2_BUCKET_NAME',
'R2_PUBLIC_BASE_URL'
]) {
if (!String(process.env[name] || '').trim()) {
throw new Error(`missing environment variable: ${name}`)
}
}
if (
String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '') !== 'https://download.ncurator.com'
) {
throw new Error('R2_PUBLIC_BASE_URL must be https://download.ncurator.com')
}
const awsExecutable =
String(process.env.NCURATOR_AWS_CLI || '').trim() ||
path.join(defaultToolRoot, 'awscli', 'bin', 'aws')
await execFileAsync(awsExecutable, ['--version']).catch(() => {
throw new Error('AWS CLI is not installed; install it before publishing macOS')
})
process.env.NCURATOR_AWS_CLI = awsExecutable
}
if (mode === 'prepare') {
if (process.env.CSC_LINK && !process.env.CSC_IDENTITY_AUTO_DISCOVERY) {
process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'true'
}
const { stdout: identities } = await execFileAsync('/usr/bin/security', [
'find-identity',
'-v',
'-p',
'codesigning'
])
if (!identities.includes('Ncurator Update Signing')) {
throw new Error('Ncurator Update Signing is not installed in the macOS login keychain')
}
result.x64Node =
String(process.env.NCURATOR_X64_NODE || '').trim() ||
path.join(defaultToolRoot, 'node-x64', 'bin', 'node')
result.x64Python =
String(process.env.NCURATOR_X64_PYTHON || '').trim() ||
path.join(defaultToolRoot, 'python-x64', 'bin', 'python3')
await assertExecutable(result.x64Node, 'NCURATOR_X64_NODE')
await assertExecutable(result.x64Python, 'NCURATOR_X64_PYTHON')
const { stdout: nodeArch } = await execFileAsync('/usr/bin/arch', [
'-x86_64',
result.x64Node,
'-p',
'process.arch'
])
if (nodeArch.trim() !== 'x64') throw new Error('NCURATOR_X64_NODE is not x64')
const { stdout: pythonArch } = await execFileAsync('/usr/bin/arch', [
'-x86_64',
result.x64Python,
'-c',
'import platform; print(platform.machine())'
])
if (pythonArch.trim() !== 'x86_64') throw new Error('NCURATOR_X64_PYTHON is not x64')
}
return result
}
const findApp = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true, recursive: true })
const match = entries.find((entry) => entry.isDirectory() && entry.name.endsWith('.app'))
if (!match) throw new Error(`macOS app was not found under ${directory}`)
return path.join(match.parentPath, match.name)
}
const extractTag = async (archivePath, directory) => {
await mkdir(directory, { recursive: true })
await run('/usr/bin/tar', ['-xf', archivePath, '-C', directory])
}
const seedNativeCache = async (sourceDirectory) => {
const cacheSource = path.join(ROOT, 'node_modules', '.native-cache')
const cacheTarget = path.join(sourceDirectory, 'node_modules', '.native-cache')
const available = await access(cacheSource)
.then(() => true)
.catch(() => false)
if (!available) return
await cp(cacheSource, cacheTarget, { recursive: true, force: false })
}
const createCandidate = async (sourceDirectory, platform, commit) => {
const distDirectory = path.join(sourceDirectory, 'dist')
const appPath = await findApp(distDirectory)
await run(process.execPath, [
path.join(sourceDirectory, 'scripts', 'release', 'verify-macos-signature.mjs'),
appPath,
path.join(distDirectory, 'mac-update-signature.txt')
])
const fragmentPath = path.join(distDirectory, `${platform}.json`)
await run(process.execPath, [
path.join(sourceDirectory, 'scripts', 'release', 'r2', 'create-fragment.mjs'),
distDirectory,
fragmentPath,
releaseTag,
platform
])
const bundlePath = path.join(distDirectory, `release-bundle-${platform}.json`)
await createReleaseBundle({ fragmentPath, outputPath: bundlePath, commit })
return await materializeReleaseCandidate({ bundlePath })
}
const sanitizedTestEnvironment = () => {
const environment = { ...process.env }
for (const name of [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'R2_ACCOUNT_ID',
'R2_BUCKET_NAME',
'R2_PUBLIC_BASE_URL'
]) {
delete environment[name]
}
return environment
}
const runCandidateE2E = async ({ candidates, sourceDirectory, pnpmPath }) => {
const resultPaths = candidates.map((candidate) =>
path.join(candidate.directory, 'package-e2e-result.json')
)
try {
await run(
pnpmPath,
[
'run',
'test:e2e:package',
'--',
...candidates.flatMap((candidate, index) => [
'--bundle',
candidate.bundlePath,
'--result',
resultPaths[index]
]),
...regression.workflowArgs
],
{ cwd: sourceDirectory, env: sanitizedTestEnvironment() }
)
return await Promise.all(
candidates.map((candidate, index) =>
recordCandidateE2E({
tag: releaseTag,
platform: candidate.state.platform,
status: 'passed',
resultPath: resultPaths[index],
selection: regression.state
})
)
)
} catch (error) {
await Promise.all(
candidates.map(async (candidate, index) => {
let status = 'failed'
try {
const result = JSON.parse(await readFile(resultPaths[index], 'utf8'))
if (result.status === 'passed') status = 'passed'
} catch {
// Missing or invalid evidence is a failed candidate result.
}
return await recordCandidateE2E({
tag: releaseTag,
platform: candidate.state.platform,
status,
resultPath: resultPaths[index],
selection: regression.state
})
})
)
throw error
}
}
const taggedCommit = await validateTag()
const tools = await validateEnvironment()
if (mode === 'prepare') {
const workspace = await prepareReleaseWorkspace({
releaseTag,
defaultRootName: 'ncurator-macos-prepare',
keepWork: false
})
const archivePath = path.join(workspace.workDirectory, 'source.tar')
const armSource = path.join(workspace.workDirectory, 'arm64')
const x64Source = path.join(workspace.workDirectory, 'x64')
try {
await run('git', ['archive', '--format=tar', '--output', archivePath, releaseTag])
await extractTag(archivePath, armSource)
await extractTag(archivePath, x64Source)
const armEnvironment = {
...process.env,
NCURATOR_SIDECAR_VENV_ROOT: tools.sidecarVenvRoot,
NCURATOR_SIDECAR_BROWSER_CACHE: path.join(tools.sidecarBrowserCacheRoot, 'darwin-arm64'),
NCURATOR_SIDECAR_PIP_CACHE: path.join(tools.sidecarPipCacheRoot, 'darwin-arm64')
}
const x64Environment = {
...process.env,
PATH: `${path.dirname(tools.x64Node)}:${process.env.PATH || ''}`,
PIP_CERT: String(process.env.PIP_CERT || '').trim() || '/private/etc/ssl/cert.pem',
NCURATOR_SIDECAR_VENV_ROOT: tools.sidecarVenvRoot,
NCURATOR_SIDECAR_BUILD_PYTHON: tools.x64Python,
NCURATOR_SIDECAR_BROWSER_CACHE: path.join(tools.sidecarBrowserCacheRoot, 'darwin-x64'),
NCURATOR_SIDECAR_PIP_CACHE: path.join(tools.sidecarPipCacheRoot, 'darwin-x64')
}
await Promise.all([
(async () => {
await run(tools.pnpmPath, ['install', '--frozen-lockfile'], {
cwd: armSource,
env: armEnvironment
})
await seedNativeCache(armSource)
})(),
(async () => {
await run(
'/usr/bin/arch',
['-x86_64', tools.x64Node, tools.pnpmPath, 'install', '--frozen-lockfile'],
{ cwd: x64Source, env: x64Environment }
)
await seedNativeCache(x64Source)
})()
])
const [armCandidate, x64Candidate] = await Promise.all([
(async () => {
await run(tools.pnpmPath, ['run', 'build:mac:release:arm64'], {
cwd: armSource,
env: armEnvironment
})
return await createCandidate(armSource, 'macos-arm64', taggedCommit)
})(),
(async () => {
await run(
'/usr/bin/arch',
['-x86_64', tools.x64Node, tools.pnpmPath, 'run', 'build:mac:release:x64'],
{ cwd: x64Source, env: x64Environment }
)
return await createCandidate(x64Source, 'macos-x64', taggedCommit)
})()
])
await recordCandidateE2E({
tag: releaseTag,
platform: 'macos-x64',
status: 'skipped',
reason: X64_E2E_DISABLED_REASON,
selection: { mode: 'disabled-by-default' }
})
process.stdout.write(
`[release] macOS candidates prepared; arm64 package E2E is required before publish and x64 package E2E is disabled by default\n${armCandidate.bundlePath}\n${x64Candidate.bundlePath}\n`
)
} finally {
await workspace.dispose()
}
process.exit(0)
}
if (mode === 'test') {
const workspace = await prepareReleaseWorkspace({
releaseTag,
defaultRootName: 'ncurator-macos-test',
keepWork: false
})
const archivePath = path.join(workspace.workDirectory, 'source.tar')
const sourceDirectory = path.join(workspace.workDirectory, 'source')
try {
await run('git', ['archive', '--format=tar', '--output', archivePath, releaseTag])
await extractTag(archivePath, sourceDirectory)
await run(tools.pnpmPath, ['install', '--frozen-lockfile'], { cwd: sourceDirectory })
const e2ePlatforms = includeX64E2E ? MACOS_PLATFORMS : DEFAULT_E2E_PLATFORMS
if (!includeX64E2E) {
await recordCandidateE2E({
tag: releaseTag,
platform: 'macos-x64',
status: 'skipped',
reason: X64_E2E_DISABLED_REASON,
selection: { mode: 'disabled-by-default' }
})
}
const candidates = await Promise.all(
e2ePlatforms.map(async (platform) => {
const candidate = await readReleaseCandidate({ tag: releaseTag, platform })
if (candidate.state.commit !== taggedCommit) {
throw new Error(`${platform} candidate commit does not match release tag`)
}
return candidate
})
)
await runCandidateE2E({ candidates, sourceDirectory, pnpmPath: tools.pnpmPath })
} finally {
await workspace.dispose()
}
process.stdout.write(
includeX64E2E
? `[release] macOS candidate E2E passed for both architectures\n`
: `[release] macOS arm64 candidate E2E passed; x64 package E2E is disabled by default\n`
)
process.exit(0)
}
await recordCandidateE2E({
tag: releaseTag,
platform: 'macos-x64',
status: 'skipped',
reason: X64_E2E_DISABLED_REASON,
selection: { mode: 'disabled-by-default' }
})
const candidates = await Promise.all(
MACOS_PLATFORMS.map(async (platform) =>
assertCandidatePublishable({ tag: releaseTag, platform, commit: taggedCommit })
)
)
if (
!candidates[0].verified.fragment.signature ||
candidates[0].verified.fragment.signature !== candidates[1].verified.fragment.signature
) {
throw new Error('macOS candidate signatures do not match')
}
for (const candidate of candidates) {
await run(process.execPath, [
path.join(ROOT, 'scripts', 'release', 'r2', 'upload-fragment.mjs'),
candidate.verified.directory,
candidate.verified.fragmentPath
])
}
await run(process.execPath, [
path.join(ROOT, 'scripts', 'release', 'r2', 'finalize-macos.mjs'),
'--tag',
releaseTag
])
for (const platform of MACOS_PLATFORMS) {
await markCandidatePublished({ tag: releaseTag, platform })
}
try {
await cleanupLocalTestLogs(ROOT)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`[release] local test log cleanup warning: ${message}\n`)
}
try {
const removed = await cleanupSupersededReleaseCandidates({
tag: releaseTag,
platforms: ['macos-arm64', 'macos-x64']
})
if (removed.length > 0) {
process.stdout.write(`[release] removed ${removed.length} superseded local candidate(s)\n`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`[release] local candidate cleanup warning: ${message}\n`)
}
process.stdout.write(`[release] macOS ${releaseTag} published from retained candidates\n`)