From db2bc61db2b6e84916491c60c0c9eafbb0ee4a66 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 1 Jul 2026 16:26:20 -0600 Subject: [PATCH] docs(release-notes): add release process check --- RELEASE_NOTES_PROCESS.md | 69 +++++++++ package.json | 1 + scripts/check-release-notes.mjs | 245 ++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 RELEASE_NOTES_PROCESS.md create mode 100644 scripts/check-release-notes.mjs diff --git a/RELEASE_NOTES_PROCESS.md b/RELEASE_NOTES_PROCESS.md new file mode 100644 index 0000000..35e395d --- /dev/null +++ b/RELEASE_NOTES_PROCESS.md @@ -0,0 +1,69 @@ +# Release Notes Process + +Release notes live under `pages/release-notes`. +They are organized by product line, not by internal team or launch bundle. + +## Product Lines + +| Product line | Path | Release format | +| --------------- | ------------------------------------- | --------------- | +| Blueprint Agent | `pages/release-notes/blueprint-agent` | Dated notes | +| Sandbox | `pages/release-notes/sandbox` | Dated notes | +| Router | `pages/release-notes/router` | Dated notes | +| Browser Agent | `pages/release-notes/browser-agent` | Dated notes | +| Audit Agent | `pages/release-notes/audit-agent` | Dated notes | +| tnt-core | `pages/release-notes/tnt-core` | Versioned notes | + +Use `tnt-core` for protocol contracts, ABIs, Rust bindings, interface changes, and migration-required protocol package releases. +Use the hosted product line for customer-visible product, service, UI, workflow, or infrastructure changes. + +## Add a Hosted Product Release + +1. Create a dated note at `pages/release-notes//.mdx`. +2. Include frontmatter with `title` and `description`. +3. Include `Published: .`, `Product line: .`, and the public URL when one exists. +4. Include `## Customer Impact`, `## What Changed`, and `## Related Links`. +5. Update `pages/release-notes//index.mdx`. +6. Update `pages/release-notes//_meta.ts`. +7. Update `pages/release-notes/all.mdx`. + +If one launch touches multiple products, create one note per affected product line. +A shared summary page can link to those notes, but it must not replace the product-line entries. + +## Add a tnt-core Release + +1. Confirm the source release, PRs, tag, changelog, crates, bindings, and migration impact from the `tnt-core` repo. +2. If dependency versions need cross-repo synchronization, run the `tnt-release-sync` skill before writing docs. +3. Create a versioned note at `pages/release-notes/tnt-core/.mdx`. +4. Include frontmatter with `title` and `description`. +5. Include `Published: .` and `Product line: tnt-core.`. +6. Include source PRs or tags, breaking changes, affected roles, migration steps, and reference links. +7. Update `pages/release-notes/tnt-core/index.mdx`. +8. Update `pages/release-notes/tnt-core/_meta.ts`. +9. Update `pages/release-notes/all.mdx`. + +Keep old protocol URLs as bridge pages only when preserving existing links. +The canonical release-note path for protocol package changes is `/release-notes/tnt-core`. + +## Writing Rules + +Write for customers and builders. +Name the product line, customer impact, required action, and where to read more. + +Do not frame public release notes around internal process needs. +Do not combine unrelated product lines into a catch-all release note. +Do not use generic protocol labels when the actual product line is `tnt-core`. + +## Required Checks + +Run these before opening a PR: + +```bash +yarn check:release-notes +yarn check:tnt-core-sync +yarn format:check +yarn build +``` + +For PRs in this repo, use `gh-drew`. +After merge, verify the live release-note URLs with `curl -L -s -o /dev/null -w '%{http_code}' `. diff --git a/package.json b/package.json index 7aa1439..138b7b2 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "export": "next export", "check:harness-copy": "node ./scripts/check-harness-copy.mjs", "check:tnt-core-sync": "node ./scripts/check-tnt-core-sync.mjs", + "check:release-notes": "node ./scripts/check-release-notes.mjs", "lint": "next lint && yarn format", "schema": "turbo-gen ./public/schema.json", "format": "prettier --write \"{components,pages}/**/*.{mdx,ts,js,jsx,tsx,json}\" ", diff --git a/scripts/check-release-notes.mjs b/scripts/check-release-notes.mjs new file mode 100644 index 0000000..bfacc0c --- /dev/null +++ b/scripts/check-release-notes.mjs @@ -0,0 +1,245 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const releaseRoot = path.join(repoRoot, "pages/release-notes"); + +const productLines = { + "blueprint-agent": { + label: "Blueprint Agent", + notePattern: /^\d{4}-\d{2}-\d{2}\.mdx$/, + requiredSections: [ + "## Customer Impact", + "## What Changed", + "## Related Links", + ], + }, + sandbox: { + label: "Sandbox", + notePattern: /^\d{4}-\d{2}-\d{2}\.mdx$/, + requiredSections: [ + "## Customer Impact", + "## What Changed", + "## Related Links", + ], + }, + router: { + label: "Router", + notePattern: /^\d{4}-\d{2}-\d{2}\.mdx$/, + requiredSections: [ + "## Customer Impact", + "## What Changed", + "## Related Links", + ], + }, + "browser-agent": { + label: "Browser Agent", + notePattern: /^\d{4}-\d{2}-\d{2}\.mdx$/, + requiredSections: [ + "## Customer Impact", + "## What Changed", + "## Related Links", + ], + }, + "audit-agent": { + label: "Audit Agent", + notePattern: /^\d{4}-\d{2}-\d{2}\.mdx$/, + requiredSections: [ + "## Customer Impact", + "## What Changed", + "## Related Links", + ], + }, + "tnt-core": { + label: "tnt-core", + notePattern: /^\d+\.\d+\.\d+\.mdx$/, + requiredSections: [ + "## Breaking Changes", + "## Migration Checklist", + "## Reference", + ], + }, +}; + +const allowedBridgeDirs = new Set(["protocol"]); +const allowedRootFiles = new Set([ + "index.mdx", + "all.mdx", + "2026-06-29-product-surfaces.mdx", +]); + +const forbiddenPublicCopy = [ + /\bProtocol and SDKs\b/i, + /\bTangle Protocol and SDKs\b/i, + /\bVanta\b/i, + /\bA-LIGN\b/i, + /\bauditors?\b/i, + /\bevidence package\b/i, + /\bsampling request\b/i, +]; + +const failures = []; + +function fail(message) { + failures.push(message); +} + +function readFile(targetPath) { + if (!fs.existsSync(targetPath)) { + fail(`Missing required file: ${path.relative(repoRoot, targetPath)}`); + return ""; + } + + return fs.readFileSync(targetPath, "utf8"); +} + +function assertIncludes(content, needle, label) { + if (!content.includes(needle)) { + fail(`${label} is missing: ${needle}`); + } +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function hasMetaKey(content, key) { + return new RegExp(`(^|[\\s,{])["']?${escapeRegExp(key)}["']?\\s*:`).test( + content, + ); +} + +function hasFrontmatterField(content, field) { + const frontmatter = content.match(/^---\n([\s\S]*?)\n---\n/); + + return Boolean(frontmatter?.[1].match(new RegExp(`^${field}:\\s*.+$`, "m"))); +} + +function routeForNote(slug, filename) { + return `/release-notes/${slug}/${filename.replace(/\.mdx$/, "")}`; +} + +function listFiles(dir) { + if (!fs.existsSync(dir)) { + return []; + } + + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return listFiles(target); + } + + return target; + }); +} + +const rootMeta = readFile(path.join(releaseRoot, "_meta.ts")); +const allReleases = readFile(path.join(releaseRoot, "all.mdx")); + +for (const [slug, config] of Object.entries(productLines)) { + const productDir = path.join(releaseRoot, slug); + const productMetaPath = path.join(productDir, "_meta.ts"); + const productIndexPath = path.join(productDir, "index.mdx"); + const productMeta = readFile(productMetaPath); + const productIndex = readFile(productIndexPath); + + if (!hasMetaKey(rootMeta, slug)) { + fail(`pages/release-notes/_meta.ts does not expose ${slug}`); + } + + assertIncludes( + productIndex, + `# ${config.label} Release Notes`, + `${slug}/index`, + ); + + const entries = fs + .readdirSync(productDir) + .filter((entry) => entry.endsWith(".mdx") && entry !== "index.mdx") + .sort(); + + if (entries.length === 0) { + fail(`${slug} has no release-note entries`); + } + + for (const filename of entries) { + const notePath = path.join(productDir, filename); + const noteLabel = path.relative(repoRoot, notePath); + const note = readFile(notePath); + const route = routeForNote(slug, filename); + const metaKey = filename.replace(/\.mdx$/, ""); + + if (!config.notePattern.test(filename)) { + fail(`${noteLabel} does not match expected filename format`); + } + + if (!hasFrontmatterField(note, "title")) { + fail(`${noteLabel} is missing frontmatter title`); + } + + if (!hasFrontmatterField(note, "description")) { + fail(`${noteLabel} is missing frontmatter description`); + } + + assertIncludes(note, "Published:", noteLabel); + assertIncludes(note, `Product line: ${config.label}.`, noteLabel); + + for (const section of config.requiredSections) { + assertIncludes(note, section, noteLabel); + } + + if (!hasMetaKey(productMeta, metaKey)) { + fail( + `${path.relative(repoRoot, productMetaPath)} does not expose ${metaKey}`, + ); + } + + assertIncludes(productIndex, route, `${slug}/index`); + assertIncludes(allReleases, route, "pages/release-notes/all.mdx"); + } +} + +const releaseFiles = listFiles(releaseRoot).filter((targetPath) => + targetPath.endsWith(".mdx"), +); + +for (const targetPath of releaseFiles) { + const rel = path.relative(releaseRoot, targetPath); + const [firstSegment] = rel.split(path.sep); + const isProductLine = Object.hasOwn(productLines, firstSegment); + const isBridge = allowedBridgeDirs.has(firstSegment); + const isRootFile = !rel.includes(path.sep) && allowedRootFiles.has(rel); + + if (!isProductLine && !isBridge && !isRootFile) { + fail( + `Unexpected release-note file: ${path.relative(repoRoot, targetPath)}`, + ); + } + + const content = readFile(targetPath); + + for (const pattern of forbiddenPublicCopy) { + if (pattern.test(content)) { + fail( + `${path.relative(repoRoot, targetPath)} contains forbidden public release-note copy: ${pattern}`, + ); + } + } +} + +if (failures.length > 0) { + console.error("Release note checks failed:"); + + for (const failure of failures) { + console.error(`- ${failure}`); + } + + process.exit(1); +} + +console.log("release note checks passed");