Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions scripts/generate-homebrew-tap.ts
Original file line number Diff line number Diff line change
@@ -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<Platform, string>;
}

/** 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<Record<string, string>> {
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<void> {
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,
);
}
18 changes: 18 additions & 0 deletions scripts/prepare-homebrew-tap-release.sh
Original file line number Diff line number Diff line change
@@ -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
94 changes: 20 additions & 74 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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" <<EOF
class $class < Formula
desc "$DESC"
homepage "https://github.com/$MAIN_REPO"
version "$VERSION"
license "GPL-2.0-only"

on_macos do
on_arm do
url "$(url_for macos-arm64)"
sha256 "$(sha_for macos-arm64)"
end
on_intel do
url "$(url_for macos-x64)"
sha256 "$(sha_for macos-x64)"
end
end

on_linux do
on_arm do
url "$(url_for linux-arm64)"
sha256 "$(sha_for linux-arm64)"
end
on_intel do
url "$(url_for linux-x64)"
sha256 "$(sha_for linux-x64)"
end
end

def install
bin.install "$BINARY"
if File.directory?("plugins")
(bin/"plugins").mkpath
cp_r "plugins/.", bin/"plugins"
end
end
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)" \
"$(sha_for macos-x64)" \
"$(sha_for linux-arm64)" \
"$(sha_for linux-x64)"

test do
assert_predicate bin/"$BINARY", :executable?
end
end
EOF
if git -C "$TAP_DIR" rev-parse --verify HEAD >/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 ------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions src/upgrade/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/generate-homebrew-tap.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading