Skip to content

Commit 56e7c6e

Browse files
Merge pull request #704 from corbitsdev/cl-7118-migrate-legacy-homebrew-corbits-installs-to-corbits-code
Preserve legacy Homebrew formula migrations
2 parents eae0170 + f60a6f0 commit 56e7c6e

6 files changed

Lines changed: 345 additions & 74 deletions

File tree

scripts/generate-homebrew-tap.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { type } from "arktype";
2+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3+
import { join } from "node:path";
4+
5+
const FormulaRenames = type({ "[string]": "string" });
6+
7+
type Platform = "macos-arm64" | "macos-x64" | "linux-arm64" | "linux-x64";
8+
9+
export interface HomebrewRelease {
10+
version: string;
11+
checksums: Record<Platform, string>;
12+
}
13+
14+
/** Release facts owned by scripts/release.sh; passed in so they live in one place. */
15+
export interface HomebrewPackage {
16+
repo: string; // GitHub owner/name
17+
binary: string; // CLI binary, tarball stem, and legacy formula name
18+
formula: string; // `brew install` name
19+
description: string;
20+
}
21+
22+
const formulaClass = (formula: string): string =>
23+
formula.replace(/(?:^|-)([a-z])/g, (_, c: string) => c.toUpperCase());
24+
25+
function renderFormula(pkg: HomebrewPackage, release: HomebrewRelease): string {
26+
const source = (
27+
platform: Platform,
28+
): string => ` url "https://github.com/${pkg.repo}/releases/download/v${release.version}/${pkg.binary}-${release.version}-${platform}.tar.gz"
29+
sha256 "${release.checksums[platform]}"`;
30+
31+
return `class ${formulaClass(pkg.formula)} < Formula
32+
desc "${pkg.description}"
33+
homepage "https://github.com/${pkg.repo}"
34+
version "${release.version}"
35+
license "GPL-2.0-only"
36+
37+
on_macos do
38+
on_arm do
39+
${source("macos-arm64")}
40+
end
41+
on_intel do
42+
${source("macos-x64")}
43+
end
44+
end
45+
46+
on_linux do
47+
on_arm do
48+
${source("linux-arm64")}
49+
end
50+
on_intel do
51+
${source("linux-x64")}
52+
end
53+
end
54+
55+
def install
56+
bin.install "${pkg.binary}"
57+
if File.directory?("plugins")
58+
(bin/"plugins").mkpath
59+
cp_r "plugins/.", bin/"plugins"
60+
end
61+
end
62+
63+
test do
64+
assert_predicate bin/"${pkg.binary}", :executable?
65+
end
66+
end
67+
`;
68+
}
69+
70+
async function readFormulaRenames(path: string): Promise<Record<string, string>> {
71+
let raw: string;
72+
try {
73+
raw = await readFile(path, "utf8");
74+
} catch (cause) {
75+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return {};
76+
throw cause;
77+
}
78+
79+
const parsed: unknown = JSON.parse(raw);
80+
if (Array.isArray(parsed)) {
81+
throw new Error("Invalid formula rename metadata: expected an object");
82+
}
83+
const renames = FormulaRenames(parsed);
84+
if (renames instanceof type.errors) {
85+
throw new Error(`Invalid formula rename metadata: ${renames.summary}`);
86+
}
87+
return renames;
88+
}
89+
90+
export async function generateHomebrewTap(
91+
tapDir: string,
92+
pkg: HomebrewPackage,
93+
release: HomebrewRelease,
94+
): Promise<void> {
95+
const formulaDir = join(tapDir, "Formula");
96+
const renamesPath = join(tapDir, "formula_renames.json");
97+
const formula = renderFormula(pkg, release);
98+
const renames = await readFormulaRenames(renamesPath);
99+
renames[pkg.binary] = pkg.formula;
100+
const renameMetadata = `${JSON.stringify(renames, null, 2)}\n`;
101+
102+
await mkdir(formulaDir, { recursive: true });
103+
await rm(join(formulaDir, `${pkg.binary}.rb`), { force: true });
104+
await writeFile(join(formulaDir, `${pkg.formula}.rb`), formula);
105+
await writeFile(renamesPath, renameMetadata);
106+
}
107+
108+
function parseRelease(args: string[]): { tapDir: string; release: HomebrewRelease } {
109+
if (args.length !== 6) {
110+
throw new Error(
111+
"usage: generate-homebrew-tap.ts TAP_DIR VERSION MACOS_ARM64 MACOS_X64 LINUX_ARM64 LINUX_X64",
112+
);
113+
}
114+
const [tapDir, version, macosArm64, macosX64, linuxArm64, linuxX64] = args;
115+
if (!tapDir || !version || !/^\d+\.\d+\.\d+$/.test(version)) {
116+
throw new Error("version must be X.Y.Z");
117+
}
118+
const isChecksum = (value: string | undefined): value is string =>
119+
value !== undefined && /^[0-9a-f]{64}$/.test(value);
120+
if (
121+
!isChecksum(macosArm64) ||
122+
!isChecksum(macosX64) ||
123+
!isChecksum(linuxArm64) ||
124+
!isChecksum(linuxX64)
125+
) {
126+
throw new Error("invalid SHA-256 checksum");
127+
}
128+
129+
return {
130+
tapDir,
131+
release: {
132+
version,
133+
checksums: {
134+
"macos-arm64": macosArm64,
135+
"macos-x64": macosX64,
136+
"linux-arm64": linuxArm64,
137+
"linux-x64": linuxX64,
138+
},
139+
},
140+
};
141+
}
142+
143+
function requireEnv(name: string): string {
144+
const value = process.env[name];
145+
if (!value) throw new Error(`missing ${name} (set by scripts/release.sh)`);
146+
return value;
147+
}
148+
149+
if (import.meta.main) {
150+
const { tapDir, release } = parseRelease(process.argv.slice(2));
151+
await generateHomebrewTap(
152+
tapDir,
153+
{
154+
repo: requireEnv("MAIN_REPO"),
155+
binary: requireEnv("BINARY"),
156+
formula: requireEnv("BREW_FORMULA"),
157+
description: requireEnv("DESC"),
158+
},
159+
release,
160+
);
161+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
TAP_DIR=${1:?tap directory is required}
6+
VERSION=${2:?version is required}
7+
8+
git -C "$TAP_DIR" add -A -- Formula/ formula_renames.json
9+
if ! git -C "$TAP_DIR" diff --cached --quiet -- Formula/ formula_renames.json; then
10+
git -C "$TAP_DIR" commit -q -m "corbits-code $VERSION"
11+
fi
12+
13+
UPSTREAM=$(git -C "$TAP_DIR" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}')
14+
if [ "$(git -C "$TAP_DIR" rev-list --count "$UPSTREAM..HEAD")" -gt 0 ]; then
15+
printf 'push-required\n'
16+
else
17+
printf 'current\n'
18+
fi

scripts/release.sh

Lines changed: 20 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,10 @@ if [ "$SKIP_TAP" != 1 ]; then
257257
brew tap "$TAP_SLUG" >/dev/null 2>&1 || \
258258
die "cannot tap $TAP_SLUG. Create https://github.com/$TAP_REPO then: brew tap $TAP_SLUG"
259259
fi
260-
info "tap: $TAP_DIR"
260+
[ -z "$(git -C "$TAP_DIR" status --porcelain)" ] || \
261+
die "tap has local changes; clean $TAP_DIR before releasing"
262+
git -C "$TAP_DIR" pull --ff-only --quiet
263+
info "tap: $TAP_DIR (fast-forwarded)"
261264
fi
262265
info "repo: $ROOT"
263266

@@ -480,80 +483,23 @@ else
480483
sha_for() { # sha_for LABEL -> sha256 of that tarball
481484
cut -d' ' -f1 "$STAGE/$BINARY-$VERSION-$1.tar.gz.sha256"
482485
}
483-
url_for() { # url_for LABEL -> download URL for that tarball
484-
echo "https://github.com/$MAIN_REPO/releases/download/$TAG/$BINARY-$VERSION-$1.tar.gz"
485-
}
486-
# Homebrew class: corbits-code -> CorbitsCode
487-
class=$(echo "$BREW_FORMULA" | awk -F'[-_]' '{
488-
s = ""
489-
for (i = 1; i <= NF; i++) s = s toupper(substr($i, 1, 1)) substr($i, 2)
490-
print s
491-
}')
492-
mkdir -p "$TAP_DIR/Formula"
493-
# Drop the old single-name formula if we renamed (corbits -> corbits-code).
494-
# git rm can remove the last file and drop the empty Formula/ directory —
495-
# recreate it before writing the new formula.
496-
if [ -f "$TAP_DIR/Formula/$BINARY.rb" ] && [ "$BINARY" != "$BREW_FORMULA" ]; then
497-
git -C "$TAP_DIR" rm -f --quiet "Formula/$BINARY.rb" 2>/dev/null \
498-
|| rm -f "$TAP_DIR/Formula/$BINARY.rb"
499-
fi
500-
mkdir -p "$TAP_DIR/Formula"
501-
cat > "$TAP_DIR/Formula/$BREW_FORMULA.rb" <<EOF
502-
class $class < Formula
503-
desc "$DESC"
504-
homepage "https://github.com/$MAIN_REPO"
505-
version "$VERSION"
506-
license "GPL-2.0-only"
507-
508-
on_macos do
509-
on_arm do
510-
url "$(url_for macos-arm64)"
511-
sha256 "$(sha_for macos-arm64)"
512-
end
513-
on_intel do
514-
url "$(url_for macos-x64)"
515-
sha256 "$(sha_for macos-x64)"
516-
end
517-
end
518-
519-
on_linux do
520-
on_arm do
521-
url "$(url_for linux-arm64)"
522-
sha256 "$(sha_for linux-arm64)"
523-
end
524-
on_intel do
525-
url "$(url_for linux-x64)"
526-
sha256 "$(sha_for linux-x64)"
527-
end
528-
end
529-
530-
def install
531-
bin.install "$BINARY"
532-
if File.directory?("plugins")
533-
(bin/"plugins").mkpath
534-
cp_r "plugins/.", bin/"plugins"
535-
end
536-
end
486+
MAIN_REPO="$MAIN_REPO" BINARY="$BINARY" BREW_FORMULA="$BREW_FORMULA" DESC="$DESC" \
487+
bun "$ROOT/scripts/generate-homebrew-tap.ts" \
488+
"$TAP_DIR" \
489+
"$VERSION" \
490+
"$(sha_for macos-arm64)" \
491+
"$(sha_for macos-x64)" \
492+
"$(sha_for linux-arm64)" \
493+
"$(sha_for linux-x64)"
537494

538-
test do
539-
assert_predicate bin/"$BINARY", :executable?
540-
end
541-
end
542-
EOF
543-
if git -C "$TAP_DIR" rev-parse --verify HEAD >/dev/null 2>&1 \
544-
&& git -C "$TAP_DIR" ls-files --error-unmatch "Formula/$BREW_FORMULA.rb" >/dev/null 2>&1 \
545-
&& git -C "$TAP_DIR" diff --quiet -- "Formula/$BREW_FORMULA.rb" \
546-
&& ! git -C "$TAP_DIR" status --porcelain -- "Formula/" | grep -q .; then
547-
skip "formula already at $VERSION"
548-
else
549-
# Untracked formula (empty or new tap) is invisible to `git diff`, so we
550-
# require the file to be tracked before treating "no diff" as up-to-date.
551-
git -C "$TAP_DIR" add "Formula/$BREW_FORMULA.rb"
552-
git -C "$TAP_DIR" add -u "Formula/" 2>/dev/null || true
553-
git -C "$TAP_DIR" commit -q -m "$BREW_FORMULA $VERSION"
554-
info "committed formula bump"
555-
git_push "$TAP_DIR"
556-
fi
495+
tap_status=$(bash "$ROOT/scripts/prepare-homebrew-tap-release.sh" "$TAP_DIR" "$VERSION")
496+
case "$tap_status" in
497+
push-required)
498+
info "formula and rename metadata ready to push"
499+
git_push "$TAP_DIR" ;;
500+
current) skip "formula and rename metadata already at $VERSION" ;;
501+
*) die "unexpected tap preparation status: $tap_status" ;;
502+
esac
557503
fi
558504

559505
# ---- done ------------------------------------------------------------------

src/upgrade/index.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ describe("detectInstallMethod", () => {
5555
).toBe("homebrew");
5656
});
5757

58+
test("detects legacy corbits Cellar installs", () => {
59+
expect(
60+
detectInstallMethod(
61+
probe({
62+
execPath: "/usr/local/bin/corbits",
63+
resolvedPath: "/usr/local/Cellar/corbits/0.2.90/bin/corbits",
64+
}),
65+
),
66+
).toBe("homebrew");
67+
});
68+
5869
test("detects Homebrew via HOMEBREW_PREFIX when the binary lives under it", () => {
5970
expect(
6071
detectInstallMethod(
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import { generateHomebrewTap } from "../../scripts/generate-homebrew-tap.js";
7+
8+
const pkg = {
9+
repo: "corbitsdev/corbits-code",
10+
binary: "corbits",
11+
formula: "corbits-code",
12+
description: "Single-process coding agent CLI built on the Interchange runtime",
13+
};
14+
15+
const release = {
16+
version: "1.2.3",
17+
checksums: {
18+
"macos-arm64": "a".repeat(64),
19+
"macos-x64": "b".repeat(64),
20+
"linux-arm64": "c".repeat(64),
21+
"linux-x64": "d".repeat(64),
22+
},
23+
};
24+
25+
describe("generateHomebrewTap", () => {
26+
let tapDir: string;
27+
28+
beforeEach(async () => {
29+
tapDir = await mkdtemp(join(tmpdir(), "corbits-homebrew-tap-"));
30+
});
31+
32+
afterEach(async () => {
33+
await rm(tapDir, { recursive: true, force: true });
34+
});
35+
36+
test("replaces the legacy formula with corbits-code and installs corbits", async () => {
37+
const formulaDir = join(tapDir, "Formula");
38+
await mkdir(formulaDir);
39+
await writeFile(join(formulaDir, "corbits.rb"), "class Corbits < Formula\nend\n");
40+
41+
await generateHomebrewTap(tapDir, pkg, release);
42+
43+
expect((await readdir(formulaDir)).sort()).toEqual(["corbits-code.rb"]);
44+
const formula = await readFile(join(formulaDir, "corbits-code.rb"), "utf8");
45+
expect(formula).toContain("class CorbitsCode < Formula");
46+
expect(formula).toContain('version "1.2.3"');
47+
expect(formula).toContain('bin.install "corbits"');
48+
expect(formula).not.toContain('bin.install "corbits-code"');
49+
});
50+
51+
test("rejects invalid rename metadata before changing formulas", async () => {
52+
const invalidMetadata = ["[]\n", '{"other": 42}\n'];
53+
54+
for (const [index, metadata] of invalidMetadata.entries()) {
55+
const caseDir = join(tapDir, `invalid-${index}`);
56+
const formulaDir = join(caseDir, "Formula");
57+
const legacyFormula = "class Corbits < Formula\nend\n";
58+
const currentFormula = "class CorbitsCode < Formula\nend\n";
59+
await mkdir(formulaDir, { recursive: true });
60+
await writeFile(join(formulaDir, "corbits.rb"), legacyFormula);
61+
await writeFile(join(formulaDir, "corbits-code.rb"), currentFormula);
62+
await writeFile(join(caseDir, "formula_renames.json"), metadata);
63+
64+
await expect(generateHomebrewTap(caseDir, pkg, release)).rejects.toThrow(
65+
"Invalid formula rename metadata",
66+
);
67+
68+
expect(await readFile(join(formulaDir, "corbits.rb"), "utf8")).toBe(legacyFormula);
69+
expect(await readFile(join(formulaDir, "corbits-code.rb"), "utf8")).toBe(currentFormula);
70+
}
71+
});
72+
73+
test("merges formula rename metadata without changing repeated output", async () => {
74+
await writeFile(
75+
join(tapDir, "formula_renames.json"),
76+
`${JSON.stringify({ retained: "other-formula" }, null, 2)}\n`,
77+
);
78+
79+
await generateHomebrewTap(tapDir, pkg, release);
80+
81+
const first = await readFile(join(tapDir, "formula_renames.json"), "utf8");
82+
expect(JSON.parse(first)).toEqual({
83+
retained: "other-formula",
84+
corbits: "corbits-code",
85+
});
86+
87+
await generateHomebrewTap(tapDir, pkg, release);
88+
expect(await readFile(join(tapDir, "formula_renames.json"), "utf8")).toBe(first);
89+
});
90+
});

0 commit comments

Comments
 (0)