feat(hololake): add gated signed release pipeline
This commit is contained in:
parent
339b562951
commit
4bb8e7459f
8 changed files with 372 additions and 0 deletions
|
|
@ -0,0 +1,266 @@
|
|||
#!/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
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue