From cd17e327bc574fc8fe32966c5eefd82c35e4c802 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 23:32:36 -0700 Subject: [PATCH 1/4] Harden Homebrew formula rename releases --- scripts/generate-homebrew-tap.ts | 130 +++++++++++++++++++++++ scripts/release.sh | 85 +++------------ src/upgrade/index.test.ts | 11 ++ tests/unit/generate-homebrew-tap.test.ts | 63 +++++++++++ 4 files changed, 219 insertions(+), 70 deletions(-) create mode 100644 scripts/generate-homebrew-tap.ts create mode 100644 tests/unit/generate-homebrew-tap.test.ts diff --git a/scripts/generate-homebrew-tap.ts b/scripts/generate-homebrew-tap.ts new file mode 100644 index 000000000..9e76c6d3b --- /dev/null +++ b/scripts/generate-homebrew-tap.ts @@ -0,0 +1,130 @@ +import { type } from "arktype"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const FormulaRenames = type({ "[string]": "string" }); + +type Platform = "macos-arm64" | "macos-x64" | "linux-arm64" | "linux-x64"; + +export interface HomebrewRelease { + version: string; + checksums: Record; +} + +const releaseURL = (version: string, platform: Platform): string => + `https://github.com/corbitsdev/corbits-code/releases/download/v${version}/corbits-${version}-${platform}.tar.gz`; + +function renderFormula(release: HomebrewRelease): string { + const source = ( + platform: Platform, + ): string => ` url "${releaseURL(release.version, platform)}" + sha256 "${release.checksums[platform]}"`; + + return `class CorbitsCode < Formula + desc "Single-process coding agent CLI built on the Interchange runtime" + homepage "https://github.com/corbitsdev/corbits-code" + version "${release.version}" + license "GPL-2.0-only" + + on_macos do + on_arm do +${source("macos-arm64")} + end + on_intel do +${source("macos-x64")} + end + end + + on_linux do + on_arm do +${source("linux-arm64")} + end + on_intel do +${source("linux-x64")} + end + end + + def install + bin.install "corbits" + if File.directory?("plugins") + (bin/"plugins").mkpath + cp_r "plugins/.", bin/"plugins" + end + end + + test do + assert_predicate bin/"corbits", :executable? + end +end +`; +} + +async function readFormulaRenames(path: string): Promise> { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return {}; + throw cause; + } + + const parsed: unknown = JSON.parse(raw); + const renames = FormulaRenames(parsed); + if (renames instanceof type.errors) { + throw new Error(`Invalid formula rename metadata: ${renames.summary}`); + } + return renames; +} + +export async function generateHomebrewTap(tapDir: string, release: HomebrewRelease): Promise { + const formulaDir = join(tapDir, "Formula"); + const renamesPath = join(tapDir, "formula_renames.json"); + await mkdir(formulaDir, { recursive: true }); + + await rm(join(formulaDir, "corbits.rb"), { force: true }); + await writeFile(join(formulaDir, "corbits-code.rb"), renderFormula(release)); + + const renames = await readFormulaRenames(renamesPath); + renames.corbits = "corbits-code"; + await writeFile(renamesPath, `${JSON.stringify(renames, null, 2)}\n`); +} + +function parseRelease(args: string[]): { tapDir: string; release: HomebrewRelease } { + if (args.length !== 6) { + throw new Error( + "usage: generate-homebrew-tap.ts TAP_DIR VERSION MACOS_ARM64 MACOS_X64 LINUX_ARM64 LINUX_X64", + ); + } + const [tapDir, version, macosArm64, macosX64, linuxArm64, linuxX64] = args; + if (!tapDir || !version || !/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error("version must be X.Y.Z"); + } + const isChecksum = (value: string | undefined): value is string => + value !== undefined && /^[0-9a-f]{64}$/.test(value); + if ( + !isChecksum(macosArm64) || + !isChecksum(macosX64) || + !isChecksum(linuxArm64) || + !isChecksum(linuxX64) + ) { + throw new Error("invalid SHA-256 checksum"); + } + + return { + tapDir, + release: { + version, + checksums: { + "macos-arm64": macosArm64, + "macos-x64": macosX64, + "linux-arm64": linuxArm64, + "linux-x64": linuxX64, + }, + }, + }; +} + +if (import.meta.main) { + const { tapDir, release } = parseRelease(process.argv.slice(2)); + await generateHomebrewTap(tapDir, release); +} diff --git a/scripts/release.sh b/scripts/release.sh index b476ee4e5..c695e28b5 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -257,7 +257,10 @@ if [ "$SKIP_TAP" != 1 ]; then brew tap "$TAP_SLUG" >/dev/null 2>&1 || \ die "cannot tap $TAP_SLUG. Create https://github.com/$TAP_REPO then: brew tap $TAP_SLUG" fi - info "tap: $TAP_DIR" + [ -z "$(git -C "$TAP_DIR" status --porcelain)" ] || \ + die "tap has local changes; clean $TAP_DIR before releasing" + git -C "$TAP_DIR" pull --ff-only --quiet + info "tap: $TAP_DIR (fast-forwarded)" fi info "repo: $ROOT" @@ -480,78 +483,20 @@ else sha_for() { # sha_for LABEL -> sha256 of that tarball cut -d' ' -f1 "$STAGE/$BINARY-$VERSION-$1.tar.gz.sha256" } - url_for() { # url_for LABEL -> download URL for that tarball - echo "https://github.com/$MAIN_REPO/releases/download/$TAG/$BINARY-$VERSION-$1.tar.gz" - } - # Homebrew class: corbits-code -> CorbitsCode - class=$(echo "$BREW_FORMULA" | awk -F'[-_]' '{ - s = "" - for (i = 1; i <= NF; i++) s = s toupper(substr($i, 1, 1)) substr($i, 2) - print s - }') - mkdir -p "$TAP_DIR/Formula" - # Drop the old single-name formula if we renamed (corbits -> corbits-code). - # git rm can remove the last file and drop the empty Formula/ directory — - # recreate it before writing the new formula. - if [ -f "$TAP_DIR/Formula/$BINARY.rb" ] && [ "$BINARY" != "$BREW_FORMULA" ]; then - git -C "$TAP_DIR" rm -f --quiet "Formula/$BINARY.rb" 2>/dev/null \ - || rm -f "$TAP_DIR/Formula/$BINARY.rb" - fi - mkdir -p "$TAP_DIR/Formula" - cat > "$TAP_DIR/Formula/$BREW_FORMULA.rb" </dev/null 2>&1 \ - && git -C "$TAP_DIR" ls-files --error-unmatch "Formula/$BREW_FORMULA.rb" >/dev/null 2>&1 \ - && git -C "$TAP_DIR" diff --quiet -- "Formula/$BREW_FORMULA.rb" \ - && ! git -C "$TAP_DIR" status --porcelain -- "Formula/" | grep -q .; then - skip "formula already at $VERSION" + git -C "$TAP_DIR" add -A -- "Formula/" formula_renames.json + if git -C "$TAP_DIR" diff --cached --quiet -- "Formula/" formula_renames.json; then + skip "formula and rename metadata already at $VERSION" else - # Untracked formula (empty or new tap) is invisible to `git diff`, so we - # require the file to be tracked before treating "no diff" as up-to-date. - git -C "$TAP_DIR" add "Formula/$BREW_FORMULA.rb" - git -C "$TAP_DIR" add -u "Formula/" 2>/dev/null || true git -C "$TAP_DIR" commit -q -m "$BREW_FORMULA $VERSION" - info "committed formula bump" + info "committed formula and rename metadata bump" git_push "$TAP_DIR" fi fi diff --git a/src/upgrade/index.test.ts b/src/upgrade/index.test.ts index 433c6077c..59c034012 100644 --- a/src/upgrade/index.test.ts +++ b/src/upgrade/index.test.ts @@ -55,6 +55,17 @@ describe("detectInstallMethod", () => { ).toBe("homebrew"); }); + test("detects legacy corbits Cellar installs", () => { + expect( + detectInstallMethod( + probe({ + execPath: "/usr/local/bin/corbits", + resolvedPath: "/usr/local/Cellar/corbits/0.2.90/bin/corbits", + }), + ), + ).toBe("homebrew"); + }); + test("detects Homebrew via HOMEBREW_PREFIX when the binary lives under it", () => { expect( detectInstallMethod( diff --git a/tests/unit/generate-homebrew-tap.test.ts b/tests/unit/generate-homebrew-tap.test.ts new file mode 100644 index 000000000..d8b5084f8 --- /dev/null +++ b/tests/unit/generate-homebrew-tap.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { generateHomebrewTap } from "../../scripts/generate-homebrew-tap.js"; + +const release = { + version: "1.2.3", + checksums: { + "macos-arm64": "a".repeat(64), + "macos-x64": "b".repeat(64), + "linux-arm64": "c".repeat(64), + "linux-x64": "d".repeat(64), + }, +}; + +describe("generateHomebrewTap", () => { + let tapDir: string; + + beforeEach(async () => { + tapDir = await mkdtemp(join(tmpdir(), "corbits-homebrew-tap-")); + }); + + afterEach(async () => { + await rm(tapDir, { recursive: true, force: true }); + }); + + test("replaces the legacy formula with corbits-code and installs corbits", async () => { + const formulaDir = join(tapDir, "Formula"); + await mkdir(formulaDir); + await writeFile(join(formulaDir, "corbits.rb"), "class Corbits < Formula\nend\n"); + + await generateHomebrewTap(tapDir, release); + + expect(await Array.fromAsync(new Bun.Glob("*.rb").scan(formulaDir))).toEqual([ + "corbits-code.rb", + ]); + const formula = await readFile(join(formulaDir, "corbits-code.rb"), "utf8"); + expect(formula).toContain("class CorbitsCode < Formula"); + expect(formula).toContain('version "1.2.3"'); + expect(formula).toContain('bin.install "corbits"'); + expect(formula).not.toContain('bin.install "corbits-code"'); + }); + + test("merges formula rename metadata without changing repeated output", async () => { + await writeFile( + join(tapDir, "formula_renames.json"), + `${JSON.stringify({ retained: "other-formula" }, null, 2)}\n`, + ); + + await generateHomebrewTap(tapDir, release); + + const first = await readFile(join(tapDir, "formula_renames.json"), "utf8"); + expect(JSON.parse(first)).toEqual({ + retained: "other-formula", + corbits: "corbits-code", + }); + + await generateHomebrewTap(tapDir, release); + expect(await readFile(join(tapDir, "formula_renames.json"), "utf8")).toBe(first); + }); +}); From 8cca66af62ca862a8da3c88be117cf7c7e6159c1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 23:49:28 -0700 Subject: [PATCH 2/4] Fix Homebrew release rerun durability --- scripts/generate-homebrew-tap.ts | 16 ++++--- scripts/prepare-homebrew-tap-release.sh | 18 ++++++++ scripts/release.sh | 16 +++---- tests/unit/generate-homebrew-tap.test.ts | 25 +++++++++++ .../unit/prepare-homebrew-tap-release.test.ts | 45 +++++++++++++++++++ 5 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 scripts/prepare-homebrew-tap-release.sh create mode 100644 tests/unit/prepare-homebrew-tap-release.test.ts diff --git a/scripts/generate-homebrew-tap.ts b/scripts/generate-homebrew-tap.ts index 9e76c6d3b..55c11417b 100644 --- a/scripts/generate-homebrew-tap.ts +++ b/scripts/generate-homebrew-tap.ts @@ -69,6 +69,9 @@ async function readFormulaRenames(path: string): Promise> } const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + throw new Error("Invalid formula rename metadata: expected an object"); + } const renames = FormulaRenames(parsed); if (renames instanceof type.errors) { throw new Error(`Invalid formula rename metadata: ${renames.summary}`); @@ -79,14 +82,15 @@ async function readFormulaRenames(path: string): Promise> export async function generateHomebrewTap(tapDir: string, release: HomebrewRelease): Promise { const formulaDir = join(tapDir, "Formula"); const renamesPath = join(tapDir, "formula_renames.json"); - await mkdir(formulaDir, { recursive: true }); - - await rm(join(formulaDir, "corbits.rb"), { force: true }); - await writeFile(join(formulaDir, "corbits-code.rb"), renderFormula(release)); - + const formula = renderFormula(release); const renames = await readFormulaRenames(renamesPath); renames.corbits = "corbits-code"; - await writeFile(renamesPath, `${JSON.stringify(renames, null, 2)}\n`); + const renameMetadata = `${JSON.stringify(renames, null, 2)}\n`; + + await mkdir(formulaDir, { recursive: true }); + await rm(join(formulaDir, "corbits.rb"), { force: true }); + await writeFile(join(formulaDir, "corbits-code.rb"), formula); + await writeFile(renamesPath, renameMetadata); } function parseRelease(args: string[]): { tapDir: string; release: HomebrewRelease } { diff --git a/scripts/prepare-homebrew-tap-release.sh b/scripts/prepare-homebrew-tap-release.sh new file mode 100644 index 000000000..d8705c51b --- /dev/null +++ b/scripts/prepare-homebrew-tap-release.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TAP_DIR=${1:?tap directory is required} +VERSION=${2:?version is required} + +git -C "$TAP_DIR" add -A -- Formula/ formula_renames.json +if ! git -C "$TAP_DIR" diff --cached --quiet -- Formula/ formula_renames.json; then + git -C "$TAP_DIR" commit -q -m "corbits-code $VERSION" +fi + +UPSTREAM=$(git -C "$TAP_DIR" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}') +if [ "$(git -C "$TAP_DIR" rev-list --count "$UPSTREAM..HEAD")" -gt 0 ]; then + printf 'push-required\n' +else + printf 'current\n' +fi diff --git a/scripts/release.sh b/scripts/release.sh index c695e28b5..cdbdf45ad 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -491,14 +491,14 @@ else "$(sha_for linux-arm64)" \ "$(sha_for linux-x64)" - git -C "$TAP_DIR" add -A -- "Formula/" formula_renames.json - if git -C "$TAP_DIR" diff --cached --quiet -- "Formula/" formula_renames.json; then - skip "formula and rename metadata already at $VERSION" - else - git -C "$TAP_DIR" commit -q -m "$BREW_FORMULA $VERSION" - info "committed formula and rename metadata bump" - git_push "$TAP_DIR" - fi + tap_status=$(bash "$ROOT/scripts/prepare-homebrew-tap-release.sh" "$TAP_DIR" "$VERSION") + case "$tap_status" in + push-required) + info "formula and rename metadata ready to push" + git_push "$TAP_DIR" ;; + current) skip "formula and rename metadata already at $VERSION" ;; + *) die "unexpected tap preparation status: $tap_status" ;; + esac fi # ---- done ------------------------------------------------------------------ diff --git a/tests/unit/generate-homebrew-tap.test.ts b/tests/unit/generate-homebrew-tap.test.ts index d8b5084f8..dbdc42b70 100644 --- a/tests/unit/generate-homebrew-tap.test.ts +++ b/tests/unit/generate-homebrew-tap.test.ts @@ -43,6 +43,31 @@ describe("generateHomebrewTap", () => { expect(formula).not.toContain('bin.install "corbits-code"'); }); + test("rejects array-shaped rename metadata", async () => { + await writeFile(join(tapDir, "formula_renames.json"), "[]\n"); + + await expect(generateHomebrewTap(tapDir, release)).rejects.toThrow( + "Invalid formula rename metadata", + ); + }); + + test("leaves formulas unchanged when rename metadata is invalid", async () => { + const formulaDir = join(tapDir, "Formula"); + const legacyFormula = "class Corbits < Formula\nend\n"; + const currentFormula = "class CorbitsCode < Formula\nend\n"; + await mkdir(formulaDir); + await writeFile(join(formulaDir, "corbits.rb"), legacyFormula); + await writeFile(join(formulaDir, "corbits-code.rb"), currentFormula); + await writeFile(join(tapDir, "formula_renames.json"), '{"other": 42}\n'); + + await expect(generateHomebrewTap(tapDir, release)).rejects.toThrow( + "Invalid formula rename metadata", + ); + + expect(await readFile(join(formulaDir, "corbits.rb"), "utf8")).toBe(legacyFormula); + expect(await readFile(join(formulaDir, "corbits-code.rb"), "utf8")).toBe(currentFormula); + }); + test("merges formula rename metadata without changing repeated output", async () => { await writeFile( join(tapDir, "formula_renames.json"), diff --git a/tests/unit/prepare-homebrew-tap-release.test.ts b/tests/unit/prepare-homebrew-tap-release.test.ts new file mode 100644 index 000000000..fe1c9ccbc --- /dev/null +++ b/tests/unit/prepare-homebrew-tap-release.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const script = join(import.meta.dir, "../../scripts/prepare-homebrew-tap-release.sh"); + +describe("prepare-homebrew-tap-release", () => { + let root: string; + let tapDir: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "corbits-release-tap-")); + const origin = join(root, "origin.git"); + tapDir = join(root, "tap"); + await execFileAsync("git", ["init", "--bare", "--initial-branch=main", origin]); + await execFileAsync("git", ["clone", origin, tapDir]); + await execFileAsync("git", ["-C", tapDir, "config", "user.name", "Release Test"]); + await execFileAsync("git", ["-C", tapDir, "config", "user.email", "release@example.test"]); + + await mkdir(join(tapDir, "Formula")); + await writeFile(join(tapDir, "Formula/corbits-code.rb"), "version one\n"); + await writeFile(join(tapDir, "formula_renames.json"), "{}\n"); + await execFileAsync("git", ["-C", tapDir, "add", "."]); + await execFileAsync("git", ["-C", tapDir, "commit", "-m", "Initial tap"]); + await execFileAsync("git", ["-C", tapDir, "push", "-u", "origin", "main"]); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + test("requests a push when generated files are unchanged but the tap is ahead", async () => { + await writeFile(join(tapDir, "Formula/corbits-code.rb"), "version two\n"); + await execFileAsync("git", ["-C", tapDir, "add", "Formula/corbits-code.rb"]); + await execFileAsync("git", ["-C", tapDir, "commit", "-m", "Pending formula"]); + + const result = await execFileAsync("bash", [script, tapDir, "1.2.3"]); + + expect(result.stdout.trim()).toBe("push-required"); + }); +}); From e8aa3e1f41f1e1baf0eb0c860db93dda0e371ca4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 08:57:40 -0700 Subject: [PATCH 3/4] Clean Homebrew tap generation tests --- tests/unit/generate-homebrew-tap.test.ts | 49 +++++++++++------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/tests/unit/generate-homebrew-tap.test.ts b/tests/unit/generate-homebrew-tap.test.ts index dbdc42b70..cb7d6d089 100644 --- a/tests/unit/generate-homebrew-tap.test.ts +++ b/tests/unit/generate-homebrew-tap.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -33,9 +33,7 @@ describe("generateHomebrewTap", () => { await generateHomebrewTap(tapDir, release); - expect(await Array.fromAsync(new Bun.Glob("*.rb").scan(formulaDir))).toEqual([ - "corbits-code.rb", - ]); + expect((await readdir(formulaDir)).sort()).toEqual(["corbits-code.rb"]); const formula = await readFile(join(formulaDir, "corbits-code.rb"), "utf8"); expect(formula).toContain("class CorbitsCode < Formula"); expect(formula).toContain('version "1.2.3"'); @@ -43,29 +41,26 @@ describe("generateHomebrewTap", () => { expect(formula).not.toContain('bin.install "corbits-code"'); }); - test("rejects array-shaped rename metadata", async () => { - await writeFile(join(tapDir, "formula_renames.json"), "[]\n"); - - await expect(generateHomebrewTap(tapDir, release)).rejects.toThrow( - "Invalid formula rename metadata", - ); - }); - - test("leaves formulas unchanged when rename metadata is invalid", async () => { - const formulaDir = join(tapDir, "Formula"); - const legacyFormula = "class Corbits < Formula\nend\n"; - const currentFormula = "class CorbitsCode < Formula\nend\n"; - await mkdir(formulaDir); - await writeFile(join(formulaDir, "corbits.rb"), legacyFormula); - await writeFile(join(formulaDir, "corbits-code.rb"), currentFormula); - await writeFile(join(tapDir, "formula_renames.json"), '{"other": 42}\n'); - - await expect(generateHomebrewTap(tapDir, release)).rejects.toThrow( - "Invalid formula rename metadata", - ); - - expect(await readFile(join(formulaDir, "corbits.rb"), "utf8")).toBe(legacyFormula); - expect(await readFile(join(formulaDir, "corbits-code.rb"), "utf8")).toBe(currentFormula); + test("rejects invalid rename metadata before changing formulas", async () => { + const invalidMetadata = ["[]\n", '{"other": 42}\n']; + + for (const [index, metadata] of invalidMetadata.entries()) { + const caseDir = join(tapDir, `invalid-${index}`); + const formulaDir = join(caseDir, "Formula"); + const legacyFormula = "class Corbits < Formula\nend\n"; + const currentFormula = "class CorbitsCode < Formula\nend\n"; + await mkdir(formulaDir, { recursive: true }); + await writeFile(join(formulaDir, "corbits.rb"), legacyFormula); + await writeFile(join(formulaDir, "corbits-code.rb"), currentFormula); + await writeFile(join(caseDir, "formula_renames.json"), metadata); + + await expect(generateHomebrewTap(caseDir, release)).rejects.toThrow( + "Invalid formula rename metadata", + ); + + expect(await readFile(join(formulaDir, "corbits.rb"), "utf8")).toBe(legacyFormula); + expect(await readFile(join(formulaDir, "corbits-code.rb"), "utf8")).toBe(currentFormula); + } }); test("merges formula rename metadata without changing repeated output", async () => { From f60a6f0cbdbc21313ad8de42443c2a62b0a9062e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 12:16:33 -0700 Subject: [PATCH 4/4] Pass release constants into the Homebrew tap generator release.sh already owned the repo, binary, formula, and description; the tap generator hard-coded copies of the same values. It now takes them from the environment release.sh sets, so they cannot drift. --- scripts/generate-homebrew-tap.ts | 57 +++++++++++++++++------- scripts/release.sh | 3 +- tests/unit/generate-homebrew-tap.test.ts | 15 +++++-- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/scripts/generate-homebrew-tap.ts b/scripts/generate-homebrew-tap.ts index 55c11417b..beda3cb2e 100644 --- a/scripts/generate-homebrew-tap.ts +++ b/scripts/generate-homebrew-tap.ts @@ -11,18 +11,26 @@ export interface HomebrewRelease { checksums: Record; } -const releaseURL = (version: string, platform: Platform): string => - `https://github.com/corbitsdev/corbits-code/releases/download/v${version}/corbits-${version}-${platform}.tar.gz`; +/** Release facts owned by scripts/release.sh; passed in so they live in one place. */ +export interface HomebrewPackage { + repo: string; // GitHub owner/name + binary: string; // CLI binary, tarball stem, and legacy formula name + formula: string; // `brew install` name + description: string; +} + +const formulaClass = (formula: string): string => + formula.replace(/(?:^|-)([a-z])/g, (_, c: string) => c.toUpperCase()); -function renderFormula(release: HomebrewRelease): string { +function renderFormula(pkg: HomebrewPackage, release: HomebrewRelease): string { const source = ( platform: Platform, - ): string => ` url "${releaseURL(release.version, platform)}" + ): string => ` url "https://github.com/${pkg.repo}/releases/download/v${release.version}/${pkg.binary}-${release.version}-${platform}.tar.gz" sha256 "${release.checksums[platform]}"`; - return `class CorbitsCode < Formula - desc "Single-process coding agent CLI built on the Interchange runtime" - homepage "https://github.com/corbitsdev/corbits-code" + return `class ${formulaClass(pkg.formula)} < Formula + desc "${pkg.description}" + homepage "https://github.com/${pkg.repo}" version "${release.version}" license "GPL-2.0-only" @@ -45,7 +53,7 @@ ${source("linux-x64")} end def install - bin.install "corbits" + bin.install "${pkg.binary}" if File.directory?("plugins") (bin/"plugins").mkpath cp_r "plugins/.", bin/"plugins" @@ -53,7 +61,7 @@ ${source("linux-x64")} end test do - assert_predicate bin/"corbits", :executable? + assert_predicate bin/"${pkg.binary}", :executable? end end `; @@ -79,17 +87,21 @@ async function readFormulaRenames(path: string): Promise> return renames; } -export async function generateHomebrewTap(tapDir: string, release: HomebrewRelease): Promise { +export async function generateHomebrewTap( + tapDir: string, + pkg: HomebrewPackage, + release: HomebrewRelease, +): Promise { const formulaDir = join(tapDir, "Formula"); const renamesPath = join(tapDir, "formula_renames.json"); - const formula = renderFormula(release); + const formula = renderFormula(pkg, release); const renames = await readFormulaRenames(renamesPath); - renames.corbits = "corbits-code"; + renames[pkg.binary] = pkg.formula; const renameMetadata = `${JSON.stringify(renames, null, 2)}\n`; await mkdir(formulaDir, { recursive: true }); - await rm(join(formulaDir, "corbits.rb"), { force: true }); - await writeFile(join(formulaDir, "corbits-code.rb"), formula); + await rm(join(formulaDir, `${pkg.binary}.rb`), { force: true }); + await writeFile(join(formulaDir, `${pkg.formula}.rb`), formula); await writeFile(renamesPath, renameMetadata); } @@ -128,7 +140,22 @@ function parseRelease(args: string[]): { tapDir: string; release: HomebrewReleas }; } +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`missing ${name} (set by scripts/release.sh)`); + return value; +} + if (import.meta.main) { const { tapDir, release } = parseRelease(process.argv.slice(2)); - await generateHomebrewTap(tapDir, release); + await generateHomebrewTap( + tapDir, + { + repo: requireEnv("MAIN_REPO"), + binary: requireEnv("BINARY"), + formula: requireEnv("BREW_FORMULA"), + description: requireEnv("DESC"), + }, + release, + ); } diff --git a/scripts/release.sh b/scripts/release.sh index cdbdf45ad..5a5a8e4a4 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -483,7 +483,8 @@ else sha_for() { # sha_for LABEL -> sha256 of that tarball cut -d' ' -f1 "$STAGE/$BINARY-$VERSION-$1.tar.gz.sha256" } - bun "$ROOT/scripts/generate-homebrew-tap.ts" \ + MAIN_REPO="$MAIN_REPO" BINARY="$BINARY" BREW_FORMULA="$BREW_FORMULA" DESC="$DESC" \ + bun "$ROOT/scripts/generate-homebrew-tap.ts" \ "$TAP_DIR" \ "$VERSION" \ "$(sha_for macos-arm64)" \ diff --git a/tests/unit/generate-homebrew-tap.test.ts b/tests/unit/generate-homebrew-tap.test.ts index cb7d6d089..90098978a 100644 --- a/tests/unit/generate-homebrew-tap.test.ts +++ b/tests/unit/generate-homebrew-tap.test.ts @@ -5,6 +5,13 @@ import { join } from "node:path"; import { generateHomebrewTap } from "../../scripts/generate-homebrew-tap.js"; +const pkg = { + repo: "corbitsdev/corbits-code", + binary: "corbits", + formula: "corbits-code", + description: "Single-process coding agent CLI built on the Interchange runtime", +}; + const release = { version: "1.2.3", checksums: { @@ -31,7 +38,7 @@ describe("generateHomebrewTap", () => { await mkdir(formulaDir); await writeFile(join(formulaDir, "corbits.rb"), "class Corbits < Formula\nend\n"); - await generateHomebrewTap(tapDir, release); + await generateHomebrewTap(tapDir, pkg, release); expect((await readdir(formulaDir)).sort()).toEqual(["corbits-code.rb"]); const formula = await readFile(join(formulaDir, "corbits-code.rb"), "utf8"); @@ -54,7 +61,7 @@ describe("generateHomebrewTap", () => { await writeFile(join(formulaDir, "corbits-code.rb"), currentFormula); await writeFile(join(caseDir, "formula_renames.json"), metadata); - await expect(generateHomebrewTap(caseDir, release)).rejects.toThrow( + await expect(generateHomebrewTap(caseDir, pkg, release)).rejects.toThrow( "Invalid formula rename metadata", ); @@ -69,7 +76,7 @@ describe("generateHomebrewTap", () => { `${JSON.stringify({ retained: "other-formula" }, null, 2)}\n`, ); - await generateHomebrewTap(tapDir, release); + await generateHomebrewTap(tapDir, pkg, release); const first = await readFile(join(tapDir, "formula_renames.json"), "utf8"); expect(JSON.parse(first)).toEqual({ @@ -77,7 +84,7 @@ describe("generateHomebrewTap", () => { corbits: "corbits-code", }); - await generateHomebrewTap(tapDir, release); + await generateHomebrewTap(tapDir, pkg, release); expect(await readFile(join(tapDir, "formula_renames.json"), "utf8")).toBe(first); }); });