mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
fix(release): preserve breaking change footers (#6745)
## What does this PR do? Preserves Conventional Commit bodies while collecting release changes so breaking-change migration guidance can reach both raw and AI-generated release notes. The change: - parses `git log` with explicit field and record separators, including multiline bodies without splitting commits; - extracts both `BREAKING CHANGE:` and `BREAKING-CHANGE:` footers and keeps their continuation lines; - recognizes only the Conventional Commit `!:` marker instead of arbitrary exclamation marks; - shares the raw release-note renderer between the release preparation script and focused tests; - adds a real temporary-Git-history regression test plus unit coverage for footer-only, `!:`-only, trailer, multiline, and empty-body cases. The implementation is intentionally limited to `scripts/release/`. Validation completed: - `pnpm exec vitest run scripts/release` — 14 files, 161 tests passed - `pnpm run build` - full test suite, with all initially environment-sensitive projects rerun successfully - `pnpm run check:packages` - `pnpm run lint` — no errors - `pnpm run check-format` - `pnpm run release:prepare:dry` - `bash scripts/release/verify-release-scope-dropdowns.sh` - targeted TypeScript and oxlint checks for all six changed files ## Related PRs and Issues - Fixes https://github.com/CopilotKit/CopilotKit/issues/6479 - Clean, release-only follow-up to https://github.com/CopilotKit/CopilotKit/pull/6632 ## Checklist - [x] I have read the [Contribution Guide](https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md) - [x] If the PR changes or adds functionality, I have updated the relevant documentation (not applicable; internal release tooling with regression coverage) - [x] "Allow edits by maintainers" is checked (lets us help iterate on your PR directly — faster turnaround for everyone)
This commit is contained in:
@@ -20,15 +20,26 @@ import path from "path";
|
||||
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(
|
||||
"git",
|
||||
["log", "--oneline", `-${count}`, "--no-merges"],
|
||||
["log", `-${count}`, "--no-merges", `--format=${GIT_LOG_FORMAT}`],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
);
|
||||
return result.stdout.trim();
|
||||
return parseCommitLog(result.stdout)
|
||||
.map((commit) =>
|
||||
[
|
||||
`commit ${commit.hash.slice(0, 7)}`,
|
||||
`subject: ${commit.subject}`,
|
||||
commit.body ? `body:\n${commit.body}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function callAnthropic(apiKey: string, prompt: string): Promise<string> {
|
||||
@@ -101,7 +112,7 @@ Here is the raw changelog extracted from git history:
|
||||
|
||||
${rawChangelog}
|
||||
|
||||
Here are the recent git commits for additional context:
|
||||
Here are the recent git commits, including their bodies, for additional context:
|
||||
|
||||
${recentCommits}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getChangesSummary, getLastReleaseTag } from "./changes.js";
|
||||
import {
|
||||
GIT_LOG_FORMAT,
|
||||
getChangesSummary,
|
||||
getLastReleaseTag,
|
||||
parseCommitLog,
|
||||
} from "./changes.js";
|
||||
|
||||
const spawnSyncMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -16,7 +24,10 @@ function mockGitHistory(): void {
|
||||
}
|
||||
|
||||
if (args[0] === "log") {
|
||||
return { stdout: "abc1234 feat(channels): shared release\n" };
|
||||
return {
|
||||
stdout:
|
||||
"abc1234\x1ffeat(channels): shared release\x1fRelease details\n\nBREAKING CHANGE: migrate the channel config\x1e\n",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`unexpected git arguments: ${args.join(" ")}`);
|
||||
@@ -47,11 +58,61 @@ describe("Channels release history", () => {
|
||||
[
|
||||
"log",
|
||||
"channels/v0.1.1..HEAD",
|
||||
"--oneline",
|
||||
"--no-merges",
|
||||
"--format=%H %s",
|
||||
`--format=${GIT_LOG_FORMAT}`,
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves multiline commit bodies and trailers from real git history", async () => {
|
||||
const actualChildProcess = await vi.importActual("child_process");
|
||||
const spawnSync = actualChildProcess.spawnSync as typeof spawnSyncMock;
|
||||
const repository = mkdtempSync(join(tmpdir(), "copilotkit-release-"));
|
||||
|
||||
const git = (args: string[]) => {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: repository,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
return result.stdout;
|
||||
};
|
||||
|
||||
try {
|
||||
git(["init", "--quiet"]);
|
||||
git(["config", "user.name", "Release Test"]);
|
||||
git(["config", "user.email", "release-test@example.com"]);
|
||||
git(["commit", "--quiet", "--allow-empty", "-m", "fix(core): baseline"]);
|
||||
git([
|
||||
"commit",
|
||||
"--quiet",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"feat(runtime)!: replace the transport",
|
||||
"-m",
|
||||
"The transport now streams every response.\n\nBREAKING CHANGE: configure a streaming adapter before upgrading.\nKeep existing adapters until migration is complete.\n\nCo-authored-by: Release Test <release-test@example.com>",
|
||||
]);
|
||||
|
||||
const output = git([
|
||||
"log",
|
||||
"HEAD",
|
||||
"--no-merges",
|
||||
`--format=${GIT_LOG_FORMAT}`,
|
||||
]);
|
||||
|
||||
expect(parseCommitLog(output)).toEqual([
|
||||
expect.objectContaining({
|
||||
subject: "feat(runtime)!: replace the transport",
|
||||
body: "The transport now streams every response.\n\nBREAKING CHANGE: configure a streaming adapter before upgrading.\nKeep existing adapters until migration is complete.\n\nCo-authored-by: Release Test <release-test@example.com>",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
subject: "fix(core): baseline",
|
||||
body: "",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
rmSync(repository, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,30 @@ export function getLastReleaseTag(scope: ReleaseScope): string | null {
|
||||
export interface Commit {
|
||||
hash: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const GIT_LOG_FORMAT = "%H%x1f%s%x1f%b%x1e";
|
||||
|
||||
export function parseCommitLog(output: string): Commit[] {
|
||||
return output
|
||||
.split("\x1e")
|
||||
.map((record) => record.replace(/^\r?\n/, "").trimEnd())
|
||||
.filter(Boolean)
|
||||
.flatMap((record) => {
|
||||
const firstSeparator = record.indexOf("\x1f");
|
||||
const secondSeparator = record.indexOf("\x1f", firstSeparator + 1);
|
||||
|
||||
if (firstSeparator === -1 || secondSeparator === -1) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
hash: record.slice(0, firstSeparator),
|
||||
subject: record.slice(firstSeparator + 1, secondSeparator),
|
||||
body: record.slice(secondSeparator + 1).trim(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function getCommitsSince(lastTag: string | null): Commit[] {
|
||||
@@ -40,21 +64,11 @@ function getCommitsSince(lastTag: string | null): Commit[] {
|
||||
|
||||
const result = spawnSync(
|
||||
"git",
|
||||
["log", range, "--oneline", "--no-merges", "--format=%H %s"],
|
||||
["log", range, "--no-merges", `--format=${GIT_LOG_FORMAT}`],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
);
|
||||
|
||||
return result.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
return {
|
||||
hash: line.slice(0, spaceIdx),
|
||||
subject: line.slice(spaceIdx + 1),
|
||||
};
|
||||
});
|
||||
return parseCommitLog(result.stdout);
|
||||
}
|
||||
|
||||
export function getCommitsSinceLastRelease(scope: ReleaseScope): Commit[] {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChangesSummary, Commit } from "./changes.js";
|
||||
import {
|
||||
extractBreakingChangeNotes,
|
||||
generateRawReleaseNotes,
|
||||
} from "./release-notes.js";
|
||||
|
||||
function summary(commits: Commit[]): ChangesSummary {
|
||||
return {
|
||||
lastTag: "v1.0.0",
|
||||
commitCount: commits.length,
|
||||
commits,
|
||||
oneline: commits.map((commit) => `- ${commit.subject}`).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractBreakingChangeNotes", () => {
|
||||
it("extracts both conventional breaking footer spellings", () => {
|
||||
expect(
|
||||
extractBreakingChangeNotes({
|
||||
hash: "abc1234",
|
||||
subject: "feat(runtime)!: replace the transport",
|
||||
body: "Context.\n\nBREAKING CHANGE: configure a streaming adapter.\nKeep the old adapter during migration.\nCo-authored-by: Release Test <release-test@example.com>\n\nBREAKING-CHANGE: remove the legacy transport.",
|
||||
}),
|
||||
).toEqual([
|
||||
"configure a streaming adapter.\nKeep the old adapter during migration.",
|
||||
"remove the legacy transport.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores markers embedded inside prose", () => {
|
||||
expect(
|
||||
extractBreakingChangeNotes({
|
||||
hash: "abc1234",
|
||||
subject: "fix(runtime): improve release docs",
|
||||
body: "This mentions BREAKING CHANGE: as an example.",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateRawReleaseNotes", () => {
|
||||
it("renders a breaking section for a footer without a !: subject", () => {
|
||||
const notes = generateRawReleaseNotes(
|
||||
"1.1.0",
|
||||
"monorepo",
|
||||
summary([
|
||||
{
|
||||
hash: "abc123456",
|
||||
subject: "feat(runtime): replace the transport",
|
||||
body: "BREAKING CHANGE: configure a streaming adapter.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(notes).toContain("### Breaking Changes");
|
||||
expect(notes).toContain(" configure a streaming adapter.");
|
||||
});
|
||||
|
||||
it("renders a breaking section for a !: subject without a footer", () => {
|
||||
const notes = generateRawReleaseNotes(
|
||||
"1.1.0",
|
||||
"monorepo",
|
||||
summary([
|
||||
{
|
||||
hash: "abc123456",
|
||||
subject: "feat(runtime)!: replace the transport",
|
||||
body: "",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(notes).toContain("### Breaking Changes");
|
||||
expect(notes).toContain(
|
||||
"- feat(runtime)!: replace the transport (abc1234)",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders parsed migration guidance for breaking commits", () => {
|
||||
const notes = generateRawReleaseNotes(
|
||||
"1.1.0",
|
||||
"monorepo",
|
||||
summary([
|
||||
{
|
||||
hash: "abc123456",
|
||||
subject: "feat(runtime)!: replace the transport",
|
||||
body: "BREAKING CHANGE: configure a streaming adapter.\nKeep the old adapter during migration.",
|
||||
},
|
||||
{
|
||||
hash: "def567890",
|
||||
subject: "fix(core): preserve tool state",
|
||||
body: "",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(notes).toContain("### Breaking Changes");
|
||||
expect(notes).toContain(
|
||||
"- feat(runtime)!: replace the transport (abc1234)",
|
||||
);
|
||||
expect(notes).toContain(" configure a streaming adapter.");
|
||||
expect(notes).toContain(" Keep the old adapter during migration.");
|
||||
});
|
||||
|
||||
it("recognizes only the conventional !: subject position", () => {
|
||||
const notes = generateRawReleaseNotes(
|
||||
"1.1.0",
|
||||
"monorepo",
|
||||
summary([
|
||||
{
|
||||
hash: "abc123456",
|
||||
subject: "fix(core): preserve wow! messages",
|
||||
body: "",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(notes).not.toContain("### Breaking Changes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ChangesSummary, Commit } from "./changes.js";
|
||||
import type { ReleaseScope } from "./config.js";
|
||||
|
||||
const BREAKING_CHANGE_MARKER = /^BREAKING(?: CHANGE|-CHANGE):[ \t]*(.*)$/;
|
||||
const BREAKING_SUBJECT = /^[a-z0-9-]+(?:\([^)]+\))?!:/i;
|
||||
const TRAILER = /^[a-z][a-z0-9-]*(?: [a-z][a-z0-9-]*)?:[ \t]+/i;
|
||||
|
||||
export function extractBreakingChangeNotes(commit: Commit): string[] {
|
||||
const lines = commit.body.split(/\r?\n/);
|
||||
const notes: string[] = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const marker = BREAKING_CHANGE_MARKER.exec(lines[index]);
|
||||
if (!marker) continue;
|
||||
|
||||
const noteLines = [marker[1]];
|
||||
while (
|
||||
index + 1 < lines.length &&
|
||||
lines[index + 1].trim() !== "" &&
|
||||
!TRAILER.test(lines[index + 1])
|
||||
) {
|
||||
index += 1;
|
||||
noteLines.push(lines[index].trimEnd());
|
||||
}
|
||||
|
||||
const note = noteLines.join("\n").trim();
|
||||
if (note) notes.push(note);
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
function isBreakingCommit(commit: Commit): boolean {
|
||||
return (
|
||||
BREAKING_SUBJECT.test(commit.subject) ||
|
||||
extractBreakingChangeNotes(commit).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function generateRawReleaseNotes(
|
||||
version: string,
|
||||
scope: ReleaseScope,
|
||||
summary: ChangesSummary,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const label = scope === "monorepo" ? "" : ` (${scope})`;
|
||||
lines.push(`## v${version}${label}`, "");
|
||||
|
||||
if (summary.commits.length === 0) {
|
||||
lines.push("No changes since last release.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const features: Commit[] = [];
|
||||
const fixes: Commit[] = [];
|
||||
const other: Commit[] = [];
|
||||
|
||||
for (const commit of summary.commits) {
|
||||
if (/^feat[:(]/.test(commit.subject)) features.push(commit);
|
||||
else if (/^fix[:(]/.test(commit.subject)) fixes.push(commit);
|
||||
else other.push(commit);
|
||||
}
|
||||
|
||||
if (features.length > 0) {
|
||||
lines.push("### Features", "");
|
||||
for (const commit of features) {
|
||||
lines.push(`- ${commit.subject} (${commit.hash.slice(0, 7)})`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (fixes.length > 0) {
|
||||
lines.push("### Fixes", "");
|
||||
for (const commit of fixes) {
|
||||
lines.push(`- ${commit.subject} (${commit.hash.slice(0, 7)})`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (other.length > 0) {
|
||||
lines.push("### Other Changes", "");
|
||||
for (const commit of other) {
|
||||
lines.push(`- ${commit.subject} (${commit.hash.slice(0, 7)})`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const breakingCommits = summary.commits.filter(isBreakingCommit);
|
||||
if (breakingCommits.length > 0) {
|
||||
lines.push("### Breaking Changes", "");
|
||||
for (const commit of breakingCommits) {
|
||||
lines.push(`- ${commit.subject} (${commit.hash.slice(0, 7)})`);
|
||||
for (const note of extractBreakingChangeNotes(commit)) {
|
||||
const [firstLine, ...continuation] = note.split(/\r?\n/);
|
||||
lines.push(` ${firstLine}`);
|
||||
for (const line of continuation) lines.push(` ${line}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -15,56 +15,10 @@ import {
|
||||
} from "./lib/versions.js";
|
||||
import type { BumpLevel } from "./lib/versions.js";
|
||||
import { getChangesSummary } from "./lib/changes.js";
|
||||
import type { ChangesSummary, Commit } from "./lib/changes.js";
|
||||
import { generateRawReleaseNotes } from "./lib/release-notes.js";
|
||||
import { ROOT, loadConfig } from "./lib/config.js";
|
||||
import type { ReleaseScope } from "./lib/config.js";
|
||||
|
||||
function generateRawReleaseNotes(
|
||||
version: string,
|
||||
scope: ReleaseScope,
|
||||
summary: ChangesSummary,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const label = scope === "monorepo" ? "" : ` (${scope})`;
|
||||
lines.push(`## v${version}${label}`, "");
|
||||
|
||||
if (summary.commits.length === 0) {
|
||||
lines.push("No changes since last release.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const features: Commit[] = [];
|
||||
const fixes: Commit[] = [];
|
||||
const other: Commit[] = [];
|
||||
|
||||
for (const c of summary.commits) {
|
||||
if (/^feat[:(]/.test(c.subject)) features.push(c);
|
||||
else if (/^fix[:(]/.test(c.subject)) fixes.push(c);
|
||||
else other.push(c);
|
||||
}
|
||||
|
||||
if (features.length > 0) {
|
||||
lines.push("### Features", "");
|
||||
for (const c of features)
|
||||
lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (fixes.length > 0) {
|
||||
lines.push("### Fixes", "");
|
||||
for (const c of fixes) lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (other.length > 0) {
|
||||
lines.push("### Other Changes", "");
|
||||
for (const c of other) lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Valid scopes come from release.config.json — the single source of truth.
|
||||
const VALID_SCOPES = Object.keys(loadConfig().scopes);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user