feat: package atomic reversible nginx releases
This commit is contained in:
88
scripts/build-release.mjs
Executable file
88
scripts/build-release.mjs
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { canonicalValueBytes } from '../src/security/canonical-digest.ts';
|
||||
import {
|
||||
createDeterministicTar, enumerateRegularFiles, packageIndexFor, parseDeterministicTar, validatePackageFiles,
|
||||
verifySignedRelease, writeArchiveFiles,
|
||||
} from './deploy/lib/release-common.mjs';
|
||||
|
||||
function parseArgs(args) {
|
||||
const allowed = new Set(['dist', 'signed-manifest', 'sbom', 'output', 'verifier-module', 'trust-policy', 'protected-state', 'now']);
|
||||
const values = {};
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const token = args[index];
|
||||
const value = args[index + 1];
|
||||
if (typeof token !== 'string' || !token.startsWith('--') || typeof value !== 'string' || value.length === 0) throw new Error('Release package arguments must be complete name/value pairs.');
|
||||
const name = token.slice(2);
|
||||
if (!allowed.has(name) || Object.hasOwn(values, name)) throw new Error(`Unknown or duplicate release package argument: ${token}`);
|
||||
values[name] = value;
|
||||
}
|
||||
if ([...allowed].some((name) => !Object.hasOwn(values, name))) throw new Error('Release package arguments are incomplete.');
|
||||
let protectedState;
|
||||
try { protectedState = JSON.parse(values['protected-state']); } catch { throw new Error('Protected state must be valid JSON.'); }
|
||||
return {
|
||||
distDir: resolve(values.dist), signedManifestPath: resolve(values['signed-manifest']), sbomPath: resolve(values.sbom),
|
||||
outputPath: resolve(values.output), verifierModulePath: resolve(values['verifier-module']), trustPolicyPath: resolve(values['trust-policy']),
|
||||
protectedState, now: values.now,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildReleasePackage(input) {
|
||||
const outputInfo = await lstat(input.outputPath).catch((error) => error?.code === 'ENOENT' ? null : Promise.reject(error));
|
||||
if (outputInfo !== null) throw new Error('Release archive output already exists.');
|
||||
const verified = await verifySignedRelease(input);
|
||||
const { admission, manifest, signedReleaseBytes } = verified;
|
||||
const sourceFiles = await enumerateRegularFiles(input.distDir);
|
||||
const actualPaths = sourceFiles.map((file) => file.publicPath);
|
||||
const expectedPaths = [...manifest.deployFiles].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
||||
if (actualPaths.length !== expectedPaths.length || actualPaths.some((path, index) => path !== expectedPaths[index])) {
|
||||
throw new Error('dist files must exactly match the signed deploy closure.');
|
||||
}
|
||||
const sourceByPath = new Map(sourceFiles.map((file) => [file.publicPath, file.path]));
|
||||
const artifactFiles = [];
|
||||
for (const artifact of manifest.artifacts) {
|
||||
if (artifact.path.startsWith('.release/')) throw new Error('Signed deploy closure uses the reserved .release path.');
|
||||
const source = sourceByPath.get(artifact.path);
|
||||
if (source === undefined) throw new Error(`Signed artifact is missing: ${artifact.path}`);
|
||||
const bytes = await readFile(source);
|
||||
const digest = (await import('../src/security/canonical-digest.ts')).byteDigest(bytes);
|
||||
if (digest !== artifact.sha256 || bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
|
||||
artifactFiles.push({ path: artifact.path, bytes });
|
||||
}
|
||||
const sbomBytes = await readFile(input.sbomPath);
|
||||
const index = packageIndexFor({ admission, manifest, signedBytes: signedReleaseBytes, sbomBytes });
|
||||
const archiveBytes = createDeterministicTar([
|
||||
...artifactFiles,
|
||||
{ path: '.release/package-index.json', bytes: canonicalValueBytes(index) },
|
||||
{ path: '.release/sbom.cdx.json', bytes: sbomBytes },
|
||||
{ path: '.release/signed-release-manifest.json', bytes: signedReleaseBytes },
|
||||
]);
|
||||
const temporary = `${input.outputPath}.partial-${process.pid}`;
|
||||
await mkdir(dirname(input.outputPath), { recursive: true });
|
||||
await writeFile(temporary, archiveBytes, { flag: 'wx', mode: 0o400 });
|
||||
const verifyRoot = `${input.outputPath}.verify-${process.pid}`;
|
||||
try {
|
||||
const parsed = parseDeterministicTar(await readFile(temporary));
|
||||
validatePackageFiles(parsed);
|
||||
await writeArchiveFiles(parsed, verifyRoot);
|
||||
const extracted = await enumerateRegularFiles(verifyRoot);
|
||||
if (extracted.length !== parsed.length) throw new Error('Extracted release archive closure mismatch.');
|
||||
await rename(temporary, input.outputPath);
|
||||
} finally {
|
||||
await rm(verifyRoot, { recursive: true, force: true });
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
return { releaseId: index.releaseId, manifestDigest: admission.manifestDigest, archivePath: input.outputPath };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await buildReleasePackage(parseArgs(process.argv.slice(2)));
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => { process.stderr.write(`Release packaging failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
||||
}
|
||||
Reference in New Issue
Block a user