Repository navigation
fix(release): carry preview changelog into latest notes #482
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
ecdb943
40ced9b
fd83ecd
11b2933
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | | ||
|
|
@@ -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 | ||
| )" | ||
| if [ -n "$preview_carry_tag" ]; then | ||
| if gh release view "$preview_carry_tag" --json body --jq .body > "${carried_file}.raw" 2>/dev/null; then | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the Create GitHub release step, this conditional treats every nonzero exit from the Useful? React with 👍 / 👎. |
||
| bun scripts/release-notes.ts strip-carried "${carried_file}.raw" > "$carried_file" | ||
| notes_range_start="$preview_carry_tag" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the matching preview release has an empty body or only metadata and the 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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, Useful? React with 👍 / 👎. |
||
| # git log may omit a trailing newline; normalize empty output for assemble. | ||
| if [ ! -s "$commits_file" ]; then | ||
| : > "$commits_file" | ||
| fi | ||
|
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" | ||
|
|
||
| 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)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.