Skip to content
Closed
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
65 changes: 65 additions & 0 deletions .github/workflows/homebrew-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Check Homebrew support

on:
pull_request:
paths:
- .github/workflows/homebrew*.yml
- .github/workflows/release.yml
- scripts/homebrew*.ts
- src/**
- bun.lock
- package.json
- tsconfig.json
push:
branches: [main]
paths:
- .github/workflows/homebrew*.yml
- .github/workflows/release.yml
- scripts/homebrew*.ts
- src/**
- bun.lock
- package.json
- tsconfig.json

permissions:
contents: read

jobs:
check:
strategy:
fail-fast: false
matrix:
os: [macos-15, ubuntu-latest]
runs-on: ${{ matrix.os }}
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
HOMEBREW_NO_INSTALL_CLEANUP: "1"
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun test

- uses: Homebrew/actions/setup-homebrew@e99025eb970cfa124b8284a35463d87426917478

- name: Generate formula from latest stable release
env:
GH_TOKEN: ${{ github.token }}
run: |
release_tag=$(gh release view --repo polarsource/cli --json tagName --jq .tagName)
gh release download "$release_tag" --repo polarsource/cli --pattern checksums.txt --dir release
brew tap-new polarsource/homebrew-validation --no-git
validation_tap=$(brew --repository polarsource/homebrew-validation)
bun scripts/homebrew.ts "$release_tag" release/checksums.txt "$validation_tap/Formula/polar.rb"

- name: Install and validate formula
run: |
brew install --build-from-source --skip-link polarsource/homebrew-validation/polar
brew test --force polarsource/homebrew-validation/polar
brew style --formula polarsource/homebrew-validation/polar
brew audit --strict polarsource/homebrew-validation/polar
84 changes: 84 additions & 0 deletions .github/workflows/homebrew.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Publish Homebrew tap

on:
workflow_call:
inputs:
tag:
description: Stable CLI release tag
required: true
type: string
secrets:
HOMEBREW_TAP_TOKEN:
required: true
workflow_dispatch:
inputs:
tag:
description: "Existing stable release to publish, e.g. v2.0.0"
required: true
type: string

permissions:
contents: read

concurrency:
group: homebrew-tap
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Validate configuration and release
env:
GH_TOKEN: ${{ github.token }}
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
test -n "$TAP_TOKEN" || { echo "Configure HOMEBREW_TAP_TOKEN; see docs/homebrew.md"; exit 1; }
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Only stable version tags can be published"; exit 1; }
gh release view "$RELEASE_TAG" --repo polarsource/cli --json isDraft,isPrerelease \
| jq -e '.isDraft == false and .isPrerelease == false'

- name: Download release checksums
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: gh release download "$RELEASE_TAG" --repo polarsource/cli --pattern checksums.txt --dir release

- name: Generate formula
env:
RELEASE_TAG: ${{ inputs.tag }}
run: bun scripts/homebrew.ts "$RELEASE_TAG" release/checksums.txt generated/Formula/polar.rb

- name: Check out tap
uses: actions/checkout@v4
with:
repository: polarsource/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap

- name: Copy formula
run: |
mkdir -p tap/Formula
cp generated/Formula/polar.rb tap/Formula/polar.rb

- name: Open tap update pull request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap
add-paths: Formula/polar.rb
branch: polar-${{ inputs.tag }}
delete-branch: true
commit-message: "polar: update to ${{ inputs.tag }}"
title: "polar: update to ${{ inputs.tag }}"
body: |
Publish Polar CLI ${{ inputs.tag }} from https://github.com/polarsource/cli/releases/tag/${{ inputs.tag }}.

Generated from the stable release's checksums.txt. Supports macOS arm64/x64 and Linux x64.
9 changes: 9 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,12 @@ jobs:
*.zip
checksums.txt
generate_release_notes: true

homebrew:
needs: release
if: github.event_name == 'push' && !contains(github.ref_name, '-')
uses: ./.github/workflows/homebrew.yml
with:
tag: ${{ github.ref_name }}
secrets:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@ A Polar CLI for your terminal.
- And much more...

Currently in development.

## Homebrew

Homebrew tap publishing and first-release setup are described in
[docs/homebrew.md](docs/homebrew.md). Once the tap's initial formula is published,
install with `brew install polarsource/tap/polar`.
87 changes: 87 additions & 0 deletions docs/homebrew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Homebrew tap

The tap is `polarsource/homebrew-tap`. Users install and upgrade with:

```sh
brew install polarsource/tap/polar
brew upgrade polarsource/tap/polar
```

The generated formula installs the existing signed/notarized macOS arm64 and
x64 archives and the Linux x64 archive. It verifies the platform's SHA-256 from
the release's `checksums.txt`. Bun is embedded in the executable and is not an
installation dependency. Linux arm64 is not included because the current release
workflow does not build that target.

## One-time setup

1. Create a public `polarsource/homebrew-tap` repository with an initial commit
on its default branch (for example, a README). This is required before the
publishing workflow can check it out and open a pull request.
2. Create a fine-grained GitHub token restricted to `polarsource/homebrew-tap`,
with **Contents: read/write** and **Pull requests: read/write**. Complete any
organization approval required for the token.
3. Store it as the `HOMEBREW_TAP_TOKEN` Actions secret in `polarsource/cli`.
The CLI repository's `GITHUB_TOKEN` cannot write to the separate tap.
4. Merge the Homebrew support change and publish a new stable CLI release that
includes it. Older binaries do not have the Homebrew updater guard; do not
bootstrap the public tap with one of those releases.
5. Merge the generated `polar: update to vX.Y.Z` pull request in the tap to make
the first formula available. Add the install command to public documentation
once that PR is merged.

## Release publishing

After the Release workflow uploads a stable version tag's assets, it calls
`homebrew.yml`. The publishing job checks that the GitHub release is neither
draft nor prerelease, downloads its checksums, generates `Formula/polar.rb`, and
opens a version-specific pull request in the tap. Draft signing-verification
runs and prerelease tags do not update the tap. Missing configuration or invalid
checksums fail the job rather than publishing an incomplete formula.

Tap updates are reviewed and merged separately from creating a CLI release.
The CLI pull request checks test the generator and compiled updater guard, then
install/test/audit a formula generated from the latest stable release on macOS
and Linux. No publishing token is needed for these checks.
Run the following checks in the tap before merging:

```sh
brew install --build-from-source polarsource/tap/polar
brew test polarsource/tap/polar
brew audit --strict polarsource/tap/polar
brew style --formula polarsource/tap/polar
```

For an installed formula, use `brew reinstall --build-from-source` instead of
`brew install` when checking an update. Validate the supported macOS architectures
and Linux x64. The install step extracts a precompiled release executable; it
does not compile the CLI from source despite Homebrew's flag name.

To retry publishing an existing release after fixing configuration:

```sh
gh workflow run homebrew.yml --repo polarsource/cli -f tag=vX.Y.Z
```

The workflow serializes publishing jobs. Do not retry an older release after a
newer formula has been merged; closing an obsolete generated PR prevents a
downgrade. Re-running the same tag updates its existing PR, and creates no PR
if the formula already matches the tap's default branch.

To generate a formula locally for inspection:

```sh
gh release download vX.Y.Z --repo polarsource/cli --pattern checksums.txt --dir /tmp/polar-release
bun scripts/homebrew.ts vX.Y.Z /tmp/polar-release/checksums.txt /tmp/polar-formula/polar.rb
```

## Homebrew-managed updates

The CLI resolves its executable symlink and checks for Homebrew's
`INSTALL_RECEIPT.json` in the installed keg. For these installations,
`polar update` prints `brew upgrade polarsource/tap/polar` and returns before
fetching a release, replacing the binary, or clearing authentication. Background
GitHub update checks are skipped because the latest CLI release may not yet be
available in the tap. An existing cached notice also shows the brew command.

Standalone installations continue to use `polar update`.
53 changes: 53 additions & 0 deletions scripts/homebrew.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test";
import { generateFormula } from "./homebrew";

const checksums = [
`${"a".repeat(64)} polar-darwin-arm64.zip`,
`${"b".repeat(64)} polar-darwin-x64.zip`,
`${"c".repeat(64)} polar-linux-x64.tar.gz`,
].join("\n");

describe("Homebrew formula generation", () => {
test("pins platform archives and checksums to the requested release", () => {
const formula = generateFormula("v2.0.1", checksums);
expect(formula).toContain("/releases/download/v2.0.1/");
for (const line of checksums.split("\n")) {
const [hash, archive] = line.split(" ");
expect(formula).toContain(`sha256 "${hash}"`);
expect(formula).toContain(`/releases/download/v2.0.1/${archive}`);
}
expect(formula).toContain("depends_on arch: :x86_64");
});

test("rejects prereleases and untrusted tag text", () => {
for (const tag of ["v2.0.1-beta.1", "main", 'v2.0.1"\nend', "v2.0"]) {
expect(() => generateFormula(tag, checksums)).toThrow(
"stable release tag",
);
}
});

test("fails if any supported platform has no checksum", () => {
for (const line of checksums.split("\n")) {
expect(() =>
generateFormula("v2.0.1", checksums.replace(line, "")),
).toThrow();
}
});

test("rejects malformed and duplicate checksums", () => {
expect(() =>
generateFormula("v2.0.1", checksums.replace("a", "z")),
).toThrow("Invalid checksum");
expect(() =>
generateFormula("v2.0.1", `${checksums}\n${checksums}`),
).toThrow("Duplicate checksum");
});

test("accepts sha256sum binary markers, CRLF and extra release assets", () => {
const text = `${checksums.replaceAll(" ", " *").replaceAll("\n", "\r\n")}\r\n${"d".repeat(64)} *polar-windows-x64.zip\r\n`;
expect(generateFormula("v2.0.1", text)).toBe(
generateFormula("v2.0.1", checksums),
);
});
});
79 changes: 79 additions & 0 deletions scripts/homebrew.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";

const archives = [
"polar-darwin-arm64.zip",
"polar-darwin-x64.zip",
"polar-linux-x64.tar.gz",
] as const;

export function generateFormula(tag: string, checksums: string): string {
if (!/^v\d+\.\d+\.\d+$/.test(tag)) {
throw new Error("Homebrew requires a stable release tag, e.g. v2.0.0");
}

const hashes = new Map<string, string>();
for (const line of checksums.trim().split(/\r?\n/)) {
const match = /^([a-fA-F0-9]{64})\s+\*?(\S+)$/.exec(line);
if (!match) throw new Error(`Invalid checksum entry: ${line}`);
const [, hash, archive] = match;
if (!hash || !archive) throw new Error(`Invalid checksum entry: ${line}`);
if (hashes.has(archive)) {
throw new Error(`Duplicate checksum for ${archive}`);
}
hashes.set(archive, hash.toLowerCase());
}
for (const archive of archives) {
if (!hashes.has(archive))
throw new Error(`Missing checksum for ${archive}`);
}

const url = `https://github.com/polarsource/cli/releases/download/${tag}`;
return `# Generated by polarsource/cli scripts/homebrew.ts. Do not edit manually.
class Polar < Formula
desc "Official command-line interface for Polar"
homepage "https://polar.sh"
license "Apache-2.0"

on_macos do
on_arm do
url "${url}/polar-darwin-arm64.zip"
sha256 "${hashes.get(archives[0])}"
end
on_intel do
url "${url}/polar-darwin-x64.zip"
sha256 "${hashes.get(archives[1])}"
end
end

on_linux do
depends_on arch: :x86_64
on_intel do
url "${url}/polar-linux-x64.tar.gz"
sha256 "${hashes.get(archives[2])}"
end
end

def install
bin.install "polar"
end

test do
assert_match "polar", shell_output("#{bin}/polar --help")
assert_match(/\\d+\\.\\d+\\.\\d+/, shell_output("#{bin}/polar --version"))
end
end
`;
}

if (import.meta.main) {
const [tag, checksumsPath, outputPath] = process.argv.slice(2);
if (!tag || !checksumsPath || !outputPath) {
throw new Error(
"Usage: bun scripts/homebrew.ts TAG CHECKSUMS_PATH OUTPUT_PATH",
);
}
const formula = generateFormula(tag, await readFile(checksumsPath, "utf8"));
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, formula);
}
Loading
Loading