installed-update-packaging.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /** Supervise one explicitly confirmed qualification build; checks never launch a child or clear a signing interlock. */
  2. import { createHash } from 'node:crypto'
  3. import { execFileSync } from 'node:child_process'
  4. import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
  5. import { homedir } from 'node:os'
  6. import { join, resolve } from 'node:path'
  7. import { readInstalledUpdateRun } from './installed-update-qualification.ts'
  8. import { createInstalledUpdateBuilderConfig } from './installed-update-builder.ts'
  9. import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
  10. import { desktopElectronBuilderEnvironment } from './package-target.ts'
  11. import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
  12. import { planInstalledUpdateDistribution } from './installed-update-distribution.ts'
  13. const APP_ROOT = resolve(import.meta.dirname, '..')
  14. const REPOSITORY = resolve(APP_ROOT, '../..')
  15. const SIGNING_FIELDS = new Set(['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL',
  16. 'DSH_DESKTOP_WINDOWS_KEY_CONTAINER', 'DSH_DESKTOP_WINDOWS_TOKEN_PIN'])
  17. /** Public refusal without credential values or contents of the incident record. */
  18. export class InstalledUpdateSigningHoldError extends Error {
  19. constructor() {
  20. super('installed update: signing interlock exists; administrator-reviewed recovery is required; do not clear it or retry automatically')
  21. }
  22. }
  23. async function absent(path: string): Promise<boolean> {
  24. try { await lstat(path); return false }
  25. catch (error) {
  26. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
  27. throw error
  28. }
  29. }
  30. /**
  31. * Reject a retained or active hardware attempt without reading its contents or changing it.
  32. * @param stateFile Per-user signing interlock; an isolated file may be supplied by tests, not the CLI.
  33. * @returns Nothing when absent; an inaccessible or existing file rejects before credentials are loaded.
  34. */
  35. export async function assertInstalledUpdateSigningClear(stateFile = join(homedir(), '.dsh-desktop-signing', 'attempt.json')): Promise<void> {
  36. if (!await absent(stateFile)) throw new InstalledUpdateSigningHoldError()
  37. }
  38. /**
  39. * Restrict builder children to ordinary tool settings and the four file-owned signing inputs.
  40. * @param environment Loaded .env.windows settings, never raw log data.
  41. * @returns A new environment without upload/LLM credentials or Node preload overrides.
  42. */
  43. export function installedUpdatePackagingEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
  44. return desktopElectronBuilderEnvironment(Object.fromEntries(Object.entries(environment)
  45. .filter(([name]) => SIGNING_FIELDS.has(name) || !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(name))), false)
  46. }
  47. async function inputHashes(manifest: string, version: string, environment: NodeJS.ProcessEnv): Promise<object> {
  48. const run = await readInstalledUpdateRun(manifest)
  49. const paths = [manifest, join(run.root, 'application/result.json'), join(run.root, version, 'dsh/desktop-runtime.json'),
  50. join(REPOSITORY, 'pnpm-lock.yaml'), join(APP_ROOT, 'package.json'),
  51. ...['electron-builder-config.mjs', 'installed-update-builder.ts', 'build-installed-update-worker.mjs',
  52. 'windows-sign.mjs', 'windows-sign.cmd', 'windows-signing-state.mjs', 'windows-directory-installer.mjs',
  53. 'installer.nsh', 'prepare-windows-installer.ps1'].map(path => join(import.meta.dirname, path)),
  54. environment.DSH_DESKTOP_WINDOWS_CER_FILE!, environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!]
  55. const files = []
  56. for (const path of paths) {
  57. files.push({ path, sha256: createHash('sha256').update(await readFile(path)).digest('hex') })
  58. }
  59. const git = (args: string[]): string => execFileSync('git', args, { cwd: REPOSITORY, windowsHide: true, encoding: 'utf8' }).trim()
  60. return { files, sourceCommit: git(['rev-parse', 'HEAD']),
  61. dirtyFiles: git(['status', '--porcelain=v1', '--untracked-files=normal']).split('\n').filter(Boolean),
  62. dependenciesByteFrozen: false }
  63. }
  64. /**
  65. * Check, or after explicit confirmation build, exactly one version with retained fail-stop records.
  66. * @param manifest Existing test-only run manifest.
  67. * @param version One exact run version; an existing packaging attempt or installer refuses reuse.
  68. * @param options Execute selection and operator confirmation; the CLI has no noninteractive approval shortcut.
  69. * @returns Check/build observations only. A successful builder is not package verification or installation acceptance.
  70. */
  71. export async function packageInstalledUpdate(
  72. manifest: string, version: string, options: { execute: boolean; confirm: (id: string, version: string) => Promise<boolean> },
  73. ): Promise<object> {
  74. const run = await readInstalledUpdateRun(manifest)
  75. if (!run.versions.includes(version)) throw new Error('installed update: package version is outside the run')
  76. await assertInstalledUpdateSigningClear()
  77. const preparation = join(run.root, version, 'packaging')
  78. const output = join(run.root, version, 'installer')
  79. if (!await absent(preparation) || !await absent(output)) throw new Error('installed update: existing packaging attempt or output requires a new run')
  80. const environment = installedUpdatePackagingEnvironment({ ...loadDesktopPackageEnvironment('win32'),
  81. DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64' })
  82. await createInstalledUpdateBuilderConfig(manifest, version, environment)
  83. const before = await inputHashes(manifest, version, environment)
  84. if (!options.execute) return { mode: 'check', version, childLaunched: false, signed: false, publicationAuthorized: false }
  85. if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('installed update: execution requires Windows x64')
  86. if (!await options.confirm(run.id, version)) throw new Error('installed update: operator did not confirm this version')
  87. await assertInstalledUpdateSigningClear()
  88. await mkdir(preparation)
  89. const record = createPackagingRun(preparation, { mode: 'operator-authorized-single-version', id: run.id, version })
  90. console.log(`INSTALLED_UPDATE_PACKAGING_RECORD ${record.directory}`)
  91. let success = false
  92. try {
  93. await mkdir(output)
  94. await writeFile(join(record.directory, 'inputs.json'), `${JSON.stringify(before, null, 2)}\n`, { flag: 'wx', flush: true })
  95. if (JSON.stringify(await inputHashes(manifest, version, environment)) !== JSON.stringify(before)) {
  96. throw new Error('installed update: recorded source or tool inputs changed before packaging')
  97. }
  98. await record.run('signed-installer', process.execPath,
  99. ['--import', 'tsx', join(import.meta.dirname, 'build-installed-update-worker.mjs'), resolve(manifest), version],
  100. { cwd: REPOSITORY, env: environment, timeoutMs: 15 * 60_000 })
  101. await createInstalledUpdateBuilderConfig(manifest, version, environment)
  102. if (JSON.stringify(await inputHashes(manifest, version, environment)) !== JSON.stringify(before)) {
  103. throw new Error('installed update: recorded source or tool inputs changed during packaging')
  104. }
  105. const files = await planInstalledUpdateDistribution(manifest, version)
  106. await writeFile(join(record.directory, 'artifact-files.json'), `${JSON.stringify(files, null, 2)}\n`, { flag: 'wx', flush: true })
  107. const result = { version, builderCompleted: true, packageVerification: 'pending', installerExecuted: false, published: false }
  108. await writeFile(join(record.directory, 'builder-result.json'), `${JSON.stringify(result)}\n`, { flag: 'wx', flush: true })
  109. success = true
  110. return result
  111. } catch (error) {
  112. recordPackagingEvent(record.directory, { type: 'qualification-failed', retryAllowed: false })
  113. throw error
  114. } finally { record.finish(success) }
  115. }