feat(hololake): add gated signed release pipeline

This commit is contained in:
冰朔 2026-08-13 15:10:25 +08:00
commit 4bb8e7459f
8 changed files with 372 additions and 0 deletions

View file

@ -6,6 +6,8 @@
/vite.config.js
/vite.config.d.ts
/work/
/release/out/
/release/inputs/
*.key
*.p12
*.pfx

View file

@ -31,3 +31,9 @@ No upstream product endpoint is inherited. When the embedded release trust is un
Before an update replaces the application, the runtime verifies and keeps one bounded last-known-good application bundle with its bundle identifier, Team ID and CDHash. The next startup requires a human health confirmation; until that decision, another update is blocked. A human may restore the verified previous bundle without automatic restart, and cleanup is confined to HoloLake-owned recovery paths and the exact current application parent.
The rollback executor is implemented, but production updater activation remains blocked until the JD controller publishes the exact trust endpoint and public key, the signed release pipeline is evidenced, and the public macOS build is Apple-notarized.
## Release pipeline
`npm run release:macos -- release/inputs/<version>.json` is the only product-owned macOS release entry. It fails before building unless the embedded trust contains the exact registered HoloLake HTTPS endpoint and updater public key, the immutable `v<version>` tag equals the clean `main` head, and the Developer ID, Tauri updater-signing and Apple notarization credential sets are supplied at runtime. The pipeline runs all product and Rust gates, creates updater artifacts through a temporary Tauri override, then requires strict code-signature verification, Gatekeeper acceptance and stapled Apple notarization before writing the HoloLake broadcast and receipts.
Generated packages, private release inputs and credentials are not committed. The pipeline never uploads or activates a release; its terminal artifact is a bounded folder ready for a separately authorized JD-controller upload and server-owned readback receipt.

View file

@ -19,6 +19,8 @@
"release_candidate_human_confirmation_runtime_implemented": true,
"release_package_signature_size_sha256_verification_implemented": true,
"release_persistent_rollback_executor_implemented": true,
"release_signed_notarized_pipeline_implemented": true,
"release_pipeline_automatic_upload_allowed": false,
"release_production_activation_state": "BLOCKED_PENDING_JD_TRUST_SIGNED_PIPELINE_AND_APPLE_NOTARIZATION",
"tauri_update_artifacts_enabled": false,
"tauri_update_artifacts_enablement_gate": "JD_CONTROLLER_PUBLIC_KEY_AND_SIGNED_RELEASE_PIPELINE_REQUIRED",

View file

@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"test": "node --test scripts/*.test.mjs",
"release:macos": "node scripts/release-pipeline.mjs",
"tauri": "tauri"
},
"dependencies": {

View file

@ -0,0 +1,21 @@
{
"schema": "hololake.release-pipeline-input/v1",
"releaseId": "GH-HOLOLAKE-RELEASE-0.1.0",
"version": "0.1.0",
"previousVersion": "0.0.0",
"minimumVersion": "0.0.0",
"releaseTag": "v0.1.0",
"sourceCommit": "0000000000000000000000000000000000000000",
"platformCode": "darwin-aarch64",
"packageUrl": "https://REPLACE_WITH_REGISTERED_HOLOLAKE_HOST/releases/0.1.0/HoloLake.app.tar.gz",
"appleTeamIdentifier": "REPLACE_WITH_REGISTERED_APPLE_TEAM",
"notes": "HoloLake 第一阶段正式发布说明",
"features": [
"个人频道与外部编程 AI 本机直连",
"只读代码通道、动态节点路由与人类确认更新"
],
"fixes": [],
"dataMigrationRequired": false,
"compatibilityNotes": "首次安装不迁移旧 HoloLake 应用数据。",
"restartMessage": "安装完成后,由你手动重新打开 HoloLake。"
}

View file

@ -44,6 +44,8 @@ test('release activation remains explicitly human controlled', () => {
assert.equal(foundation.release_candidate_human_confirmation_runtime_implemented, true)
assert.equal(foundation.release_package_signature_size_sha256_verification_implemented, true)
assert.equal(foundation.release_persistent_rollback_executor_implemented, true)
assert.equal(foundation.release_signed_notarized_pipeline_implemented, true)
assert.equal(foundation.release_pipeline_automatic_upload_allowed, false)
assert.equal(
foundation.release_production_activation_state,
'BLOCKED_PENDING_JD_TRUST_SIGNED_PIPELINE_AND_APPLE_NOTARIZATION',

View file

@ -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
})
}

View file

@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
validateCredentialEnvironment,
validateReleaseInput,
validateReleaseTrust,
} from './release-pipeline.mjs'
const readyTrust = () => ({
schema: 'hololake.release-trust/v1',
state: 'PROVISIONED',
endpoints: ['https://release.guanghu.test/latest.json'],
publicKey: 'A'.repeat(64),
allowedReleaseHosts: ['release.guanghu.test'],
automaticCheckOnStartup: false,
automaticDownload: false,
humanOptInInstallRequired: true,
automaticRestart: false,
})
const readyInput = () => ({
schema: 'hololake.release-pipeline-input/v1',
releaseId: 'GH-HOLOLAKE-RELEASE-0.2.0',
version: '0.2.0',
previousVersion: '0.1.0',
minimumVersion: '0.1.0',
releaseTag: 'v0.2.0',
sourceCommit: 'a'.repeat(40),
platformCode: 'darwin-aarch64',
packageUrl: 'https://release.guanghu.test/releases/0.2.0/HoloLake.app.tar.gz',
appleTeamIdentifier: '825A9L3G7Q',
notes: 'Signed release',
features: ['Persistent direct connection'],
fixes: [],
dataMigrationRequired: false,
restartMessage: 'Restart manually',
})
test('release pipeline refuses an unprovisioned or upstream-owned trust document', () => {
const unprovisioned = readyTrust()
unprovisioned.state = 'UNPROVISIONED_FAIL_CLOSED'
assert.throws(() => validateReleaseTrust(unprovisioned), /TRUST_UNPROVISIONED/)
const upstream = readyTrust()
upstream.endpoints = ['https://updates.vendor.test/latest.json']
assert.throws(() => validateReleaseTrust(upstream), /ENDPOINT_NOT_HOLOLAKE_HTTPS/)
})
test('release package must use the exact registered HoloLake HTTPS host and immutable tag', () => {
const trust = validateReleaseTrust(readyTrust())
const wrongHost = readyInput()
wrongHost.packageUrl = 'https://github.com/example/HoloLake.app.tar.gz'
assert.throws(() => validateReleaseInput(wrongHost, trust), /PACKAGE_HOST_NOT_TRUSTED/)
const wrongTag = readyInput()
wrongTag.releaseTag = 'latest'
assert.throws(() => validateReleaseInput(wrongTag, trust), /IMMUTABLE_TAG_INVALID/)
assert.equal(validateReleaseInput(readyInput(), trust).version, '0.2.0')
})
test('release pipeline requires updater signing, Developer ID and Apple notarization credentials together', () => {
assert.throws(() => validateCredentialEnvironment({}), /CREDENTIALS_MISSING/)
assert.doesNotThrow(() => validateCredentialEnvironment({
APPLE_SIGNING_IDENTITY: 'Developer ID Application: HoloLake (TEAM)',
TAURI_SIGNING_PRIVATE_KEY: 'runtime-secret-material',
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: 'provided-at-runtime',
APPLE_ID: 'release@example.test',
APPLE_PASSWORD: 'provided-at-runtime',
APPLE_TEAM_ID: 'TEAM',
}))
})