diff --git a/scripts/generate-homebrew-tap.ts b/scripts/generate-homebrew-tap.ts new file mode 100644 index 000000000..beda3cb2e --- /dev/null +++ b/scripts/generate-homebrew-tap.ts @@ -0,0 +1,161 @@ +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; +} + +/** 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(pkg: HomebrewPackage, release: HomebrewRelease): string { + const source = ( + platform: 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 ${formulaClass(pkg.formula)} < Formula + desc "${pkg.description}" + homepage "https://github.com/${pkg.repo}" + 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 "${pkg.binary}" + if File.directory?("plugins") + (bin/"plugins").mkpath + cp_r "plugins/.", bin/"plugins" + end + end + + test do + assert_predicate bin/"${pkg.binary}", :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); + 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}`); + } + return renames; +} + +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(pkg, release); + const renames = await readFormulaRenames(renamesPath); + renames[pkg.binary] = pkg.formula; + const renameMetadata = `${JSON.stringify(renames, null, 2)}\n`; + + await mkdir(formulaDir, { recursive: true }); + await rm(join(formulaDir, `${pkg.binary}.rb`), { force: true }); + await writeFile(join(formulaDir, `${pkg.formula}.rb`), formula); + await writeFile(renamesPath, renameMetadata); +} + +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, + }, + }, + }; +} + +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, + { + repo: requireEnv("MAIN_REPO"), + binary: requireEnv("BINARY"), + formula: requireEnv("BREW_FORMULA"), + description: requireEnv("DESC"), + }, + release, + ); +} 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 b476ee4e5..5a5a8e4a4 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,80 +483,23 @@ 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" - 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" - 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/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..90098978a --- /dev/null +++ b/tests/unit/generate-homebrew-tap.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readdir, 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 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: { + "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, pkg, release); + + 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"'); + expect(formula).toContain('bin.install "corbits"'); + expect(formula).not.toContain('bin.install "corbits-code"'); + }); + + 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, pkg, 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"), + `${JSON.stringify({ retained: "other-formula" }, null, 2)}\n`, + ); + + await generateHomebrewTap(tapDir, pkg, 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, pkg, release); + expect(await readFile(join(tapDir, "formula_renames.json"), "utf8")).toBe(first); + }); +}); 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"); + }); +});