132 lines
5.0 KiB
JavaScript
132 lines
5.0 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");
|
|
|
|
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 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));
|