Files
Web_FreeCAD_Bitbybit/scripts/generate-release-artifacts.mjs

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))
}