hololake-system-architecture/product-source/hololake-native-desktop/scripts/release-pipeline.mjs

498 lines
24 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync, spawnSync } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const PUBLIC_RELEASE_ENDPOINT_PATH = '/hololake/releases/latest.json'
const PUBLIC_RELEASE_PREFIX = '/hololake/releases'
const fail = (code) => {
throw new Error(code)
}
const requireText = (value, code) => {
if (typeof value !== 'string' || value.trim() === '') fail(code)
return value.trim()
}
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'))
const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
export function validateReleaseTrust(trust) {
if (trust?.schema !== 'hololake.release-trust/v1') fail('HOLOLAKE_RELEASE_PIPELINE_TRUST_SCHEMA_INVALID')
if (trust.state !== 'PROVISIONED') fail('HOLOLAKE_RELEASE_PIPELINE_TRUST_UNPROVISIONED')
if (!Array.isArray(trust.endpoints) || trust.endpoints.length !== 1) fail('HOLOLAKE_RELEASE_PIPELINE_EXACT_ENDPOINT_REQUIRED')
if (!Array.isArray(trust.allowedReleaseHosts) || trust.allowedReleaseHosts.length !== 1) fail('HOLOLAKE_RELEASE_PIPELINE_EXACT_HOST_REQUIRED')
const endpoint = new URL(trust.endpoints[0])
const host = requireText(trust.allowedReleaseHosts[0], 'HOLOLAKE_RELEASE_PIPELINE_HOST_REQUIRED')
if (
endpoint.protocol !== 'https:' ||
endpoint.hostname !== host ||
endpoint.username ||
endpoint.password ||
endpoint.search ||
endpoint.hash ||
endpoint.pathname !== PUBLIC_RELEASE_ENDPOINT_PATH
) {
fail('HOLOLAKE_RELEASE_PIPELINE_ENDPOINT_NOT_HOLOLAKE_HTTPS')
}
if (requireText(trust.publicKey, 'HOLOLAKE_RELEASE_PIPELINE_UPDATER_PUBLIC_KEY_REQUIRED').length < 32) {
fail('HOLOLAKE_RELEASE_PIPELINE_UPDATER_PUBLIC_KEY_INVALID')
}
if (trust.automaticCheckOnStartup || trust.automaticDownload || trust.automaticRestart || !trust.humanOptInInstallRequired) {
fail('HOLOLAKE_RELEASE_PIPELINE_HUMAN_CONTROL_POLICY_INVALID')
}
return { endpoint, host }
}
export function validateReleaseInput(input, trustFacts) {
if (input?.schema !== 'hololake.release-pipeline-input/v1') fail('HOLOLAKE_RELEASE_PIPELINE_INPUT_SCHEMA_INVALID')
const version = requireText(input.version, 'HOLOLAKE_RELEASE_PIPELINE_VERSION_REQUIRED')
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
fail('HOLOLAKE_RELEASE_PIPELINE_VERSION_INVALID')
}
if (input.platformCode !== 'darwin-aarch64') fail('HOLOLAKE_RELEASE_PIPELINE_PLATFORM_NOT_IMPLEMENTED')
if (!/^[a-f0-9]{40}$/.test(input.sourceCommit || '')) fail('HOLOLAKE_RELEASE_PIPELINE_SOURCE_COMMIT_INVALID')
if (input.releaseTag !== `v${version}`) fail('HOLOLAKE_RELEASE_PIPELINE_IMMUTABLE_TAG_INVALID')
const packageUrl = new URL(requireText(input.packageUrl, 'HOLOLAKE_RELEASE_PIPELINE_PACKAGE_URL_REQUIRED'))
if (
packageUrl.protocol !== 'https:' ||
packageUrl.hostname !== trustFacts.host ||
packageUrl.username ||
packageUrl.password ||
packageUrl.search ||
packageUrl.hash ||
!packageUrl.pathname.startsWith(`${PUBLIC_RELEASE_PREFIX}/`)
) {
fail('HOLOLAKE_RELEASE_PIPELINE_PACKAGE_HOST_NOT_TRUSTED')
}
if (!Array.isArray(input.features) || input.features.length === 0 || input.features.some((item) => typeof item !== 'string' || !item.trim())) {
fail('HOLOLAKE_RELEASE_PIPELINE_FEATURES_REQUIRED')
}
if (!Array.isArray(input.fixes) || input.fixes.some((item) => typeof item !== 'string' || !item.trim())) {
fail('HOLOLAKE_RELEASE_PIPELINE_FIXES_INVALID')
}
for (const [field, code] of [
['releaseId', 'HOLOLAKE_RELEASE_PIPELINE_RELEASE_ID_REQUIRED'],
['previousVersion', 'HOLOLAKE_RELEASE_PIPELINE_PREVIOUS_VERSION_REQUIRED'],
['minimumVersion', 'HOLOLAKE_RELEASE_PIPELINE_MINIMUM_VERSION_REQUIRED'],
['notes', 'HOLOLAKE_RELEASE_PIPELINE_NOTES_REQUIRED'],
['restartMessage', 'HOLOLAKE_RELEASE_PIPELINE_RESTART_MESSAGE_REQUIRED'],
['appleTeamIdentifier', 'HOLOLAKE_RELEASE_PIPELINE_APPLE_TEAM_REQUIRED'],
]) requireText(input[field], code)
if (typeof input.dataMigrationRequired !== 'boolean') fail('HOLOLAKE_RELEASE_PIPELINE_MIGRATION_FLAG_REQUIRED')
return { ...input, version, packageUrl }
}
export function validateCredentialEnvironment(env) {
const required = [
'APPLE_SIGNING_IDENTITY',
'TAURI_SIGNING_PRIVATE_KEY_PASSWORD',
]
const missing = required.filter((name) => typeof env[name] !== 'string' || env[name].trim() === '')
if (!env.TAURI_SIGNING_PRIVATE_KEY && !env.TAURI_SIGNING_PRIVATE_KEY_PATH) {
missing.push('TAURI_SIGNING_PRIVATE_KEY_OR_PATH')
} else if (env.TAURI_SIGNING_PRIVATE_KEY_PATH && !fs.statSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH, { throwIfNoEntry: false })?.isFile()) {
missing.push('TAURI_SIGNING_PRIVATE_KEY_PATH_NOT_READABLE')
}
const appleIdFlow = env.APPLE_ID && env.APPLE_PASSWORD && env.APPLE_TEAM_ID
const appleApiFlow = env.APPLE_API_KEY && env.APPLE_API_ISSUER && env.APPLE_API_KEY_PATH
const xcodeOrganizerFlow = env.APPLE_XCODE_ORGANIZER_FLOW === '1'
if (!appleIdFlow && !appleApiFlow && !xcodeOrganizerFlow) missing.push('APPLE_NOTARIZATION_CREDENTIAL_SET')
if (missing.length) fail(`HOLOLAKE_RELEASE_PIPELINE_CREDENTIALS_MISSING:${[...new Set(missing)].sort().join(',')}`)
return xcodeOrganizerFlow ? 'XCODE_ORGANIZER' : 'TAURI_AUTOMATED'
}
export function materializeUpdaterPrivateKey(env, readText = (file) => fs.readFileSync(file, 'utf8')) {
if (typeof env.TAURI_SIGNING_PRIVATE_KEY === 'string' && env.TAURI_SIGNING_PRIVATE_KEY.trim()) {
return { ...env }
}
const privateKeyPath = requireText(
env.TAURI_SIGNING_PRIVATE_KEY_PATH,
'HOLOLAKE_RELEASE_PIPELINE_UPDATER_PRIVATE_KEY_PATH_REQUIRED',
)
const privateKey = requireText(
readText(privateKeyPath),
'HOLOLAKE_RELEASE_PIPELINE_UPDATER_PRIVATE_KEY_EMPTY',
)
const { TAURI_SIGNING_PRIVATE_KEY_PATH: _privateKeyPath, ...childEnvironment } = env
return {
...childEnvironment,
TAURI_SIGNING_PRIVATE_KEY: privateKey,
}
}
function run(program, args, options = {}) {
return execFileSync(program, args, {
cwd: options.cwd || root,
encoding: 'utf8',
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
env: options.env || process.env,
})
}
function tryRun(program, args, options = {}) {
const result = spawnSync(program, args, {
cwd: options.cwd || root,
encoding: 'utf8',
env: options.env || process.env,
})
return {
ok: !result.error && result.status === 0,
output: `${result.stdout || ''}\n${result.stderr || ''}`.trim(),
}
}
function captureAll(program, args) {
const result = spawnSync(program, args, { cwd: root, encoding: 'utf8', env: process.env })
if (result.error) throw result.error
if (result.status !== 0) fail(`HOLOLAKE_RELEASE_PIPELINE_COMMAND_FAILED:${program}`)
return `${result.stdout || ''}\n${result.stderr || ''}`
}
function requireCleanImmutableSource(input) {
if (run('git', ['status', '--porcelain'], { capture: true }).trim()) fail('HOLOLAKE_RELEASE_PIPELINE_SOURCE_NOT_CLEAN')
if (run('git', ['branch', '--show-current'], { capture: true }).trim() !== 'main') fail('HOLOLAKE_RELEASE_PIPELINE_MAIN_BRANCH_REQUIRED')
const head = run('git', ['rev-parse', 'HEAD'], { capture: true }).trim()
if (head !== input.sourceCommit) fail('HOLOLAKE_RELEASE_PIPELINE_SOURCE_COMMIT_MISMATCH')
const tagCommit = run('git', ['rev-list', '-n', '1', input.releaseTag], { capture: true }).trim()
if (tagCommit !== head) fail('HOLOLAKE_RELEASE_PIPELINE_TAG_COMMIT_MISMATCH')
const packageDocument = readJson(path.join(root, 'package.json'))
const tauriDocument = readJson(path.join(root, 'src-tauri/tauri.conf.json'))
if (packageDocument.version !== input.version || tauriDocument.version !== input.version) {
fail('HOLOLAKE_RELEASE_PIPELINE_VERSION_FILES_MISMATCH')
}
}
function findOne(rootPath, predicate, code) {
const matches = []
const visit = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) visit(file)
else if (entry.isFile() && predicate(file)) matches.push(file)
}
}
if (fs.existsSync(rootPath)) visit(rootPath)
if (matches.length !== 1) fail(`${code}:${matches.length}`)
return matches[0]
}
function codeSignatureEvidence(appPath, input) {
run('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=4', appPath])
const display = captureAll('/usr/bin/codesign', ['-dv', '--verbose=4', appPath])
const value = (name) => display.split('\n').find((line) => line.startsWith(`${name}=`))?.slice(name.length + 1)
if (value('Identifier') !== 'world.guanghu.hololake' || value('TeamIdentifier') !== input.appleTeamIdentifier) {
fail('HOLOLAKE_RELEASE_PIPELINE_CODE_SIGNATURE_IDENTITY_MISMATCH')
}
run('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath])
run('/usr/bin/xcrun', ['stapler', 'validate', appPath])
return {
schema: 'hololake.platform-code-signature-receipt/v1',
state: 'DEVELOPER_ID_SIGNATURE_STRICT_AND_GATEKEEPER_ACCEPTED',
identifier: value('Identifier'),
teamIdentifier: value('TeamIdentifier'),
cdHash: value('CDHash'),
executableSha256: sha256File(path.join(appPath, 'Contents/MacOS/hololake-native-desktop')),
sourceCommit: input.sourceCommit,
}
}
function notarizationEvidence(appPath, dmgPath, input, options = {}) {
const dmgGatekeeper = tryRun('/usr/sbin/spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=4', dmgPath])
const dmgStapler = tryRun('/usr/bin/xcrun', ['stapler', 'validate', dmgPath])
if (options.requireNotarizedDmg && (!dmgGatekeeper.ok || !dmgStapler.ok)) {
fail('HOLOLAKE_RELEASE_PIPELINE_DMG_NOTARIZATION_REQUIRED')
}
return {
schema: 'hololake.apple-notarization-receipt/v1',
state: dmgGatekeeper.ok && dmgStapler.ok
? 'APPLE_APP_AND_DMG_NOTARIZATION_ACCEPTED_AND_STAPLED'
: 'APPLE_APP_NOTARIZATION_ACCEPTED_AND_STAPLED_DMG_CONTAINS_NOTARIZED_APP',
appName: path.basename(appPath),
dmgName: path.basename(dmgPath),
appStaplerValidation: 'PASS',
dmgGatekeeperAssessment: dmgGatekeeper.ok ? 'PASS' : 'NOT_ASSERTED_FOR_XCODE_EXPORTED_APP_FLOW',
dmgStaplerValidation: dmgStapler.ok ? 'PASS' : 'NOT_STAPLED_OUTER_CONTAINER',
...(process.env.APPLE_XCODE_SUBMISSION_ID ? { submissionIdentifier: process.env.APPLE_XCODE_SUBMISSION_ID } : {}),
appleTeamIdentifier: input.appleTeamIdentifier,
sourceCommit: input.sourceCommit,
}
}
function writeJson(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
}
function localDateDirectory() {
const date = new Date()
const part = (value) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${part(date.getMonth() + 1)}-${part(date.getDate())}`
}
function unsignedExecutableSha256(executable) {
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-unsigned-executable-'))
const copy = path.join(temporary, path.basename(executable))
try {
fs.copyFileSync(executable, copy)
run('/usr/bin/codesign', ['--remove-signature', copy])
return sha256File(copy)
} finally {
fs.rmSync(temporary, { recursive: true, force: true })
}
}
export function formatPlistDate(date) {
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const part = (value) => String(value).padStart(2, '0')
return `${weekdays[date.getUTCDay()]} ${months[date.getUTCMonth()]} ${part(date.getUTCDate())} ${part(date.getUTCHours())}:${part(date.getUTCMinutes())}:${part(date.getUTCSeconds())} ${date.getUTCFullYear()}`
}
function createXcodeArchive(application, input) {
const archive = path.join(
os.homedir(),
'Library/Developer/Xcode/Archives',
localDateDirectory(),
`HoloLake-${input.version}-${input.sourceCommit.slice(0, 12)}.xcarchive`,
)
if (fs.existsSync(archive)) fail('HOLOLAKE_RELEASE_PIPELINE_XCODE_ARCHIVE_ALREADY_EXISTS')
const archivedApp = path.join(archive, 'Products/Applications/HoloLake.app')
fs.mkdirSync(path.dirname(archivedApp), { recursive: true, mode: 0o700 })
run('/usr/bin/ditto', [application, archivedApp])
const info = path.join(archive, 'Info.plist')
run('/usr/bin/plutil', ['-create', 'xml1', info])
const add = (key, type, value) => run('/usr/libexec/PlistBuddy', ['-c', `Add :${key} ${type} ${value}`, info])
add('ArchiveVersion', 'integer', '2')
add('CreationDate', 'date', formatPlistDate(new Date()))
add('Name', 'string', `HoloLake ${input.version} ${input.sourceCommit.slice(0, 12)}`)
add('SchemeName', 'string', 'HoloLake')
add('HoloLakeSourceCommit', 'string', input.sourceCommit)
const executable = path.join(application, 'Contents/MacOS/hololake-native-desktop')
add('HoloLakePreNotarizationExecutableSha256', 'string', sha256File(executable))
add('HoloLakeUnsignedExecutableSha256', 'string', unsignedExecutableSha256(executable))
add('ApplicationProperties', 'dict', '')
add('ApplicationProperties:ApplicationPath', 'string', 'Applications/HoloLake.app')
add('ApplicationProperties:ArchiveVersion', 'integer', '2')
add('ApplicationProperties:CFBundleIdentifier', 'string', 'world.guanghu.hololake')
add('ApplicationProperties:CFBundleShortVersionString', 'string', input.version)
add('ApplicationProperties:CFBundleVersion', 'string', input.version)
add('ApplicationProperties:SigningIdentity', 'string', process.env.APPLE_SIGNING_IDENTITY)
add('ApplicationProperties:Team', 'string', input.appleTeamIdentifier)
return archive
}
function findPreparedXcodeArchive(input) {
const archivesRoot = path.join(os.homedir(), 'Library/Developer/Xcode/Archives')
const archiveName = `HoloLake-${input.version}-${input.sourceCommit.slice(0, 12)}.xcarchive`
const matches = []
for (const dateEntry of fs.readdirSync(archivesRoot, { withFileTypes: true })) {
if (!dateEntry.isDirectory()) continue
const candidate = path.join(archivesRoot, dateEntry.name, archiveName)
if (fs.statSync(candidate, { throwIfNoEntry: false })?.isDirectory()) matches.push(candidate)
}
if (matches.length !== 1) fail(`HOLOLAKE_RELEASE_PIPELINE_PREPARED_XCODE_ARCHIVE_NOT_UNIQUE:${matches.length}`)
return matches[0]
}
function resolveNotarizedApplication(argument, input) {
const candidate = path.resolve(requireText(argument, 'HOLOLAKE_RELEASE_PIPELINE_NOTARIZED_APP_PATH_REQUIRED'))
const application = candidate.endsWith('.app') ? candidate : path.join(candidate, 'HoloLake.app')
if (!fs.statSync(application, { throwIfNoEntry: false })?.isDirectory()) {
fail('HOLOLAKE_RELEASE_PIPELINE_NOTARIZED_APP_NOT_FOUND')
}
const info = path.join(application, 'Contents/Info.plist')
const identifier = run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', info], { capture: true }).trim()
const version = run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleShortVersionString', info], { capture: true }).trim()
if (identifier !== 'world.guanghu.hololake' || version !== input.version) {
fail('HOLOLAKE_RELEASE_PIPELINE_NOTARIZED_APP_IDENTITY_MISMATCH')
}
const archive = findPreparedXcodeArchive(input)
const expectedUnsignedExecutableSha256 = run('/usr/libexec/PlistBuddy', [
'-c', 'Print :HoloLakeUnsignedExecutableSha256', path.join(archive, 'Info.plist'),
], { capture: true }).trim()
const actualUnsignedExecutableSha256 = unsignedExecutableSha256(
path.join(application, 'Contents/MacOS/hololake-native-desktop'),
)
if (actualUnsignedExecutableSha256 !== expectedUnsignedExecutableSha256) {
fail('HOLOLAKE_RELEASE_PIPELINE_NOTARIZED_APP_SOURCE_BINARY_MISMATCH')
}
return application
}
function buildUpdaterFromNotarizedApp(application, destination, credentialEnvironment) {
const updater = path.join(destination, 'HoloLake.app.tar.gz')
run('/usr/bin/tar', ['-czf', updater, '-C', path.dirname(application), path.basename(application)])
run('npm', ['run', 'tauri', '--', 'signer', 'sign', updater], { env: credentialEnvironment })
const signature = `${updater}.sig`
if (!fs.statSync(signature, { throwIfNoEntry: false })?.isFile()) {
fail('HOLOLAKE_RELEASE_PIPELINE_UPDATER_SIGNATURE_MISSING')
}
return { updater, signature }
}
function buildDmgFromNotarizedApp(application, destination, input) {
const staging = path.join(destination, 'dmg-root')
fs.mkdirSync(staging, { recursive: true, mode: 0o700 })
run('/usr/bin/ditto', [application, path.join(staging, 'HoloLake.app')])
fs.symlinkSync('/Applications', path.join(staging, 'Applications'))
const dmg = path.join(destination, `HoloLake_${input.version}_aarch64.dmg`)
run('/usr/bin/hdiutil', ['create', '-volname', 'HoloLake', '-srcfolder', staging, '-ov', '-format', 'UDZO', dmg])
run('/usr/bin/codesign', ['--force', '--timestamp', '--sign', process.env.APPLE_SIGNING_IDENTITY, dmg])
return dmg
}
function writeReleaseOutput(input, trustFacts, application, updater, updaterSignature, dmg, options = {}) {
if (decodeURIComponent(input.packageUrl.pathname.split('/').pop()) !== path.basename(updater)) {
fail('HOLOLAKE_RELEASE_PIPELINE_PACKAGE_URL_FILENAME_MISMATCH')
}
const codeReceipt = codeSignatureEvidence(application, input)
const notarizationReceipt = notarizationEvidence(application, dmg, input, options)
const outputRoot = path.join(root, 'release/out', input.version)
if (fs.existsSync(outputRoot)) fail('HOLOLAKE_RELEASE_PIPELINE_OUTPUT_ALREADY_EXISTS')
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 })
const codeReceiptId = `HOLOLAKE-CODESIGN-${input.version}-${input.sourceCommit.slice(0, 12)}`
const notarizationReceiptId = `HOLOLAKE-NOTARIZATION-${input.version}-${input.sourceCommit.slice(0, 12)}`
writeJson(path.join(outputRoot, `${codeReceiptId}.json`), codeReceipt)
writeJson(path.join(outputRoot, `${notarizationReceiptId}.json`), notarizationReceipt)
fs.copyFileSync(updater, path.join(outputRoot, path.basename(updater)), fs.constants.COPYFILE_EXCL)
fs.copyFileSync(updaterSignature, path.join(outputRoot, path.basename(updaterSignature)), fs.constants.COPYFILE_EXCL)
fs.copyFileSync(dmg, path.join(outputRoot, path.basename(dmg)), fs.constants.COPYFILE_EXCL)
const broadcast = {
schema: 'hololake.release-broadcast/v1',
releaseId: input.releaseId,
version: input.version,
pub_date: new Date().toISOString(),
notes: input.notes,
platforms: {
[input.platformCode]: {
url: input.packageUrl.toString(),
signature: fs.readFileSync(updaterSignature, 'utf8').trim(),
size: fs.statSync(updater).size,
sha256: sha256File(updater),
platformCodeSignatureReceipt: codeReceiptId,
notarizationReceipt: notarizationReceiptId,
},
},
hololake: {
features: input.features,
fixes: input.fixes,
compatibility: {
minimumVersion: input.minimumVersion,
dataMigrationRequired: input.dataMigrationRequired,
...(input.compatibilityNotes ? { notes: input.compatibilityNotes } : {}),
},
restart: { required: true, automaticAllowed: false, message: input.restartMessage },
rollback: { supported: true, healthReceiptRequired: true, previousVersion: input.previousVersion },
},
}
writeJson(path.join(outputRoot, 'latest.json'), broadcast)
writeJson(path.join(outputRoot, 'pipeline-receipt.json'), {
schema: 'hololake.signed-release-pipeline-receipt/v1',
state: 'SIGNED_NOTARIZED_RELEASE_BROADCAST_READY_FOR_JD_CONTROLLER_UPLOAD',
sourceCommit: input.sourceCommit,
releaseTag: input.releaseTag,
releaseEndpoint: trustFacts.endpoint.toString(),
broadcastSha256: sha256File(path.join(outputRoot, 'latest.json')),
automaticUpload: false,
automaticActivation: false,
})
process.stdout.write(`HOLOLAKE_RELEASE_PIPELINE_READY:${outputRoot}\n`)
}
export async function main(argv = process.argv.slice(2)) {
const inputArgument = argv[0]
if (!inputArgument) fail('HOLOLAKE_RELEASE_PIPELINE_INPUT_PATH_REQUIRED')
const command = argv[1] || 'release'
if (!['release', 'prepare-xcode', 'finalize-xcode'].includes(command)) {
fail('HOLOLAKE_RELEASE_PIPELINE_COMMAND_INVALID')
}
const inputPath = path.resolve(inputArgument)
const trust = readJson(path.join(root, 'src-tauri/release-trust.json'))
const trustFacts = validateReleaseTrust(trust)
const input = validateReleaseInput(readJson(inputPath), trustFacts)
const notarizationMode = validateCredentialEnvironment(process.env)
if (command !== 'release' && notarizationMode !== 'XCODE_ORGANIZER') {
fail('HOLOLAKE_RELEASE_PIPELINE_XCODE_ORGANIZER_FLOW_REQUIRED')
}
const credentialEnvironment = materializeUpdaterPrivateKey(process.env)
requireCleanImmutableSource(input)
if (process.platform !== 'darwin' || process.arch !== 'arm64') fail('HOLOLAKE_RELEASE_PIPELINE_BUILD_HOST_MISMATCH')
if (!process.env.APPLE_SIGNING_IDENTITY.includes(input.appleTeamIdentifier)) fail('HOLOLAKE_RELEASE_PIPELINE_SIGNING_TEAM_MISMATCH')
if (command === 'finalize-xcode') {
const application = resolveNotarizedApplication(argv[2], input)
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-finalize-'))
try {
const { updater, signature } = buildUpdaterFromNotarizedApp(application, temporary, credentialEnvironment)
run('cargo', [
'run', '--quiet', '--manifest-path', 'src-tauri/Cargo.toml', '--example', 'verify_updater_signature', '--',
'src-tauri/release-trust.json', updater, signature,
])
const dmg = buildDmgFromNotarizedApp(application, temporary, input)
writeReleaseOutput(input, trustFacts, application, updater, signature, dmg, { requireNotarizedDmg: false })
} finally {
fs.rmSync(temporary, { recursive: true, force: true })
}
return
}
run('npm', ['test'])
run('cargo', ['clippy', '--manifest-path', 'src-tauri/Cargo.toml', '--all-targets', '--all-features', '--', '-D', 'warnings'])
run('cargo', ['test', '--manifest-path', 'src-tauri/Cargo.toml', '--all-targets', '--all-features'])
const buildConfig = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-config-')), 'tauri.release.json')
fs.writeFileSync(buildConfig, `${JSON.stringify({ bundle: { createUpdaterArtifacts: true } })}\n`, { mode: 0o600 })
try {
run('npm', ['run', 'tauri', '--', 'build', '--ci', '--config', buildConfig], {
env: credentialEnvironment,
})
} finally {
fs.rmSync(path.dirname(buildConfig), { recursive: true, force: true })
}
const bundleRoot = path.join(root, 'src-tauri/target/release/bundle')
const appExecutable = findOne(path.join(bundleRoot, 'macos'), (file) => file.endsWith('/Contents/MacOS/hololake-native-desktop'), 'HOLOLAKE_RELEASE_PIPELINE_APP_NOT_UNIQUE')
const application = path.resolve(appExecutable, '../../..')
const dmg = findOne(path.join(bundleRoot, 'dmg'), (file) => file.endsWith('.dmg'), 'HOLOLAKE_RELEASE_PIPELINE_DMG_NOT_UNIQUE')
const updater = findOne(bundleRoot, (file) => file.endsWith('.app.tar.gz'), 'HOLOLAKE_RELEASE_PIPELINE_UPDATER_NOT_UNIQUE')
const updaterSignature = `${updater}.sig`
if (!fs.statSync(updaterSignature, { throwIfNoEntry: false })?.isFile()) fail('HOLOLAKE_RELEASE_PIPELINE_UPDATER_SIGNATURE_MISSING')
run('cargo', [
'run',
'--quiet',
'--manifest-path',
'src-tauri/Cargo.toml',
'--example',
'verify_updater_signature',
'--',
'src-tauri/release-trust.json',
updater,
updaterSignature,
])
if (command === 'prepare-xcode') {
run('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=4', application])
const archive = createXcodeArchive(application, input)
process.stdout.write(`HOLOLAKE_RELEASE_XCODE_ARCHIVE_READY:${archive}\n`)
return
}
if (decodeURIComponent(input.packageUrl.pathname.split('/').pop()) !== path.basename(updater)) {
fail('HOLOLAKE_RELEASE_PIPELINE_PACKAGE_URL_FILENAME_MISMATCH')
}
writeReleaseOutput(input, trustFacts, application, updater, updaterSignature, dmg, { requireNotarizedDmg: true })
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`)
process.exitCode = 1
})
}