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:
Ben Taylor
2026-08-30 09:59:26 -05:00
committed by GitHub
6 changed files with 329 additions and 66 deletions
+14 -3
View File
@@ -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}
+65 -4
View File
@@ -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 });
}
});
});
+26 -12
View File
@@ -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[] {
+120
View File
@@ -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");
});
});
+103
View File
@@ -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");
}
+1 -47
View File
@@ -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);