diff --git a/product-source/hololake-native-desktop/.gitignore b/product-source/hololake-native-desktop/.gitignore index e5861af..de256ed 100644 --- a/product-source/hololake-native-desktop/.gitignore +++ b/product-source/hololake-native-desktop/.gitignore @@ -6,6 +6,8 @@ /vite.config.js /vite.config.d.ts /work/ +/release/out/ +/release/inputs/ *.key *.p12 *.pfx diff --git a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md index a754928..aacf6f4 100644 --- a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md @@ -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/.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` 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. diff --git a/product-source/hololake-native-desktop/foundation.json b/product-source/hololake-native-desktop/foundation.json index d53ec05..997e3ef 100644 --- a/product-source/hololake-native-desktop/foundation.json +++ b/product-source/hololake-native-desktop/foundation.json @@ -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", diff --git a/product-source/hololake-native-desktop/package.json b/product-source/hololake-native-desktop/package.json index 317f564..86ea575 100644 --- a/product-source/hololake-native-desktop/package.json +++ b/product-source/hololake-native-desktop/package.json @@ -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": { diff --git a/product-source/hololake-native-desktop/release/release-input.example.json b/product-source/hololake-native-desktop/release/release-input.example.json new file mode 100644 index 0000000..01a8abf --- /dev/null +++ b/product-source/hololake-native-desktop/release/release-input.example.json @@ -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。" +} diff --git a/product-source/hololake-native-desktop/scripts/foundation.test.mjs b/product-source/hololake-native-desktop/scripts/foundation.test.mjs index c73a49f..3604cbc 100644 --- a/product-source/hololake-native-desktop/scripts/foundation.test.mjs +++ b/product-source/hololake-native-desktop/scripts/foundation.test.mjs @@ -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', diff --git a/product-source/hololake-native-desktop/scripts/release-pipeline.mjs b/product-source/hololake-native-desktop/scripts/release-pipeline.mjs new file mode 100644 index 0000000..973ece0 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/release-pipeline.mjs @@ -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 + }) +} diff --git a/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs b/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs new file mode 100644 index 0000000..8fef6bf --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs @@ -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', + })) +})