From f0fffc4791c860c0321e87d01adbe8c571e578b4 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Sat, 11 Jul 2026 08:01:59 -0700 Subject: [PATCH 1/2] feat: ship rulesync via a self-contained Homebrew tap Add an in-repo Homebrew tap so users can install the prebuilt binary from our own tap with same-minute availability, instead of relying solely on the homebrew-core formula bump. - Add Formula/rulesync.rb, a binary formula that downloads the per-platform release asset (macOS/Linux, arm64/x64) with no node runtime dependency. - Add scripts/generate-homebrew-formula.ts (with tests) to render the formula from a version and the release SHA256SUMS. - Replace the homebrew-core bump job in publish.yml with a job that regenerates the formula on release and opens an auto-merge PR to update it on main. - Document the two-argument `brew tap ` install flow in README and the installation docs. Closes #2218 --- .github/workflows/publish.yml | 118 +++++++++++-------- Formula/rulesync.rb | 42 +++++++ README.md | 12 +- docs/getting-started/installation.md | 22 +++- scripts/generate-homebrew-formula.test.ts | 78 +++++++++++++ scripts/generate-homebrew-formula.ts | 134 ++++++++++++++++++++++ skills/rulesync/installation.md | 22 +++- 7 files changed, 375 insertions(+), 53 deletions(-) create mode 100644 Formula/rulesync.rb create mode 100644 scripts/generate-homebrew-formula.test.ts create mode 100644 scripts/generate-homebrew-formula.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d28d1e598..b003fac61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,69 +48,91 @@ jobs: gh release edit "$TAG" --draft=false --latest homebrew: - name: Bump Homebrew/core formula + name: Update Homebrew tap formula needs: publish runs-on: ubuntu-latest - # Only bump for real releases. `success()` keeps the implicit `needs: publish` + # Only update for real releases. `success()` keeps the implicit `needs: publish` # success gate — a custom `if:` that omits it would replace that gate and let # this job run even when `publish` failed. The remaining checks mirror the # publish job's release-trigger gating. if: success() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' && startsWith(github.event.workflow_run.head_branch, 'release/v') - # This job only opens a cross-repo PR with its own HOMEBREW_BUMP_TOKEN; it never - # checks out this repo, uses GITHUB_TOKEN, or requests OIDC, so it needs none of - # the workflow-level permissions. - permissions: {} + permissions: + contents: write # Push the formula update branch. + pull-requests: write # Open and auto-merge the formula PR. steps: - - name: Open Homebrew/core bump PR + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: main + # Prefer a maintainer PAT when present: a branch-protected `main` blocks + # auto-merge of a GITHUB_TOKEN-authored PR because that token cannot + # trigger the required status checks. Without the PAT we fall back to + # GITHUB_TOKEN and leave the PR open for a manual merge. + token: ${{ secrets.HOMEBREW_FORMULA_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Configure git + uses: ./.github/actions/git-config + + - name: Setup mise + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v2 + with: + experimental: true + + - name: Setup Takumi Guard npm registry + uses: flatt-security/setup-takumi-guard-npm@9a5d797c2085b6326d3a2985c08e3a114b9b88c1 # v1 + + - name: Install dependencies + run: pnpm install --ignore-scripts + + - name: Determine tag + id: tag env: - # Maintainer-owned PAT with `public_repo` scope (forking + PRs against - # Homebrew/homebrew-core). The default GITHUB_TOKEN cannot open - # cross-repository PRs, so the step is skipped when this is absent. - HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_BUMP_TOKEN }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | - if [ -z "${HOMEBREW_GITHUB_API_TOKEN}" ]; then - echo "::warning::HOMEBREW_BUMP_TOKEN secret is not set; skipping Homebrew formula bump." - exit 0 - fi - TAG="${HEAD_BRANCH#release/}" if ! echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::error::Invalid release tag derived from branch: $HEAD_BRANCH" exit 1 fi - VERSION="${TAG#v}" - URL="https://registry.npmjs.org/rulesync/-/rulesync-${VERSION}.tgz" - - # Wait until the npm tarball for this version is actually downloadable - # (npm registry propagation can lag behind `pnpm publish`). - for attempt in $(seq 1 30); do - if curl -sfIL "$URL" >/dev/null 2>&1; then - break - fi - echo "Waiting for npm tarball to become available (attempt ${attempt}/30)..." - sleep 20 - done - if ! curl -sfIL "$URL" >/dev/null 2>&1; then - echo "::error::npm tarball not available after timeout: $URL" - exit 1 - fi + { + echo "tag=$TAG" + echo "version=${TAG#v}" + } >> "$GITHUB_OUTPUT" - SHA256="$(curl -sfL "$URL" | sha256sum | cut -d' ' -f1)" - if [ -z "${SHA256}" ]; then - echo "::error::Failed to compute sha256 for $URL" - exit 1 + - name: Download release checksums + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.tag }} + run: gh release download "$TAG" --pattern SHA256SUMS --output SHA256SUMS + + - name: Regenerate formula + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + pnpm exec tsx scripts/generate-homebrew-formula.ts "$VERSION" SHA256SUMS Formula/rulesync.rb + rm -f SHA256SUMS + + - name: Open auto-merge pull request + env: + GH_TOKEN: ${{ secrets.HOMEBREW_FORMULA_TOKEN || secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + if git diff --quiet -- Formula/rulesync.rb; then + echo "Formula already up to date for ${TAG}; nothing to do." + exit 0 fi - # GitHub-hosted runners ship Homebrew preinstalled but do not add it to - # PATH, so load its shell environment before invoking brew. - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" - - # Skip if Homebrew/core already has this exact version or an open bump PR - # for it; brew bump-formula-pr is idempotent and will refuse duplicates. - brew bump-formula-pr rulesync \ - --version "${VERSION}" \ - --url "${URL}" \ - --sha256 "${SHA256}" \ - --no-browse \ - --message "Automated bump from rulesync ${TAG} release." + BRANCH="homebrew-formula/${TAG}" + git switch -c "$BRANCH" + git add Formula/rulesync.rb + git commit -m "chore: update Homebrew formula to ${TAG}" + git push --force-with-lease origin "$BRANCH" + + PR_URL="$(gh pr create --base main --head "$BRANCH" \ + --title "chore: update Homebrew formula to ${TAG}" \ + --body "Automated formula update for the ${TAG} release.")" + echo "Opened ${PR_URL}" + + if ! gh pr merge "$PR_URL" --auto --merge; then + echo "::warning::Could not enable auto-merge; merge the formula PR manually." + fi diff --git a/Formula/rulesync.rb b/Formula/rulesync.rb new file mode 100644 index 000000000..b0bb06ac1 --- /dev/null +++ b/Formula/rulesync.rb @@ -0,0 +1,42 @@ +# typed: false +# frozen_string_literal: true + +# This file is generated on each release by the "homebrew" job in +# .github/workflows/publish.yml (scripts/generate-homebrew-formula.ts). +# Do not edit it by hand; changes are overwritten on the next release. +class Rulesync < Formula + desc "Unified AI rules management CLI that generates config files for AI dev tools" + homepage "https://github.com/dyoshikawa/rulesync" + version "9.6.3" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/dyoshikawa/rulesync/releases/download/v#{version}/rulesync-darwin-arm64" + sha256 "90844bbf409016b3641578ff4a8508a1d748d558933c66c9ff74471e1a250492" + end + on_intel do + url "https://github.com/dyoshikawa/rulesync/releases/download/v#{version}/rulesync-darwin-x64" + sha256 "1ac8cf074608a02d5a16d4a9f331b560757db5ed1821229622d10fcadb9f46d4" + end + end + + on_linux do + on_arm do + url "https://github.com/dyoshikawa/rulesync/releases/download/v#{version}/rulesync-linux-arm64" + sha256 "3bf6af02261ee1ac6fa5533a3db2a76554ba93819313533cf419182bc9dc202f" + end + on_intel do + url "https://github.com/dyoshikawa/rulesync/releases/download/v#{version}/rulesync-linux-x64" + sha256 "613a098b1d9f993151ade442edd7383d1f30972600dda5cdd8f7343bec1c5404" + end + end + + def install + bin.install Dir["rulesync-*"].first => "rulesync" + end + + test do + assert_match version.to_s, shell_output("#{bin}/rulesync --version") + end +end diff --git a/README.md b/README.md index 9c2453fa9..fd7818696 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,20 @@ A Node.js CLI tool that automatically generates configuration files for various ```bash npm install -g rulesync -# or +``` + +Or install from our Homebrew tap (macOS and Linux): + +```bash +brew tap dyoshikawa/rulesync https://github.com/dyoshikawa/rulesync brew install rulesync ``` +The tap lives inside this repository, so it does not have a `homebrew-` prefix. +The two-argument `brew tap ` form is therefore required — the +shorthand `brew install dyoshikawa/rulesync/rulesync` without tapping first does +not work. + ### Single Binary ```bash diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index deb76f69c..fc81c5bdc 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -4,14 +4,32 @@ ```bash npm install -g rulesync -# or -brew install rulesync # And then rulesync --version rulesync --help ``` +## Homebrew (macOS and Linux) + +rulesync ships a self-contained [Homebrew](https://brew.sh/) tap inside this +repository. Because the repository is not named `homebrew-rulesync`, you must use +the two-argument `brew tap ` form to add it — the auto-tap shorthand +`brew install dyoshikawa/rulesync/rulesync` cannot resolve it on its own: + +```bash +brew tap dyoshikawa/rulesync https://github.com/dyoshikawa/rulesync +brew install rulesync + +# And then +rulesync --version +``` + +The formula installs the prebuilt binary for your platform (macOS/Linux, arm64 +and x64), so it does not depend on a Node.js runtime. It is updated +automatically on every release. Homebrew does not support Windows; use npm or the +single-binary download below there. + ## Single Binary Download pre-built binaries from the [latest release](https://github.com/dyoshikawa/rulesync/releases/latest). These binaries are built using [Bun's single-file executable bundler](https://bun.sh/docs/bundler/executables). diff --git a/scripts/generate-homebrew-formula.test.ts b/scripts/generate-homebrew-formula.test.ts new file mode 100644 index 000000000..206a1c759 --- /dev/null +++ b/scripts/generate-homebrew-formula.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + FORMULA_PLATFORMS, + generateHomebrewFormula, + parseSha256Sums, + resolveFormulaShas, +} from "./generate-homebrew-formula.js"; + +const SAMPLE_SUMS = [ + "90844bbf409016b3641578ff4a8508a1d748d558933c66c9ff74471e1a250492 rulesync-darwin-arm64", + "1ac8cf074608a02d5a16d4a9f331b560757db5ed1821229622d10fcadb9f46d4 rulesync-darwin-x64", + "3bf6af02261ee1ac6fa5533a3db2a76554ba93819313533cf419182bc9dc202f rulesync-linux-arm64", + "613a098b1d9f993151ade442edd7383d1f30972600dda5cdd8f7343bec1c5404 rulesync-linux-x64", + "5a4bc48edaf2dca0451b0296c7f76f8b4a3019f3951c3dfe4cc76be9737cceea rulesync-windows-x64.exe", +].join("\n"); + +describe("parseSha256Sums", () => { + it("parses plain sha256sum output into an asset-to-digest map", () => { + const sums = parseSha256Sums(SAMPLE_SUMS); + expect(sums["rulesync-darwin-arm64"]).toBe( + "90844bbf409016b3641578ff4a8508a1d748d558933c66c9ff74471e1a250492", + ); + expect(sums["rulesync-windows-x64.exe"]).toBe( + "5a4bc48edaf2dca0451b0296c7f76f8b4a3019f3951c3dfe4cc76be9737cceea", + ); + }); + + it("accepts the binary-mode `*name` format and lowercases digests", () => { + const sums = parseSha256Sums("ABCDEF0123456789".repeat(4) + " *rulesync-linux-x64"); + expect(sums["rulesync-linux-x64"]).toBe("abcdef0123456789".repeat(4)); + }); + + it("ignores blank and malformed lines", () => { + const sums = parseSha256Sums("\n \nnot-a-checksum line\n"); + expect(Object.keys(sums)).toHaveLength(0); + }); +}); + +describe("resolveFormulaShas", () => { + it("returns a digest for every required platform", () => { + const shas = resolveFormulaShas(parseSha256Sums(SAMPLE_SUMS)); + for (const platform of FORMULA_PLATFORMS) { + expect(shas[platform]).toMatch(/^[0-9a-f]{64}$/); + } + }); + + it("throws when a platform binary is missing", () => { + const partial = parseSha256Sums( + "90844bbf409016b3641578ff4a8508a1d748d558933c66c9ff74471e1a250492 rulesync-darwin-arm64", + ); + expect(() => resolveFormulaShas(partial)).toThrow(/Missing sha256 for rulesync-darwin-x64/); + }); +}); + +describe("generateHomebrewFormula", () => { + const shas = resolveFormulaShas(parseSha256Sums(SAMPLE_SUMS)); + + it("renders a formula with the version and all four checksums", () => { + const formula = generateHomebrewFormula("9.6.3", shas); + expect(formula).toContain('version "9.6.3"'); + expect(formula).toContain(`sha256 "${shas["darwin-arm64"]}"`); + expect(formula).toContain(`sha256 "${shas["linux-x64"]}"`); + // The URL interpolates the Homebrew `version` variable, not the literal. + expect(formula).toContain( + 'url "https://github.com/dyoshikawa/rulesync/releases/download/v#{version}/rulesync-darwin-arm64"', + ); + expect(formula).toContain("class Rulesync < Formula"); + }); + + it("rejects a version with a leading v", () => { + expect(() => generateHomebrewFormula("v9.6.3", shas)).toThrow(/Invalid version/); + }); + + it("rejects a non-semver version", () => { + expect(() => generateHomebrewFormula("9.6", shas)).toThrow(/Invalid version/); + }); +}); diff --git a/scripts/generate-homebrew-formula.ts b/scripts/generate-homebrew-formula.ts new file mode 100644 index 000000000..2a5c43cea --- /dev/null +++ b/scripts/generate-homebrew-formula.ts @@ -0,0 +1,134 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +/** + * Generates the Homebrew tap formula (`Formula/rulesync.rb`) from a released + * version and the sha256 checksums of the per-platform binaries that + * `publish-assets.yml` uploads to the GitHub release. The formula installs the + * prebuilt Bun binary directly, so it needs no `node` runtime dependency. + */ + +export const FORMULA_PLATFORMS = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", +] as const; + +export type FormulaPlatform = (typeof FORMULA_PLATFORMS)[number]; + +export type FormulaShas = Record; + +/** + * Parses a `sha256sum`-formatted file into a map of asset name to lowercase + * hex digest. Both the plain (` `) and binary (` *`) + * output formats are accepted. + */ +export function parseSha256Sums(content: string): Record { + const result: Record = {}; + for (const line of content.split("\n")) { + const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(\S+)$/); + if (match?.[1] && match[2]) { + result[match[2]] = match[1].toLowerCase(); + } + } + return result; +} + +/** + * Resolves the four required binary checksums from a parsed SHA256SUMS map, + * throwing when any platform binary is missing. + */ +export function resolveFormulaShas(sums: Record): FormulaShas { + const shas = {} as FormulaShas; + for (const platform of FORMULA_PLATFORMS) { + const sha = sums[`rulesync-${platform}`]; + if (!sha) { + throw new Error(`Missing sha256 for rulesync-${platform} in SHA256SUMS`); + } + shas[platform] = sha; + } + return shas; +} + +/** + * Builds the release download URL for an asset. The `#{version}` token is + * interpolated by Homebrew at install time, not by this script. + */ +function downloadUrl(asset: string): string { + return `https://github.com/dyoshikawa/rulesync/releases/download/v#{version}/${asset}`; +} + +/** + * Renders the Ruby formula source for the given version (without a leading + * `v`) and per-platform checksums. + */ +export function generateHomebrewFormula(version: string, shas: FormulaShas): string { + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid version (expected X.Y.Z without a leading 'v'): ${version}`); + } + + return `# typed: false +# frozen_string_literal: true + +# This file is generated on each release by the "homebrew" job in +# .github/workflows/publish.yml (scripts/generate-homebrew-formula.ts). +# Do not edit it by hand; changes are overwritten on the next release. +class Rulesync < Formula + desc "Unified AI rules management CLI that generates config files for AI dev tools" + homepage "https://github.com/dyoshikawa/rulesync" + version "${version}" + license "MIT" + + on_macos do + on_arm do + url "${downloadUrl("rulesync-darwin-arm64")}" + sha256 "${shas["darwin-arm64"]}" + end + on_intel do + url "${downloadUrl("rulesync-darwin-x64")}" + sha256 "${shas["darwin-x64"]}" + end + end + + on_linux do + on_arm do + url "${downloadUrl("rulesync-linux-arm64")}" + sha256 "${shas["linux-arm64"]}" + end + on_intel do + url "${downloadUrl("rulesync-linux-x64")}" + sha256 "${shas["linux-x64"]}" + end + end + + def install + bin.install Dir["rulesync-*"].first => "rulesync" + end + + test do + assert_match version.to_s, shell_output("#{bin}/rulesync --version") + end +end +`; +} + +function main(): void { + const [version, sumsPath, outPath] = process.argv.slice(2); + if (!version || !sumsPath || !outPath) { + throw new Error( + "Usage: tsx scripts/generate-homebrew-formula.ts ", + ); + } + + const sums = parseSha256Sums(readFileSync(sumsPath, "utf8")); + const shas = resolveFormulaShas(sums); + const formula = generateHomebrewFormula(version, shas); + writeFileSync(outPath, formula); + // oxlint-disable-next-line no-console + console.log(`Wrote ${outPath} for v${version}`); +} + +// Only run the CLI when executed directly, not when imported by tests. +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/skills/rulesync/installation.md b/skills/rulesync/installation.md index cf8eccaa7..1063dc637 100644 --- a/skills/rulesync/installation.md +++ b/skills/rulesync/installation.md @@ -4,14 +4,32 @@ ```bash npm install -g rulesync -# or -brew install rulesync # And then rulesync --version rulesync --help ``` +## Homebrew (macOS and Linux) + +rulesync ships a self-contained [Homebrew](https://brew.sh/) tap inside this +repository. Because the repository is not named `homebrew-rulesync`, you must use +the two-argument `brew tap ` form to add it — the auto-tap shorthand +`brew install dyoshikawa/rulesync/rulesync` cannot resolve it on its own: + +```bash +brew tap dyoshikawa/rulesync https://github.com/dyoshikawa/rulesync +brew install rulesync + +# And then +rulesync --version +``` + +The formula installs the prebuilt binary for your platform (macOS/Linux, arm64 +and x64), so it does not depend on a Node.js runtime. It is updated +automatically on every release. Homebrew does not support Windows; use npm or the +single-binary download below there. + ## Single Binary Download pre-built binaries from the [latest release](https://github.com/dyoshikawa/rulesync/releases/latest). These binaries are built using [Bun's single-file executable bundler](https://bun.sh/docs/bundler/executables). From b6993c2b8d8b02e4726eb849d4ad96e9bbf4dd5b Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Sat, 11 Jul 2026 08:09:47 -0700 Subject: [PATCH 2/2] fix: harden homebrew formula job (PAT exposure, re-run idempotency) - Checkout the repo with persist-credentials: false so the formula token is not left in the runner's git config during pnpm install / tsx; authenticate the branch push explicitly instead. - Reuse an existing open PR for the formula branch so re-running the job for the same tag is idempotent rather than failing in gh pr create. --- .github/workflows/publish.yml | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b003fac61..4531c9a91 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -64,11 +64,10 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: main - # Prefer a maintainer PAT when present: a branch-protected `main` blocks - # auto-merge of a GITHUB_TOKEN-authored PR because that token cannot - # trigger the required status checks. Without the PAT we fall back to - # GITHUB_TOKEN and leave the PR open for a manual merge. - token: ${{ secrets.HOMEBREW_FORMULA_TOKEN || secrets.GITHUB_TOKEN }} + # Do not persist any token in the runner's git config: the push/PR step + # below authenticates explicitly, so the (potentially powerful) formula + # token is never left available to `pnpm install`/`tsx` in between. + persist-credentials: false - name: Configure git uses: ./.github/actions/git-config @@ -114,6 +113,10 @@ jobs: - name: Open auto-merge pull request env: + # Prefer a maintainer PAT when present: a branch-protected `main` blocks + # auto-merge of a GITHUB_TOKEN-authored PR because that token cannot + # trigger the required status checks. Without the PAT we fall back to + # GITHUB_TOKEN and leave the PR open for a manual merge. GH_TOKEN: ${{ secrets.HOMEBREW_FORMULA_TOKEN || secrets.GITHUB_TOKEN }} TAG: ${{ steps.tag.outputs.tag }} run: | @@ -126,12 +129,22 @@ jobs: git switch -c "$BRANCH" git add Formula/rulesync.rb git commit -m "chore: update Homebrew formula to ${TAG}" - git push --force-with-lease origin "$BRANCH" - - PR_URL="$(gh pr create --base main --head "$BRANCH" \ - --title "chore: update Homebrew formula to ${TAG}" \ - --body "Automated formula update for the ${TAG} release.")" - echo "Opened ${PR_URL}" + # Authenticate the push here only (checkout persisted no credentials). + git push --force-with-lease \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/${BRANCH}" + + # Reuse an existing open PR for this branch so re-running the job for the + # same tag is idempotent rather than failing in `gh pr create`. + PR_URL="$(gh pr list --head "$BRANCH" --base main --state open --json url --jq '.[0].url // empty')" + if [ -z "$PR_URL" ]; then + PR_URL="$(gh pr create --base main --head "$BRANCH" \ + --title "chore: update Homebrew formula to ${TAG}" \ + --body "Automated formula update for the ${TAG} release.")" + echo "Opened ${PR_URL}" + else + echo "Reusing existing PR ${PR_URL}" + fi if ! gh pr merge "$PR_URL" --auto --merge; then echo "::warning::Could not enable auto-merge; merge the formula PR manually."