299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
#!/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", "scripts/tests/verify-release-source.test.mjs", "scripts/tests/release-gates.test.mjs", "scripts/tests/generate-release-evidence.test.mjs", "scripts/tests/generate-release-manifest.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 `<assembly>_<timestamp>.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("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);
|
|
}
|