diff --git a/.github/workflows/homebrew-check.yml b/.github/workflows/homebrew-check.yml new file mode 100644 index 0000000..5c3b74f --- /dev/null +++ b/.github/workflows/homebrew-check.yml @@ -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 diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml new file mode 100644 index 0000000..a903bef --- /dev/null +++ b/.github/workflows/homebrew.yml @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73b3e4b..19cc6bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/README.md b/README.md index f962f91..d34c372 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/homebrew.md b/docs/homebrew.md new file mode 100644 index 0000000..003fdad --- /dev/null +++ b/docs/homebrew.md @@ -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`. diff --git a/scripts/homebrew.test.ts b/scripts/homebrew.test.ts new file mode 100644 index 0000000..05eeb90 --- /dev/null +++ b/scripts/homebrew.test.ts @@ -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), + ); + }); +}); diff --git a/scripts/homebrew.ts b/scripts/homebrew.ts new file mode 100644 index 0000000..6c1c339 --- /dev/null +++ b/scripts/homebrew.ts @@ -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(); + 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); +} diff --git a/src/commands/update.ts b/src/commands/update.ts index d6594ef..8c47e18 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -5,6 +5,10 @@ import { dirname, join } from "node:path"; import { BunFileSystem } from "@effect/platform-bun"; import { Console, Data, Effect, FileSystem, Schema } from "effect"; import { Command } from "effect/unstable/cli"; +import { + HOMEBREW_UPGRADE_COMMAND, + isHomebrewInstallation, +} from "../services/installation"; import * as OAuth from "../services/oauth"; import { VERSION } from "../version"; @@ -307,6 +311,13 @@ const downloadAndUpdate = ( export const update = Command.make("update", {}, () => Effect.gen(function* () { + if (isHomebrewInstallation()) { + yield* Console.log( + `Polar is managed by Homebrew. Run ${HOMEBREW_UPGRADE_COMMAND} to update.`, + ); + return; + } + const green = "\x1b[32m"; const dim = "\x1b[2m"; const reset = "\x1b[0m"; diff --git a/src/services/installation.test.ts b/src/services/installation.test.ts new file mode 100644 index 0000000..269d973 --- /dev/null +++ b/src/services/installation.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isHomebrewInstallation } from "./installation"; + +describe("Homebrew installation detection", () => { + let directory: string; + let binary: string; + let keg: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "polar-installation-")); + keg = join(directory, "custom-cellar", "polar", "2.0.1"); + binary = join(keg, "bin", "polar"); + await mkdir(join(keg, "bin"), { recursive: true }); + await writeFile(binary, "binary"); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + test("detects a keg through a symlink with a custom Homebrew prefix", async () => { + await writeFile(join(keg, "INSTALL_RECEIPT.json"), "{}"); + const link = join(directory, "polar"); + await symlink(binary, link); + expect(isHomebrewInstallation(binary)).toBe(true); + expect(isHomebrewInstallation(link)).toBe(true); + }); + + test("does not classify a standalone binary as Homebrew", () => { + expect(isHomebrewInstallation(binary)).toBe(false); + }); + + test("compiled CLI refuses to self-update inside a Homebrew keg", async () => { + const build = Bun.spawn( + ["bun", "build", "./src/cli.ts", "--compile", "--outfile", binary], + { stdout: "pipe", stderr: "pipe" }, + ); + expect(await build.exited).toBe(0); + await writeFile(join(keg, "INSTALL_RECEIPT.json"), "{}"); + const link = join(directory, "polar"); + await symlink(binary, link); + const before = Bun.hash(await Bun.file(binary).arrayBuffer()); + const update = Bun.spawn([link, "update"], { + stdout: "pipe", + stderr: "pipe", + }); + const output = await new Response(update.stdout).text(); + expect(await update.exited).toBe(0); + expect(output).toContain("brew upgrade polarsource/tap/polar"); + expect(output).not.toContain("Checking for updates"); + expect(Bun.hash(await Bun.file(binary).arrayBuffer())).toBe(before); + }, 30_000); + + test("handles a missing binary without throwing", () => { + expect(isHomebrewInstallation(join(directory, "missing"))).toBe(false); + }); +}); diff --git a/src/services/installation.ts b/src/services/installation.ts new file mode 100644 index 0000000..a375380 --- /dev/null +++ b/src/services/installation.ts @@ -0,0 +1,19 @@ +import { existsSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export const HOMEBREW_UPGRADE_COMMAND = "brew upgrade polarsource/tap/polar"; + +export function isHomebrewInstallation(binaryPath = process.execPath): boolean { + try { + // Resolve Homebrew's bin/opt symlinks to the installed keg. This also + // supports custom prefixes and Cellar locations without invoking brew. + const keg = dirname(dirname(realpathSync(binaryPath))); + return existsSync(join(keg, "INSTALL_RECEIPT.json")); + } catch { + return false; + } +} + +export function getUpgradeCommand(): string { + return isHomebrewInstallation() ? HOMEBREW_UPGRADE_COMMAND : "polar update"; +} diff --git a/src/services/update-check.ts b/src/services/update-check.ts index 910f8e8..2375599 100644 --- a/src/services/update-check.ts +++ b/src/services/update-check.ts @@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { VERSION } from "../version"; +import { getUpgradeCommand, isHomebrewInstallation } from "./installation"; const REPO = "polarsource/cli"; const STATE_DIR = join(homedir(), ".polar"); @@ -30,7 +31,7 @@ export function showUpdateNotice(): void { process.stderr.write( `\n ${dim}Update available:${reset} ${dim}${VERSION}${reset} ${dim}→${reset} ${bold}${cyan}${state.latestVersion}${reset}\n` + - ` ${dim}Run${reset} ${cyan}polar update${reset} ${dim}to update${reset}\n\n`, + ` ${dim}Run${reset} ${cyan}${getUpgradeCommand()}${reset} ${dim}to update${reset}\n\n`, ); } catch { // Silently ignore any errors @@ -38,6 +39,10 @@ export function showUpdateNotice(): void { } export function checkForUpdateInBackground(): void { + // Homebrew tracks available formula versions. GitHub's latest release may + // not be available in the tap yet, so do not advertise it to brew users. + if (isHomebrewInstallation()) return; + try { let shouldCheck = true; diff --git a/tsconfig.json b/tsconfig.json index 7db8322..aef969c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,5 @@ } ] }, - "include": ["src"] + "include": ["src", "scripts"] }