From f5cb737440f2183fbafd1476dcb9652c07076e3f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 20:54:20 -0400 Subject: [PATCH 1/5] fix: stop rewriting dev during release publish --- script/publish.ts | 48 +++++++++++++++++++++++++++++------------------ script/version.ts | 6 +++--- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/script/publish.ts b/script/publish.ts index ac0af9c4e6..06f9e66821 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -12,30 +12,34 @@ const pkgjsons = await Array.fromAsync( }), ).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist"))) -for (const file of pkgjsons) { - let pkg = await Bun.file(file).text() - pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) - console.log("updated:", file) - await Bun.file(file).write(pkg) +const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) + +async function prepareReleaseFiles() { + for (const file of pkgjsons) { + let pkg = await Bun.file(file).text() + pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) + console.log("updated:", file) + await Bun.file(file).write(pkg) + } + + let toml = await Bun.file(extensionToml).text() + toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) + toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) + console.log("updated:", extensionToml) + await Bun.file(extensionToml).write(toml) + + await $`bun install` + await $`./packages/sdk/js/script/build.ts` } -const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) -let toml = await Bun.file(extensionToml).text() -toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) -toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) -console.log("updated:", extensionToml) -await Bun.file(extensionToml).write(toml) - -await $`bun install` -await import(`../packages/sdk/js/script/build.ts`) +await prepareReleaseFiles() if (Script.release) { if (!Script.preview) { + await $`git switch --detach` await $`git commit -am "release: v${Script.version}"` - await $`git tag v${Script.version}` - await $`git fetch origin` - await $`git cherry-pick HEAD..origin/dev`.nothrow() - await $`git push origin HEAD --tags --no-verify --force-with-lease` + await $`git tag -f v${Script.version}` + await $`git push origin refs/tags/v${Script.version} --force --no-verify` await new Promise((resolve) => setTimeout(resolve, 5_000)) } @@ -54,5 +58,13 @@ await import(`../packages/sdk/js/script/publish.ts`) console.log("\n=== plugin ===\n") await import(`../packages/plugin/script/publish.ts`) +if (Script.release && !Script.preview) { + await $`git fetch origin` + await $`git checkout -B dev origin/dev` + await prepareReleaseFiles() + await $`git commit -am "sync release versions for v${Script.version}"` + await $`git push origin HEAD:dev --no-verify` +} + const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) diff --git a/script/version.ts b/script/version.ts index 3ca4f661d2..c1ad021b69 100755 --- a/script/version.ts +++ b/script/version.ts @@ -4,9 +4,9 @@ import { Script } from "@opencode-ai/script" import { $ } from "bun" const output = [`version=${Script.version}`] +const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() if (!Script.preview) { - const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd()) const file = `${process.cwd()}/UPCOMING_CHANGELOG.md` const body = await Bun.file(file) @@ -15,12 +15,12 @@ if (!Script.preview) { const dir = process.env.RUNNER_TEMP ?? "/tmp" const notesFile = `${dir}/opencode-release-notes.txt` await Bun.write(notesFile, body) - await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${notesFile}` + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}` const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) } else if (Script.channel === "beta") { - await $`gh release create v${Script.version} -d --title "v${Script.version}" --repo ${process.env.GH_REPO}` + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --repo ${process.env.GH_REPO}` const release = await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json() output.push(`release=${release.databaseId}`) From c79859be43e8e876214f7c196ea64227eb76a998 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 22:18:33 -0400 Subject: [PATCH 2/5] fix(release): make publish reruns safe Reuse the existing draft release and tagged snapshot, skip already-published packages on retry, and sync dev before undrafting so release reruns stop rewriting history and can recover cleanly from partial failures. --- packages/opencode/script/publish.ts | 29 ++++++++++----- packages/plugin/script/publish.ts | 12 ++++++- packages/sdk/js/script/publish.ts | 37 ++++++++++++------- script/publish.ts | 55 ++++++++++++++++++++++------- script/version.ts | 34 +++++++++++++++--- 5 files changed, 127 insertions(+), 40 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 9c4b8f187d..b444106a1b 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -7,6 +7,20 @@ import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + +async function publish(dir: string, name: string, version: string) { + if (await published(name, version)) { + console.log(`already published ${name}@${version}`) + return + } + if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) + await $`bun pm pack`.cwd(dir) + await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) +} + const binaries: Record = {} for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) { const pkg = await Bun.file(`./dist/${filepath}`).json() @@ -40,14 +54,10 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write( ) const tasks = Object.entries(binaries).map(async ([name]) => { - if (process.platform !== "win32") { - await $`chmod -R 755 .`.cwd(`./dist/${name}`) - } - await $`bun pm pack`.cwd(`./dist/${name}`) - await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(`./dist/${name}`) + await publish(`./dist/${name}`, name, binaries[name]) }) await Promise.all(tasks) -await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel}` +await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version) const image = "ghcr.io/anomalyco/opencode" const platforms = "linux/amd64,linux/arm64" @@ -104,6 +114,7 @@ if (!Script.preview) { await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild) await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO` await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO` + if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"` await $`cd ./dist/aur-${pkg} && git push` break @@ -176,6 +187,8 @@ if (!Script.preview) { await $`git clone ${tap} ./dist/homebrew-tap` await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula) await $`cd ./dist/homebrew-tap && git add opencode.rb` - await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` - await $`cd ./dist/homebrew-tap && git push` + if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) { + await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` + await $`cd ./dist/homebrew-tap && git push` + } } diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts index d2fe49f23c..de129918cd 100755 --- a/packages/plugin/script/publish.ts +++ b/packages/plugin/script/publish.ts @@ -6,9 +6,19 @@ import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + await $`bun tsc` -const pkg = await import("../package.json").then((m) => m.default) +const pkg = await import("../package.json").then( + (m) => m.default as { name: string; version: string; exports: Record }, +) const original = JSON.parse(JSON.stringify(pkg)) +if (await published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} for (const [key, value] of Object.entries(pkg.exports)) { const file = value.replace("./src/", "./dist/").replace(".ts", "") // @ts-ignore diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts index ea5c5d634b..b5e1211fc4 100755 --- a/packages/sdk/js/script/publish.ts +++ b/packages/sdk/js/script/publish.ts @@ -7,24 +7,35 @@ import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + const pkg = (await import("../package.json").then((m) => m.default)) as { - exports: Record + name: string + version: string + exports: Record } const original = JSON.parse(JSON.stringify(pkg)) -function transformExports(exports: Record) { - for (const [key, value] of Object.entries(exports)) { - if (typeof value === "object" && value !== null) { - transformExports(value as Record) - } else if (typeof value === "string") { - const file = value.replace("./src/", "./dist/").replace(".ts", "") - exports[key] = { - import: file + ".js", - types: file + ".d.ts", +function transformExports(exports: Record) { + return Object.fromEntries( + Object.entries(exports).map(([key, value]) => { + if (typeof value === "string") { + const file = value.replace("./src/", "./dist/").replace(".ts", "") + return [key, { import: file + ".js", types: file + ".d.ts" }] } - } - } + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return [key, transformExports(value)] + } + return [key, value] + }), + ) } -transformExports(pkg.exports) +if (await published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} +pkg.exports = transformExports(pkg.exports) await Bun.write("package.json", JSON.stringify(pkg, null, 2)) await $`bun pm pack` await $`npm publish *.tgz --tag ${Script.channel} --access public` diff --git a/script/publish.ts b/script/publish.ts index 06f9e66821..994d0bf2c1 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -6,6 +6,9 @@ import { fileURLToPath } from "url" console.log("=== publishing ===\n") +const tag = `v${Script.version}` +const release_commit = `release: ${tag}` + const pkgjsons = await Array.fromAsync( new Bun.Glob("**/package.json").scan({ absolute: true, @@ -14,6 +17,16 @@ const pkgjsons = await Array.fromAsync( const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) +async function hasChanges() { + return (await $`git diff --quiet && git diff --cached --quiet`.nothrow()).exitCode !== 0 +} + +async function releaseTagReady() { + const ref = await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow() + if (ref.exitCode !== 0) return false + return (await $`git log -1 --format=%s refs/tags/${tag}`.text()).trim() === release_commit +} + async function prepareReleaseFiles() { for (const file of pkgjsons) { let pkg = await Bun.file(file).text() @@ -32,21 +45,24 @@ async function prepareReleaseFiles() { await $`./packages/sdk/js/script/build.ts` } +if (Script.release && !Script.preview) { + await $`git fetch origin --tags` + if (await releaseTagReady()) await $`git switch --detach refs/tags/${tag}` + else await $`git switch --detach` +} + await prepareReleaseFiles() -if (Script.release) { - if (!Script.preview) { - await $`git switch --detach` - await $`git commit -am "release: v${Script.version}"` - await $`git tag -f v${Script.version}` - await $`git push origin refs/tags/v${Script.version} --force --no-verify` +if (Script.release && !Script.preview && !(await releaseTagReady())) { + await $`git commit -am ${release_commit}` + if ((await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).exitCode === 0) { + await $`git tag -f ${tag}` + await $`git push origin refs/tags/${tag} --force --no-verify` + } else { + await $`git tag ${tag}` + await $`git push origin refs/tags/${tag} --no-verify` await new Promise((resolve) => setTimeout(resolve, 5_000)) } - - await import(`../packages/desktop/scripts/finalize-latest-json.ts`) - await import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`) - - await $`gh release edit v${Script.version} --draft=false --repo ${process.env.GH_REPO}` } console.log("\n=== cli ===\n") @@ -58,12 +74,25 @@ await import(`../packages/sdk/js/script/publish.ts`) console.log("\n=== plugin ===\n") await import(`../packages/plugin/script/publish.ts`) +if (Script.release) { + await import(`../packages/desktop/scripts/finalize-latest-json.ts`) + await import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`) +} + if (Script.release && !Script.preview) { await $`git fetch origin` await $`git checkout -B dev origin/dev` await prepareReleaseFiles() - await $`git commit -am "sync release versions for v${Script.version}"` - await $`git push origin HEAD:dev --no-verify` + if (await hasChanges()) { + await $`git commit -am "sync release versions for v${Script.version}"` + await $`git push origin HEAD:dev --no-verify` + } else { + console.log(`dev already synced for ${tag}`) + } +} + +if (Script.release) { + await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}` } const dir = fileURLToPath(new URL("..", import.meta.url)) diff --git a/script/version.ts b/script/version.ts index c1ad021b69..c2dbd6c189 100755 --- a/script/version.ts +++ b/script/version.ts @@ -5,6 +5,33 @@ import { $ } from "bun" const output = [`version=${Script.version}`] const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() +const repo = process.env.GH_REPO + +async function releaseView() { + if (repo) return await $`gh release view v${Script.version} --json tagName,databaseId --repo ${repo}`.json() + return await $`gh release view v${Script.version} --json tagName,databaseId`.json() +} + +async function ensureRelease(notesFile?: string) { + const existing = repo + ? await $`gh release view v${Script.version} --json tagName,databaseId --repo ${repo}`.nothrow() + : await $`gh release view v${Script.version} --json tagName,databaseId`.nothrow() + if (existing.exitCode === 0) return await releaseView() + if (notesFile) { + if (repo) { + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile} --repo ${repo}` + return await releaseView() + } + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}` + return await releaseView() + } + if (repo) { + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --repo ${repo}` + return await releaseView() + } + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}"` + return await releaseView() +} if (!Script.preview) { await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd()) @@ -15,14 +42,11 @@ if (!Script.preview) { const dir = process.env.RUNNER_TEMP ?? "/tmp" const notesFile = `${dir}/opencode-release-notes.txt` await Bun.write(notesFile, body) - await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}` - const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() + const release = await ensureRelease(notesFile) output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) } else if (Script.channel === "beta") { - await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --repo ${process.env.GH_REPO}` - const release = - await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json() + const release = await ensureRelease() output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) } From 421f891e6be1f897cd8c12b2ca1d5ced666eb4ee Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 22:37:37 -0400 Subject: [PATCH 3/5] refactor(release): simplify detached publish flow Keep the detached release commit and retry-safe package publishes, but drop the draft and tag reuse logic so the release workflow stays focused on preventing dev rewrites while syncing version files back onto the latest dev tip. --- script/publish.ts | 19 +++++++------------ script/version.ts | 34 +++++----------------------------- 2 files changed, 12 insertions(+), 41 deletions(-) diff --git a/script/publish.ts b/script/publish.ts index 994d0bf2c1..6cd244e0e6 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -7,7 +7,6 @@ import { fileURLToPath } from "url" console.log("=== publishing ===\n") const tag = `v${Script.version}` -const release_commit = `release: ${tag}` const pkgjsons = await Array.fromAsync( new Bun.Glob("**/package.json").scan({ @@ -21,10 +20,8 @@ async function hasChanges() { return (await $`git diff --quiet && git diff --cached --quiet`.nothrow()).exitCode !== 0 } -async function releaseTagReady() { - const ref = await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow() - if (ref.exitCode !== 0) return false - return (await $`git log -1 --format=%s refs/tags/${tag}`.text()).trim() === release_commit +async function releaseTagExists() { + return (await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).exitCode === 0 } async function prepareReleaseFiles() { @@ -47,18 +44,16 @@ async function prepareReleaseFiles() { if (Script.release && !Script.preview) { await $`git fetch origin --tags` - if (await releaseTagReady()) await $`git switch --detach refs/tags/${tag}` - else await $`git switch --detach` + await $`git switch --detach` } await prepareReleaseFiles() -if (Script.release && !Script.preview && !(await releaseTagReady())) { - await $`git commit -am ${release_commit}` - if ((await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).exitCode === 0) { - await $`git tag -f ${tag}` - await $`git push origin refs/tags/${tag} --force --no-verify` +if (Script.release && !Script.preview) { + if (await releaseTagExists()) { + console.log(`release tag ${tag} already exists, skipping tag creation`) } else { + await $`git commit -am "release: ${tag}"` await $`git tag ${tag}` await $`git push origin refs/tags/${tag} --no-verify` await new Promise((resolve) => setTimeout(resolve, 5_000)) diff --git a/script/version.ts b/script/version.ts index c2dbd6c189..c1ad021b69 100755 --- a/script/version.ts +++ b/script/version.ts @@ -5,33 +5,6 @@ import { $ } from "bun" const output = [`version=${Script.version}`] const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() -const repo = process.env.GH_REPO - -async function releaseView() { - if (repo) return await $`gh release view v${Script.version} --json tagName,databaseId --repo ${repo}`.json() - return await $`gh release view v${Script.version} --json tagName,databaseId`.json() -} - -async function ensureRelease(notesFile?: string) { - const existing = repo - ? await $`gh release view v${Script.version} --json tagName,databaseId --repo ${repo}`.nothrow() - : await $`gh release view v${Script.version} --json tagName,databaseId`.nothrow() - if (existing.exitCode === 0) return await releaseView() - if (notesFile) { - if (repo) { - await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile} --repo ${repo}` - return await releaseView() - } - await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}` - return await releaseView() - } - if (repo) { - await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --repo ${repo}` - return await releaseView() - } - await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}"` - return await releaseView() -} if (!Script.preview) { await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd()) @@ -42,11 +15,14 @@ if (!Script.preview) { const dir = process.env.RUNNER_TEMP ?? "/tmp" const notesFile = `${dir}/opencode-release-notes.txt` await Bun.write(notesFile, body) - const release = await ensureRelease(notesFile) + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}` + const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) } else if (Script.channel === "beta") { - const release = await ensureRelease() + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --repo ${process.env.GH_REPO}` + const release = + await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json() output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) } From 7f4dc4a2499b880a1b78ba99574b48e2a9a62988 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 22:54:35 -0400 Subject: [PATCH 4/5] refactor(release): rewrite publish scripts with Effect Refactor the detached release flow and retry-safe package publishers into Effect.gen pipelines while preserving the stacked branch behavior from the release-fix PR. --- packages/opencode/script/publish.ts | 209 ++++++++++++++++------------ packages/plugin/script/publish.ts | 56 +++++--- packages/sdk/js/script/publish.ts | 66 ++++++--- script/publish.ts | 131 ++++++++--------- script/version.ts | 70 ++++++---- 5 files changed, 311 insertions(+), 221 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index b444106a1b..23c88e28fb 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -1,5 +1,7 @@ #!/usr/bin/env bun + import { $ } from "bun" +import { Effect } from "effect" import pkg from "../package.json" import { Script } from "@opencode-ai/script" import { fileURLToPath } from "url" @@ -7,82 +9,110 @@ import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) -async function published(name: string, version: string) { - return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 -} +const published = (name: string, version: string) => + Effect.promise(() => $`npm view ${name}@${version} version`.nothrow()).pipe( + Effect.map((result) => result.exitCode === 0), + ) -async function publish(dir: string, name: string, version: string) { - if (await published(name, version)) { - console.log(`already published ${name}@${version}`) - return +const publishPackage = (dir: string, name: string, version: string) => + Effect.gen(function* () { + if (yield* published(name, version)) { + console.log(`already published ${name}@${version}`) + return + } + if (process.platform !== "win32") yield* Effect.promise(() => $`chmod -R 755 .`.cwd(dir)) + yield* Effect.promise(() => $`bun pm pack`.cwd(dir)) + yield* Effect.promise(() => $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)) + }) + +const binaryVersion = (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + "name" in value && + typeof value.name === "string" && + "version" in value && + typeof value.version === "string" + ) { + return value } - if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) - await $`bun pm pack`.cwd(dir) - await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) + throw new Error("invalid dist package manifest") } -const binaries: Record = {} -for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) { - const pkg = await Bun.file(`./dist/${filepath}`).json() - binaries[pkg.name] = pkg.version +const ensureVersion = (value: unknown) => { + if (typeof value === "string") return value + throw new Error("missing dist package version") } -console.log("binaries", binaries) -const version = Object.values(binaries)[0] -await $`mkdir -p ./dist/${pkg.name}` -await $`cp -r ./bin ./dist/${pkg.name}/bin` -await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` -await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()) +const program = Effect.gen(function* () { + const binaries: Record = Object.fromEntries( + yield* Effect.promise(async () => + Array.fromAsync(new Bun.Glob("*/package.json").scan({ cwd: "./dist" }), async (filepath) => { + const current = binaryVersion(await Bun.file(`./dist/${filepath}`).json()) + return [current.name, current.version] as const + }), + ), + ) + console.log("binaries", binaries) -await Bun.file(`./dist/${pkg.name}/package.json`).write( - JSON.stringify( - { - name: pkg.name + "-ai", - bin: { - [pkg.name]: `./bin/${pkg.name}`, - }, - scripts: { - postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs", - }, - version: version, - license: pkg.license, - optionalDependencies: binaries, - }, - null, - 2, - ), -) + const version = ensureVersion(Object.values(binaries)[0]) -const tasks = Object.entries(binaries).map(async ([name]) => { - await publish(`./dist/${name}`, name, binaries[name]) -}) -await Promise.all(tasks) -await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version) + yield* Effect.promise(() => $`mkdir -p ./dist/${pkg.name}`) + yield* Effect.promise(() => $`cp -r ./bin ./dist/${pkg.name}/bin`) + yield* Effect.promise(() => $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`) + yield* Effect.promise(async () => + Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()), + ) + yield* Effect.promise(() => + Bun.write( + `./dist/${pkg.name}/package.json`, + JSON.stringify( + { + name: pkg.name + "-ai", + bin: { [pkg.name]: `./bin/${pkg.name}` }, + scripts: { postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs" }, + version, + license: pkg.license, + optionalDependencies: binaries, + }, + null, + 2, + ), + ), + ) -const image = "ghcr.io/anomalyco/opencode" -const platforms = "linux/amd64,linux/arm64" -const tags = [`${image}:${version}`, `${image}:${Script.channel}`] -const tagFlags = tags.flatMap((t) => ["-t", t]) -await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` + yield* Effect.all(Object.entries(binaries).map(([name, version]) => publishPackage(`./dist/${name}`, name, version))) + yield* publishPackage(`./dist/${pkg.name}`, `${pkg.name}-ai`, version) -// registries -if (!Script.preview) { - // Calculate SHA values - const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) - const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) - const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) - const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + const image = "ghcr.io/anomalyco/opencode" + const tags = [`${image}:${version}`, `${image}:${Script.channel}`] + yield* Effect.promise( + () => $`docker buildx build --platform linux/amd64,linux/arm64 ${tags.flatMap((t) => ["-t", t])} --push .`, + ) - const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2) + if (Script.preview) return + + const arm64Sha = (yield* Effect.promise(() => + $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text(), + )).trim() + const x64Sha = (yield* Effect.promise(() => + $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text(), + )).trim() + const macX64Sha = (yield* Effect.promise(() => + $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text(), + )).trim() + const macArm64Sha = (yield* Effect.promise(() => + $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text(), + )).trim() + const [pkgver, subver = ""] = Script.version.split(/(-.*)/, 2) - // arch const binaryPkgbuild = [ "# Maintainer: dax", "# Maintainer: adam", "", "pkgname='opencode-bin'", `pkgver=${pkgver}`, - `_subver=${_subver}`, + `_subver=${subver}`, "options=('!debug' '!strip')", "pkgrel=1", "pkgdesc='The AI coding agent built for the terminal.'", @@ -95,7 +125,6 @@ if (!Script.preview) { "", `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`, `sha256sums_aarch64=('${arm64Sha}')`, - `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`, `sha256sums_x86_64=('${x64Sha}')`, "", @@ -105,37 +134,40 @@ if (!Script.preview) { "", ].join("\n") - for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) { + yield* Effect.promise(async () => { for (let i = 0; i < 30; i++) { try { - await $`rm -rf ./dist/aur-${pkg}` - await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}` - await $`cd ./dist/aur-${pkg} && git checkout master` - await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild) - await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO` - await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO` - if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break - await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"` - await $`cd ./dist/aur-${pkg} && git push` - break - } catch { - continue - } + await $`rm -rf ./dist/aur-opencode-bin` + await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin` + await $`cd ./dist/aur-opencode-bin && git checkout master` + await Bun.write(`./dist/aur-opencode-bin/PKGBUILD`, binaryPkgbuild) + await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO` + await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO` + if ((await $`cd ./dist/aur-opencode-bin && git diff --cached --quiet`.nothrow()).exitCode === 0) return + await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${Script.version}"` + await $`cd ./dist/aur-opencode-bin && git push` + return + } catch {} } + }) + + const token = process.env.GITHUB_TOKEN + if (!token) { + console.error("GITHUB_TOKEN is required to update homebrew tap") + process.exit(1) } - // Homebrew formula const homebrewFormula = [ "# typed: false", "# frozen_string_literal: true", "", "# This file was generated by GoReleaser. DO NOT EDIT.", "class Opencode < Formula", - ` desc "The AI coding agent built for the terminal."`, - ` homepage "https://github.com/anomalyco/opencode"`, + ' desc "The AI coding agent built for the terminal."', + ' homepage "https://github.com/anomalyco/opencode"', ` version "${Script.version.split("-")[0]}"`, "", - ` depends_on "ripgrep"`, + ' depends_on "ripgrep"', "", " on_macos do", " if Hardware::CPU.intel?", @@ -177,18 +209,15 @@ if (!Script.preview) { "", ].join("\n") - const token = process.env.GITHUB_TOKEN - if (!token) { - console.error("GITHUB_TOKEN is required to update homebrew tap") - process.exit(1) - } - const tap = `https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git` - await $`rm -rf ./dist/homebrew-tap` - await $`git clone ${tap} ./dist/homebrew-tap` - await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula) - await $`cd ./dist/homebrew-tap && git add opencode.rb` - if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) { + yield* Effect.promise(async () => { + await $`rm -rf ./dist/homebrew-tap` + await $`git clone https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git ./dist/homebrew-tap` + await Bun.write("./dist/homebrew-tap/opencode.rb", homebrewFormula) + await $`cd ./dist/homebrew-tap && git add opencode.rb` + if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode === 0) return await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` await $`cd ./dist/homebrew-tap && git push` - } -} + }) +}) + +await Effect.runPromise(program) diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts index de129918cd..fd02f34967 100755 --- a/packages/plugin/script/publish.ts +++ b/packages/plugin/script/publish.ts @@ -1,32 +1,46 @@ #!/usr/bin/env bun + import { Script } from "@opencode-ai/script" import { $ } from "bun" +import { Effect } from "effect" import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) -async function published(name: string, version: string) { - return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +type PackageJson = { + name: string + version: string + exports: Record } -await $`bun tsc` -const pkg = await import("../package.json").then( - (m) => m.default as { name: string; version: string; exports: Record }, -) -const original = JSON.parse(JSON.stringify(pkg)) -if (await published(pkg.name, pkg.version)) { - console.log(`already published ${pkg.name}@${pkg.version}`) - process.exit(0) -} -for (const [key, value] of Object.entries(pkg.exports)) { - const file = value.replace("./src/", "./dist/").replace(".ts", "") - // @ts-ignore - pkg.exports[key] = { - import: file + ".js", - types: file + ".d.ts", +const published = (name: string, version: string) => + Effect.promise(() => $`npm view ${name}@${version} version`.nothrow()).pipe( + Effect.map((result) => result.exitCode === 0), + ) + +const program = Effect.gen(function* () { + yield* Effect.promise(() => $`bun tsc`) + + const pkg = (yield* Effect.promise(() => import("../package.json").then((m) => m.default))) as PackageJson + if (yield* published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) + return } -} -await Bun.write("package.json", JSON.stringify(pkg, null, 2)) -await $`bun pm pack && npm publish *.tgz --tag ${Script.channel} --access public` -await Bun.write("package.json", JSON.stringify(original, null, 2)) + + const next = { + ...pkg, + exports: Object.fromEntries( + Object.entries(pkg.exports).map(([key, value]) => { + const file = value.replace("./src/", "./dist/").replace(".ts", "") + return [key, { import: file + ".js", types: file + ".d.ts" }] + }), + ), + } + + yield* Effect.promise(() => Bun.write("package.json", JSON.stringify(next, null, 2))) + yield* Effect.promise(() => $`bun pm pack && npm publish *.tgz --tag ${Script.channel} --access public`) + yield* Effect.promise(() => Bun.write("package.json", JSON.stringify(pkg, null, 2))) +}) + +await Effect.runPromise(program) diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts index b5e1211fc4..faa1c407c1 100755 --- a/packages/sdk/js/script/publish.ts +++ b/packages/sdk/js/script/publish.ts @@ -2,21 +2,38 @@ import { Script } from "@opencode-ai/script" import { $ } from "bun" +import { Effect } from "effect" import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) -async function published(name: string, version: string) { - return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +const packageJson = (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + "name" in value && + typeof value.name === "string" && + "version" in value && + typeof value.version === "string" && + "exports" in value && + typeof value.exports === "object" && + value.exports !== null + ) { + return { + name: value.name, + version: value.version, + exports: value.exports, + } + } + throw new Error("invalid sdk package manifest") } -const pkg = (await import("../package.json").then((m) => m.default)) as { - name: string - version: string - exports: Record -} -const original = JSON.parse(JSON.stringify(pkg)) +const published = (name: string, version: string) => + Effect.promise(() => $`npm view ${name}@${version} version`.nothrow()).pipe( + Effect.map((result) => result.exitCode === 0), + ) + function transformExports(exports: Record) { return Object.fromEntries( Object.entries(exports).map(([key, value]) => { @@ -24,19 +41,28 @@ function transformExports(exports: Record) { const file = value.replace("./src/", "./dist/").replace(".ts", "") return [key, { import: file + ".js", types: file + ".d.ts" }] } - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - return [key, transformExports(value)] - } + if (typeof value === "object" && value !== null && !Array.isArray(value)) return [key, transformExports(value)] return [key, value] }), ) } -if (await published(pkg.name, pkg.version)) { - console.log(`already published ${pkg.name}@${pkg.version}`) - process.exit(0) -} -pkg.exports = transformExports(pkg.exports) -await Bun.write("package.json", JSON.stringify(pkg, null, 2)) -await $`bun pm pack` -await $`npm publish *.tgz --tag ${Script.channel} --access public` -await Bun.write("package.json", JSON.stringify(original, null, 2)) + +const program = Effect.gen(function* () { + const pkg = packageJson(yield* Effect.promise(() => import("../package.json").then((m) => m.default))) + if (yield* published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) + return + } + + const next = { + ...pkg, + exports: transformExports(pkg.exports), + } + + yield* Effect.promise(() => Bun.write("package.json", JSON.stringify(next, null, 2))) + yield* Effect.promise(() => $`bun pm pack`) + yield* Effect.promise(() => $`npm publish *.tgz --tag ${Script.channel} --access public`) + yield* Effect.promise(() => Bun.write("package.json", JSON.stringify(pkg, null, 2))) +}) + +await Effect.runPromise(program) diff --git a/script/publish.ts b/script/publish.ts index 6cd244e0e6..46ca94d852 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -2,93 +2,94 @@ import { Script } from "@opencode-ai/script" import { $ } from "bun" +import { Effect } from "effect" import { fileURLToPath } from "url" console.log("=== publishing ===\n") const tag = `v${Script.version}` - const pkgjsons = await Array.fromAsync( new Bun.Glob("**/package.json").scan({ absolute: true, }), ).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist"))) - const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) -async function hasChanges() { - return (await $`git diff --quiet && git diff --cached --quiet`.nothrow()).exitCode !== 0 -} +const readText = (path: string) => Effect.promise(() => Bun.file(path).text()) +const writeText = (path: string, value: string) => Effect.promise(() => Bun.write(path, value)) +const shell = (run: () => Promise) => Effect.promise(run) +const log = (message: string) => Effect.sync(() => console.log(message)) -async function releaseTagExists() { - return (await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).exitCode === 0 -} +const hasChanges = shell(() => $`git diff --quiet && git diff --cached --quiet`.nothrow()).pipe( + Effect.map((result) => result.exitCode !== 0), +) -async function prepareReleaseFiles() { - for (const file of pkgjsons) { - let pkg = await Bun.file(file).text() - pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) - console.log("updated:", file) - await Bun.file(file).write(pkg) +const releaseTagExists = shell(() => $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).pipe( + Effect.map((result) => result.exitCode === 0), +) + +const prepareReleaseFiles = Effect.gen(function* () { + yield* Effect.forEach(pkgjsons, (file) => + Effect.gen(function* () { + const next = (yield* readText(file)).replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) + yield* log(`updated: ${file}`) + yield* writeText(file, next) + }), + ) + + const nextToml = (yield* readText(extensionToml)) + .replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) + .replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) + yield* log(`updated: ${extensionToml}`) + yield* writeText(extensionToml, nextToml) + yield* shell(() => $`bun install`) + yield* shell(() => $`./packages/sdk/js/script/build.ts`) +}) + +const program = Effect.gen(function* () { + if (Script.release && !Script.preview) { + yield* shell(() => $`git fetch origin --tags`) + yield* shell(() => $`git switch --detach`) } - let toml = await Bun.file(extensionToml).text() - toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) - toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) - console.log("updated:", extensionToml) - await Bun.file(extensionToml).write(toml) + yield* prepareReleaseFiles - await $`bun install` - await $`./packages/sdk/js/script/build.ts` -} - -if (Script.release && !Script.preview) { - await $`git fetch origin --tags` - await $`git switch --detach` -} - -await prepareReleaseFiles() - -if (Script.release && !Script.preview) { - if (await releaseTagExists()) { - console.log(`release tag ${tag} already exists, skipping tag creation`) - } else { - await $`git commit -am "release: ${tag}"` - await $`git tag ${tag}` - await $`git push origin refs/tags/${tag} --no-verify` - await new Promise((resolve) => setTimeout(resolve, 5_000)) + if (Script.release && !Script.preview) { + if (yield* releaseTagExists) yield* log(`release tag ${tag} already exists, skipping tag creation`) + else { + yield* shell(() => $`git commit -am "release: ${tag}"`) + yield* shell(() => $`git tag ${tag}`) + yield* shell(() => $`git push origin refs/tags/${tag} --no-verify`) + yield* shell(() => new Promise((resolve) => setTimeout(resolve, 5_000))) + } } -} -console.log("\n=== cli ===\n") -await import(`../packages/opencode/script/publish.ts`) + yield* log("\n=== cli ===\n") + yield* shell(() => import(`../packages/opencode/script/publish.ts`)) + yield* log("\n=== sdk ===\n") + yield* shell(() => import(`../packages/sdk/js/script/publish.ts`)) + yield* log("\n=== plugin ===\n") + yield* shell(() => import(`../packages/plugin/script/publish.ts`)) -console.log("\n=== sdk ===\n") -await import(`../packages/sdk/js/script/publish.ts`) - -console.log("\n=== plugin ===\n") -await import(`../packages/plugin/script/publish.ts`) - -if (Script.release) { - await import(`../packages/desktop/scripts/finalize-latest-json.ts`) - await import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`) -} - -if (Script.release && !Script.preview) { - await $`git fetch origin` - await $`git checkout -B dev origin/dev` - await prepareReleaseFiles() - if (await hasChanges()) { - await $`git commit -am "sync release versions for v${Script.version}"` - await $`git push origin HEAD:dev --no-verify` - } else { - console.log(`dev already synced for ${tag}`) + if (Script.release) { + yield* shell(() => import(`../packages/desktop/scripts/finalize-latest-json.ts`)) + yield* shell(() => import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`)) } -} -if (Script.release) { - await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}` -} + if (Script.release && !Script.preview) { + yield* shell(() => $`git fetch origin`) + yield* shell(() => $`git checkout -B dev origin/dev`) + yield* prepareReleaseFiles + if (yield* hasChanges) { + yield* shell(() => $`git commit -am "sync release versions for v${Script.version}"`) + yield* shell(() => $`git push origin HEAD:dev --no-verify`) + } else yield* log(`dev already synced for ${tag}`) + } + + if (Script.release) yield* shell(() => $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}`) +}) + +await Effect.runPromise(program) const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) diff --git a/script/version.ts b/script/version.ts index c1ad021b69..dfff2ad1c7 100755 --- a/script/version.ts +++ b/script/version.ts @@ -2,35 +2,55 @@ import { Script } from "@opencode-ai/script" import { $ } from "bun" +import { Effect } from "effect" -const output = [`version=${Script.version}`] +const tag = `v${Script.version}` const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() +const betaPreview = Script.preview && Script.channel === "beta" -if (!Script.preview) { - await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd()) - const file = `${process.cwd()}/UPCOMING_CHANGELOG.md` - const body = await Bun.file(file) - .text() - .catch(() => "No notable changes") - const dir = process.env.RUNNER_TEMP ?? "/tmp" - const notesFile = `${dir}/opencode-release-notes.txt` - await Bun.write(notesFile, body) - await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}` - const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() - output.push(`release=${release.databaseId}`) - output.push(`tag=${release.tagName}`) -} else if (Script.channel === "beta") { - await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --repo ${process.env.GH_REPO}` - const release = - await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json() - output.push(`release=${release.databaseId}`) - output.push(`tag=${release.tagName}`) +const changelog = Effect.promise(() => $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd())) +const readNotes = Effect.promise(() => Bun.file(`${process.cwd()}/UPCOMING_CHANGELOG.md`).text()).pipe( + Effect.catchAll(() => Effect.succeed("No notable changes")), +) +const writeOutput = (lines: ReadonlyArray) => + process.env.GITHUB_OUTPUT + ? Effect.promise(() => Bun.write(process.env.GITHUB_OUTPUT!, lines.join("\n"))) + : Effect.void + +const createRelease = (notesFile?: string) => { + if (!notesFile && betaPreview) { + return Effect.promise( + () => $`gh release create ${tag} -d --target ${sha} --title ${tag} --repo ${process.env.GH_REPO}`, + ) + } + if (notesFile) + return Effect.promise(() => $`gh release create ${tag} -d --target ${sha} --title ${tag} --notes-file ${notesFile}`) + return Effect.void } -output.push(`repo=${process.env.GH_REPO}`) +const viewRelease = betaPreview + ? Effect.promise(() => $`gh release view ${tag} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json()) + : Effect.promise(() => $`gh release view ${tag} --json tagName,databaseId`.json()) -if (process.env.GITHUB_OUTPUT) { - await Bun.write(process.env.GITHUB_OUTPUT, output.join("\n")) -} +const output = Effect.gen(function* () { + const lines = [`version=${Script.version}`] -process.exit(0) + if (!Script.preview) { + yield* changelog + const body = yield* readNotes + const notesFile = `${process.env.RUNNER_TEMP ?? "/tmp"}/opencode-release-notes.txt` + yield* Effect.promise(() => Bun.write(notesFile, body)) + yield* createRelease(notesFile) + const release = yield* viewRelease + lines.push(`release=${release.databaseId}`, `tag=${release.tagName}`) + } else if (Script.channel === "beta") { + yield* createRelease() + const release = yield* viewRelease + lines.push(`release=${release.databaseId}`, `tag=${release.tagName}`) + } + + lines.push(`repo=${process.env.GH_REPO}`) + yield* writeOutput(lines) +}) + +await Effect.runPromise(output) From 3496b9caf53152fc3ac19368ee229a4d0580d985 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 23:00:25 -0400 Subject: [PATCH 5/5] refactor(release): polish Effect publish scripts Use Effect.ensuring to restore temporary package manifests on failure and tidy the opencode packager with a small parallel hash helper so the stacked Effect rewrite reads cleaner without changing behavior. --- packages/opencode/script/publish.ts | 21 +++++++--------- packages/plugin/script/publish.ts | 38 ++++++++++++++++++++++++++--- packages/sdk/js/script/publish.ts | 12 ++++++--- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 23c88e28fb..5af35468c5 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -14,6 +14,9 @@ const published = (name: string, version: string) => Effect.map((result) => result.exitCode === 0), ) +const sha256 = (path: string) => + Effect.promise(() => $`sha256sum ${path} | cut -d' ' -f1`.text()).pipe(Effect.map((text) => text.trim())) + const publishPackage = (dir: string, name: string, version: string) => Effect.gen(function* () { if (yield* published(name, version)) { @@ -92,18 +95,12 @@ const program = Effect.gen(function* () { if (Script.preview) return - const arm64Sha = (yield* Effect.promise(() => - $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text(), - )).trim() - const x64Sha = (yield* Effect.promise(() => - $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text(), - )).trim() - const macX64Sha = (yield* Effect.promise(() => - $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text(), - )).trim() - const macArm64Sha = (yield* Effect.promise(() => - $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text(), - )).trim() + const [arm64Sha, x64Sha, macX64Sha, macArm64Sha] = yield* Effect.all([ + sha256("./dist/opencode-linux-arm64.tar.gz"), + sha256("./dist/opencode-linux-x64.tar.gz"), + sha256("./dist/opencode-darwin-x64.zip"), + sha256("./dist/opencode-darwin-arm64.zip"), + ]) const [pkgver, subver = ""] = Script.version.split(/(-.*)/, 2) const binaryPkgbuild = [ diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts index fd02f34967..4d22a0cfd0 100755 --- a/packages/plugin/script/publish.ts +++ b/packages/plugin/script/publish.ts @@ -14,15 +14,47 @@ type PackageJson = { exports: Record } +const packageJson = (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + "name" in value && + typeof value.name === "string" && + "version" in value && + typeof value.version === "string" && + "exports" in value && + typeof value.exports === "object" && + value.exports !== null + ) { + return { + name: value.name, + version: value.version, + exports: Object.fromEntries( + Object.entries(value.exports).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), + } + } + throw new Error("invalid plugin package manifest") +} + const published = (name: string, version: string) => Effect.promise(() => $`npm view ${name}@${version} version`.nothrow()).pipe( Effect.map((result) => result.exitCode === 0), ) +const withPackageJson = ( + pkg: PackageJson, + next: { name: string; version: string; exports: Record }, +) => + Effect.promise(() => Bun.write("package.json", JSON.stringify(next, null, 2))).pipe( + Effect.zipRight(Effect.promise(() => $`bun pm pack && npm publish *.tgz --tag ${Script.channel} --access public`)), + Effect.ensuring(Effect.promise(() => Bun.write("package.json", JSON.stringify(pkg, null, 2)))), + ) + const program = Effect.gen(function* () { yield* Effect.promise(() => $`bun tsc`) - const pkg = (yield* Effect.promise(() => import("../package.json").then((m) => m.default))) as PackageJson + const pkg = packageJson(yield* Effect.promise(() => import("../package.json").then((m) => m.default))) if (yield* published(pkg.name, pkg.version)) { console.log(`already published ${pkg.name}@${pkg.version}`) return @@ -38,9 +70,7 @@ const program = Effect.gen(function* () { ), } - yield* Effect.promise(() => Bun.write("package.json", JSON.stringify(next, null, 2))) - yield* Effect.promise(() => $`bun pm pack && npm publish *.tgz --tag ${Script.channel} --access public`) - yield* Effect.promise(() => Bun.write("package.json", JSON.stringify(pkg, null, 2))) + yield* withPackageJson(pkg, next) }) await Effect.runPromise(program) diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts index faa1c407c1..4696afe661 100755 --- a/packages/sdk/js/script/publish.ts +++ b/packages/sdk/js/script/publish.ts @@ -34,6 +34,13 @@ const published = (name: string, version: string) => Effect.map((result) => result.exitCode === 0), ) +const withPackageJson = (pkg: ReturnType, next: ReturnType) => + Effect.promise(() => Bun.write("package.json", JSON.stringify(next, null, 2))).pipe( + Effect.zipRight(Effect.promise(() => $`bun pm pack`)), + Effect.zipRight(Effect.promise(() => $`npm publish *.tgz --tag ${Script.channel} --access public`)), + Effect.ensuring(Effect.promise(() => Bun.write("package.json", JSON.stringify(pkg, null, 2)))), + ) + function transformExports(exports: Record) { return Object.fromEntries( Object.entries(exports).map(([key, value]) => { @@ -59,10 +66,7 @@ const program = Effect.gen(function* () { exports: transformExports(pkg.exports), } - yield* Effect.promise(() => Bun.write("package.json", JSON.stringify(next, null, 2))) - yield* Effect.promise(() => $`bun pm pack`) - yield* Effect.promise(() => $`npm publish *.tgz --tag ${Script.channel} --access public`) - yield* Effect.promise(() => Bun.write("package.json", JSON.stringify(pkg, null, 2))) + yield* withPackageJson(pkg, next) }) await Effect.runPromise(program)