Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-release-docs.mjs
T

269 lines
10 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const REQUIRED_RELEASE_DOCS = [
"docs/release-checklist.md",
"docs/boilerplate-version-tracking.md",
"docs/production-checklist.md",
"docs/releases/README.md",
"docs/versioning.md",
"docs/upgrade.md",
"docs/deployment.md",
"docs/migration-registry.md",
];
export const REQUIRED_SECTIONS = {
"docs/release-checklist.md": [
"# Release Checklist",
"## Pre-release verification",
"## Changelog and version tagging",
"## Artifacts and rollback",
"## Manual runtime evidence",
"## Redaction requirements",
],
"docs/boilerplate-version-tracking.md": [
"# Boilerplate Version Tracking",
"## SemVer source version",
"## Downstream version ledger",
"## Changelog alignment",
"## Upgrade and rollback notes",
"## Redacted evidence",
],
"docs/production-checklist.md": [
"# Production Checklist",
"## Production readiness",
"## Deployment boundary",
"## Manual runtime checks",
"## Rollback plan",
"## Redacted release evidence",
],
};
export const REQUIRED_TERM_GROUPS = [
{ id: "semver", description: "SemVer or semantic versioning", terms: [/\bsemver\b|\bsemantic version(?:ing)?\b/i] },
{ id: "changelog", description: "changelog alignment", terms: [/\bchangelog\b|\bchange log\b/i] },
{ id: "artifact", description: "release artifact handling", terms: [/\bartifacts?\b/i] },
{ id: "rollback", description: "rollback planning", terms: [/\brollback\b/i] },
{ id: "manual-runtime", description: "manual runtime verification boundary", terms: [/\bmanual(?:-only)?\b/i, /\bruntime\b/i] },
{ id: "redaction", description: "redacted evidence and secret handling", terms: [/\bredact(?:ed|ion)?\b|\bmask(?:ed|ing)?\b/i] },
{ id: "version-tracking", description: "boilerplate version tracking", terms: [/\bboilerplate version\b|\bversion tracking\b|\bsource version\b/i] },
{ id: "production-readiness", description: "production readiness", terms: [/\bproduction readiness\b|\bproduction checklist\b/i] },
{ id: "deployment", description: "deployment boundary", terms: [/\bdeploy(?:ment)?\b/i] },
{
id: "worker-group-evidence",
description: "separate worker-group readiness evidence",
terms: [
/\bworker-general\b/i,
/\bworker-mail\b/i,
/\bworker-import\b/i,
/\bworker-webhook\b/i,
/\bworker-plugin\b/i,
/\bWORKER_QUEUE_GROUP=mail\b/,
/\brevision evidence\b/i,
/\bqueue list evidence\b/i,
/\bexecute-health evidence\b/i,
],
},
];
export const FORBIDDEN_PLACEHOLDER_PATTERN = /\b(?:TODO|TBD|FIXME|XXX)\b/i;
export const FORBIDDEN_AUTOMATIC_E2E_PATTERNS = [
{ id: "playwright-test-command", pattern: /(?:^|[`\n>\s])(?:pnpm\s+(?:exec\s+)?|npx\s+)?playwright\s+test\b/i },
{ id: "cypress-command", pattern: /(?:^|[`\n>\s])(?:pnpm\s+(?:exec\s+)?|npx\s+)?cypress\s+(?:run|open)\b/i },
{ id: "browser-e2e-script-command", pattern: /(?:^|[`\n>\s])(?:pnpm|npm|yarn)\s+(?:run\s+)?(?:test:e2e|e2e|browser)\b/i },
];
export const FORBIDDEN_SECRET_EXAMPLE_PATTERNS = [
{ id: "token-like-string", pattern: /\b(?:sk|pk|api|tok|key|secret)_[A-Za-z0-9]{20,}\b/i },
{ id: "webhook-secret-url", pattern: /https?:\/\/[^\s)`'"<>]*webhook[^\s)`'"<>]*(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[A-Za-z0-9_-]{24,})/i },
{ id: "raw-credential-assignment", pattern: /\b(?:API_KEY|TOKEN|SECRET|PASSWORD|WEBHOOK_SECRET|DATABASE_URL)\s*=\s*(?!<|\[|REDACTED\b|redacted\b|placeholder\b|example\b)[^\s`'"#]{8,}/i },
];
const MARKDOWN_LINK_PATTERN = /!?\[[^\]]*\]\(([^)]+)\)/g;
const RELEASE_ENTRY_PATTERN = /^\d{4}-\d{2}\.md$/;
function normalizeRelativePath(relativePath) {
return relativePath.split(path.sep).join("/");
}
function lineAndColumnForIndex(content, index) {
const prefix = content.slice(0, index);
const lines = prefix.split(/\r?\n/);
return { line: lines.length, column: lines.at(-1).length + 1 };
}
function readDocs(rootDir, requiredDocs = REQUIRED_RELEASE_DOCS) {
const docs = new Map();
const failures = [];
for (const relativePath of requiredDocs) {
const normalizedPath = normalizeRelativePath(relativePath);
const absolutePath = path.join(rootDir, normalizedPath);
if (!fs.existsSync(absolutePath)) {
failures.push(`${normalizedPath}: missing required documentation file`);
continue;
}
const content = fs.readFileSync(absolutePath, "utf8");
if (content.trim().length === 0) {
failures.push(`${normalizedPath}: documentation file is empty`);
continue;
}
docs.set(normalizedPath, content);
}
return { docs, failures };
}
function listReleaseEntryDocs(rootDir) {
const releasesDir = path.join(rootDir, "docs", "releases");
if (!fs.existsSync(releasesDir)) return [];
return fs
.readdirSync(releasesDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && RELEASE_ENTRY_PATTERN.test(entry.name))
.map((entry) => normalizeRelativePath(path.join("docs", "releases", entry.name)))
.sort();
}
function checkRequiredSections(docs, requiredSections = REQUIRED_SECTIONS) {
const failures = [];
for (const [relativePath, sections] of Object.entries(requiredSections)) {
const normalizedPath = normalizeRelativePath(relativePath);
const content = docs.get(normalizedPath);
if (!content) continue;
for (const section of sections) {
const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`^${escaped}\\s*$`, "im");
if (!pattern.test(content)) failures.push(`${normalizedPath}: missing required section heading "${section}"`);
}
}
return failures;
}
function checkRequiredTermGroups(docs, termGroups = REQUIRED_TERM_GROUPS) {
const failures = [];
const combined = [...docs.entries()].map(([relativePath, content]) => `\n--- ${relativePath} ---\n${content}`).join("\n");
for (const group of termGroups) {
const missingTerms = group.terms.filter((term) => !term.test(combined));
if (missingTerms.length > 0) failures.push(`required term group "${group.id}" missing ${missingTerms.length} term(s): ${group.description}`);
}
return failures;
}
function checkForbiddenPattern(docs, patterns, messageForMatch) {
const failures = [];
for (const [relativePath, content] of docs.entries()) {
for (const patternEntry of patterns) {
patternEntry.pattern.lastIndex = 0;
const match = patternEntry.pattern.exec(content);
if (!match) continue;
const firstNonWhitespace = match[0].search(/\S/);
const { line, column } = lineAndColumnForIndex(content, match.index + Math.max(firstNonWhitespace, 0));
failures.push(messageForMatch(relativePath, line, column, patternEntry.id, match[0].trim()));
}
}
return failures;
}
function checkForbiddenPlaceholders(docs) {
const failures = [];
for (const [relativePath, content] of docs.entries()) {
FORBIDDEN_PLACEHOLDER_PATTERN.lastIndex = 0;
const match = FORBIDDEN_PLACEHOLDER_PATTERN.exec(content);
if (!match) continue;
const { line, column } = lineAndColumnForIndex(content, match.index);
failures.push(`${relativePath}:${line}:${column}: forbidden placeholder marker "${match[0]}"`);
}
return failures;
}
function isExternalOrAnchorOnly(linkTarget) {
return /^(?:https?:|mailto:|tel:)/i.test(linkTarget) || linkTarget.startsWith("#");
}
function targetPathForMarkdownLink(rootDir, sourceRelativePath, rawTarget) {
const withoutTitle = rawTarget.trim().replace(/\s+"[^"]*"\s*$/, "");
const withoutFragment = withoutTitle.split("#")[0].split("?")[0];
if (!withoutFragment || isExternalOrAnchorOnly(withoutFragment)) return null;
return path.resolve(rootDir, path.dirname(sourceRelativePath), decodeURIComponent(withoutFragment));
}
function checkLocalMarkdownLinks(rootDir, docs) {
const failures = [];
for (const [relativePath, content] of docs.entries()) {
MARKDOWN_LINK_PATTERN.lastIndex = 0;
for (const match of content.matchAll(MARKDOWN_LINK_PATTERN)) {
const targetPath = targetPathForMarkdownLink(rootDir, relativePath, match[1]);
if (!targetPath) continue;
if (fs.existsSync(targetPath)) continue;
const { line, column } = lineAndColumnForIndex(content, match.index ?? 0);
failures.push(`${relativePath}:${line}:${column}: unresolved local Markdown link "${match[1]}"`);
}
}
return failures;
}
export function verifyReleaseDocsReleaseDocs(rootDir = process.cwd(), options = {}) {
const requiredDocs = options.requiredDocs ?? [...REQUIRED_RELEASE_DOCS, ...listReleaseEntryDocs(rootDir)];
const requiredSections = options.requiredSections ?? REQUIRED_SECTIONS;
const termGroups = options.termGroups ?? REQUIRED_TERM_GROUPS;
const { docs, failures: readFailures } = readDocs(rootDir, requiredDocs);
const failures = [
...readFailures,
...checkRequiredSections(docs, requiredSections),
...checkRequiredTermGroups(docs, termGroups),
...checkLocalMarkdownLinks(rootDir, docs),
...checkForbiddenPlaceholders(docs),
...checkForbiddenPattern(docs, FORBIDDEN_AUTOMATIC_E2E_PATTERNS, (relativePath, line, column, id) => `${relativePath}:${line}:${column}: forbidden automatic browser/E2E command (${id})`),
...checkForbiddenPattern(docs, FORBIDDEN_SECRET_EXAMPLE_PATTERNS, (relativePath, line, column, id) => `${relativePath}:${line}:${column}: forbidden secret-shaped example (${id})`),
];
return {
passes: failures.length === 0 ? [`${requiredDocs.length} release documentation files satisfy the Release Docs release documentation contract`] : [],
failures,
observations: [`checked files: ${requiredDocs.join(", ")}`],
};
}
export function formatReleaseDocsReleaseDocsResult(result) {
const status = result.failures.length > 0 ? "FAIL" : "PASS";
return [
`${status} Release Docs release documentation verification`,
...result.passes.map((pass) => `PASS ${pass}`),
...result.observations.map((observation) => `OBSERVE ${observation}`),
...result.failures.map((failure) => `FAIL ${failure}`),
`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`,
"",
].join("\n");
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const result = verifyReleaseDocsReleaseDocs(process.cwd());
const output = formatReleaseDocsReleaseDocsResult(result);
if (result.failures.length > 0) {
process.stderr.write(output);
process.exitCode = 1;
} else {
process.stdout.write(output);
}
}