269 lines
10 KiB
JavaScript
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);
|
|
}
|
|
}
|