Files
2026-11-05 15:50:28 -05:00

231 lines
11 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import { $ } from "bun"
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/service"
import { parse } from "@opencode/script/highlights"
import { rm } from "fs/promises"
import path from "path"
import { parseArgs } from "util"
const root = path.resolve(import.meta.dir, "..")
// Commits that only touch these folders, such as the console, stats, cloud functions, docs, websites, and infra, are
// left out of the changelog.
const changelogExclusions = [
":(exclude)packages/console",
":(exclude)packages/function",
":(exclude)packages/stats",
":(exclude)packages/web",
":(exclude)services",
":(exclude)infra",
]
const review = path.join(root, "RELEASE_REVIEW.md")
const { values, positionals } = parseArgs({
args: Bun.argv.slice(2),
options: {
from: { type: "string", short: "f" },
to: { type: "string", short: "t", default: "HEAD" },
print: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
allowPositionals: true,
})
const version = positionals[0] ?? "patch"
if (values.help) {
console.log(`Usage: ./script/release.ts [major|minor|patch|version]
./script/release.ts --print [--from <tag>] [--to <ref>]
Generates release notes and a public API review, opens the result in your
editor, and asks for confirmation before triggering the release workflow.
The editor is selected from $VISUAL, then $EDITOR, and defaults to vim.
Options:
-f, --from <tag> Previous release tag (default: latest stable V2 tag)
-t, --to <ref> Ending ref (default: HEAD)
--print Print the generated review and exit without releasing`)
process.exit(0)
}
const input = ["major", "minor", "patch"].includes(version)
? ["-f", `bump=${version}`]
: /^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(version)
? ["-f", `version=${version}`]
: undefined
if (!input || positionals.length > 1 || (!values.print && (values.from || values.to !== "HEAD"))) {
console.error("Usage: ./script/release.ts [major|minor|patch|version]")
console.error(" ./script/release.ts --print [--from <tag>] [--to <ref>]")
process.exit(1)
}
process.chdir(root)
console.log("\n=== Generating changelog ===\n")
await rm(review, { force: true })
await $`git fetch origin "+refs/tags/v2.*:refs/tags/v2.*" "+refs/heads/v1:refs/remotes/origin/v1"`
const base =
values.from ??
(await $`git tag --list "v2.*" --sort=-version:refname`.text()).split("\n").find((tag) => /^v2\.\d+\.\d+$/.test(tag))
if (!base) throw new Error("No stable V2 release tag was found")
console.log(`Comparing ${base}..${values.to}`)
const commits = (await $`git log --format=${"%h %s"} ${commitRange(base, values.to)}`.text()).trim()
if (!commits) throw new Error(`No OpenCode or client changes were found since ${base}`)
console.log(`Found ${commits.split("\n").length} commits for the changelog`)
await generateReview(base, values.to, commits)
if (!(await Bun.file(review).exists())) throw new Error("OpenCode did not create RELEASE_REVIEW.md")
if (values.print) {
process.stdout.write(await Bun.file(review).text())
process.exit(0)
}
const editor = Bun.spawn(["sh", "-c", 'exec ${VISUAL:-${EDITOR:-vim}} "$1"', "release", review], {
cwd: root,
env: process.env,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
if ((await editor.exited) !== 0) {
console.error("Release cancelled because the editor exited with an error")
process.exit(1)
}
const document = await Bun.file(review).text()
const notes = document.match(/<!-- changelog:start -->\s*([\s\S]*?)\s*<!-- changelog:end -->/)
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")
const highlights = parse(releaseNotes)
highlights.problems.forEach((problem) => console.warn(`Warning: ${problem}`))
console.log(
highlights.items.length
? `TUI highlights will show:\n${highlights.items.map((item) => ` - ${item.title}`).join("\n")}`
: "TUI highlights will not show for this release",
)
const answer = prompt(`Trigger the ${version} release? [y/N]`)
if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "yes") {
console.log("Release cancelled")
process.exit(0)
}
await $`gh workflow run release.yml --ref dev ${input} -f release_notes=${releaseNotes}`
console.log(`Triggered the ${version} release`)
async function generateReview(base: string, head: string, commits: string) {
const endpoint = await Service.ensure()
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
fetch: ((request: RequestInfo | URL, init?: RequestInit) =>
fetch(request, { ...init, timeout: false } as BunFetchRequestInit)) as typeof fetch,
})
const controller = new AbortController()
const events = client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const connected = await events.next()
if (connected.done) throw new Error("OpenCode event stream disconnected")
const session = await client.session.create({
title: `Review ${version} release`,
location: { directory: root },
model: { providerID: "opencode", id: "gpt-5.6-sol", variant: "low" },
permissions: [
{ action: "*", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "shell", resource: "git *", effect: "allow" },
{ action: "shell", resource: "gh *", effect: "allow" },
],
})
const completed = (async () => {
while (true) {
const next = await events.next()
if (next.done) throw new Error("OpenCode event stream disconnected during release review")
if (!("sessionID" in next.value.data) || next.value.data.sessionID !== session.id) continue
if (next.value.type === "session.execution.succeeded") return
if (next.value.type === "session.execution.failed") throw new Error(next.value.data.error.message)
if (next.value.type === "session.execution.interrupted")
throw new Error(`OpenCode release review was interrupted: ${next.value.data.reason}`)
}
})()
try {
await client.session.prompt({ sessionID: session.id, text: reviewPrompt(base, head, commits) })
await completed
const messages = await client.message.list({ sessionID: session.id, limit: 100, order: "desc" })
const response = messages.data.find((message) => message.type === "assistant")
const text = response?.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
if (!text) throw new Error("OpenCode did not return a release review")
await Bun.write(review, text)
} finally {
controller.abort()
await events.return?.()
}
}
// V1 history was merged into dev when V2 was promoted, so it must be excluded from the release range. Merges are skipped
// because dev only takes squash merges, and the promotion merge would otherwise show the whole V1 tree.
function commitRange(base: string, head: string) {
return ["--no-merges", `${base}..${head}`, "--not", "origin/v1", "--", ".", ...changelogExclusions]
}
function reviewPrompt(base: string, head: string, commits: string) {
const pathspec = [".", ...changelogExclusions.map((exclusion) => `'${exclusion}'`)].join(" ")
return `Create a concise pre-release review for a maintainer.
The previous stable V2 release is ${base}. These commits from
\`git log --no-merges ${base}..${head} --not origin/v1 -- ${pathspec}\` are the only ones in scope:
${commits}
Inspect every relevant commit and read its actual diff with \`git show <commit> -- ${pathspec}\`. The excluded folders
are not part of OpenCode or its clients, so ignore any part of a commit in them. Do not use a whole-tree \`git diff\`
between the endpoints, which includes V1 history merged into dev. Do not compare against V1 release tags or commits
outside that range. Do not rely only on commit titles. Write a user-facing changelog with sections for Core, TUI,
Desktop, SDK, and Extensions, omitting empty sections and changes that are entirely internal. Group bug fixes separately
from improvements. Preserve community contributor attribution when it is available from merged pull requests, but never
thank authors listed in .github/TEAM_MEMBERS. Leave out changes to the @opencode/ai package in packages/ai unless they
change how OpenCode itself behaves for users; then describe only that OpenCode behavior.
Start the changelog with a "### Highlights" section: the few changes worth telling people about, written for humans.
Pick 2 to 5 user-visible changes a user would notice, try, or share: new capabilities, clearly better workflows, or fixes
for problems many users hit. Merge related small changes into one highlight. Skip internal work, refactors, tests, CI,
dependency bumps, broad polish without one concrete outcome, and anything not usable in this release. When nothing
qualifies, omit the section instead of padding it. Order highlights by how much users will care.
Before writing a highlight, read its pull request with gh pr view and confirm the behavior in the diff. Name the exact
command, flag, setting, or place in the UI, and describe how to use it as the pull request does. Never invent, guess, or
embellish, and avoid vague claims like "better" or "more naturally". Write each highlight as one bullet with a few-word
Title Case title (lowercase short words like "and" or "to"), then one or two plain sentences in second person about what
you can now do and why it matters. Vary the sentence openings, and leave contributor thanks to the detailed
sections. For example:
- **Reopen Closed Tabs**: Right-click the new-session button in the tab bar to bring back a session you closed by
mistake. ([#123](https://github.com/anomalyco/opencode/pull/123))
Every highlight must also appear in the detailed sections that follow. Use "###" for Highlights and each release section,
"####" for Improvements and Bug fixes, and never use "#" or "##" headings in the changelog, since each release is filed
under its own "##" heading.
Put the complete editable changelog between these markers, which must each appear exactly once outside code fences:
## Changelog
<!-- changelog:start -->
...changelog...
<!-- changelog:end -->
Then add a "## Public surface audit" with "### HTTP API", "### Plugin API", and "### Other risks". Report added,
removed, or changed public HTTP routes, schemas, generated client methods, plugin hooks, plugin methods, RPC and tool
contracts, plus any other compatibility risk. Name each affected route or symbol and explain its impact. Write
"None found." under groups without changes. This is an audit, not a second changelog.
Return only the complete Markdown review in your response. Do not modify any files.`
}