diff --git a/.gitignore b/.gitignore index 183888091c6..026873ffd72 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ target # Local dev files opencode-dev UPCOMING_CHANGELOG.md +RELEASE_REVIEW.md logs/ *.bun-build tsconfig.tsbuildinfo diff --git a/bun.lock b/bun.lock index fc71cce4875..a783d969c0a 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "devDependencies": { "@actions/artifact": "5.0.1", "@ast-grep/cli": "0.44.0", + "@opencode/client": "workspace:*", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@types/react": "19.2.17", diff --git a/package.json b/package.json index 35e392e5c0b..002b133eab6 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "devDependencies": { "@actions/artifact": "5.0.1", "@ast-grep/cli": "0.44.0", + "@opencode/client": "workspace:*", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@tsconfig/bun": "catalog:", diff --git a/script/release b/script/release deleted file mode 100755 index 8296b8fd6d5..00000000000 --- a/script/release +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -VERSION=${1:-patch} - -case "$VERSION" in - major|minor|patch) - gh workflow run publish.yml --ref v2 -f bump="$VERSION" - ;; - *) - if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then - gh workflow run publish.yml --ref v2 -f version="$VERSION" - exit 0 - fi - echo "usage: $0 [major|minor|patch|version]" >&2 - exit 1 - ;; -esac diff --git a/script/release.ts b/script/release.ts new file mode 100755 index 00000000000..fdc42c74b21 --- /dev/null +++ b/script/release.ts @@ -0,0 +1,144 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { OpenCode } from "@opencode/client" +import { Service } from "@opencode/client/service" +import { rm } from "fs/promises" +import path from "path" + +const root = path.resolve(import.meta.dir, "..") +const review = path.join(root, "RELEASE_REVIEW.md") +const changelog = path.join(root, "UPCOMING_CHANGELOG.md") +const version = Bun.argv[2] ?? "patch" + +if (Bun.argv.includes("--help") || Bun.argv.includes("-h")) { + console.log(`Usage: ./script/release.ts [major|minor|patch|version] + +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.`) + 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 || Bun.argv.length > 3) { + console.error("Usage: ./script/release.ts [major|minor|patch|version]") + process.exit(1) +} + +process.chdir(root) + +console.log("\n=== Generating changelog ===\n") +await rm(review, { force: true }) +await generateReview() + +if (!(await Bun.file(review).exists())) throw new Error("OpenCode did not create RELEASE_REVIEW.md") + +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(/\s*([\s\S]*?)\s*/) +if (!notes) throw new Error("Release review is missing its changelog markers") +await Bun.write(changelog, `${notes[1].trim()}\n`) + +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 publish.yml --ref v2 ${input}` +console.log(`Triggered the ${version} release`) + +async function generateReview() { + 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() }) + 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?.() + } +} + +function reviewPrompt() { + return `Create a concise pre-release review for a maintainer. + +Determine the latest non-draft GitHub release and inspect every relevant commit and actual diff from that release through +HEAD. 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. + +Put the complete editable changelog between these markers, which must each appear exactly once outside code fences: + +## Changelog + + +...changelog... + + +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.` +}