Files
itworx-knowledge-hub-Public/scripts/generate-release-manifest.mjs
T

150 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
// Canonical, machine-readable release identity (docs/adr/ADR-029-v2-r5-final-hardening.md,
// brief Phase 9). Computes everything genuinely knowable at *build* time — source
// commit, OpenAPI contract hash, frontend build hash, semantic version, build
// timestamp — and writes release-manifest.json, which the Dockerfile copies into the
// image and the running application reads at startup to enrich GET /api/v1/system/info.
//
// Deliberately leaves deployment-time-only fields (image tag, image ID, immutable
// digest, deployment timestamp, database migration range as actually applied,
// inventory snapshot ID, backup ID) as null here — those are not knowable until the
// image has actually been built and deployed (the registry/daemon assigns a digest
// only after push; a migration range depends on which database the running instance
// is actually talking to). Per the brief: "Tag de nieuwe release pas nadat de
// daadwerkelijke productie-image ... is gebouwd en succesvol gedeployed" — this script
// never invents those values, and a real deployment record (docs/deployments/*.md)
// is the source of truth for them, cross-referenced from here only after the fact.
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
const repoRoot = process.cwd();
const outputPath = process.argv[2] ?? path.join(repoRoot, "release-manifest.json");
const semverIdentifier = "(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)";
const semverPattern = new RegExp(
`^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` +
`(?:-(${semverIdentifier}(?:\\.${semverIdentifier})*))?` +
`(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`
);
function sha256File(relativePath) {
const fullPath = path.join(repoRoot, relativePath);
if (!fs.existsSync(fullPath)) {
return null;
}
const hash = createHash("sha256");
hash.update(fs.readFileSync(fullPath));
return `sha256:${hash.digest("hex")}`;
}
// The built frontend bundle's content-hashed filenames already make every asset's own
// hash visible in its filename (Vite's default), but there is no single "the bundle"
// file — hash the whole dist/ directory's file list + each file's own bytes instead,
// so any output change (including a filename/hash change) changes this value too.
function sha256Directory(relativeDir) {
const fullDir = path.join(repoRoot, relativeDir);
if (!fs.existsSync(fullDir)) {
return null;
}
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
} else {
files.push(entryPath);
}
}
};
walk(fullDir);
files.sort();
const hash = createHash("sha256");
for (const file of files) {
hash.update(path.relative(fullDir, file).replace(/\\/g, "/"));
hash.update(fs.readFileSync(file));
}
return `sha256:${hash.digest("hex")}`;
}
function gitCommit() {
if (process.env.BUILD_COMMIT && process.env.BUILD_COMMIT !== "development") {
return process.env.BUILD_COMMIT;
}
const result = spawnSync("git", ["rev-parse", "--short=12", "HEAD"], {
cwd: repoRoot,
encoding: "utf8"
});
return result.status === 0 ? result.stdout.trim() : "unknown";
}
function readOpenApiVersion() {
try {
const contents = fs.readFileSync(path.join(repoRoot, "docs/api/openapi.yaml"), "utf8");
const match = contents.match(/^\s*version:\s*(.+)$/m);
return match ? match[1].trim() : null;
} catch {
return null;
}
}
function readSemanticVersion() {
const releaseTag = nonEmpty(process.env.RELEASE_TAG);
if (releaseTag) {
const version = releaseTag.startsWith("v") ? releaseTag.slice(1) : "";
if (!version || !semverPattern.test(version)) {
throw new Error(`RELEASE_TAG must be a v-prefixed semantic version; received '${releaseTag}'.`);
}
// Docker's web-build stage intentionally has no .git directory. A validated
// release tag is therefore the authoritative semantic version for tagged
// images, while the full tag remains separately recorded as releaseTag.
return version;
}
const versionFile = path.join(repoRoot, "VERSION");
if (fs.existsSync(versionFile)) {
return fs.readFileSync(versionFile, "utf8").trim();
}
// Falls back to the most recent annotated/lightweight tag reachable from HEAD, the
// same source `git describe` uses — never invents a version number.
const described = spawnSync("git", ["describe", "--tags", "--always"], {
cwd: repoRoot,
encoding: "utf8"
});
return described.status === 0 ? described.stdout.trim() : null;
}
// An empty string (as opposed to an unset variable — Docker build args with no
// override are still passed as "") means "not actually known" just as much as
// unset does; never record it as a real, empty-but-present value.
const nonEmpty = (value) => (value && value.trim().length > 0 ? value.trim() : null);
const manifest = {
schemaVersion: 1,
semanticVersion: readSemanticVersion(),
releaseTag: nonEmpty(process.env.RELEASE_TAG),
sourceCommit: gitCommit(),
imageRepository: process.env.IMAGE_REPOSITORY ?? "itworx/knowledge-hub",
imageTag: nonEmpty(process.env.IMAGE_TAG),
imageId: null,
imageDigest: null,
buildTimestamp: new Date().toISOString(),
deploymentTimestamp: null,
environment: process.env.BUILD_ENVIRONMENT ?? "production",
openApiVersion: readOpenApiVersion(),
openApiHash: sha256File("docs/api/openapi.yaml"),
frontendBuildHash: sha256Directory("src/frontend/dist"),
databaseMigrationRange: null,
inventorySnapshotId: null,
backupId: null
};
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
console.log(`Wrote release manifest to ${outputPath}:`);
console.log(JSON.stringify(manifest, null, 2));