Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
80 changes: 57 additions & 23 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ jobs:
exit 1
fi

# Generate notes against the previous tag from the same release channel.
# Channel previous tag (stable↔stable / preview↔preview) for the Full Changelog link.
if [[ "$RELEASE_VERSION" == *-preview.* ]]; then
previous_tag="$(
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
Expand Down Expand Up @@ -329,47 +329,81 @@ jobs:
# tag need not exist yet. Preflight already rejects an existing GitHub Release for
# non-dry runs, so this step only creates.
notes_file="$(mktemp)"
pr_notes=""
if [ -n "$previous_tag" ]; then
carried_file="$(mktemp)"
delta_file="$(mktemp)"
commits_file="$(mktemp)"
: > "$carried_file"
: > "$delta_file"
: > "$commits_file"

# Stable releases after a matching preview: copy that preview changelog, then only
# generate-notes / commits for the preview→latest delta. Preview tags often are not
# ancestors of main, so match by version prefix (not --merged).
notes_range_start="$previous_tag"
if [[ "$RELEASE_VERSION" != *-preview.* ]]; then
preview_carry_tag="$(
git tag --list "v${RELEASE_VERSION}-preview.*" --sort=-v:refname |
head -n 1 || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Carry changelogs from every matching preview

When a stable version has multiple previews, this selects only the newest matching tag. The preview path above generates each preview's notes relative to the immediately preceding preview, so the newest release body is only an incremental changelog; changes documented exclusively in earlier previews of the same version disappear because the subsequent delta also starts at the newest tag. Aggregate the stripped bodies of all matching preview releases in order, or generate notes from a baseline covering the full preview sequence.

Useful? React with 👍 / 👎.

)"
if [ -n "$preview_carry_tag" ]; then
if gh release view "$preview_carry_tag" --json body --jq .body > "${carried_file}.raw" 2>/dev/null; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail when the preview release lookup has an operational error

In the Create GitHub release step, this conditional treats every nonzero exit from the gh release view lookup as proof that the release does not exist. A timeout, rate limit, permission failure, or GitHub API error therefore silently reverts to the known-incomplete channel-only notes and continues creating the stable release after npm has already been published; the next normal release attempt is then blocked because that npm version is used. Fall back only for a confirmed not-found response, and retry or fail closed for other lookup errors.

Useful? React with 👍 / 👎.

bun scripts/release-notes.ts strip-carried "${carried_file}.raw" > "$carried_file"
notes_range_start="$preview_carry_tag"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the stable baseline when carried notes are empty

When the matching preview release has an empty body or only metadata and the ## Commits section, strip-carried produces no usable changelog, but this still moves notes_range_start to the preview tag. This occurs for a first preview-channel release, where the workflow deliberately creates commits-only notes; the stable release then generates and lists only the post-preview delta, omitting all earlier changes. Only switch the baseline after confirming that the stripped body contains meaningful release notes; otherwise retain the previous stable tag.

Useful? React with 👍 / 👎.

echo "::notice::Carrying preview release notes from ${preview_carry_tag} into ${release_tag}"
else
echo "::notice::Matching preview tag ${preview_carry_tag} has no GitHub Release; using channel notes only"
: > "$carried_file"
fi
fi
fi

if [ -n "$notes_range_start" ]; then
generate_notes_api=(
"repos/${GITHUB_REPOSITORY}/releases/generate-notes"
-f "tag_name=${release_tag}"
-f "target_commitish=${GITHUB_SHA}"
-f "previous_tag_name=${previous_tag}"
-f "previous_tag_name=${notes_range_start}"
)
# Fail closed: missing PR categories is a broken release note, not a soft skip.
pr_notes="$(gh api "${generate_notes_api[@]}" --jq '.body')"
# Drop generate-notes' trailing compare link; we re-append it after the commit list.
pr_notes="$(printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d')"
printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d' > "$delta_file"
else
# First release on this channel: never call generate-notes without previous_tag_name.
# GitHub would baseline the newest repo tag, which may belong to the other channel.
echo "::notice::No previous channel tag; skipping generate-notes (commits-only notes)"
fi

if [ -n "$previous_tag" ]; then
commit_range="${previous_tag}..${GITHUB_SHA}"
commits="$(git log --pretty=format:'- %s (%h)' "$commit_range")"
if [ -n "$notes_range_start" ]; then
commit_range="${notes_range_start}..${GITHUB_SHA}"
git log --pretty=format:'- %s (%h)' "$commit_range" > "$commits_file"
Comment on lines +417 to +419

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid two-dot logs from a divergent preview tag

When the selected preview tag is not an ancestor of the stable commit—a case the workflow explicitly says is common—this two-dot range is not a chronological preview-to-stable delta. Under Git's revision-range semantics, A..B includes every commit reachable from B but not A, so the release's ## Commits section can contain unrelated main-side history all the way back to the branches' merge base. Use the preview tag as the commit baseline only after git merge-base --is-ancestor succeeds; otherwise retain an ancestor such as the previous stable tag.

Useful? React with 👍 / 👎.

# git log may omit a trailing newline; normalize empty output for assemble.
if [ ! -s "$commits_file" ]; then
: > "$commits_file"
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
else
# First release on a channel has no previous tag; cap history so the notes
# body stays within GitHub release size limits.
commits="$(git log --pretty=format:'- %s (%h)' --max-count=100 "$GITHUB_SHA")"
git log --pretty=format:'- %s (%h)' --max-count=100 "$GITHUB_SHA" > "$commits_file"
fi

{
printf '%s\n\n' "$npm_metadata"
if [ -n "$(printf '%s' "$pr_notes" | tr -d '[:space:]')" ]; then
printf '%s\n\n' "$pr_notes"
fi
if [ -n "$commits" ]; then
printf '## Commits\n\n'
printf '%s\n\n' "$commits"
fi
if [ -n "$previous_tag" ]; then
printf '**Full Changelog**: https://github.com/%s/compare/%s...%s\n' \
"$GITHUB_REPOSITORY" "$previous_tag" "$release_tag"
fi
} > "$notes_file"
assemble_args=(
bun scripts/release-notes.ts assemble
--npm-metadata "$npm_metadata"
--carried "$carried_file"
--delta "$delta_file"
--commits "$commits_file"
--out "$notes_file"
--compare-to "$release_tag"
--repository "$GITHUB_REPOSITORY"
)
# Prefer the stable-channel previous tag for the compare link when present.
if [ -n "$previous_tag" ]; then
assemble_args+=(--compare-from "$previous_tag")
elif [ -n "$notes_range_start" ]; then
assemble_args+=(--compare-from "$notes_range_start")
fi
"${assemble_args[@]}"

if [ -z "$existing_tag_sha" ]; then
git tag "$release_tag" "$GITHUB_SHA"
Expand Down
193 changes: 193 additions & 0 deletions scripts/release-notes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env bun
/**
* Pure helpers for GitHub release note assembly.
* Used by `.github/workflows/release.yml` so stable (latest) releases can carry
* the matching preview changelog, plus any delta since that preview.
*
* CLI:
* bun scripts/release-notes.ts strip-carried <body-file>
* bun scripts/release-notes.ts matching-preview-tag <version>
* bun scripts/release-notes.ts assemble --npm-metadata ... --out ...
*/

export function matchingPreviewTag(version: string, tags: string[]): string | null {
if (!version || version.includes("-")) return null;
const prefix = `v${version}-preview.`;
const matches = tags
.map(tag => tag.trim())
.filter(tag => tag.startsWith(prefix));
if (matches.length === 0) return null;
matches.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: "base" }));
return matches[0] ?? null;
}

/** Drop npm blurb, Commits section, and Full Changelog link from a prior release body. */
export function stripCarriedReleaseNotes(body: string): string {
const lines = body.replace(/\r\n/g, "\n").split("\n");
const kept: string[] = [];
let inCommits = false;

for (const line of lines) {
if (/^Published to npm as /.test(line)) continue;
if (/^\*\*Full Changelog\*\*:/.test(line)) continue;
if (/^## Commits\s*$/.test(line)) {
inCommits = true;
continue;
}
if (inCommits) {
if (/^## /.test(line)) {
inCommits = false;
} else {
continue;
}
}
kept.push(line);
}

return kept.join("\n").replace(/^\n+/, "").replace(/\n+$/, "").trim();
}

/** Drop generate-notes trailing compare link (workflow re-appends its own). */
export function stripGenerateNotesCompareLink(body: string): string {
return body
.replace(/\r\n/g, "\n")
.split("\n")
.filter(line => !/^\*\*Full Changelog\*\*:/.test(line))
.join("\n")
.replace(/\n+$/, "")
.trim();
}

/** True when generate-notes returned only the config comment / blank lines. */
export function isEmptyGeneratedNotes(body: string): boolean {
const withoutComment = stripGenerateNotesCompareLink(body)
.split("\n")
.filter(line => !/^<!--.*-->$/.test(line.trim()))
.join("\n");
return !hasNonWhitespace(withoutComment);
}

export function hasNonWhitespace(text: string): boolean {
return text.replace(/\s+/g, "").length > 0;
}

export function assembleReleaseNotes(input: {
npmMetadata: string;
carriedPreviewNotes?: string;
deltaPrNotes?: string;
commits?: string;
compareFrom?: string | null;
compareTo?: string;
repository?: string;
}): string {
const parts: string[] = [];
parts.push(input.npmMetadata.trim());

const carried = (input.carriedPreviewNotes ?? "").trim();
if (hasNonWhitespace(carried)) {
parts.push(carried);
}

const deltaRaw = (input.deltaPrNotes ?? "").trim();
const delta = isEmptyGeneratedNotes(deltaRaw) ? "" : stripGenerateNotesCompareLink(deltaRaw);
if (hasNonWhitespace(delta)) {
if (hasNonWhitespace(carried)) {
parts.push("## Since preview\n\n" + delta);
} else {
parts.push(delta);
}
}

const commits = (input.commits ?? "").trim();
if (commits) {
parts.push("## Commits\n\n" + commits);
}

const from = input.compareFrom?.trim();
const to = input.compareTo?.trim();
const repo = input.repository?.trim();
if (from && to && repo) {
parts.push(`**Full Changelog**: https://github.com/${repo}/compare/${from}...${to}`);
}

return parts.join("\n\n").replace(/\n+$/, "") + "\n";
}

async function readStdinOrFile(path: string | undefined): Promise<string> {
if (path && path !== "-") {
return await Bun.file(path).text();
}
return await new Response(Bun.stdin).text();
}

async function main(argv: string[]): Promise<void> {
const [cmd, ...rest] = argv;
if (cmd === "strip-carried") {
const stripped = stripCarriedReleaseNotes(await readStdinOrFile(rest[0]));
process.stdout.write(stripped.endsWith("\n") ? stripped : stripped + "\n");
return;
}

if (cmd === "matching-preview-tag") {
const version = rest[0];
if (!version) {
console.error("Usage: bun scripts/release-notes.ts matching-preview-tag <version>");
process.exit(1);
}
const tagsText = await new Response(Bun.stdin).text();
const tag = matchingPreviewTag(version, tagsText.split(/\r?\n/));
if (tag) process.stdout.write(tag + "\n");
return;
}

if (cmd === "assemble") {
const args = new Map<string, string>();
for (let i = 0; i < rest.length; i += 1) {
const key = rest[i];
if (!key?.startsWith("--")) continue;
const value = rest[i + 1];
if (!value || value.startsWith("--")) {
console.error(`Missing value for ${key}`);
process.exit(1);
}
args.set(key.slice(2), value);
i += 1;
}

const npmMetadata = args.get("npm-metadata");
const out = args.get("out");
if (!npmMetadata || !out) {
console.error("Usage: bun scripts/release-notes.ts assemble --npm-metadata <text> --out <file> [--carried <file>] [--delta <file>] [--commits <file>] [--compare-from <tag>] [--compare-to <tag>] [--repository <owner/name>]");
process.exit(1);
}

const readOptional = async (name: string): Promise<string> => {
const path = args.get(name);
if (!path) return "";
return await Bun.file(path).text();
};

const notes = assembleReleaseNotes({
npmMetadata,
carriedPreviewNotes: await readOptional("carried"),
deltaPrNotes: await readOptional("delta"),
commits: await readOptional("commits"),
compareFrom: args.get("compare-from") ?? null,
compareTo: args.get("compare-to"),
repository: args.get("repository"),
});
await Bun.write(out, notes);
return;
}

console.error(`Unknown command: ${cmd ?? "(none)"}
Usage:
bun scripts/release-notes.ts strip-carried [body-file]
bun scripts/release-notes.ts matching-preview-tag <version> # tags on stdin
bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`);
process.exit(1);
}

if (import.meta.main) {
await main(process.argv.slice(2));
}
23 changes: 15 additions & 8 deletions tests/ci-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,28 +126,35 @@ describe("GitHub Actions hardening", () => {

// Release notes must include PR categories and the full channel commit range
// (branch merges + direct commits). Preflight forbids an existing release, so
// only create (not edit) is wired.
// only create (not edit) is wired. Stable releases also carry matching preview notes.
expect(workflow).toContain("releases/generate-notes");
expect(workflow).toContain("## Commits");
expect(workflow).toContain("git log --pretty=format:'- %s (%h)'");
expect(workflow).toContain('commit_range="${previous_tag}..${GITHUB_SHA}"');
expect(workflow).toContain('previous_tag_name=${previous_tag}');
expect(workflow).toContain('commit_range="${notes_range_start}..${GITHUB_SHA}"');
expect(workflow).toContain('previous_tag_name=${notes_range_start}');
expect(workflow).toContain("skipping generate-notes (commits-only notes)");
expect(workflow).toContain("bun scripts/release-notes.ts strip-carried");
expect(workflow).toContain("bun scripts/release-notes.ts assemble");
expect(workflow).toContain("--commits");
expect(workflow).toContain('git tag --list "v${RELEASE_VERSION}-preview.*"');
expect(workflow).toContain("Carrying preview release notes from");
const releaseNotesHelper = await readText("scripts/release-notes.ts");
expect(releaseNotesHelper).toContain("## Commits");
expect(releaseNotesHelper).toContain("## Since preview");
expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/);
expect(workflow).not.toContain("gh release edit");
expect(workflow).not.toContain("--generate-notes");
// Fail closed when generate-notes fails (no soft skip).
expect(workflow).not.toMatch(/generate-notes[\s\S]*?\|\| true/);
// Notes must be assembled before tagging so a notes API failure does not leave
// a remote tag that blocks release retries at preflight.
const createStep = workflow.split("- name: Create GitHub release")[1]!.split(/\n {6}- name:/)[0]!;
// Fail closed when generate-notes fails (no soft skip on the API call itself).
expect(createStep).not.toMatch(/pr_notes="\$\(gh api[\s\S]*?\|\| true\)"/);
expect(createStep.indexOf("gh api")).toBeGreaterThan(-1);
expect(createStep.indexOf('git tag "$release_tag"')).toBeGreaterThan(-1);
expect(createStep.indexOf("gh api")).toBeLessThan(createStep.indexOf('git tag "$release_tag"'));
// First-channel releases must not call generate-notes without an explicit channel baseline
// First-channel releases must not call generate-notes without an explicit baseline
// (GitHub would otherwise pick the newest repo tag, possibly from the other channel).
expect(createStep).toMatch(
/if \[ -n "\$previous_tag" \]; then[\s\S]*previous_tag_name=\$\{previous_tag\}[\s\S]*else[\s\S]*skipping generate-notes/,
/if \[ -n "\$notes_range_start" \]; then[\s\S]*previous_tag_name=\$\{notes_range_start\}[\s\S]*else[\s\S]*skipping generate-notes/,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
);
});

Expand Down
Loading
Loading