#!/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); } }