Publish curated Knowledge Hub source from 2ed833f290b439a7bc91e5fd19b042c269e3bee9
This commit is contained in:
1 parent
10f2edcd29
commit
59f15b2d08
218 files changed
+23995
-1849
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 + " +
|
||||
|
||||
Reference in new issue
Block a user