mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
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:
@@ -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: |
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user