Compare commits

..
19 changed files with 81 additions and 171 deletions
-20
View File
@@ -24,10 +24,6 @@ on:
description: "Override version (optional)"
required: false
type: string
release_notes:
description: "Reviewed V2 release notes for the Discord announcement (optional)"
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
@@ -657,19 +653,3 @@ jobs:
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
notify-discord-v2:
needs:
- version
- publish
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
- name: Announce V2 release in Discord
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
release_body: ${{ inputs.release_notes }}
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
+2 -3
View File
@@ -129,9 +129,8 @@ VercelAIGateway.configure().experimental.evaluation("typesafe-ai/jev")
OpenRouter reads `OPENROUTER_API_KEY`. Vercel reads `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN`.
The common API uses `boolean`; System One routes lower it to native `noul`.
Choice and score answers include `confidence` when the provider returns it, such as
`response.answers.department.confidence`. Score legends remain available in provider metadata, and
the provider's rounded probabilities are returned unchanged.
Choice and score confidence plus score legends remain available in provider metadata, and the
provider's rounded probabilities are returned unchanged.
## Alibaba Cloud Model Studio
@@ -63,7 +63,6 @@ export const ChoiceAnswer = Schema.Struct({
type: Schema.Literal("choice"),
choice: Schema.String,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
@@ -71,7 +70,6 @@ export const ScoreAnswer = Schema.Struct({
type: Schema.Literal("score"),
score: Schema.Number,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
@@ -94,7 +92,6 @@ export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
readonly type: "choice"
readonly choice: Extract<keyof Criteria, string>
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
readonly confidence?: number
}
: Question extends { readonly type: "score" }
? ScoreAnswer
+5 -10
View File
@@ -142,37 +142,32 @@ export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
)
const confidence: Record<string, number> = {}
const legend: Record<string, Record<string, Schema.Json>> = {}
const answers = Object.fromEntries(
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
if (answer.type === "choice") {
if (answer.confidence !== undefined) confidence[id] = answer.confidence
return [
id,
{
type: "choice",
choice: answer.choice,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}
if (answer.confidence !== undefined) confidence[id] = answer.confidence
if (answer.legend !== undefined) legend[id] = answer.legend
return [
id,
{
type: "score",
score: answer.score,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
}),
)
const meta = {
...(data.id === undefined ? {} : { responseId: data.id }),
...(data.provider === undefined ? {} : { provider: data.provider }),
...data.provider_metadata?.[cfg.providerMetadataKey],
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
...(Object.keys(legend).length === 0 ? {} : { legend }),
}
return new EvaluationResponse({
+1 -2
View File
@@ -39,18 +39,17 @@ describe("experimental Evaluation", () => {
type: "choice",
choice: "billing",
probabilities: { billing: 0.9, technical: 0.1 },
confidence: 0.8,
})
expect(response.answers.urgency).toEqual({
type: "score",
score: 1.2,
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
confidence: 0.6,
})
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
expect(response.usage?.totalTokens).toBe(36)
expect(response.providerMetadata).toEqual({
typesafe: {
confidence: { department: 0.8, urgency: 0.6 },
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
},
})
-2
View File
@@ -26,10 +26,8 @@ const request = Evaluation.request({
const result = EvaluationClient.evaluate(request)
type Result = Success<typeof result>
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
type Confidence = Assert<Equal<Result["answers"]["topic"]["confidence"], number | undefined>>
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
void (true satisfies Choice)
void (true satisfies Confidence)
void (true satisfies ClientRequirements)
Effect.gen(function* () {
@@ -92,6 +92,5 @@ const assertEvaluation = <Options extends EvaluationOptions>(
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.answers.department.confidence).toBeGreaterThan(0)
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
})
@@ -0,0 +1,42 @@
# RFC: The `.exe` name of the npm-installed CLI on Unix
Status: Proposed decision record (2026-09-28)
## Problem
A Linux user can run `opencode` and see `opencode.exe` in a process list. That looks like a Windows program, even though the running file is a native Linux ELF executable. The suffix is a pathname chosen by our npm packaging, not the executable format or a compatibility layer.
This RFC records a deliberate *non-change*: why that pathname exists and the cost of changing it. It does not propose changing the binary in this PR.
## How it works today
- The published `@opencode/cli` package has a single `bin` target, `./bin/opencode.exe`, for Linux, macOS, and Windows. The same target also serves the `opencode2` command. See [`script/publish.ts`](../script/publish.ts).
- Each platform-specific package contains a native binary: `opencode` on Linux/macOS and `opencode.exe` on Windows. The generic package's [`postinstall.mjs`](../script/postinstall.mjs) selects the appropriate optional dependency and hard-links or copies its binary to that *same* `bin` target. Therefore, on Unix, a native executable ends up at a pathname ending in `.exe`.
- The direct [install script](../../../install) and release archives keep the Unix executable named `opencode`. This is specific to the generic npm package; the public command users type remains `opencode`.
The constraint is the combination of **one cross-platform npm package, one static `bin` target, and direct execution of the native binary without a launcher**. Windows needs an executable target there, while the generic package cannot select a different `bin` path per OS at install time. Choosing `opencode.exe` lets the Windows target be a real executable and lets npm expose the same command everywhere. Unix permits an ELF or Mach-O file to have that suffix, but process tools can then display it.
The package also ships a text placeholder at the target path until postinstall replaces it. A skipped postinstall is a *separate functional install problem*; removing the misleading Unix suffix alone would not resolve that failure mode.
## Other npm CLIs
These are different choices, checked against their published npm packages on 2026-09-28:
- [Pi (`@earendil-works/pi-coding-agent@0.87.1`)](https://registry.npmjs.org/%40earendil-works%2Fpi-coding-agent/0.87.1) maps `pi` to a JavaScript entrypoint, `dist/bundle/cli.js`, run with Node. It is not trying to expose one direct native binary through npm.
- [Codex (`@openai/codex@0.158.0`)](https://registry.npmjs.org/%40openai%2Fcodex/0.158.0) maps `codex` to `bin/codex.js`. That Node launcher selects `codex` on Unix or `codex.exe` on Windows from a platform package, spawns it, and handles signals and exit status. This avoids a `.exe`-named Unix process at the cost of a launcher process.
- [Claude Code (`@anthropic-ai/claude-code@2.1.283`)](https://registry.npmjs.org/%40anthropic-ai%2Fclaude-code/2.1.283) makes the same direct-execution choice as OpenCode: its npm `bin` is `bin/claude.exe` on all platforms, with postinstall replacing the placeholder with the native binary. Unix users can see `.exe` in the pathname there too.
- [esbuild (`esbuild@0.28.2`)](https://registry.npmjs.org/esbuild/0.28.2) uses an extensionless JavaScript `bin/esbuild` that locates the native binary. Its postinstall *sometimes* replaces that entrypoint with a Unix native binary for direct execution; Windows keeps the launcher. This avoids the Unix suffix and often the extra Unix process, but adds platform- and package-manager-dependent behavior and still needs a Windows launcher.
## Options
1. **Keep the direct native target (recommended for now).** No new process, signal forwarding, runtime dependency at launch, or install-path migration. Cost: Unix process lists can say `opencode.exe`, which is confusing. Explain the packaging trade-off when asked.
2. **Use a platform-aware launcher as the npm `bin` target.** It could select `opencode` on Unix and `opencode.exe` on Windows from the platform-specific package, so the child process has its native filename. Cost: an additional process or platform-specific `exec` behavior, startup work, signal and exit-code forwarding, and a larger test surface across package managers. A launcher already exists for development in [`bin/opencode.cjs`](../bin/opencode.cjs); using one in release packaging would be a deliberate change to today's direct-execution behavior, not a rename.
3. **Use an esbuild-style hybrid launcher.** Publish a suffix-free JavaScript entrypoint; replace it with the native executable on Unix where safe, but keep the launcher on Windows and as a fallback. This improves the Unix process name and often avoids a second Unix process, but complicates postinstall, upgrades, cross-platform startup, and script-disabled behavior. It still crosses the no-launcher boundary on Windows.
4. **Publish different top-level packages or mutate package-manager links per OS.** This can give Unix a suffix-free target without a launcher, but loses the simple single-package install or makes startup depend on nonportable install/link behavior. Do not assume rewriting `package.json` in postinstall will reliably relink a previously created command.
5. **Change the displayed process title.** This might cosmetically affect some monitors, but leaves the actual filename and packaging contract unchanged. It is not a fix for the underlying discrepancy.
## Proposal and decision boundary
**Proposed decision: do not add a launcher or rename the npm target just to change the process-list label.** Keep the existing packaging until the name causes enough user confusion to justify a packaging change, or a broader install/reliability redesign makes the launcher cost worthwhile. Treat a skipped-postinstall/broken-upgrade fix on its own merits rather than claiming the filename change fixes it. This RFC is a record for review, not approval or implementation of a launcher.
If we choose an alternative, prove it against the current behavior before release: native binary format and process name on Linux and macOS; `.exe` startup on Windows; npm, Bun, pnpm, and Yarn installs; blocked or skipped install scripts; upgrades; exit codes and signals; and both `opencode` and the legacy `opencode2` command. Keep direct-install and archive filenames unchanged unless there is a separate reason to alter them.
+9 -19
View File
@@ -1,7 +1,6 @@
export * as Generate from "./generate.js"
import { LLM, LLMClient, AIError } from "@opencode/ai"
import { SessionID } from "@opencode/schema/session-id"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform.js"
@@ -61,24 +60,15 @@ export const layer = Layer.effect(
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm
.generate(
LLM.request({
model: resolved.model,
prompt: input.prompt,
// Gateways require session attribution even for a stateless call; no Session is stored.
http: { headers: { "x-opencode-session": SessionID.create() } },
}),
)
.pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
return response.text
})
@@ -11,7 +11,6 @@ import { Model } from "../../model.js"
import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
@@ -273,7 +272,7 @@ export const GithubCopilotPlugin = define({
.pipe(Effect.orElseSucceed(() => undefined))
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
evt.headers["X-Interaction-Type"] = interaction
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.of(session) : evt.sessionID
evt.headers["X-Interaction-Id"] = evt.sessionID
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
}),
{ providerID: Provider.ID.githubCopilot },
+2 -7
View File
@@ -9,7 +9,6 @@ import { Bus } from "../../bus.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
@@ -300,16 +299,12 @@ export const OpenAIPlugin = define({
yield* ctx.session.hook(
"model.request",
(evt) =>
Effect.gen(function* () {
Effect.sync(() => {
if (!chatgpt) return
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
evt.baseURL = codexBaseURL
const session = yield* ctx.session
.get({ sessionID: evt.sessionID })
.pipe(Effect.orElseSucceed(() => undefined))
evt.headers.originator = "opencode"
// ChatGPT routes its prompt cache on this header, so children share the parent's.
evt.headers["session-id"] = session ? SessionAffinity.of(session) : evt.sessionID
evt.headers["session-id"] = evt.sessionID
}),
{ providerID: Provider.ID.openai },
)
-8
View File
@@ -1,8 +0,0 @@
export * as SessionAffinity from "./affinity.js"
import type { SessionSchema } from "./schema.js"
// TODO: Should the `model.request` hook expose affinity so plugins stop deriving it from `ctx.session.get`?
/** The Session ID that groups model requests for provider cache routing: children share the parent's, forks the fork source's. */
export const of = (session: Pick<SessionSchema.Info, "id" | "parentID" | "fork">) =>
session.parentID ?? session.fork?.sessionID ?? session.id
+4 -5
View File
@@ -31,7 +31,6 @@ import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionAffinity } from "./affinity.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -269,17 +268,17 @@ export const layer = Layer.effect(
const entries = Object.entries(shaped.options)
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
const affinity = SessionAffinity.of(session)
const affinity = session.parentID ?? session.fork?.sessionID ?? session.id
const base = LLM.request({
model: model.model,
http: {
headers: {
"x-session-affinity": affinity,
"X-Session-Id": affinity,
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": affinity,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
},
},
+1 -56
View File
@@ -1,6 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { TestLLM } from "@opencode/ai/testing"
import { AISDK } from "@opencode/core/aisdk"
@@ -11,7 +10,6 @@ import { ID, Info, Model, Ref } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Npm } from "@opencode/util/npm"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -91,56 +89,3 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
})
}),
)
testEffect(Layer.empty).effect("attributes each stateless completion without creating a stored session", () =>
Effect.gen(function* () {
const sessions: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
const session = request.headers["x-opencode-session"]
if (!session)
return HttpClientResponse.fromWeb(
request,
Response.json(
{
error: { type: "MissingSessionID", message: "Session ID is required" },
},
{ status: 400 },
),
)
sessions.push(session)
return HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({
id: "completion",
object: "chat.completion.chunk",
created: 1,
model: "gemini",
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: "stop" }],
})}\n\ndata: [DONE]\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
}),
),
)
const native = LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(http))))
yield* Effect.gen(function* () {
const generate = yield* Generate.Service
for (let index = 0; index < 2; index++) {
expect(
yield* generate.text({
prompt: "Return exactly OK",
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
}),
).toBe("OK")
}
}).pipe(Effect.provide(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, native)))))
expect(sessions).toHaveLength(2)
expect(sessions[0]).toStartWith("ses_")
expect(sessions[1]).not.toBe(sessions[0])
}),
)
@@ -183,11 +183,10 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies child-session steps as subagent interactions", () =>
Effect.gen(function* () {
yield* addPlugin()
const ids = yield* sessions()
const event = yield* modelRequest(ids.child, "primary")
const event = yield* modelRequest((yield* sessions()).child, "primary")
expect(event.headers).toEqual({
"X-Interaction-Type": "conversation-subagent",
"X-Interaction-Id": ids.parent,
"X-Interaction-Id": event.sessionID,
"x-initiator": "agent",
})
}),
@@ -208,11 +207,10 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies compaction requests by kind rather than agent", () =>
Effect.gen(function* () {
yield* addPlugin()
const ids = yield* sessions()
const event = yield* modelRequest(ids.child, "compaction", "build")
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
expect(event.headers).toEqual({
"X-Interaction-Type": "conversation-compaction",
"X-Interaction-Id": ids.parent,
"X-Interaction-Id": event.sessionID,
"x-initiator": "agent",
})
}),
@@ -1,6 +1,6 @@
import { Money } from "@opencode/schema/money"
import { Agent } from "@opencode/schema/agent"
import { Session } from "@opencode/core/session"
import { Session } from "@opencode/schema/session"
import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses"
import { describe, expect } from "bun:test"
import { ConfigProvider, DateTime, Effect } from "effect"
@@ -41,14 +41,10 @@ function required<T>(value: T | undefined): T {
return value
}
const request = Effect.fn(function* (
providerID: Provider.ID,
baseURL: string,
sessionID = Session.ID.make("ses_test"),
) {
const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "model.request", {
sessionID,
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
kind: "primary",
@@ -160,12 +156,6 @@ describe("OpenAIPlugin", () => {
expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const sessions = yield* Session.Service
const location = yield* Location.Service
const parent = yield* sessions.create({ location: { directory: location.directory } })
const child = yield* sessions.create({ parentID: parent.id })
const childRequest = yield* request(Provider.ID.openai, "https://api.openai.com/v1", child.id)
expect(childRequest.headers).toMatchObject({ "session-id": parent.id })
const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode/ai/providers/openai")
expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
@@ -445,12 +445,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(requests).toHaveLength(1)
expect(requests[0]?.promptCacheKey).toBe(parentID)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": parentID,
"X-Session-Id": parentID,
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"x-parent-session-id": parentID,
"User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global,
"x-opencode-session": parentID,
"x-opencode-session": sessionID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
+1 -6
View File
@@ -4693,12 +4693,7 @@ describe("SessionRunnerLLM", () => {
.pipe(Effect.orDie)
yield* s.runPrompt("Run child request")
expect(s.requests[0]?.http?.headers).toMatchObject({
"x-session-affinity": parentID,
"X-Session-Id": parentID,
"x-parent-session-id": parentID,
"x-opencode-session": parentID,
})
expect(s.requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
expect(s.requests[0]?.promptCacheKey).toBe(parentID)
})
+2 -4
View File
@@ -61,9 +61,7 @@ if ((await editor.exited) !== 0) {
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")
await Bun.write(changelog, `${releaseNotes}\n`)
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") {
@@ -71,7 +69,7 @@ if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "ye
process.exit(0)
}
await $`gh workflow run publish.yml --ref v2 ${input} -f release_notes=${releaseNotes}`
await $`gh workflow run publish.yml --ref v2 ${input}`
console.log(`Triggered the ${version} release`)
async function generateReview(base: string) {