fix: stabilize release changelog generation#19987
Conversation
Keep release commit discovery and contributor thanks deterministic while letting the changelog command write diff-aware summaries.
There was a problem hiding this comment.
Pull request overview
Refactors the release changelog workflow to make the “data gathering” step deterministic (commit selection, section grouping, contributor input) and keep the .opencode changelog command focused on diff-aware release-note writing.
Changes:
- Reworks
script/changelog.tsto produce a stable, sectioned commit list plus deterministic community-contributor input. - Updates the
changelogopencode command prompt to consume the structured script output (and to ignore any existingUPCOMING_CHANGELOG.md). - Adds
UPCOMING_CHANGELOG.mdto.gitignoreto keep generated previews out of git state.
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| script/changelog.ts | Generates deterministic grouped commit bullets and optional community-contributor block for changelog generation. |
| .opencode/command/changelog.md | Updates the LLM instructions to rely on bun script/changelog.ts output and preserve deterministic attribution/thanks behavior. |
| .gitignore | Ignores the generated UPCOMING_CHANGELOG.md file. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
script/changelog.ts
Outdated
| function tag(input: string) { | ||
| if (input === "HEAD") return input | ||
| if (input.startsWith("v")) return input | ||
| return `v${input}` |
There was a problem hiding this comment.
tag() unconditionally prefixes non-v* inputs with v, which breaks legitimate git refs like main, HEAD~1, or a commit SHA (e.g. --to main becomes vmain, causing gh api .../compare/... and git log to fail). Consider only adding the v prefix when the input looks like a version (e.g. semver), and otherwise pass the ref through unchanged.
| function tag(input: string) { | |
| if (input === "HEAD") return input | |
| if (input.startsWith("v")) return input | |
| return `v${input}` | |
| function isVersionLike(input: string): boolean { | |
| // Match simple semver-like patterns without a leading "v", e.g. "1", "1.2", "1.2.3", optionally with suffixes. | |
| return /^[0-9]+(\.[0-9]+){0,2}([\-+].*)?$/.test(input) | |
| } | |
| function tag(input: string) { | |
| if (input === "HEAD") return input | |
| if (input.startsWith("v")) return input | |
| if (isVersionLike(input)) return `v${input}` | |
| return input |
script/changelog.ts
Outdated
| } | ||
| async function published(to: string) { | ||
| if (to === "HEAD") return | ||
| const body = await $`gh release view ${tag(to)} --json body --jq .body`.text().catch(() => "") |
There was a problem hiding this comment.
published() uses gh release view ... without scoping to the repo/GH_REPO value used elsewhere in this script. If GH_REPO is set (or the working directory isn't the same repo), this can read the wrong release or fail. Pass --repo ${repo} (or -R ${repo}) to keep behavior consistent with the other gh api "/repos/${repo}/..." calls.
| const body = await $`gh release view ${tag(to)} --json body --jq .body`.text().catch(() => "") | |
| const body = await $`gh release view ${tag(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "") |
| const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const | ||
| const sections = { | ||
| core: "Core", | ||
| tui: "TUI", | ||
| app: "Desktop", | ||
| tauri: "Desktop", | ||
| sdk: "SDK", | ||
| plugin: "SDK", | ||
| "extensions/zed": "Extensions", | ||
| "extensions/vscode": "Extensions", | ||
| github: "Extensions", | ||
| } as const | ||
|
|
||
| function tag(input: string) { | ||
| if (input === "HEAD") return input | ||
| if (input.startsWith("v")) return input | ||
| return `v${input}` | ||
| } | ||
|
|
||
| async function latest() { | ||
| const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json() | ||
| const release = (data as Release[]).find((item) => !item.draft) | ||
| if (!release) throw new Error("No releases found") | ||
| return release.tag_name.replace(/^v/, "") | ||
| } | ||
|
|
||
| function section(areas: Set<string>) { | ||
| const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"] | ||
| for (const area of priority) { | ||
| if (areas.has(area)) return sections[area as keyof typeof sections] | ||
| } | ||
| return "Core" |
There was a problem hiding this comment.
sections/section() still include plugin and github keys, but commits() no longer adds plugin or github to areas (plugin is folded into sdk, and github/ is folded into extensions/vscode). Dropping the unused keys (and removing them from the priority list) will prevent future confusion about which area strings are actually emitted.
Pass the version job's exact commit into the changelog command and allow changelog refs to use SHAs as well as version tags.
Paginate compare data for long release windows, avoid reusing published contributor blocks for explicit from/to previews, and remove the no-notable-changes prompt ambiguity.
Summary
Release changelogs were shifted by one version (e.g. v1.3.5 showed v1.3.4 changes) because the LLM was improvising the commit range instead of computing it deterministically.
script/changelog.tsnow gathers commits, sections, reverts, and contributors viagh api+git log-- LLM only summarizes diffsscript/version.tspins the upper bound toGITHUB_SHAso future commits cannot leak into the current release.opencode/command/changelog.mdconsumes the structured data via shell interpolation instead of querying GitHub itselfUPCOMING_CHANGELOG.mdto.gitignore