72 lines
4.4 KiB
JavaScript
72 lines
4.4 KiB
JavaScript
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))
|
|
}
|