Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions RELEASE_NOTES_PROCESS.md
Original file line number Diff line number Diff line change
@@ -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/<product>/<YYYY-MM-DD>.mdx`.
2. Include frontmatter with `title` and `description`.
3. Include `Published: <Month D, YYYY>.`, `Product line: <Product>.`, and the public URL when one exists.
4. Include `## Customer Impact`, `## What Changed`, and `## Related Links`.
5. Update `pages/release-notes/<product>/index.mdx`.
6. Update `pages/release-notes/<product>/_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/<major.minor.patch>.mdx`.
4. Include frontmatter with `title` and `description`.
5. Include `Published: <Month D, YYYY>.` 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}' <url>`.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\" ",
Expand Down
245 changes: 245 additions & 0 deletions scripts/check-release-notes.mjs
Original file line number Diff line number Diff line change
@@ -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");
Loading