Skip to content

Commit fd409df

Browse files
authored
feat: add Homebrew tap distribution (#273)
1 parent 8421576 commit fd409df

8 files changed

Lines changed: 379 additions & 10 deletions

File tree

.github/workflows/release-prebuilt-npm.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ jobs:
174174
- name: Check out repository
175175
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
176176

177+
- name: Set up Bun
178+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
179+
with:
180+
bun-version: 1.3.10
181+
177182
- name: Download platform artifacts
178183
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
179184
with:
@@ -209,3 +214,37 @@ jobs:
209214
"${prerelease_args[@]}" \
210215
dist/release/github/*
211216
fi
217+
218+
- name: Check out Homebrew tap
219+
if: ${{ !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-rc') }}
220+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
221+
with:
222+
repository: modem-dev/homebrew-tap
223+
path: dist/release/homebrew-tap
224+
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
225+
226+
- name: Update Homebrew formula
227+
if: ${{ !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-rc') }}
228+
env:
229+
TAG_NAME: ${{ github.ref_name }}
230+
run: |
231+
bun run update:homebrew-formula -- \
232+
--tag "$TAG_NAME" \
233+
--asset-root dist/release/github \
234+
--output-root dist/release/homebrew-tap
235+
236+
- name: Commit Homebrew formula update
237+
if: ${{ !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-rc') }}
238+
working-directory: dist/release/homebrew-tap
239+
env:
240+
TAG_NAME: ${{ github.ref_name }}
241+
run: |
242+
git config user.name "github-actions[bot]"
243+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
244+
git add Formula/hunk.rb
245+
if git diff --cached --quiet; then
246+
echo "Homebrew formula already up to date."
247+
else
248+
git commit -m "hunk $TAG_NAME"
249+
git push
250+
fi

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.
66

77
### Added
88

9+
- Added Homebrew tap release automation and Homebrew-aware startup update notices.
10+
911
### Changed
1012

1113
- Auto-detect Jujutsu checkouts for `hunk diff` and `hunk show`, while keeping explicit `vcs` config overrides.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built
3333
npm i -g hunkdiff
3434
```
3535

36+
Or with Homebrew:
37+
38+
```bash
39+
brew install modem-dev/tap/hunk
40+
```
41+
3642
Requirements:
3743

3844
- Node.js 18+

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts",
6666
"smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts",
6767
"publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts",
68+
"update:homebrew-formula": "bun run ./scripts/update-homebrew-formula.ts",
6869
"prepack": "bun run build:npm",
6970
"bench:bootstrap-load": "bun run benchmarks/bootstrap-load.ts",
7071
"bench:highlight-prefetch": "bun run benchmarks/highlight-prefetch.ts",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import path from "node:path";
5+
6+
const ASSETS = [
7+
"hunkdiff-darwin-arm64.tar.gz",
8+
"hunkdiff-darwin-x64.tar.gz",
9+
"hunkdiff-linux-arm64.tar.gz",
10+
"hunkdiff-linux-x64.tar.gz",
11+
] as const;
12+
13+
/** Create minimal release asset placeholders for formula checksum generation. */
14+
function createTestAssets(assetRoot: string) {
15+
for (const asset of ASSETS) {
16+
writeFileSync(path.join(assetRoot, asset), `contents for ${asset}`);
17+
}
18+
}
19+
20+
describe("update-homebrew-formula", () => {
21+
test("rejects unsafe repository slugs before writing Ruby formula URLs", () => {
22+
const root = mkdtempSync(path.join(tmpdir(), "hunk-homebrew-formula-"));
23+
const assetRoot = path.join(root, "assets");
24+
const outputRoot = path.join(root, "tap");
25+
26+
try {
27+
mkdirSync(assetRoot, { recursive: true });
28+
createTestAssets(assetRoot);
29+
const proc = Bun.spawnSync(
30+
[
31+
"bun",
32+
"run",
33+
path.resolve(import.meta.dir, "update-homebrew-formula.ts"),
34+
"--tag",
35+
"v1.2.3",
36+
"--asset-root",
37+
assetRoot,
38+
"--output-root",
39+
outputRoot,
40+
"--repo",
41+
'modem-dev/hunk"; system("echo owned") #',
42+
],
43+
{ stdout: "pipe", stderr: "pipe" },
44+
);
45+
46+
expect(proc.exitCode).not.toBe(0);
47+
expect(proc.stderr.toString()).toContain("Invalid GitHub repository slug");
48+
} finally {
49+
rmSync(root, { recursive: true, force: true });
50+
}
51+
});
52+
53+
test("writes a formula that installs the binary through the Homebrew update-notice wrapper", () => {
54+
const root = mkdtempSync(path.join(tmpdir(), "hunk-homebrew-formula-"));
55+
const assetRoot = path.join(root, "assets");
56+
const outputRoot = path.join(root, "tap");
57+
58+
try {
59+
mkdirSync(assetRoot, { recursive: true });
60+
createTestAssets(assetRoot);
61+
const proc = Bun.spawnSync(
62+
[
63+
"bun",
64+
"run",
65+
path.resolve(import.meta.dir, "update-homebrew-formula.ts"),
66+
"--tag",
67+
"v1.2.3",
68+
"--asset-root",
69+
assetRoot,
70+
"--output-root",
71+
outputRoot,
72+
],
73+
{ stdout: "pipe", stderr: "pipe" },
74+
);
75+
76+
expect(proc.exitCode).toBe(0);
77+
const formula = readFileSync(path.join(outputRoot, "Formula", "hunk.rb"), "utf8");
78+
expect(formula).toContain('version "1.2.3"');
79+
expect(formula).toContain("hunkdiff-darwin-arm64.tar.gz");
80+
expect(formula).toContain("hunkdiff-linux-x64.tar.gz");
81+
expect(formula).toContain('chmod 0755, "hunk"');
82+
expect(formula).toContain('libexec.install "hunk"');
83+
expect(formula).toContain(
84+
'(bin/"hunk").write_env_script libexec/"hunk", HUNK_INSTALL_SOURCE: "homebrew"',
85+
);
86+
} finally {
87+
rmSync(root, { recursive: true, force: true });
88+
}
89+
});
90+
});

scripts/update-homebrew-formula.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env bun
2+
3+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4+
import { createHash } from "node:crypto";
5+
import path from "node:path";
6+
7+
const FORMULA_RELATIVE_PATH = path.join("Formula", "hunk.rb");
8+
const RELEASE_ASSET_NAMES = {
9+
darwinArm64: "hunkdiff-darwin-arm64.tar.gz",
10+
darwinX64: "hunkdiff-darwin-x64.tar.gz",
11+
linuxArm64: "hunkdiff-linux-arm64.tar.gz",
12+
linuxX64: "hunkdiff-linux-x64.tar.gz",
13+
} as const;
14+
15+
interface Options {
16+
assetRoot: string;
17+
outputRoot: string;
18+
repo: string;
19+
tag: string;
20+
}
21+
22+
function parseArgs(argv: string[]): Options {
23+
const repoRoot = path.resolve(import.meta.dir, "..");
24+
const options: Options = {
25+
assetRoot: path.join(repoRoot, "dist", "release", "github"),
26+
outputRoot: repoRoot,
27+
repo: "modem-dev/hunk",
28+
tag: "",
29+
};
30+
31+
for (let index = 0; index < argv.length; index += 1) {
32+
const argument = argv[index];
33+
const value = argv[index + 1];
34+
35+
if (argument === "--asset-root") {
36+
if (!value) {
37+
throw new Error("Missing value for --asset-root.");
38+
}
39+
options.assetRoot = path.resolve(value);
40+
index += 1;
41+
continue;
42+
}
43+
44+
if (argument === "--output-root") {
45+
if (!value) {
46+
throw new Error("Missing value for --output-root.");
47+
}
48+
options.outputRoot = path.resolve(value);
49+
index += 1;
50+
continue;
51+
}
52+
53+
if (argument === "--repo") {
54+
if (!value) {
55+
throw new Error("Missing value for --repo.");
56+
}
57+
options.repo = value;
58+
index += 1;
59+
continue;
60+
}
61+
62+
if (argument === "--tag") {
63+
if (!value) {
64+
throw new Error("Missing value for --tag.");
65+
}
66+
options.tag = value;
67+
index += 1;
68+
continue;
69+
}
70+
71+
throw new Error(`Unknown argument: ${argument}`);
72+
}
73+
74+
if (!options.tag) {
75+
throw new Error("Missing required --tag vX.Y.Z argument.");
76+
}
77+
78+
return options;
79+
}
80+
81+
function versionFromTag(tag: string) {
82+
const version = tag.startsWith("v") ? tag.slice(1) : tag;
83+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
84+
throw new Error(`Homebrew formula updates only support stable semver tags, got ${tag}.`);
85+
}
86+
87+
return version;
88+
}
89+
90+
function assertSafeRepoSlug(repo: string) {
91+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
92+
throw new Error(`Invalid GitHub repository slug: ${repo}`);
93+
}
94+
}
95+
96+
function sha256(file: string) {
97+
return createHash("sha256").update(readFileSync(file)).digest("hex");
98+
}
99+
100+
function assetUrl(repo: string, tag: string, assetName: string) {
101+
return `https://github.com/${repo}/releases/download/${tag}/${assetName}`;
102+
}
103+
104+
function formulaContent(options: Options) {
105+
const version = versionFromTag(options.tag);
106+
assertSafeRepoSlug(options.repo);
107+
const checksums = Object.fromEntries(
108+
Object.entries(RELEASE_ASSET_NAMES).map(([key, assetName]) => {
109+
const assetPath = path.join(options.assetRoot, assetName);
110+
if (!existsSync(assetPath)) {
111+
const found = existsSync(options.assetRoot)
112+
? readdirSync(options.assetRoot).join(", ")
113+
: "";
114+
throw new Error(`Missing release asset ${assetPath}. Found: ${found}`);
115+
}
116+
117+
return [key, sha256(assetPath)];
118+
}),
119+
) as Record<keyof typeof RELEASE_ASSET_NAMES, string>;
120+
121+
return `class Hunk < Formula
122+
desc "Desktop-inspired terminal diff viewer for agent-authored changesets"
123+
homepage "https://github.com/modem-dev/hunk"
124+
version "${version}"
125+
license "MIT"
126+
127+
on_macos do
128+
if Hardware::CPU.arm?
129+
url "${assetUrl(options.repo, options.tag, RELEASE_ASSET_NAMES.darwinArm64)}"
130+
sha256 "${checksums.darwinArm64}"
131+
else
132+
url "${assetUrl(options.repo, options.tag, RELEASE_ASSET_NAMES.darwinX64)}"
133+
sha256 "${checksums.darwinX64}"
134+
end
135+
end
136+
137+
on_linux do
138+
if Hardware::CPU.arm?
139+
url "${assetUrl(options.repo, options.tag, RELEASE_ASSET_NAMES.linuxArm64)}"
140+
sha256 "${checksums.linuxArm64}"
141+
else
142+
url "${assetUrl(options.repo, options.tag, RELEASE_ASSET_NAMES.linuxX64)}"
143+
sha256 "${checksums.linuxX64}"
144+
end
145+
end
146+
147+
def install
148+
chmod 0755, "hunk"
149+
libexec.install "hunk"
150+
(bin/"hunk").write_env_script libexec/"hunk", HUNK_INSTALL_SOURCE: "homebrew"
151+
end
152+
153+
test do
154+
assert_match version.to_s, shell_output("#{bin}/hunk --version")
155+
end
156+
end
157+
`;
158+
}
159+
160+
const options = parseArgs(process.argv.slice(2));
161+
const formulaPath = path.join(options.outputRoot, FORMULA_RELATIVE_PATH);
162+
mkdirSync(path.dirname(formulaPath), { recursive: true });
163+
writeFileSync(formulaPath, formulaContent(options));
164+
console.log(`Updated ${formulaPath}`);

0 commit comments

Comments
 (0)