import { createHash } from 'node:crypto' import { lstat, readFile, readdir, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { signManifest, verifyManifestSignature } from './release-signature.mjs' const root = resolve(new URL('..', import.meta.url).pathname) const distRoot = resolve(root, 'dist') const sbomPath = resolve(root, 'config/sbom.cdx.json') const outputPath = resolve(root, 'config/release-artifacts.json') const publicKeyPath = resolve(root, 'config/release-signing-public.pem') const checkOnly = process.argv.includes('--check') const hashFile = async (path) => { const hash = createHash('sha256') hash.update(await readFile(path)) return hash.digest('hex') } const collectFiles = async (directory, prefix = '') => { const entries = [] for (const entry of (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))) { const relative = prefix ? `${prefix}/${entry.name}` : entry.name const path = resolve(directory, entry.name) const stats = await lstat(path) if (stats.isSymbolicLink()) throw new Error(`Release artifact contains a symlink: dist/${relative}`) if (entry.isDirectory()) entries.push(...await collectFiles(path, relative)) else if (entry.isFile()) entries.push({ path: relative, bytes: stats.size, sha256: await hashFile(path) }) else throw new Error(`Release artifact is not a regular file: dist/${relative}`) } return entries } const files = await collectFiles(distRoot) if (!files.length) throw new Error('Release artifact directory dist is empty; run `npm run build` first.') const sbomSha256 = await hashFile(sbomPath) const packageManifest = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8')) const unsigned = { schemaVersion: 1, package: { name: packageManifest.name, version: packageManifest.version }, build: { output: 'dist', files }, sbom: { path: 'config/sbom.cdx.json', sha256: sbomSha256 }, } const signingKey = process.env.RELEASE_SIGNING_PRIVATE_KEY const signingKeyId = process.env.RELEASE_SIGNING_KEY_ID const signable = JSON.stringify(unsigned) let document if (checkOnly) { try { document = JSON.parse(await readFile(outputPath, 'utf8')) } catch { throw new Error(`Release artifact manifest is missing: ${outputPath}`) } const actualUnsigned = { schemaVersion: document.schemaVersion, package: document.package, build: document.build, sbom: document.sbom } if (JSON.stringify(actualUnsigned) !== signable) throw new Error('Release artifact manifest is stale; regenerate with the controlled signing key.') if (document.signature?.status !== 'signed' || document.signature.algorithm !== 'Ed25519') throw new Error('Release artifact manifest is unsigned; REL-03 requires a signed artifact.') let publicKey try { publicKey = await readFile(publicKeyPath, 'utf8') } catch { throw new Error(`Release signing public key is missing: ${publicKeyPath}`) } if (!verifyManifestSignature(signable, document.signature, publicKey)) throw new Error('Release artifact signature verification failed.') console.log(JSON.stringify({ status: 'release-artifacts-pass', files: files.length, totalBytes: files.reduce((sum, file) => sum + file.bytes, 0), sbomSha256, signature: document.signature.status, keyId: document.signature.keyId }, null, 2)) } else { if (signingKey || signingKeyId) document = { ...unsigned, signature: signManifest(signable, signingKey, signingKeyId) } else { try { const existing = JSON.parse(await readFile(outputPath, 'utf8')) const existingUnsigned = { schemaVersion: existing.schemaVersion, package: existing.package, build: existing.build, sbom: existing.sbom } const publicKey = await readFile(publicKeyPath, 'utf8') if (JSON.stringify(existingUnsigned) !== signable || !verifyManifestSignature(signable, existing.signature, publicKey)) throw new Error('existing signed manifest does not match current build') document = existing } catch (error) { throw new Error(`Release generation requires RELEASE_SIGNING_PRIVATE_KEY/RELEASE_SIGNING_KEY_ID for a new or changed build: ${error instanceof Error ? error.message : String(error)}`) } } const serialized = `${JSON.stringify(document, null, 2)}\n` await writeFile(outputPath, serialized) console.log(JSON.stringify({ status: 'release-artifacts-generated', output: outputPath, files: files.length, totalBytes: files.reduce((sum, file) => sum + file.bytes, 0), sbomSha256, signature: document.signature.status }, null, 2)) }