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
39 changes: 39 additions & 0 deletions .github/workflows/release-prebuilt-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ jobs:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.10

- name: Download platform artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
Expand Down Expand Up @@ -209,3 +214,37 @@ jobs:
"${prerelease_args[@]}" \
dist/release/github/*
fi

- name: Check out Homebrew tap
if: ${{ !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-rc') }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: modem-dev/homebrew-tap
path: dist/release/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}

- name: Update Homebrew formula
if: ${{ !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-rc') }}
env:
TAG_NAME: ${{ github.ref_name }}
run: |
bun run update:homebrew-formula -- \
--tag "$TAG_NAME" \
--asset-root dist/release/github \
--output-root dist/release/homebrew-tap

- name: Commit Homebrew formula update
if: ${{ !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-rc') }}
working-directory: dist/release/homebrew-tap
env:
TAG_NAME: ${{ github.ref_name }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/hunk.rb
if git diff --cached --quiet; then
echo "Homebrew formula already up to date."
else
git commit -m "hunk $TAG_NAME"
git push
fi
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added Homebrew tap release automation and Homebrew-aware startup update notices.

### Changed

- Auto-detect Jujutsu checkouts for `hunk diff` and `hunk show`, while keeping explicit `vcs` config overrides.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built
npm i -g hunkdiff
```

Or with Homebrew:

```bash
brew install modem-dev/tap/hunk
```

Requirements:

- Node.js 18+
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts",
"smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts",
"publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts",
"update:homebrew-formula": "bun run ./scripts/update-homebrew-formula.ts",
"prepack": "bun run build:npm",
"bench:bootstrap-load": "bun run benchmarks/bootstrap-load.ts",
"bench:highlight-prefetch": "bun run benchmarks/highlight-prefetch.ts",
Expand Down
90 changes: 90 additions & 0 deletions scripts/update-homebrew-formula.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

const ASSETS = [
"hunkdiff-darwin-arm64.tar.gz",
"hunkdiff-darwin-x64.tar.gz",
"hunkdiff-linux-arm64.tar.gz",
"hunkdiff-linux-x64.tar.gz",
] as const;

/** Create minimal release asset placeholders for formula checksum generation. */
function createTestAssets(assetRoot: string) {
for (const asset of ASSETS) {
writeFileSync(path.join(assetRoot, asset), `contents for ${asset}`);
}
}

describe("update-homebrew-formula", () => {
test("rejects unsafe repository slugs before writing Ruby formula URLs", () => {
const root = mkdtempSync(path.join(tmpdir(), "hunk-homebrew-formula-"));
const assetRoot = path.join(root, "assets");
const outputRoot = path.join(root, "tap");

try {
mkdirSync(assetRoot, { recursive: true });
createTestAssets(assetRoot);
const proc = Bun.spawnSync(
[
"bun",
"run",
path.resolve(import.meta.dir, "update-homebrew-formula.ts"),
"--tag",
"v1.2.3",
"--asset-root",
assetRoot,
"--output-root",
outputRoot,
"--repo",
'modem-dev/hunk"; system("echo owned") #',
],
{ stdout: "pipe", stderr: "pipe" },
);

expect(proc.exitCode).not.toBe(0);
expect(proc.stderr.toString()).toContain("Invalid GitHub repository slug");
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test("writes a formula that installs the binary through the Homebrew update-notice wrapper", () => {
const root = mkdtempSync(path.join(tmpdir(), "hunk-homebrew-formula-"));
const assetRoot = path.join(root, "assets");
const outputRoot = path.join(root, "tap");

try {
mkdirSync(assetRoot, { recursive: true });
createTestAssets(assetRoot);
const proc = Bun.spawnSync(
[
"bun",
"run",
path.resolve(import.meta.dir, "update-homebrew-formula.ts"),
"--tag",
"v1.2.3",
"--asset-root",
assetRoot,
"--output-root",
outputRoot,
],
{ stdout: "pipe", stderr: "pipe" },
);

expect(proc.exitCode).toBe(0);
const formula = readFileSync(path.join(outputRoot, "Formula", "hunk.rb"), "utf8");
expect(formula).toContain('version "1.2.3"');
expect(formula).toContain("hunkdiff-darwin-arm64.tar.gz");
expect(formula).toContain("hunkdiff-linux-x64.tar.gz");
expect(formula).toContain('chmod 0755, "hunk"');
expect(formula).toContain('libexec.install "hunk"');
expect(formula).toContain(
'(bin/"hunk").write_env_script libexec/"hunk", HUNK_INSTALL_SOURCE: "homebrew"',
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
164 changes: 164 additions & 0 deletions scripts/update-homebrew-formula.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env bun

import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";

const FORMULA_RELATIVE_PATH = path.join("Formula", "hunk.rb");
const RELEASE_ASSET_NAMES = {
darwinArm64: "hunkdiff-darwin-arm64.tar.gz",
darwinX64: "hunkdiff-darwin-x64.tar.gz",
linuxArm64: "hunkdiff-linux-arm64.tar.gz",
linuxX64: "hunkdiff-linux-x64.tar.gz",
} as const;

interface Options {
assetRoot: string;
outputRoot: string;
repo: string;
tag: string;
}

function parseArgs(argv: string[]): Options {
const repoRoot = path.resolve(import.meta.dir, "..");
const options: Options = {
assetRoot: path.join(repoRoot, "dist", "release", "github"),
outputRoot: repoRoot,
repo: "modem-dev/hunk",
tag: "",
};

for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
const value = argv[index + 1];

if (argument === "--asset-root") {
if (!value) {
throw new Error("Missing value for --asset-root.");
}
options.assetRoot = path.resolve(value);
index += 1;
continue;
}

if (argument === "--output-root") {
if (!value) {
throw new Error("Missing value for --output-root.");
}
options.outputRoot = path.resolve(value);
index += 1;
continue;
}

if (argument === "--repo") {
if (!value) {
throw new Error("Missing value for --repo.");
}
options.repo = value;
index += 1;
continue;
}

if (argument === "--tag") {
if (!value) {
throw new Error("Missing value for --tag.");
}
options.tag = value;
index += 1;
continue;
}

throw new Error(`Unknown argument: ${argument}`);
}

if (!options.tag) {
throw new Error("Missing required --tag vX.Y.Z argument.");
}

return options;
}

function versionFromTag(tag: string) {
const version = tag.startsWith("v") ? tag.slice(1) : tag;
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Homebrew formula updates only support stable semver tags, got ${tag}.`);
}

return version;
}

function assertSafeRepoSlug(repo: string) {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
throw new Error(`Invalid GitHub repository slug: ${repo}`);
}
}

function sha256(file: string) {
return createHash("sha256").update(readFileSync(file)).digest("hex");
}

function assetUrl(repo: string, tag: string, assetName: string) {
return `https://github.com/${repo}/releases/download/${tag}/${assetName}`;
}

function formulaContent(options: Options) {
const version = versionFromTag(options.tag);
assertSafeRepoSlug(options.repo);
const checksums = Object.fromEntries(
Object.entries(RELEASE_ASSET_NAMES).map(([key, assetName]) => {
const assetPath = path.join(options.assetRoot, assetName);
if (!existsSync(assetPath)) {
const found = existsSync(options.assetRoot)
? readdirSync(options.assetRoot).join(", ")
: "";
throw new Error(`Missing release asset ${assetPath}. Found: ${found}`);
}

return [key, sha256(assetPath)];
}),
) as Record<keyof typeof RELEASE_ASSET_NAMES, string>;

return `class Hunk < Formula
desc "Desktop-inspired terminal diff viewer for agent-authored changesets"
homepage "https://github.com/modem-dev/hunk"
version "${version}"
license "MIT"

on_macos do
if Hardware::CPU.arm?
url "${assetUrl(options.repo, options.tag, RELEASE_ASSET_NAMES.darwinArm64)}"
sha256 "${checksums.darwinArm64}"
else
url "${assetUrl(options.repo, options.tag, RELEASE_ASSET_NAMES.darwinX64)}"
sha256 "${checksums.darwinX64}"
end
end

on_linux do
if Hardware::CPU.arm?
url "${assetUrl(options.repo, options.tag, RELEASE_ASSET_NAMES.linuxArm64)}"
sha256 "${checksums.linuxArm64}"
else
url "${assetUrl(options.repo, options.tag, RELEASE_ASSET_NAMES.linuxX64)}"
sha256 "${checksums.linuxX64}"
end
end

def install
chmod 0755, "hunk"
libexec.install "hunk"
(bin/"hunk").write_env_script libexec/"hunk", HUNK_INSTALL_SOURCE: "homebrew"
end

test do
assert_match version.to_s, shell_output("#{bin}/hunk --version")
end
end
`;
}

const options = parseArgs(process.argv.slice(2));
const formulaPath = path.join(options.outputRoot, FORMULA_RELATIVE_PATH);
mkdirSync(path.dirname(formulaPath), { recursive: true });
writeFileSync(formulaPath, formulaContent(options));
console.log(`Updated ${formulaPath}`);
Loading
Loading