diff --git a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md index 4c30797fa..3b757ccb8 100644 --- a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md @@ -90,7 +90,7 @@ HoloLake 0.3.0 includes a live, read-only projection of the PNCC resident runtim ## 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. A protected updater-key path is materialized only into the child build environment; the key is never printed or copied into source. The pipeline runs all product and Rust gates, creates updater artifacts through a temporary Tauri override, verifies the updater signature against the embedded product trust, then requires strict code-signature verification, Gatekeeper acceptance and stapled Apple notarization before writing the HoloLake broadcast and receipts. +`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 plus Tauri updater-signing material are supplied at runtime. Apple notarization can run either through Tauri's Apple ID/API credential flow, or through the two-step Xcode Organizer flow already owned by the local Apple developer account: append `prepare-xcode` to build, verify the updater signature, and create a source-hash-bound `.xcarchive`; after Xcode reports `Ready to distribute`, export the notarized app and append `finalize-xcode ` to bind the exported executable back to that archive, require the app's stapled ticket and Gatekeeper acceptance, regenerate and sign the updater archive, create a Developer ID-signed DMG containing that notarized app, and write the release broadcast and receipts. A protected updater-key path is materialized only into child processes; neither the private key nor its password is printed or copied into source. The Xcode flow does not claim that the outer DMG itself has an Apple ticket unless its own Gatekeeper and stapler checks pass. 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/scripts/release-pipeline.mjs b/product-source/hololake-native-desktop/scripts/release-pipeline.mjs index af53c89e0..7a9b8ac89 100644 --- a/product-source/hololake-native-desktop/scripts/release-pipeline.mjs +++ b/product-source/hololake-native-desktop/scripts/release-pipeline.mjs @@ -102,8 +102,10 @@ export function validateCredentialEnvironment(env) { } 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') + 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')) { @@ -126,13 +128,25 @@ export function materializeUpdaterPrivateKey(env, readText = (file) => fs.readFi function run(program, args, options = {}) { return execFileSync(program, args, { - cwd: root, + 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 @@ -183,18 +197,28 @@ function codeSignatureEvidence(appPath, input) { 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) { - run('/usr/sbin/spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=4', dmgPath]) - run('/usr/bin/xcrun', ['stapler', 'validate', dmgPath]) +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: 'APPLE_NOTARIZATION_ACCEPTED_AND_STAPLED', + 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, } @@ -204,58 +228,107 @@ 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) - 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') +function localDateDirectory() { + const date = new Date() + const part = (value) => String(value).padStart(2, '0') + return `${date.getFullYear()}-${part(date.getMonth() + 1)}-${part(date.getDate())}` +} - 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']) +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', new Date().toISOString()) + add('Name', 'string', `HoloLake ${input.version} ${input.sourceCommit.slice(0, 12)}`) + add('SchemeName', 'string', 'HoloLake') + add('HoloLakeSourceCommit', 'string', input.sourceCommit) + add('HoloLakeExecutableSha256', 'string', sha256File(path.join(application, 'Contents/MacOS/hololake-native-desktop'))) + 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 +} - 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 }) +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] +} - 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, - ]) +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 expectedExecutableSha256 = run('/usr/libexec/PlistBuddy', [ + '-c', 'Print :HoloLakeExecutableSha256', path.join(archive, 'Info.plist'), + ], { capture: true }).trim() + const actualExecutableSha256 = sha256File(path.join(application, 'Contents/MacOS/hololake-native-desktop')) + if (actualExecutableSha256 !== expectedExecutableSha256) { + 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) + 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 }) @@ -309,6 +382,90 @@ export async function main(argv = process.argv.slice(2)) { 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`) diff --git a/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs b/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs index f2b386a03..0ae839dc0 100644 --- a/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs +++ b/product-source/hololake-native-desktop/scripts/release-pipeline.test.mjs @@ -75,6 +75,16 @@ test('release pipeline requires updater signing, Developer ID and Apple notariza APPLE_PASSWORD: 'provided-at-runtime', APPLE_TEAM_ID: 'TEAM', })) + assert.equal(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_XCODE_ORGANIZER_FLOW: '1', + }), 'XCODE_ORGANIZER') + assert.throws(() => validateCredentialEnvironment({ + APPLE_SIGNING_IDENTITY: 'Developer ID Application: HoloLake (TEAM)', + APPLE_XCODE_ORGANIZER_FLOW: '1', + }), /TAURI_SIGNING_PRIVATE_KEY_OR_PATH/) }) test('release pipeline materializes a protected updater key path only inside the build environment', () => { @@ -94,6 +104,9 @@ test('release pipeline verifies the updater signature against embedded product t const source = readFileSync(new URL('./release-pipeline.mjs', import.meta.url), 'utf8') assert.match(source, /--example',\s*'verify_updater_signature'/) assert.match(source, /src-tauri\/release-trust\.json/) + assert.match(source, /HoloLakeExecutableSha256/) + assert.match(source, /NOTARIZED_APP_SOURCE_BINARY_MISMATCH/) + assert.match(source, /APPLE_APP_NOTARIZATION_ACCEPTED_AND_STAPLED_DMG_CONTAINS_NOTARIZED_APP/) }) test('Windows updater keeps signed installation but never claims the macOS rollback boundary', () => {