fix(release): commit release-notes.md to the release branch, drop the Notion round-trip

release-notes.md and release-notes-notion.json were both gitignored, so
create-pull-request silently skipped them. The notes never reached the release
branch, the publish job's readFileSync missed, and every release since this
lane was built shipped its "Release <tag>" fallback body — v1.70.0,
channels/v0.6.0 and angular/v0.4.0 all have bodyless GitHub Releases.

The same ignore rule severed the Notion lane: without the json ref in the
checkout, publish-release could never read an edited draft back, so that path
had never run either. Remove it rather than repair it — the release PR is
already the review surface, and editing release-notes.md on the branch is a
plainer gate than a Notion page.

Guard the ignore rule with a test, since re-adding it would break the lane
again without breaking anything else.
This commit is contained in:
Benjamin Taylor
2026-09-01 15:30:38 -05:00
parent 8ddb6158a4
commit 476b48a7d6
7 changed files with 55 additions and 292 deletions
+4 -6
View File
@@ -14,7 +14,7 @@
# the normal flow failed BEFORE npm publish succeeded.
# - workflow_dispatch with mode=prerelease → canary publish. Bumps versions
# in the build job to <X.Y.Z>-canary.<suffix>, publishes with --tag canary,
# skips tag push + GH Release + Notion notification.
# skips tag push + GH Release + Slack notification.
name: release / publish
# This workflow handles two independent release lanes:
@@ -384,10 +384,9 @@ jobs:
# or `prepare`, so `pnpm pack` runs no lifecycle scripts; voice's
# `prepublishOnly` fires for neither `pnpm pack` nor `npm publish <tarball>`.
#
# Stable keeps the FULL install: publish-release.ts additionally imports
# lib/notion.js and the channels umbrella verifier runs a root workspace
# script, so its dependency surface is wider and far less worth trimming on
# the highest-stakes path.
# Stable keeps the FULL install: the channels umbrella verifier runs a
# root workspace script, so its dependency surface is wider and far less
# worth trimming on the highest-stakes path.
- name: Install Dependencies (prerelease — root + packages only)
if: ${{ steps.meta.outputs.mode == 'prerelease' }}
run: pnpm install --frozen-lockfile --filter . --filter "./packages/**"
@@ -425,7 +424,6 @@ jobs:
if: ${{ inputs.dry-run != true }}
env:
NODE_AUTH_TOKEN: ""
NOTION_API_KEY: ${{ steps.meta.outputs.mode == 'stable' && secrets.NOTION_API_KEY || '' }}
PUBLISH_SCRIPT: ${{ steps.meta.outputs.mode == 'prerelease' && 'prerelease.ts' || 'publish-release.ts' }}
SCOPE: ${{ steps.meta.outputs.scope }}
run: |
+3 -25
View File
@@ -111,8 +111,6 @@ jobs:
run: pnpm tsx scripts/release/generate-ai-release-notes.ts "${{ steps.prepare.outputs.version }}"
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
NOTION_RELEASE_NOTES_PAGE: ${{ secrets.NOTION_RELEASE_NOTES_PAGE }}
- name: Mint devops-bot token
if: inputs.dry_run != true
@@ -156,7 +154,8 @@ jobs:
must pass before merging. This is the review gate.
3. **Review the release notes** in `release-notes.md` in this PR.
If a Notion draft was created, you can edit the release notes there before merging.
Edit that file on this branch to change what ships — the publish job
reads it verbatim as the GitHub Release body.
4. **When this PR is merged**, the `release / publish` workflow automatically:
- Builds all packages
@@ -168,31 +167,10 @@ jobs:
- [ ] CI is green (tests, lint, types, build)
- [ ] Version bumps look correct
- [ ] Release notes are accurate (edit in Notion if a draft was created)
- [ ] Release notes are accurate (edit `release-notes.md` on this branch)
---
> **Do not merge until CI is fully green.** The full test suite runs automatically on this PR.
labels: release
- name: Comment Notion link on PR
if: inputs.dry_run != true && steps.ai_notes.outputs.notion_url
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
NOTION_URL: ${{ steps.ai_notes.outputs.notion_url }}
PR_NUMBER: ${{ steps.create_pr.outputs.pull-request-number }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const notionUrl = process.env.NOTION_URL;
if (prNumber && notionUrl) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: `📝 **Release notes draft:** ${notionUrl}\n\nYou can edit the release notes in Notion before merging. The final content will be used for the GitHub Release.`,
});
}
-4
View File
@@ -71,10 +71,6 @@ private-agents.md
# Agent screenshots from the Inspector workbench skill. Session-only. Never commit.
.inspector-workbench/
# Release artifacts
release-notes.md
release-notes-notion.json
# Binary artifacts
*.dSYM/
*.exe
@@ -0,0 +1,42 @@
import { spawnSync } from "child_process";
import path from "path";
import { describe, expect, it } from "vitest";
import { ROOT } from "../lib/config.js";
/**
* `release-notes.md` is how the generated notes travel from the create-pr
* workflow to the publish job: prepare-release writes it, create-pull-request
* commits it onto the release branch, and publish-release reads it back as the
* GitHub Release body.
*
* It was gitignored, so create-pull-request silently skipped it and every
* release since the lane was built shipped the `Release <tag>` fallback body
* instead of notes. An ignore rule is the one way to break this without
* breaking any other test, hence this guard.
*/
describe("release-notes.md reaches the publish job", () => {
it("is not gitignored", () => {
const result = spawnSync("git", ["check-ignore", "release-notes.md"], {
cwd: ROOT,
encoding: "utf8",
});
// git check-ignore exits 0 when the path IS ignored, 1 when it is not.
expect(
result.status,
`release-notes.md is gitignored (matched by: ${result.stdout.trim()}), so it ` +
`cannot be committed to the release PR branch and the publish job will ` +
`fall back to a bodyless "Release <tag>" GitHub Release.`,
).not.toBe(0);
});
it("is the path both halves of the lane agree on", () => {
const prepare = path.join(ROOT, "scripts/release/prepare-release.ts");
const publish = path.join(ROOT, ".github/workflows/publish-release.yml");
const read = (p: string) =>
spawnSync("cat", [p], { encoding: "utf8" }).stdout;
expect(read(prepare)).toContain('"release-notes.md"');
expect(read(publish)).toContain('"./release-notes.md"');
});
});
+6 -38
View File
@@ -1,16 +1,15 @@
/**
* AI-powered release notes generator + Notion draft creator.
* AI-powered release notes generator.
*
* 1. Reads the raw changelog from release-notes.md
* 2. Calls Claude API to generate polished release notes
* 3. Creates a Notion page with the draft (for human editing)
* 4. Writes release-notes.md with the AI version
* 5. Outputs the Notion page URL + ID for the workflow
* 3. Writes release-notes.md with the AI version
*
* release-notes.md is committed to the release PR branch, which is both the
* review surface and how the notes reach the publish job.
*
* Env vars:
* ANTHROPIC_API_KEY — for AI generation (falls back to raw if missing)
* NOTION_API_KEY — for creating the Notion draft (skipped if missing)
* NOTION_RELEASE_NOTES_PAGE — parent page ID in Notion
* ANTHROPIC_API_KEY — for AI generation (falls back to raw if missing)
*
* Usage: tsx scripts/release/generate-ai-release-notes.ts <version>
*/
@@ -21,7 +20,6 @@ import https from "https";
import { spawnSync } from "child_process";
import { ROOT } from "./lib/config.js";
import { GIT_LOG_FORMAT, parseCommitLog } from "./lib/changes.js";
import { createReleaseDraft } from "./lib/notion.js";
function getRecentCommits(count = 50): string {
const result = spawnSync(
@@ -142,36 +140,6 @@ Output ONLY the release notes content, nothing else.`;
);
}
// Step 2: Create a Notion draft page for human editing
const notionKey = process.env.NOTION_API_KEY;
const notionParent = process.env.NOTION_RELEASE_NOTES_PAGE;
if (notionKey && notionParent) {
console.log("Creating Notion release notes draft...");
try {
const { pageId, url } = await createReleaseDraft(version, finalNotes);
console.log(`Notion draft created: ${url}`);
// Write the Notion reference so the publish workflow can find it
const notionRef = { pageId, url, version };
const refPath = path.join(ROOT, "release-notes-notion.json");
fs.writeFileSync(refPath, JSON.stringify(notionRef, null, 2) + "\n");
// Output for CI
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath) {
fs.appendFileSync(outputPath, `notion_url=${url}\n`);
fs.appendFileSync(outputPath, `notion_page_id=${pageId}\n`);
}
} catch (err: any) {
console.error(`Notion draft creation failed: ${err.message}`);
console.log("Continuing without Notion draft.");
}
} else {
console.log(
"No NOTION_API_KEY/NOTION_RELEASE_NOTES_PAGE found. Skipping Notion draft.",
);
}
}
main().catch((err) => {
-196
View File
@@ -1,196 +0,0 @@
import https from "https";
/**
* Notion API helper for release notes management.
*
* Creates a draft release notes page under the configured parent page.
* On merge, reads the (potentially edited) page content back for the
* GitHub Release body.
*
* Required env vars:
* NOTION_API_KEY — Notion internal integration token
* NOTION_RELEASE_NOTES_PAGE — Parent page ID for release note drafts
*/
const NOTION_API_VERSION = "2022-06-28";
function notionRequest(
apiKey: string,
method: string,
path: string,
body?: unknown,
): Promise<any> {
return new Promise((resolve, reject) => {
const options = {
hostname: "api.notion.com",
path,
method,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Notion-Version": NOTION_API_VERSION,
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk: string) => (data += chunk));
res.on("end", () => {
const parsed = JSON.parse(data);
if (res.statusCode && res.statusCode >= 400) {
reject(
new Error(
`Notion API ${res.statusCode}: ${parsed.message || data}`,
),
);
} else {
resolve(parsed);
}
});
});
req.on("error", reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
/** Convert a markdown string into Notion block children (simplified). */
function markdownToBlocks(markdown: string): any[] {
const blocks: any[] = [];
const lines = markdown.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("### ")) {
blocks.push({
object: "block",
type: "heading_3",
heading_3: {
rich_text: [{ type: "text", text: { content: line.slice(4) } }],
},
});
} else if (line.startsWith("## ")) {
blocks.push({
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: line.slice(3) } }],
},
});
} else if (line.startsWith("- ")) {
blocks.push({
object: "block",
type: "bulleted_list_item",
bulleted_list_item: {
rich_text: [{ type: "text", text: { content: line.slice(2) } }],
},
});
} else if (line.trim() === "") {
continue;
} else {
blocks.push({
object: "block",
type: "paragraph",
paragraph: {
rich_text: [{ type: "text", text: { content: line } }],
},
});
}
}
return blocks;
}
/** Convert Notion blocks back to markdown. */
function blocksToMarkdown(blocks: any[]): string {
const lines: string[] = [];
for (const block of blocks) {
const richText =
block[block.type]?.rich_text
?.map((t: any) => t.plain_text || "")
.join("") || "";
switch (block.type) {
case "heading_1":
lines.push(`# ${richText}`);
break;
case "heading_2":
lines.push(`## ${richText}`);
break;
case "heading_3":
lines.push(`### ${richText}`);
break;
case "bulleted_list_item":
lines.push(`- ${richText}`);
break;
case "numbered_list_item":
lines.push(`1. ${richText}`);
break;
case "paragraph":
lines.push(richText || "");
break;
case "divider":
lines.push("---");
break;
default:
if (richText) lines.push(richText);
}
}
return lines.join("\n");
}
/**
* Create a Notion page with release notes content.
* Returns { pageId, url }.
*/
export async function createReleaseDraft(
version: string,
markdownContent: string,
): Promise<{ pageId: string; url: string }> {
const apiKey = process.env.NOTION_API_KEY;
const parentPageId = process.env.NOTION_RELEASE_NOTES_PAGE;
if (!apiKey || !parentPageId) {
throw new Error("NOTION_API_KEY and NOTION_RELEASE_NOTES_PAGE must be set");
}
const blocks = markdownToBlocks(markdownContent);
const page = await notionRequest(apiKey, "POST", "/v1/pages", {
parent: { page_id: parentPageId },
properties: {
title: {
title: [{ text: { content: `v${version} Release Notes (Draft)` } }],
},
},
children: blocks,
});
return {
pageId: page.id,
url: page.url,
};
}
/**
* Read a Notion page's content back as markdown.
* Used on merge to get the (potentially human-edited) release notes.
*/
export async function readReleaseDraft(pageId: string): Promise<string> {
const apiKey = process.env.NOTION_API_KEY;
if (!apiKey) {
throw new Error("NOTION_API_KEY must be set");
}
const response = await notionRequest(
apiKey,
"GET",
`/v1/blocks/${pageId}/children?page_size=100`,
);
return blocksToMarkdown(response.results);
}
-23
View File
@@ -2,7 +2,6 @@
* Publish a stable release (runs after merge of a release PR).
*
* 1. Reads the scope and current version from package.json (already bumped by the release PR)
* 2. Optionally reads the Notion draft for the final release notes
* 3. Publishes pre-built packages to npm with "latest" tag
* 4. Outputs the version for downstream steps (git tag, GitHub Release)
*
@@ -10,7 +9,6 @@
* This script receives pre-built artifacts and only performs the publish step.
*
* Env vars:
* NOTION_API_KEY — for reading edited release notes from Notion (optional)
* GITHUB_OUTPUT — CI output file
*
* Auth: Uses npm OIDC trusted publishers (id-token: write) via the pinned npm 11
@@ -28,7 +26,6 @@ import {
getPackagesForScope,
parseSemver,
} from "./lib/versions.js";
import { readReleaseDraft } from "./lib/notion.js";
import { ROOT, getScopeConfig, loadConfig } from "./lib/config.js";
import type { ReleaseScope } from "./lib/config.js";
import { emitGithubOutputs } from "./lib/github-output.js";
@@ -169,26 +166,6 @@ async function main() {
}
}
// Try to read edited release notes from Notion
const notionRefPath = path.join(ROOT, "release-notes-notion.json");
const releaseNotesPath = path.join(ROOT, "release-notes.md");
if (phase !== "dependencies" && fs.existsSync(notionRefPath)) {
try {
const ref = JSON.parse(fs.readFileSync(notionRefPath, "utf8"));
if (ref.pageId && process.env.NOTION_API_KEY) {
console.log("Reading edited release notes from Notion...");
const notionContent = await readReleaseDraft(ref.pageId);
if (notionContent.trim()) {
fs.writeFileSync(releaseNotesPath, notionContent);
console.log("Release notes updated from Notion draft.");
}
}
} catch (err: any) {
console.error(`Failed to read Notion draft: ${err.message}`);
console.log("Using release notes from the PR branch.");
}
}
// NOTE: Build is handled by the CI build job (no secrets).
// The publish job receives pre-built artifacts via download-artifact.