chore(release): require notes for stable releases and remove old changelog tools (#60429)

This commit is contained in:
Aiden Cline
2026-10-29 21:37:15 -05:00
committed by GitHub
Unverified
parent 4785478249
commit c45aae77ab
8 changed files with 8 additions and 414 deletions
+5 -1
View File
@@ -3,7 +3,7 @@
# (INPUT_BUMP, INPUT_VERSION) only, and writes them to $GITHUB_OUTPUT. Nothing here reads repository files.
#
# push or dispatch on dev -> dev 0.0.0-dev-<run> npm tag dev, desktop
# dispatch on dev with bump/version -> latest x.y.z npm tag latest, releasers only
# dispatch on dev with bump/version -> latest x.y.z npm tag latest, releasers only, notes required
# push or dispatch on beta -> beta 0.0.0-beta-<run> desktop only
# pull_request -> pr 0.0.0-pr-<run> everything, dry run
#
@@ -20,6 +20,10 @@ case "$GITHUB_EVENT_NAME:$GITHUB_REF:$versioned" in
*) die "$GITHUB_EVENT_NAME on $GITHUB_REF cannot publish (versioned: $versioned)" ;;
esac
if [ "$channel" = latest ] && [ -z "${INPUT_RELEASE_NOTES//[[:space:]]/}" ]; then
die "Stable releases need release notes; generate them with ./script/release.ts --print"
fi
if [ "$channel" = latest ]; then
actor=${GITHUB_TRIGGERING_ACTOR:-${GITHUB_ACTOR:-}}
[ -n "$actor" ] || die "Stable releases can only be started by releasers; the triggering actor is unknown"
+1 -1
View File
@@ -9,7 +9,7 @@ payload=$(jq -n --arg tag "$tag" --arg notes "${OPENCODE_RELEASE_NOTES:-}" \
($notes | if length > 4000 then .[:4000] + "\n\n[Full release notes](\($url))" else . end) as $description |
{
allowed_mentions: {parse: []},
embeds: [{title: "OpenCode V2 \($tag)", url: $url, color: 2829099} + if $description == "" then {} else {description: $description} end]
embeds: [{title: "OpenCode V2 \($tag)", url: $url, description: $description, color: 2829099}]
}')
echo "$payload" | jq .
if dry_run; then exit 0; fi
+2 -1
View File
@@ -24,7 +24,7 @@ on:
required: false
type: string
release_notes:
description: "Reviewed V2 release notes for CHANGELOG.md and the GitHub release (optional)"
description: "Reviewed V2 release notes for CHANGELOG.md and the GitHub release (required with bump or version)"
required: false
type: string
@@ -72,6 +72,7 @@ jobs:
GH_TOKEN: ${{ github.token }} # reads the triggering actor's repository role for stable releases
INPUT_BUMP: ${{ inputs.bump }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_RELEASE_NOTES: ${{ inputs.release_notes }}
# A versioned release builds from a commit with its versions and changelog on the dispatched dev commit,
# tagged v<version>. Runs git only.
-1
View File
@@ -31,7 +31,6 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
RELEASE_REVIEW.md
logs/
*.bun-build
-49
View File
@@ -1,49 +0,0 @@
---
model: opencode/gpt-5.4
---
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
The input already contains the exact commit range since the last non-draft release.
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
Before writing any entry you keep, inspect the real diff with
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
Rules:
- Write the final file with release sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Within each release section, keep bug fixes grouped under `### Bugfixes`
- Keep other notable entries under `### Improvements` when a section has bug fixes too
- Omit empty subsections
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
<changelog_input>
!`bun script/raw-changelog.ts $ARGUMENTS`
</changelog_input>
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bun
import { rm } from "fs/promises"
import path from "path"
import { parseArgs } from "util"
const root = path.resolve(import.meta.dir, "..")
const file = path.join(root, "UPCOMING_CHANGELOG.md")
const { values, positionals } = parseArgs({
args: Bun.argv.slice(2),
options: {
from: { type: "string", short: "f" },
to: { type: "string", short: "t" },
variant: { type: "string", default: "low" },
quiet: { type: "boolean", default: false },
print: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
allowPositionals: true,
})
const args = [...positionals]
if (values.from) args.push("--from", values.from)
if (values.to) args.push("--to", values.to)
if (values.help) {
console.log(`
Usage: bun script/changelog.ts [options]
Generates UPCOMING_CHANGELOG.md by running the opencode changelog command.
Options:
-f, --from <version> Starting version (default: latest non-draft GitHub release)
-t, --to <ref> Ending ref (default: HEAD)
--variant <name> Thinking variant for opencode run (default: low)
--quiet Suppress opencode command output unless it fails
--print Print the generated UPCOMING_CHANGELOG.md after success
-h, --help Show this help message
Examples:
bun script/changelog.ts
bun script/changelog.ts --from 1.0.200
bun script/changelog.ts -f 1.0.200 -t 1.0.205
`)
process.exit(0)
}
await rm(file, { force: true })
const quiet = values.quiet
const cmd = ["opencode", "run"]
cmd.push("--variant", values.variant)
cmd.push("--command", "changelog", "--", ...args)
const proc = Bun.spawn(cmd, {
cwd: root,
stdin: "inherit",
stdout: quiet ? "pipe" : "inherit",
stderr: quiet ? "pipe" : "inherit",
})
const [out, err] = quiet
? await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
: ["", ""]
const code = await proc.exited
if (code === 0) {
if (values.print) process.stdout.write(await Bun.file(file).text())
process.exit(0)
}
if (quiet) {
if (out) process.stdout.write(out)
if (err) process.stderr.write(err)
}
process.exit(code)
-283
View File
@@ -1,283 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { parseArgs } from "util"
type Release = {
tag_name: string
draft: boolean
}
type Commit = {
hash: string
author: string | null
message: string
areas: Set<string>
}
type User = Map<string, Set<string>>
type Diff = {
sha: string
login: string | null
message: string
}
const repo = process.env.GH_REPO ?? "anomalyco/opencode"
const bot = ["actions-user", "github-actions[bot]", "opencode", "opencode-agent[bot]"]
const team = [
...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url))
.text()
.then((x) => x.split(/\r?\n/).map((x) => x.trim()))
.then((x) => x.filter((x) => x && !x.startsWith("#")))),
...bot,
]
const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const
const sections = {
core: "Core",
tui: "TUI",
app: "Desktop",
tauri: "Desktop",
sdk: "SDK",
plugin: "SDK",
"extensions/vscode": "Extensions",
github: "Extensions",
} as const
function ref(input: string) {
if (input === "HEAD") return input
if (input.startsWith("v")) return input
if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}`
return 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/, "")
}
async function diff(base: string, head: string) {
const list: Diff[] = []
for (let page = 1; ; page++) {
const text =
await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
const batch = text
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as Diff)
if (batch.length === 0) break
list.push(...batch)
if (batch.length < 100) break
}
return list
}
function section(areas: Set<string>) {
const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/vscode", "github"]
for (const area of priority) {
if (areas.has(area)) return sections[area as keyof typeof sections]
}
return "Core"
}
function type(message: string) {
if (message.match(/fix/i)) return "Bugfixes"
return "Improvements"
}
function reverted(commits: Commit[]) {
const seen = new Map<string, Commit>()
for (const commit of commits) {
const match = commit.message.match(/^Revert "(.+)"$/)
if (match) {
const msg = match[1]!
if (seen.has(msg)) seen.delete(msg)
else seen.set(commit.message, commit)
continue
}
const revert = `Revert "${commit.message}"`
if (seen.has(revert)) {
seen.delete(revert)
continue
}
seen.set(commit.message, commit)
}
return [...seen.values()]
}
async function commits(from: string, to: string) {
const base = ref(from)
const head = ref(to)
const data = new Map<string, { login: string | null; message: string }>()
for (const item of await diff(base, head)) {
data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" })
}
const log =
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
const list: Commit[] = []
for (const hash of log.split("\n").filter(Boolean)) {
const item = data.get(hash)
if (!item) continue
if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
const areas = new Set<string>()
for (const file of diff.split("\n").filter(Boolean)) {
if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
else if (file.startsWith("packages/opencode/")) areas.add("core")
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
else if (file.startsWith("packages/plugin/")) areas.add("sdk")
else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
}
if (areas.size === 0) continue
list.push({
hash: hash.slice(0, 7),
author: item.login,
message: item.message,
areas,
})
}
return reverted(list)
}
async function contributors(from: string, to: string) {
const base = ref(from)
const head = ref(to)
const users: User = new Map()
for (const item of await diff(base, head)) {
const title = item.message.split("\n")[0] ?? ""
if (!item.login || team.includes(item.login)) continue
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
if (!users.has(item.login)) users.set(item.login, new Set())
users.get(item.login)!.add(title)
}
return users
}
async function published(to: string) {
if (to === "HEAD") return
const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "")
if (!body) return
const lines = body.split(/\r?\n/)
const start = lines.findIndex((line) => line.startsWith("**Thank you to "))
if (start < 0) return
return lines.slice(start).join("\n").trim()
}
async function thanks(from: string, to: string, reuse: boolean) {
const release = reuse ? await published(to) : undefined
if (release) return release.split(/\r?\n/)
const users = await contributors(from, to)
if (users.size === 0) return []
const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`]
for (const [name, commits] of users) {
lines.push(`- @${name}:`)
for (const commit of commits) lines.push(` - ${commit}`)
}
return lines
}
function format(from: string, to: string, list: Commit[], thanks: string[]) {
const grouped = new Map<string, Map<string, string[]>>()
for (const title of order) {
grouped.set(
title,
new Map([
["Improvements", []],
["Bugfixes", []],
]),
)
}
for (const commit of list) {
const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
grouped.get(section(commit.areas))!.get(type(commit.message))!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
}
const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
if (list.length === 0) {
lines.push("No notable changes.")
}
for (const title of order) {
const groups = grouped.get(title)
if (!groups || [...groups.values()].every((entries) => entries.length === 0)) continue
lines.push(`## ${title}`)
const improvements = groups.get("Improvements")!
const bugfixes = groups.get("Bugfixes")!
if (bugfixes.length === 0) {
lines.push(...improvements)
lines.push("")
continue
}
for (const [subtitle, entries] of groups) {
if (entries.length === 0) continue
lines.push(`### ${subtitle}`)
lines.push(...entries)
lines.push("")
}
}
if (thanks.length > 0) {
if (lines.at(-1) !== "") lines.push("")
lines.push("## Community Contributors Input")
lines.push("")
lines.push(...thanks)
}
if (lines.at(-1) === "") lines.pop()
return lines.join("\n")
}
if (import.meta.main) {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
from: { type: "string", short: "f" },
to: { type: "string", short: "t", default: "HEAD" },
help: { type: "boolean", short: "h", default: false },
},
})
if (values.help) {
console.log(`
Usage: bun script/raw-changelog.ts [options]
Options:
-f, --from <version> Starting version (default: latest non-draft GitHub release)
-t, --to <ref> Ending ref (default: HEAD)
-h, --help Show this help message
Examples:
bun script/raw-changelog.ts
bun script/raw-changelog.ts --from 1.0.200
bun script/raw-changelog.ts -f 1.0.200 -t 1.0.205
`)
process.exit(0)
}
const to = values.to!
const from = values.from ?? (await latest())
const list = await commits(from, to)
console.log(format(from, to, list, await thanks(from, to, !values.from)))
}
-2
View File
@@ -10,7 +10,6 @@ import { parseArgs } from "util"
const root = path.resolve(import.meta.dir, "..")
const review = path.join(root, "RELEASE_REVIEW.md")
const changelog = path.join(root, "UPCOMING_CHANGELOG.md")
const { values, positionals } = parseArgs({
args: Bun.argv.slice(2),
options: {
@@ -86,7 +85,6 @@ const notes = document.match(/<!-- changelog:start -->\s*([\s\S]*?)\s*<!-- chang
if (!notes) throw new Error("Release review is missing its changelog markers")
const releaseNotes = notes[1].trim()
if (!releaseNotes) throw new Error("Release review has no changelog")
await Bun.write(changelog, `${releaseNotes}\n`)
const highlights = parse(releaseNotes)
highlights.problems.forEach((problem) => console.warn(`Warning: ${problem}`))