#!/usr/bin/env node import fs from "node:fs"; import process from "node:process"; import { spawnSync } from "node:child_process"; 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"); 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 // app-execution-alias stub (present even when no Store install exists), // which `where`/commandExists reports as found but which fails at runtime. // Probe candidates by actually invoking them rather than trusting PATH // resolution alone. const candidates = ["python3", "python"]; for (const candidate of candidates) { if (!commandExists(candidate)) { continue; } const probe = spawnSync(candidate, ["--version"], { stdio: "ignore", shell: false }); if (probe.status === 0) { return candidate; } } throw new Error("No working Python 3 interpreter found (tried python3, python)."); } const python = findWorkingPython(); async function section(name, action) { console.log(`\n=== ${name} ===`); await 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"]); } }); await section("LeafWiki inspector tests", async () => { await run(python, [ "-m", "unittest", "discover", "-s", "tools/leafwiki-inspector/tests", "-p", "test_*.py" ]); }); await section("Knowledge-ingestion policy tests", async () => { if (!publicSourceExport) { await run(process.execPath, ["--test", "tools/knowledge-ingestion/tests/*.test.mjs"]); } }); if (structureOnly) { console.log("\nStructure-only verification passed."); process.exit(0); } await section(".NET restore, format, build and tests", async () => { await run("dotnet", ["restore", "ItWorx.KnowledgeHub.slnx"]); await run("dotnet", [ "format", "ItWorx.KnowledgeHub.slnx", "--verify-no-changes", "--no-restore" ]); await run("dotnet", [ "build", "ItWorx.KnowledgeHub.slnx", "--configuration", "Release", "--no-restore" ]); await run("dotnet", [ "test", "ItWorx.KnowledgeHub.slnx", "--configuration", "Release", "--no-build", // Excludes the M8 full-scale search/tree suite (100,000 pages / 1,000,000 // revisions, docs/10-search-and-indexing.md "Scale verification") — a // deliberate, occasional release-gate exercise (run explicitly in the // "release" section below), not a per-commit check; it alone would add well // over a minute to every routine verification run. "--filter", "Category!=ReleaseScale", // No explicit LogFileName: `dotnet test` runs every project in the solution // through the same invocation, so a fixed name would have each project's // results overwrite the previous one. Omitting it lets each project write its // own default `_.trx` file instead. "--logger", "trx", "--results-directory", "artifacts/test-results" ], { env: backendTestEnvironment }); }); await section("Frontend install and verification", async () => { const installArgs = fs.existsSync("pnpm-lock.yaml") ? ["pnpm", "install", "--frozen-lockfile"] : ["pnpm", "install", "--no-frozen-lockfile"]; if (!fs.existsSync("pnpm-lock.yaml")) { console.warn("Pre-M0: generating pnpm-lock.yaml. Commit it after reviewing dependency versions."); } await run("corepack", installArgs); await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "lint"]); await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "typecheck"]); await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "test"]); await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "build"]); // Depends on the root workspace's node_modules (js-yaml), so it must run after // `pnpm install` above, not in the earlier structure-only "Bundle structure" // section that runs before any dependency install exists on a clean checkout. await run(process.execPath, ["scripts/validate-openapi.mjs"]); // Contract-coverage gate (docs/adr/ADR-029-v2-r5-final-hardening.md): fails when a // registered /api/v1 endpoint is not documented in docs/api/openapi.yaml. Same // js-yaml dependency, same placement. await run(process.execPath, ["scripts/check-openapi-coverage.mjs"]); }); if (!fast) { await section("Docker configuration", async () => { await run("docker", ["compose", "config"], { env: { POSTGRES_PASSWORD: "verification-only-not-for-production" } }); }); } if (ci || release) { await section("Production image build", async () => { 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", "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/ItWorx.KnowledgeHub.IntegrationTests.csproj", "--configuration", "Release", "--no-build", "--filter", "Category=ReleaseScale", "--logger", "trx", "--results-directory", "artifacts/test-results" ], { env: backendTestEnvironment }); }); await section(".NET dependency vulnerability scan", async () => { const result = await run( "dotnet", ["list", "ItWorx.KnowledgeHub.slnx", "package", "--vulnerable", "--include-transitive"], { capture: true } ); // `dotnet list package --vulnerable` always exits 0 and just prints findings — // the gate itself must fail the release on any reported advisory (M8-A02: "No // known Critical or High security finding remains unresolved" — since the // command does not separate by severity in its text output, any finding at all // blocks release pending explicit triage, matching the release gate's // zero-tolerance-until-reviewed posture). if (/has the following vulnerable packages/i.test(result.stdout)) { throw new Error( "dotnet reported vulnerable NuGet packages — review the output above before releasing.\n" + result.stdout ); } }); await section("Frontend dependency vulnerability scan", async () => { await run("corepack", ["pnpm", "audit", "--prod"]); }); await section("Secret scan", async () => { await run(releaseGitleaks, ["detect", "--source", ".", "--redact"]); }); 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 + " + "mobile, axe), backup/restore rehearsal and runtime-inspection gates to be run " + "manually against the built image, per the documented procedure in EVIDENCE.md " + "— these involve bringing up docker compose and are recorded there rather than " + "orchestrated here." ); } console.log("\nVerification completed successfully for the selected baseline gates."); } catch (error) { console.error(`\nVerification failed: ${error.message}`); process.exit(typeof error.code === "number" ? error.code : 1); }