Publish curated Knowledge Hub source from 2ed833f290b439a7bc91e5fd19b042c269e3bee9

This commit is contained in:
Public Source Publisher committed 2026-10-03 17:07:21 +00:00
1 parent 10f2edcd29
commit 59f15b2d08
218 files changed
+23995 -1849

No files matched your search

+2 -36
View File
@@ -15,6 +15,7 @@ import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import yaml from "js-yaml";
import { extractRoutesFromSource } from "./lib/openapi-routes.mjs";
const endpointsDir = "src/backend/ItWorx.KnowledgeHub.Api/Endpoints";
const openApiPath = "docs/api/openapi.yaml";
@@ -32,42 +33,7 @@ function listEndpointFiles(dir) {
}
function extractRoutesFromFile(filePath) {
const source = fs.readFileSync(filePath, "utf8");
const routes = [];
// MapGroup("/api/v1/xyz") establishes a prefix for every Map* call chained off the
// returned variable within the same file — every endpoint file in this codebase uses
// exactly this one-group-per-file pattern (verified by inspection), so a single
// "most recent MapGroup literal in this file" tracking variable is sufficient and
// avoids needing a real C# parser.
const groupMatch = source.match(/MapGroup\(\s*"([^"]+)"\s*\)/);
const groupPrefix = groupMatch ? groupMatch[1] : "";
const mapCallPattern = /\.Map(Get|Post|Put|Delete|Patch)\(\s*"([^"]*)"/g;
let match;
while ((match = mapCallPattern.exec(source)) !== null) {
const method = match[1].toUpperCase();
const routeLiteral = match[2];
const fullPath = groupPrefix + routeLiteral;
routes.push({ method, path: normalizeRoute(fullPath), file: filePath });
}
return routes;
}
// Normalizes an ASP.NET route template's typed route constraints
// (`{attachmentId:guid}`) to OpenAPI's untyped `{attachmentId}` style, and strips the
// leading `/api/v1` prefix to match how docs/api/openapi.yaml keys its paths (relative
// to the documented `servers[0].url`).
function normalizeRoute(route) {
let normalized = route.replace(/\{([a-zA-Z0-9_]+)(:[a-zA-Z]+)?\}/g, "{$1}");
if (normalized.startsWith("/api/v1")) {
normalized = normalized.slice("/api/v1".length);
}
if (normalized === "") {
normalized = "/";
}
return normalized;
return extractRoutesFromSource(fs.readFileSync(filePath, "utf8"), filePath);
}
function isExcluded(routePath) {
+18
View File
@@ -24,6 +24,12 @@ 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);
@@ -88,6 +94,18 @@ function readOpenApiVersion() {
}
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();
+23
View File
@@ -0,0 +1,23 @@
/** Extract literal Minimal API routes and their receiver-specific MapGroup prefix. */
export function extractRoutesFromSource(source, file) {
const prefixes = new Map([["app", ""]]);
const groupPattern = /\b(?:var|const|RouteGroupBuilder)\s+(\w+)\s*=\s*(\w+)\.MapGroup\(\s*"([^"]+)"\s*\)/g;
for (const match of source.matchAll(groupPattern)) {
const parent = prefixes.get(match[2]);
if (parent === undefined) throw new Error(`Unknown MapGroup receiver ${match[2]} in ${file}`);
prefixes.set(match[1], parent + match[3]);
}
const routes = [];
const mapPattern = /\b(\w+)\.Map(Get|Post|Put|Delete|Patch)\(\s*"([^"]*)"/g;
for (const match of source.matchAll(mapPattern)) {
const prefix = prefixes.get(match[1]);
if (prefix === undefined) throw new Error(`Unknown route receiver ${match[1]} in ${file}`);
const path = (prefix + match[3]).replace(/\{([a-zA-Z0-9_]+)(:[a-zA-Z]+)?\}/g, "{$1}");
if (!path.startsWith("/api/v1")) {
if (!path.startsWith("/health")) throw new Error(`Unexpected non-API route ${path} in ${file}`);
continue;
}
routes.push({ method: match[2].toUpperCase(), path: path.slice("/api/v1".length) || "/", file });
}
return routes;
}
+50
View File
@@ -0,0 +1,50 @@
export function validateProductBacklog(backlog, plan) {
const errors = [];
if (backlog?.schemaVersion !== 2) errors.push("Product backlog schemaVersion must be 2.");
if (!Array.isArray(backlog?.statusValues)) errors.push("Product backlog statusValues must be an array.");
if (!Array.isArray(backlog?.items)) {
errors.push("Product backlog items must be an array.");
return errors;
}
const milestoneById = new Map((plan?.milestones ?? []).map((milestone) => [milestone.id, milestone]));
const itemIds = new Set();
const statuses = new Set(backlog.statusValues ?? []);
for (const item of backlog.items) {
if (!item || typeof item !== "object") {
errors.push("Product backlog contains an invalid item.");
continue;
}
if (typeof item.id !== "string" || !item.id || itemIds.has(item.id)) {
errors.push(`Product backlog item has a missing or duplicate ID: ${item.id ?? "(missing)"}.`);
}
itemIds.add(item.id);
if (!statuses.has(item.status)) errors.push(`${item.id} has an invalid status: ${item.status}.`);
const milestone = milestoneById.get(item.milestone);
if (!milestone) {
errors.push(`${item.id} references an unknown milestone: ${item.milestone}.`);
continue;
}
if (item.status === "complete" && (!Array.isArray(item.evidence) || item.evidence.length === 0)) {
errors.push(`${item.id} is complete without evidence references.`);
}
if (item.status === "todo" && milestone.status === "complete" && item.postMilestone !== true) {
errors.push(`${item.id} is todo in a completed milestone without an explicit postMilestone marker.`);
}
if (item.status === "partial" && (!item.notes || !Array.isArray(item.evidence) || item.evidence.length === 0)) {
errors.push(`${item.id} is partial without notes and evidence references.`);
}
if (item.status === "blocked_external") {
const acceptance = milestone.acceptance?.find((criterion) => criterion.id === item.acceptance);
if (!item.externalOwner || !acceptance?.external || acceptance.met !== false) {
errors.push(`${item.id} must reference an unmet external acceptance criterion and owner.`);
}
}
if (item.status === "complete" && milestone.status !== "complete") {
const acceptance = milestone.acceptance?.find((criterion) => criterion.id === item.acceptance);
if (!acceptance || acceptance.met !== true) {
errors.push(`${item.id} completes an unfinished milestone without a met acceptance criterion.`);
}
}
}
return errors;
}
+8
View File
@@ -0,0 +1,8 @@
import { commandExists } from "./exec.mjs";
export function requireReleaseTool(command, exists = commandExists) {
if (!exists(command)) {
throw new Error(`${command} is required for the release gate but was not found on PATH.`);
}
return command;
}
+234
View File
@@ -0,0 +1,234 @@
function skipQuoted(source, start) {
const quote = source[start];
let escaped = false;
for (let index = start + 1; index < source.length; index += 1) {
const character = source[index];
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === quote) {
return index + 1;
}
}
return source.length;
}
function readRouteTag(source, start) {
const closing = source[start + 1] === "/";
const nameStart = start + (closing ? 2 : 1);
const nameMatch = /^[A-Za-z_$][\w$.-]*/u.exec(source.slice(nameStart));
if (!nameMatch || nameMatch[0] !== "Route") return null;
let curlyDepth = 0;
let quote = null;
let escaped = false;
for (let index = nameStart + nameMatch[0].length; index < source.length; index += 1) {
const character = source[index];
if (quote !== null) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = null;
continue;
}
if (character === "\"" || character === "'" || character === "`") {
quote = character;
} else if (character === "{") {
curlyDepth += 1;
} else if (character === "}" && curlyDepth > 0) {
curlyDepth -= 1;
} else if (character === ">" && curlyDepth === 0) {
const text = source.slice(nameStart + nameMatch[0].length, index);
return {
closing,
selfClosing: !closing && /\/\s*$/u.test(text),
attributes: closing ? "" : text.replace(/\/\s*$/u, ""),
end: index + 1
};
}
}
return { error: "AuthenticatedRoutes.tsx contains an unterminated <Route> tag." };
}
function findStaticPath(attributes) {
const pathAttribute = /(?:^|\s)path\s*=\s*(?:"([^"]*)"|'([^']*)')/u.exec(attributes);
if (pathAttribute) return { value: pathAttribute[1] ?? pathAttribute[2] };
if (/(?:^|\s)path\s*=\s*\{/u.test(attributes)) return { error: "dynamic" };
return null;
}
function routePath(parentPath, childPath) {
if (childPath === "*") return parentPath ? `${parentPath.replace(/\/$/u, "")}/*` : "*";
if (childPath.startsWith("/")) return childPath.replace(/\/{2,}/gu, "/");
const joined = `${parentPath || ""}/${childPath}`.replace(/\/{2,}/gu, "/");
return joined || "/";
}
function routeFeatures(attributes) {
const features = new Set();
for (const match of attributes.matchAll(/\bfeatures\s*\?\.\s*([A-Za-z_$][\w$]*)/gu)) {
features.add(match[1]);
}
return [...features].sort();
}
function extractRouteStructure(source) {
const errors = [];
const routeStack = [];
const records = [];
for (let index = 0; index < source.length;) {
if (source.startsWith("//", index)) {
const newline = source.indexOf("\n", index + 2);
index = newline < 0 ? source.length : newline + 1;
continue;
}
if (source.startsWith("/*", index)) {
const commentEnd = source.indexOf("*/", index + 2);
index = commentEnd < 0 ? source.length : commentEnd + 2;
continue;
}
if (source[index] === "\"" || source[index] === "'" || source[index] === "`") {
index = skipQuoted(source, index);
continue;
}
if (source[index] === "<" && (source[index + 1] === "/" || /[A-Za-z_$]/u.test(source[index + 1] ?? ""))) {
const tag = readRouteTag(source, index);
if (tag?.error) {
errors.push(tag.error);
break;
}
if (tag) {
if (tag.closing) {
if (routeStack.length === 0) errors.push("AuthenticatedRoutes.tsx closes a <Route> without a matching opening tag.");
else routeStack.pop();
} else {
const parent = routeStack.at(-1);
const parentPath = parent?.path ?? "";
const pathAttribute = findStaticPath(tag.attributes);
const hasIndex = /(?:^|\s)index(?:\s|=|$)/u.test(tag.attributes);
const localFeatures = routeFeatures(tag.attributes);
const features = [...new Set([...(parent?.features ?? []), ...localFeatures])].sort();
const administrator = Boolean(parent?.administrator)
|| /\bsession\.role\s*===?\s*["']Administrator["']/u.test(tag.attributes);
if (pathAttribute?.error === "dynamic") {
errors.push("AuthenticatedRoutes.tsx uses a dynamic Route path; route-map validation requires a static path.");
}
if (hasIndex && pathAttribute) errors.push("AuthenticatedRoutes.tsx declares a Route with both index and path.");
let fullPath = null;
if (hasIndex) fullPath = parentPath || "/";
else if (pathAttribute && !pathAttribute.error) fullPath = routePath(parentPath, pathAttribute.value);
if (fullPath !== null) {
records.push({
path: fullPath,
featureGates: features,
auth: administrator ? "administrator" : "authenticated",
depth: routeStack.length,
parentPath
});
}
if (!tag.selfClosing) {
routeStack.push({
path: fullPath ?? parentPath,
features,
administrator
});
}
}
index = tag.end;
continue;
}
}
index += 1;
}
if (routeStack.length > 0) errors.push("AuthenticatedRoutes.tsx contains an unclosed <Route> tag.");
const uniqueRecords = [];
for (const record of records) {
const priorIndex = uniqueRecords.findIndex((candidate) => candidate.path === record.path);
if (priorIndex < 0) {
uniqueRecords.push(record);
continue;
}
const prior = uniqueRecords[priorIndex];
const isNestedSamePath = record.parentPath === record.path && record.depth > prior.depth;
if (isNestedSamePath) {
uniqueRecords[priorIndex] = record;
} else {
errors.push(`AuthenticatedRoutes.tsx declares duplicate route path ${record.path}.`);
}
}
return { records: uniqueRecords, errors };
}
export function extractAuthenticatedRoutes(source) {
return extractRouteStructure(source).records;
}
export function extractAuthenticatedRoutePaths(source) {
return extractAuthenticatedRoutes(source).map((route) => route.path);
}
function mappedFeatureGates(route) {
if (Array.isArray(route?.featureGates)) return [...route.featureGates].sort();
return typeof route?.featureGate === "string" ? [route.featureGate] : [];
}
export function validateRouteMap(routeMap, authenticatedSource, sessionSource) {
const errors = [];
const { records, errors: routeErrors } = extractRouteStructure(authenticatedSource);
errors.push(...routeErrors);
const actualPaths = records.map((route) => route.path);
const mappedRoutes = Array.isArray(routeMap?.routes) ? routeMap.routes : [];
const mappedPaths = mappedRoutes.map((route) => route?.path);
if (new Set(mappedPaths).size !== mappedPaths.length) {
errors.push("product/route-map.json declares duplicate paths.");
}
const missing = actualPaths.filter((routePath) => !mappedPaths.includes(routePath));
const stale = mappedPaths.filter((routePath) => !actualPaths.includes(routePath));
if (missing.length > 0) errors.push(`Route map is missing: ${missing.join(", ")}.`);
if (stale.length > 0) errors.push(`Route map has stale entries: ${stale.join(", ")}.`);
if (routeMap?.sourceOfTruth !== "src/frontend/src/router/AuthenticatedRoutes.tsx") {
errors.push("Route map sourceOfTruth must identify AuthenticatedRoutes.tsx.");
}
const states = routeMap?.sessionStates;
const expectedStates = ["loading", "error", "setupRequired", "unauthenticated"];
if (!states || typeof states !== "object" || Array.isArray(states)) {
errors.push("Route map must document the pre-route session states.");
} else {
const actualStates = Object.keys(states).sort();
if (JSON.stringify(actualStates) !== JSON.stringify([...expectedStates].sort())) {
errors.push(`Route map session states must be ${expectedStates.join(", ")}.`);
}
for (const [condition, screen] of Object.entries(states)) {
if (typeof screen !== "string" || !sessionSource.includes(`<${screen}`)) {
errors.push(`Session screen is not present in App.tsx: ${condition}.`);
}
}
}
for (const actualRoute of records) {
const mappedRoute = mappedRoutes.find((route) => route?.path === actualRoute.path);
if (!mappedRoute) continue;
if (mappedRoute.auth !== actualRoute.auth) {
errors.push(`${actualRoute.path} must be marked ${actualRoute.auth}-only.`);
}
const expectedFeatures = actualRoute.featureGates;
const mappedFeatures = mappedFeatureGates(mappedRoute);
if (JSON.stringify(expectedFeatures) !== JSON.stringify(mappedFeatures)) {
const expected = expectedFeatures.length > 0 ? expectedFeatures.join(", ") : "none";
errors.push(`${actualRoute.path} feature-gate registry must be ${expected}.`);
}
}
return errors;
}
+46 -1
View File
@@ -28,4 +28,49 @@ if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
process.exit(1);
}
console.log(`Public source structure passed (${manifest.files.length} allowlisted files).`);
const markdownFiles = [];
function walkMarkdown(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if ([".git", "node_modules", "__pycache__"].includes(entry.name)) continue;
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) walkMarkdown(fullPath);
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) markdownFiles.push(fullPath);
}
}
function stripCode(markdown) {
const withoutFences = markdown.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/g, (block) =>
block.replace(/[^\n]/g, " "));
return withoutFences.replace(/`[^`\n]*`/g, (span) => " ".repeat(span.length));
}
walkMarkdown(process.cwd());
const errors = [];
const linkPattern = /\[[^\]]+\]\(([^)]+)\)/g;
for (const markdownFile of markdownFiles) {
const content = stripCode(fs.readFileSync(markdownFile, "utf8"));
for (const match of content.matchAll(linkPattern)) {
let target = match[1].trim();
if (target.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) continue;
target = target.split("#", 1)[0].split("?", 1)[0];
if (!target) continue;
try {
target = decodeURIComponent(target);
} catch {
errors.push(`Could not URL-decode link in ${path.relative(process.cwd(), markdownFile)}: ${target}`);
continue;
}
const resolved = path.resolve(path.dirname(markdownFile), target);
if (!fs.existsSync(resolved)) {
errors.push(`Broken local Markdown link in ${path.relative(process.cwd(), markdownFile)}: ${match[1]}`);
}
}
}
if (errors.length > 0) {
console.error("Public source validation failed:");
for (const error of errors) console.error(`- ${error}`);
process.exit(1);
}
console.log(`Public source structure passed (${manifest.files.length} allowlisted files, ${markdownFiles.length} Markdown files, local links resolved).`);
+102 -33
View File
@@ -3,7 +3,8 @@
import fs from "node:fs";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { commandExists, run } from "./lib/exec.mjs";
import { capture, commandExists, run } from "./lib/exec.mjs";
import { requireReleaseTool } from "./lib/release-gates.mjs";
const flags = new Set(process.argv.slice(2));
const structureOnly = flags.has("--structure-only");
@@ -11,6 +12,24 @@ const fast = flags.has("--fast");
const ci = flags.has("--ci");
const release = flags.has("--release");
const publicSourceExport = fs.existsSync("PUBLIC_SOURCE_EXPORT.md");
const releaseImageReference = process.env.RELEASE_IMAGE_REFERENCE ?? "itworx/knowledge-hub:local";
let releaseImageId = null;
// Test hosts use fixed configuration and do not need appsettings hot reload.
// Avoid consuming the shared Linux host's inotify quota for hundreds of factories.
const backendTestEnvironment = { DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE: "false" };
function splitImageReference(reference) {
const tagSeparator = reference.lastIndexOf(":");
if (tagSeparator <= reference.lastIndexOf("/") || tagSeparator === 0 || /\s/.test(reference)) {
throw new Error(`Release image reference must include a repository and tag: '${reference}'.`);
}
const repository = reference.slice(0, tagSeparator);
const tag = reference.slice(tagSeparator + 1);
if (!repository || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag)) {
throw new Error(`Release image reference has an invalid repository or tag: '${reference}'.`);
}
return { repository, tag };
}
function findWorkingPython() {
// On some Windows setups `python3` resolves via PATH to the Microsoft Store
@@ -39,12 +58,22 @@ async function section(name, action) {
}
try {
// Fail before starting a long release matrix when a mandatory scanner is
// unavailable. CI provides these through the pinned release workflow setup;
// local release verification must install the same tools explicitly.
const releaseGitleaks = release ? requireReleaseTool("gitleaks") : null;
const releaseTrivy = release ? requireReleaseTool("trivy") : null;
await section("Bundle structure", async () => {
if (publicSourceExport) {
await run(process.execPath, ["scripts/validate-public-source.mjs"]);
} else {
await run(process.execPath, ["scripts/validate-bundle.mjs"]);
await run(process.execPath, ["scripts/test-safety-hook.mjs"]);
await run(process.execPath, ["scripts/validate-product-state.mjs"]);
await run(process.execPath, ["scripts/validate-route-map.mjs"]);
await run(process.execPath, ["--test", "scripts/tests/route-map.test.mjs", "scripts/tests/product-backlog.test.mjs", "scripts/tests/validate-public-source.test.mjs", "scripts/tests/validate-bundle.test.mjs", "scripts/tests/v2-r4-real-backup-restore-drill.test.mjs", "scripts/tests/cleanup-temporary-resources.test.mjs"]);
await run(python, ["-m", "unittest", "scripts.tests.test_public_source_pipeline"]);
}
});
@@ -108,7 +137,7 @@ try {
"trx",
"--results-directory",
"artifacts/test-results"
]);
], { env: backendTestEnvironment });
});
await section("Frontend install and verification", async () => {
@@ -149,16 +178,60 @@ try {
if (ci || release) {
await section("Production image build", async () => {
await run("docker", ["compose", "build", "app"], {
env: {
POSTGRES_PASSWORD: "verification-only-not-for-production",
BUILD_COMMIT: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? "verification"
const buildCommit = process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? "verification";
if (release) {
const { repository, tag } = splitImageReference(releaseImageReference);
await run("docker", [
"build",
"--file",
"deploy/docker/Dockerfile",
"--tag",
releaseImageReference,
"--build-arg",
`BUILD_COMMIT=${buildCommit}`,
"--build-arg",
`RELEASE_TAG=${process.env.RELEASE_TAG ?? ""}`,
"--build-arg",
`IMAGE_REPOSITORY=${repository}`,
"--build-arg",
`IMAGE_TAG=${tag}`,
"."
]);
releaseImageId = await capture("docker", [
"image",
"inspect",
"--format",
"{{.Id}}",
releaseImageReference
]);
if (!/^sha256:[0-9a-f]{64}$/i.test(releaseImageId)) {
throw new Error(`Docker returned an invalid image ID for '${releaseImageReference}'.`);
}
});
const imageIdPath = "artifacts/release-verification/image-id.txt";
fs.mkdirSync("artifacts/release-verification", { recursive: true });
fs.writeFileSync(imageIdPath, `${releaseImageId}\n`, "utf8");
console.log(`Captured immutable release image ID in ${imageIdPath}.`);
} else {
await run("docker", ["compose", "build", "app"], {
env: {
POSTGRES_PASSWORD: "verification-only-not-for-production",
BUILD_COMMIT: buildCommit
}
});
}
});
}
if (release) {
await section("Release evidence integrity tests", async () => {
await run(process.execPath, [
"--test",
"scripts/tests/release-gates.test.mjs",
"scripts/tests/generate-release-evidence.test.mjs",
"scripts/tests/generate-release-manifest.test.mjs"
]);
});
await section("Full-scale search/tree suite (release-gate only)", async () => {
await run("dotnet", [
"test",
@@ -167,8 +240,12 @@ try {
"Release",
"--no-build",
"--filter",
"Category=ReleaseScale"
]);
"Category=ReleaseScale",
"--logger",
"trx",
"--results-directory",
"artifacts/test-results"
], { env: backendTestEnvironment });
});
await section(".NET dependency vulnerability scan", async () => {
@@ -195,32 +272,24 @@ try {
});
await section("Secret scan", async () => {
if (!commandExists("gitleaks")) {
throw new Error("gitleaks is required for the release secret scan but was not found on PATH.");
}
await run("gitleaks", ["detect", "--source", ".", "--redact"]);
await run(releaseGitleaks, ["detect", "--source", ".", "--redact"]);
});
if (commandExists("trivy")) {
await section("Container vulnerability scan", async () => {
await run("trivy", [
"image",
"--severity",
"CRITICAL,HIGH",
"--exit-code",
"1",
"--scanners",
"vuln",
"itworx/knowledge-hub:local"
]);
});
} else {
console.warn(
"\ntrivy not found on PATH — container vulnerability scan skipped. Install " +
"trivy before a real release; this is not a substitute for the scan."
);
}
await section("Container vulnerability scan", async () => {
if (!releaseImageId) {
throw new Error("The release image ID was not captured after the production image build.");
}
await run(releaseTrivy, [
"image",
"--severity",
"CRITICAL,HIGH",
"--exit-code",
"1",
"--scanners",
"vuln",
releaseImageId
]);
});
console.warn(
"\nRelease verification still requires the milestone-defined E2E (desktop + " +