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
129 changes: 82 additions & 47 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,69 +48,104 @@ 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
# 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

- 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
{
echo "tag=$TAG"
echo "version=${TAG#v}"
} >> "$GITHUB_OUTPUT"

- 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:
# 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: |
if git diff --quiet -- Formula/rulesync.rb; then
echo "Formula already up to date for ${TAG}; nothing to do."
exit 0
fi

SHA256="$(curl -sfL "$URL" | sha256sum | cut -d' ' -f1)"
if [ -z "${SHA256}" ]; then
echo "::error::Failed to compute sha256 for $URL"
exit 1
BRANCH="homebrew-formula/${TAG}"
git switch -c "$BRANCH"
git add Formula/rulesync.rb
git commit -m "chore: update Homebrew formula to ${TAG}"
# 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

# 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."
if ! gh pr merge "$PR_URL" --auto --merge; then
echo "::warning::Could not enable auto-merge; merge the formula PR manually."
fi
42 changes: 42 additions & 0 deletions Formula/rulesync.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <url>` form is therefore required — the
shorthand `brew install dyoshikawa/rulesync/rulesync` without tapping first does
not work.

### Single Binary

```bash
Expand Down
22 changes: 20 additions & 2 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <url>` 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).
Expand Down
78 changes: 78 additions & 0 deletions scripts/generate-homebrew-formula.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading