#!/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));