Skip to content

Commit 11ace89

Browse files
authored
feat(scripts): add Homebrew distribution (#149)
* feat(scripts): add Homebrew distribution library * feat(scripts): add Homebrew distribution script and CI job * docs: add Homebrew distribution to README and releasing docs * feat(homebrew): add parseMajorVersion helper * fix(homebrew): validate parseMajorVersion input * feat(homebrew): support versioned formula rendering * feat(homebrew): write versioned formula alongside latest * refactor(homebrew): flatten arg parsing and replace shell-outs with node:fs Move parseArgs to the top level to match the pattern in scripts/run-tests.ts. Replace Bun.spawnSync calls to mkdir and cp with mkdirSync and copyFileSync. * docs(homebrew): fix stale file paths and count in releasing docs * fix(lint): pass oxlint config explicitly in pre-commit hook The nano-staged hook invoked `oxlint` without `-c .oxlintrc.json`, so the `scripts/**` overrides that disable `no-console` and `unicorn/no-process-exit` did not apply and any commit touching a script file failed the hook. Mirrors the fix already applied to the `lint` script in 69a9627. * fix(homebrew): address review feedback Fail fast when HOMEBREW_TAP_TOKEN is missing instead of after archive upload. Replace run() with Bun.spawnSync for `git remote set-url` so the token is not interpolated into thrown error messages. Drop the leading article from `desc` and use modern on_arm/on_intel DSL blocks.
1 parent 62790dd commit 11ace89

8 files changed

Lines changed: 460 additions & 21 deletions

File tree

.github/workflows/release.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,30 @@ jobs:
132132
gh release upload "$tag" "${dir}clerk-${target}${ext}" --clobber
133133
done
134134
135+
homebrew:
136+
needs: [versioning, build, sign-macos, smoke-test, publish-npm]
137+
runs-on: ubuntu-latest
138+
timeout-minutes: 10
139+
permissions:
140+
contents: write
141+
steps:
142+
- uses: actions/checkout@v6
143+
- uses: oven-sh/setup-bun@v2
144+
- run: bun install --frozen-lockfile
145+
146+
- uses: actions/download-artifact@v8
147+
with:
148+
pattern: clerk-{darwin-arm64,darwin-x64,linux-arm64,linux-x64}
149+
path: dist/artifacts
150+
151+
- name: Publish Homebrew formula
152+
run: bun run scripts/homebrew.ts --version "$VERSION"
153+
env:
154+
VERSION: ${{ needs.versioning.outputs.version }}
155+
ARTIFACTS_DIR: ${{ github.workspace }}/dist/artifacts
156+
GH_TOKEN: ${{ github.token }}
157+
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
158+
135159
# ─── Canary release ────────────────────────────────────────────────
136160

137161
canary-version:

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ The Clerk command-line interface.
44

55
## Installation
66

7+
### Homebrew (macOS / Linux)
8+
9+
```sh
10+
brew install clerk/stable/clerk
11+
```
12+
13+
### npm
14+
715
```sh
816
npm install -g clerk
917
```

docs/releasing.md

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,25 @@ This document describes how the Clerk CLI is built, versioned, and published.
66

77
```
88
push to main
9-
→ changesets/action creates/updates "Version Packages" PR
10-
→ merge "Version Packages" PR
11-
→ check-release.ts detects unpublished version
12-
→ build job: cross-compile all 8 targets (~5.5s total)
13-
→ sign-macos job: code sign + notarize darwin binaries
14-
→ smoke-test job: verify binaries on native runners
15-
→ publish-npm: generate platform packages + publish wrapper
16-
→ upload-github-assets: attach binaries to the GitHub Release
17-
→ (if no stable release needed) canary.ts versions packages
18-
→ build → sign-macos → smoke-test subset → upload GitHub pre-release → publish @canary (npm)
9+
-> changesets/action creates/updates "Version Packages" PR
10+
-> merge "Version Packages" PR
11+
-> check-release.ts detects unpublished version
12+
-> build job: cross-compile all 8 targets (~5.5s total)
13+
-> sign-macos job: code sign + notarize darwin binaries
14+
-> smoke-test job: verify binaries on native runners
15+
-> publish-npm: generate platform packages + publish wrapper
16+
-> upload-github-assets: attach binaries to the GitHub Release
17+
-> homebrew: create archives, upload, render formula, push to clerk/homebrew-stable
18+
-> (if no stable release needed) canary.ts versions packages
19+
-> build -> sign-macos -> smoke-test subset -> upload GitHub pre-release -> publish @canary (npm)
1920
2021
PR comment "!snapshot [name]"
21-
snapshot.ts versions packages from PR branch
22-
build job: cross-compile binaries
23-
sign-macos job: code sign + notarize darwin binaries
24-
smoke-test job: verify linux-x64 binary
25-
publish-npm: publish @snapshot packages
26-
post installation comment on PR
22+
-> snapshot.ts versions packages from PR branch
23+
-> build job: cross-compile binaries
24+
-> sign-macos job: code sign + notarize darwin binaries
25+
-> smoke-test job: verify linux-x64 binary
26+
-> publish-npm: publish @snapshot packages
27+
-> post installation comment on PR
2728
```
2829

2930
## Architecture
@@ -192,6 +193,14 @@ Publishing uses [npm OIDC trusted publishing](https://docs.npmjs.com/trusted-pub
192193

193194
Attaches the compiled binaries to the GitHub Release for direct download. Binaries are uploaded with display names following the `clerk-<target>` convention (e.g., `clerk-darwin-arm64`, `clerk-win32-x64.exe`).
194195

196+
### 6. Homebrew Job
197+
198+
Creates `.tar.gz` archives of the 4 Homebrew-relevant binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64) downloaded directly from Actions artifacts, uploads them to the GitHub Release, computes SHA256 checksums, renders `Formula/clerk.rb`, and pushes the result to `clerk/homebrew-stable`. The push uses the `HOMEBREW_TAP_TOKEN` secret (a fine-grained PAT or GitHub App token with `contents: write` on `clerk/homebrew-stable`).
199+
200+
This job runs in parallel with `publish-github` since both depend on `publish-npm` (which creates the GitHub Release).
201+
202+
Install: `brew install clerk/stable/clerk`
203+
195204
## Key Files
196205

197206
| File | Purpose |
@@ -209,20 +218,23 @@ Attaches the compiled binaries to the GitHub Release for direct download. Binari
209218
| `scripts/canary.ts` | Versions packages for canary channel using Changesets snapshots |
210219
| `scripts/snapshot.ts` | Versions packages for snapshot channel using Changesets snapshots |
211220
| `scripts/check-release.ts` | Detects if a stable release is needed (compares version to npm registry) |
221+
| `scripts/homebrew.ts` | Creates Homebrew archives, uploads to release, renders and pushes formula |
222+
| `scripts/lib/homebrew.ts` | Homebrew formula renderer, target list, and helper utilities |
212223
| `.changeset/config.json` | Changesets configuration |
213224
| `.github/workflows/build-binaries.yml` | Reusable workflow for cross-compiling binaries (called by release + snapshot) |
214225
| `.github/workflows/sign-macos.yml` | Reusable workflow for macOS code signing and notarization |
215226
| `.github/workflows/smoke-test.yml` | Reusable workflow for smoke-testing binaries (called by release + snapshot) |
216-
| `.github/workflows/release.yml` | GitHub Actions release, canary, and snapshot workflow |
227+
| `.github/workflows/release.yml` | GitHub Actions release, canary, and snapshot workflow |
217228

218229
## Keeping Targets in Sync
219230

220231
The target list exists in these places that must stay in sync:
221232

222233
1. `scripts/releaser/targets.ts` -- used by the releaser to generate platform packages and by `scripts/build.ts` to cross-compile binaries
223234
2. `.github/workflows/smoke-test.yml` preset definitions -- defines the target matrix for each preset (`stable`, `canary`, `snapshot`)
235+
3. `scripts/lib/homebrew.ts` -- the `HOMEBREW_TARGETS` array of Homebrew-relevant targets (darwin-arm64, darwin-x64, linux-arm64, linux-x64)
224236

225-
If you add or remove a target, update both of these. Note that the smoke-test presets may not cover every target if a native runner isn't available (e.g., `win32-arm64`).
237+
If you add or remove a target, update all three of these. Note that the smoke-test presets may not cover every target if a native runner isn't available (e.g., `win32-arm64`).
226238

227239
## Local Development
228240

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"nano-staged": {
4444
"*.{ts,tsx,js,jsx}": [
4545
"oxfmt --write",
46-
"oxlint"
46+
"oxlint -c .oxlintrc.json"
4747
],
4848
"*.{md,json,css}": "oxfmt --write"
4949
},

scripts/homebrew.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
2+
import { join, basename } from "node:path";
3+
import { tmpdir } from "node:os";
4+
import { parseArgs } from "node:util";
5+
import {
6+
renderFormula,
7+
createArchive,
8+
computeChecksum,
9+
parseMajorVersion,
10+
HOMEBREW_TARGETS,
11+
type FormulaInput,
12+
} from "./lib/homebrew.ts";
13+
import { run } from "./lib/npm.ts";
14+
15+
const DEFAULT_ARTIFACTS_DIR =
16+
process.env.ARTIFACTS_DIR ?? join(import.meta.dir, "../dist/artifacts");
17+
18+
const { values } = parseArgs({
19+
options: {
20+
version: { type: "string" },
21+
"artifacts-dir": { type: "string" },
22+
"tap-repo": { type: "string" },
23+
"dry-run": { type: "boolean", default: false },
24+
},
25+
strict: true,
26+
});
27+
28+
if (!values.version) {
29+
console.error("--version is required.");
30+
process.exit(1);
31+
}
32+
33+
const version = values.version;
34+
const artifactsDir = values["artifacts-dir"] ?? DEFAULT_ARTIFACTS_DIR;
35+
const tapRepo = values["tap-repo"] ?? "clerk/homebrew-stable";
36+
const dryRun = values["dry-run"]!;
37+
38+
if (!dryRun && !process.env.HOMEBREW_TAP_TOKEN) {
39+
console.error("HOMEBREW_TAP_TOKEN is required (use --dry-run to skip).");
40+
process.exit(1);
41+
}
42+
43+
console.log(`Homebrew distribution: v${version}${dryRun ? " (dry run)" : ""}`);
44+
console.log(`Artifacts dir: ${artifactsDir}`);
45+
console.log(`Tap repo: ${tapRepo}`);
46+
47+
const workDir = await mkdtemp(join(tmpdir(), "homebrew-archives-"));
48+
console.log(`Work directory: ${workDir}`);
49+
50+
const archivePaths = new Map<string, string>();
51+
52+
for (const target of HOMEBREW_TARGETS) {
53+
const binaryPath = join(artifactsDir, `clerk-${target.name}`, "clerk");
54+
const archivePath = join(workDir, `homebrew-clerk-${target.name}.tar.gz`);
55+
console.log(`Creating archive for ${target.name}...`);
56+
createArchive(binaryPath, archivePath);
57+
archivePaths.set(target.name, archivePath);
58+
}
59+
60+
const tagName = `v${version}`;
61+
for (const target of HOMEBREW_TARGETS) {
62+
const archivePath = archivePaths.get(target.name)!;
63+
if (dryRun) {
64+
console.log(`[dry-run] Would upload ${basename(archivePath)} to ${tagName}`);
65+
} else {
66+
console.log(`Uploading ${basename(archivePath)} to ${tagName}...`);
67+
run(["gh", "release", "upload", tagName, archivePath, "--clobber"]);
68+
}
69+
}
70+
71+
console.log("Computing checksums...");
72+
const checksums = {} as FormulaInput["checksums"];
73+
for (const target of HOMEBREW_TARGETS) {
74+
const archivePath = archivePaths.get(target.name)!;
75+
checksums[target.name] = await computeChecksum(archivePath);
76+
}
77+
78+
for (const target of HOMEBREW_TARGETS) {
79+
console.log(` ${target.name}: ${checksums[target.name]}`);
80+
}
81+
82+
const formula = renderFormula({ version, checksums });
83+
console.log("\nRendered formula:");
84+
console.log(formula);
85+
86+
const major = parseMajorVersion(version);
87+
const versionedFormula = renderFormula({ version, checksums, major });
88+
console.log("\nRendered versioned formula:");
89+
console.log(versionedFormula);
90+
91+
if (dryRun) {
92+
console.log("[dry-run] Skipping tap clone and push.");
93+
console.log(`[dry-run] Would write Formula/clerk.rb and Formula/clerk@${major}.rb`);
94+
} else {
95+
const token = process.env.HOMEBREW_TAP_TOKEN!;
96+
97+
const tapWorkDir = join(workDir, "tap-workdir");
98+
console.log(`Cloning tap repo ${tapRepo}...`);
99+
run(["git", "clone", `https://github.com/${tapRepo}.git`, tapWorkDir]);
100+
const setUrlResult = Bun.spawnSync(
101+
[
102+
"git",
103+
"remote",
104+
"set-url",
105+
"origin",
106+
`https://x-access-token:${token}@github.com/${tapRepo}.git`,
107+
],
108+
{ cwd: tapWorkDir, stdio: ["ignore", "pipe", "pipe"] },
109+
);
110+
if (setUrlResult.exitCode !== 0) {
111+
throw new Error("Failed to configure authenticated remote for tap repo");
112+
}
113+
114+
const formulaDir = join(tapWorkDir, "Formula");
115+
await mkdir(formulaDir, { recursive: true });
116+
const formulaPath = join(formulaDir, "clerk.rb");
117+
await writeFile(formulaPath, formula, "utf-8");
118+
console.log(`Wrote formula to ${formulaPath}`);
119+
120+
const versionedFormulaPath = join(formulaDir, `clerk@${major}.rb`);
121+
await writeFile(versionedFormulaPath, versionedFormula, "utf-8");
122+
console.log(`Wrote versioned formula to ${versionedFormulaPath}`);
123+
124+
run(["git", "config", "user.name", "clerk-bot"], { cwd: tapWorkDir });
125+
run(["git", "config", "user.email", "bot@clerk.com"], { cwd: tapWorkDir });
126+
run(["git", "add", "Formula/clerk.rb", `Formula/clerk@${major}.rb`], { cwd: tapWorkDir });
127+
128+
const diffResult = Bun.spawnSync(["git", "diff", "--cached", "--quiet"], {
129+
cwd: tapWorkDir,
130+
stdio: ["ignore", "pipe", "pipe"],
131+
});
132+
133+
if (diffResult.exitCode === 0) {
134+
console.log("No changes to formula, skipping commit and push.");
135+
} else {
136+
console.log(`Committing and pushing formula for clerk ${version}...`);
137+
run(["git", "commit", "-m", `clerk ${version}`], { cwd: tapWorkDir });
138+
run(["git", "push", "origin", "main"], { cwd: tapWorkDir });
139+
console.log("Pushed formula to tap.");
140+
}
141+
}
142+
143+
console.log("Done!");

0 commit comments

Comments
 (0)