#!/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 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) { 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) { 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 if (!appleIdFlow && !appleApiFlow) missing.push('APPLE_NOTARIZATION_CREDENTIAL_SET') if (missing.length) fail(`HOLOLAKE_RELEASE_PIPELINE_CREDENTIALS_MISSING:${[...new Set(missing)].sort().join(',')}`) } function run(program, args, options = {}) { return execFileSync(program, args, { cwd: root, encoding: 'utf8', stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', env: process.env, }) } 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'), sourceCommit: input.sourceCommit, } } function notarizationEvidence(appPath, dmgPath, input) { run('/usr/sbin/spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=4', dmgPath]) run('/usr/bin/xcrun', ['stapler', 'validate', dmgPath]) return { schema: 'hololake.apple-notarization-receipt/v1', state: 'APPLE_NOTARIZATION_ACCEPTED_AND_STAPLED', appName: path.basename(appPath), dmgName: path.basename(dmgPath), 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' }) } export async function main(argv = process.argv.slice(2)) { const inputArgument = argv[0] if (!inputArgument) fail('HOLOLAKE_RELEASE_PIPELINE_INPUT_PATH_REQUIRED') 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) validateCredentialEnvironment(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') 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]) } 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') 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) 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`) } 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 }) }