diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6776b032..ce199446 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,12 @@ on: release: types: - published + workflow_dispatch: + inputs: + tag: + description: "Release tag to regenerate the Homebrew formula for (e.g. v1.3.1)" + required: true + type: string permissions: contents: write @@ -12,6 +18,7 @@ permissions: jobs: releases-matrix: name: Release Go Binary + if: github.event_name == 'release' runs-on: ubuntu-latest strategy: matrix: @@ -32,4 +39,134 @@ jobs: goarch: ${{ matrix.goarch }} binary_name: "rune" ldflags: "-w -s -X 'github.com/ArjenSchwarz/rune/cmd.Version=${{ env.APP_VERSION }}' -X 'github.com/ArjenSchwarz/rune/cmd.BuildTime=${{ env.BUILD_TIME }}' -X 'github.com/ArjenSchwarz/rune/cmd.GitCommit=${{ env.GIT_COMMIT }}'" - extra_files: "LICENSE README.md" \ No newline at end of file + extra_files: "LICENSE README.md" + sha256sum: true + + homebrew: + name: Update Homebrew tap + needs: releases-matrix + if: always() && needs.releases-matrix.result != 'failure' && needs.releases-matrix.result != 'cancelled' + runs-on: macos-latest + concurrency: + group: homebrew-${{ github.repository }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - name: Resolve release tag + id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="$INPUT_TAG" + else + TAG="${GITHUB_REF_NAME}" + fi + if [ -z "$TAG" ]; then + echo "No tag resolved" >&2 + exit 1 + fi + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Download sha256 sidecars + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + gh release download "$TAG" -R "$GITHUB_REPOSITORY" \ + -p 'rune-*-darwin-*.tar.gz.sha256' \ + -p 'rune-*-linux-*.tar.gz.sha256' + ls -1 rune-*.tar.gz.sha256 + - name: Parse sha256 digests + id: digests + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + DARWIN_ARM64=$(awk '{print $1}' "rune-v${VERSION}-darwin-arm64.tar.gz.sha256") + DARWIN_AMD64=$(awk '{print $1}' "rune-v${VERSION}-darwin-amd64.tar.gz.sha256") + LINUX_ARM64=$(awk '{print $1}' "rune-v${VERSION}-linux-arm64.tar.gz.sha256") + LINUX_AMD64=$(awk '{print $1}' "rune-v${VERSION}-linux-amd64.tar.gz.sha256") + for v in "$DARWIN_ARM64" "$DARWIN_AMD64" "$LINUX_ARM64" "$LINUX_AMD64"; do + if [ -z "$v" ] || [ ${#v} -ne 64 ]; then + echo "Invalid sha256 digest: $v" >&2 + exit 1 + fi + done + echo "darwin_arm64=$DARWIN_ARM64" >> "$GITHUB_OUTPUT" + echo "darwin_amd64=$DARWIN_AMD64" >> "$GITHUB_OUTPUT" + echo "linux_arm64=$LINUX_ARM64" >> "$GITHUB_OUTPUT" + echo "linux_amd64=$LINUX_AMD64" >> "$GITHUB_OUTPUT" + - name: Render Formula/rune.rb + env: + VERSION: ${{ steps.tag.outputs.version }} + DARWIN_ARM64: ${{ steps.digests.outputs.darwin_arm64 }} + DARWIN_AMD64: ${{ steps.digests.outputs.darwin_amd64 }} + LINUX_ARM64: ${{ steps.digests.outputs.linux_arm64 }} + LINUX_AMD64: ${{ steps.digests.outputs.linux_amd64 }} + run: | + mkdir -p Formula + cat > Formula/rune.rb < v1.3.1 release end-to-end populates the tap with a working formula and `brew install arjenschwarz/rune/rune` installs and runs the binary. + +All prerequisites (tap repo, PAT, secret) are marked complete. The next release tag will be the integration test. + +### Validation Findings + +No gaps found between the spec and implementation. Minor polish items addressed during review: + +- `git push origin HEAD` changed to `git push origin HEAD:main` to match spec and protect against tap default-branch drift. +- Garbled `Blocked-by` metadata on `tasks.md` task 3 cleaned up. diff --git a/specs/homebrew-install/manual-steps.md b/specs/homebrew-install/manual-steps.md new file mode 100644 index 00000000..80f76af2 --- /dev/null +++ b/specs/homebrew-install/manual-steps.md @@ -0,0 +1,20 @@ +--- +references: + - specs/homebrew-install/smolspec.md + - specs/homebrew-install/tasks.md +--- +# Homebrew Install — Manual Steps + +## Before first automated release + +- [x] 1. Tap repo ArjenSchwarz/homebrew-rune exists with Formula/rune.rb committed using the placeholder content specified in smolspec.md + +- [x] 2. Fine-grained PAT scoped to ArjenSchwarz/homebrew-rune with contents:write is generated; expiry date noted on calendar for rotation + +- [x] 3. HOMEBREW_TAP_TOKEN secret is configured on ArjenSchwarz/rune using the PAT + - Blocked-by: rcb7ioi (Tap repo ArjenSchwarz/homebrew-rune exists with Formula/rune.rb committed using the placeholder content specified in smolspec.md), rcb7ioj (Fine-grained PAT scoped to ArjenSchwarz/homebrew-rune with contents:write is generated; expiry date noted on calendar for rotation) + +## After implementation lands + +- [ ] 4. v1.3.1 release end-to-end populates the tap with a working formula and brew install arjenschwarz/rune/rune installs and runs the binary + - Blocked-by: rcb7iok (HOMEBREW_TAP_TOKEN secret is configured on ArjenSchwarz/rune using the PAT) diff --git a/specs/homebrew-install/smolspec.md b/specs/homebrew-install/smolspec.md new file mode 100644 index 00000000..8407a989 --- /dev/null +++ b/specs/homebrew-install/smolspec.md @@ -0,0 +1,104 @@ +# Homebrew Install + +## Overview + +Enable installing rune via Homebrew by publishing a formula to a companion tap repo (`ArjenSchwarz/homebrew-rune`) and automating formula updates on every GitHub release. Pattern mirrors the sibling project `fog`/`homebrew-fog`, but replaces fog's manual formula bumps with a job appended to rune's existing `release.yml` that commits the updated formula automatically once the binary matrix finishes. + +## Requirements + +- The system MUST provide a Homebrew formula that installs the `rune` binary on darwin-arm64, darwin-amd64, linux-arm64, and linux-amd64 from the GitHub release tarballs produced by `release.yml`. +- The system MUST, on every `release: published` event, automatically commit an updated `Formula/rune.rb` in `ArjenSchwarz/homebrew-rune` with the new version and per-platform `sha256` values, only after all four tarball assets are available. +- The formula MUST pass `brew audit --strict --online Formula/rune.rb` and `brew test Formula/rune.rb` on a `macos-latest` runner before being pushed to the tap repo. +- The rune `README.md` MUST document `brew install arjenschwarz/rune/rune` as an install option alongside the existing `go install` instructions. +- The workflow MUST fail loudly (non-zero exit, visible in Actions UI) if any platform tarball is missing, a checksum cannot be computed, `brew audit` or `brew test` fails, or the push to `homebrew-rune` is rejected. +- The workflow MUST be idempotent: re-running for the same tag MUST NOT fail and MUST NOT create empty commits. +- The workflow MUST use a repository secret `HOMEBREW_TAP_TOKEN` — a fine-grained PAT with `contents:write` on `homebrew-rune` only — to push cross-repo. +- The workflow SHOULD expose a `workflow_dispatch` trigger with a `tag` input so it can be re-run manually for an existing release (for recovery or retesting). +- The workflow SHOULD use a `concurrency` group keyed on the repo to prevent two concurrent releases racing for the same tap commit. + +## Implementation Approach + +### Files in this repo (`rune`) + +- **`.github/workflows/release.yml`** (modified): + - **Existing `releases-matrix` job**: add `sha256sum: true` to the `wangyoucao577/go-release-action` step so each matrix leg publishes a `.sha256` sidecar (e.g. `rune-v1.3.0-darwin-amd64.tar.gz.sha256`) alongside the tarball. The action already supports this (confirmed via action README); default is `FALSE`. This avoids a re-download of tarballs downstream. + - **New `homebrew` job** with `needs: releases-matrix`, `runs-on: macos-latest`. Steps: + 1. `actions/checkout@v4` (rune repo). + 2. Resolve tag: `TAG="${GITHUB_REF_NAME}"` (e.g. `v1.3.0`); `VERSION="${TAG#v}"` (e.g. `1.3.0`). Used in the formula's `version "..."` line; the literal `v` stays in the URL template so filenames match rune's actual asset names (confirmed: `rune-v1.3.0-darwin-amd64.tar.gz`). + 3. Fetch the four `.sha256` sidecars (~80 bytes each) — not the tarballs — with `gh release download "$TAG" -R "$GITHUB_REPOSITORY" -p 'rune-*-darwin-*.tar.gz.sha256' -p 'rune-*-linux-*.tar.gz.sha256'` (uses built-in `GITHUB_TOKEN`). + 4. Parse each sidecar: `awk '{print $1}' rune-v${VERSION}--.tar.gz.sha256` to extract the hex digest. + 5. Render `Formula/rune.rb` via an inline heredoc in the workflow step (no separate template file) — structure copied from `../homebrew-fog/Formula/fog.rb` with substitutions for `version`, URLs, and the 4 sha256 values. + 6. Validate: `brew audit --strict --online Formula/rune.rb` then `brew test Formula/rune.rb`. (`brew test` itself downloads and installs the macOS tarball — this is the only unavoidable tarball fetch in the pipeline and is genuine install validation, not redundant work.) + 7. Clone the tap repo using `actions/checkout@v4` with `repository: ArjenSchwarz/homebrew-rune`, `token: ${{ secrets.HOMEBREW_TAP_TOKEN }}`, `path: homebrew-rune`. + 8. Copy rendered formula into `homebrew-rune/Formula/rune.rb`; `cd homebrew-rune`; set `git config user.name "rune-release-bot"` and `user.email "rune-release-bot@users.noreply.github.com"`; `git add Formula/rune.rb`; `git diff --cached --quiet || git commit -m "rune ${TAG}"`; `git push origin main`. + 9. Job-level: `concurrency: group: homebrew-${{ github.repository }}, cancel-in-progress: false`. +- **`.github/workflows/release.yml`** also grows a `workflow_dispatch` trigger with input `tag` (string, required). When dispatched, the new job resolves the tag from the input instead of `GITHUB_REF_NAME`. The original binary-matrix job must be gated so it does not rebuild on manual dispatch — simplest: skip `releases-matrix` and `needs:` when `github.event_name == 'workflow_dispatch'`, allowing the `homebrew` job to run against the existing release assets for recovery. +- **`README.md`**: add a Homebrew subsection under `## Installation`, before the `go install` block: + ```markdown + ### Homebrew (macOS/Linux) + + ```bash + brew install arjenschwarz/rune/rune + ``` + ``` + +### Files in the tap repo (`ArjenSchwarz/homebrew-rune`, manual prerequisites) + +- **`Formula/rune.rb`**: commit a placeholder formula — the automation will overwrite it. The user will cut a `v1.3.1` release immediately after the tap is created, which fires the new workflow and populates the real formula. No manual sha256 computation is required. + + Placeholder content (non-functional, only exists so the repo is a valid tap until the first automated commit): + + ```ruby + class Rune < Formula + desc "CLI for managing hierarchical markdown task lists" + homepage "https://github.com/ArjenSchwarz/rune" + version "0.0.0" + url "https://github.com/ArjenSchwarz/rune/releases/download/v0.0.0/placeholder.tar.gz" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + + def install + bin.install "rune" + end + + test do + system "#{bin}/rune", "--version" + end + end + ``` + +- **`README.md`**: one-liner explaining the tap and the install command (may also note that the formula is auto-updated from the rune release pipeline and who owns `HOMEBREW_TAP_TOKEN`). + +### Existing patterns leveraged + +- Release tarballs are already produced by `.github/workflows/release.yml` via `wangyoucao577/go-release-action` with `binary_name: rune` and `extra_files: "LICENSE README.md"`. Verified asset names: `rune-v1.3.0-darwin-amd64.tar.gz` and peers. +- The release action's built-in `sha256sum: true` parameter (confirmed in the action's README) publishes `.sha256` sidecars with zero extra scripting. The current release (v1.3.0) publishes only `.md5` sidecars; enabling `sha256sum: true` adds `.sha256` sidecars going forward and is what the new homebrew job consumes. +- Formula shape copied from `/Users/arjen/projects/personal/homebrew-fog/Formula/fog.rb`; the only structural differences are the binary name, the tap name (`arjenschwarz/rune/rune`), and the literal `v` in the URL template (fog tags without `v`, rune tags with `v`). +- `rune --version` is verified to exit 0 on the current codebase (prints `rune version dev` for unstamped builds, stamped `Version` otherwise via ldflags in `cmd/version.go` and `cmd/root.go`), so the formula's `test do` block works. + +### Out of scope + +- Homebrew core tap submission — this stays in a personal tap. +- Windows builds via Homebrew (Homebrew does not target Windows). +- Binary signing/notarisation — tracked separately under T-872 (Rune) and T-873 (Fog), deferred to a follow-up now that an Apple Developer account exists. +- Changes to how tarballs are built by `wangyoucao577/go-release-action`. +- Adding a `--version` CLI — already exists. + +### Version handling (summary) + +| Item | Value for v1.3.0 example | +| --- | --- | +| Git tag | `v1.3.0` | +| `$GITHUB_REF_NAME` | `v1.3.0` | +| Asset filename | `rune-v1.3.0-darwin-amd64.tar.gz` | +| Formula `version` line | `"1.3.0"` | +| Formula URL template | `https://github.com/ArjenSchwarz/rune/releases/download/v#{version}/rune-v#{version}--.tar.gz` | + +## Risks and Assumptions + +- **Risk:** `HOMEBREW_TAP_TOKEN` (fine-grained PAT) has a maximum lifetime of 1 year and will eventually expire, silently breaking releases. **Mitigation:** tap README documents required scope and owner; workflow fails loudly on `git push` auth error; owner sets a calendar reminder for rotation. +- **Risk:** `brew audit --strict --online` on `macos-latest` is slow (~2 minutes) and occasionally flaky (upstream Homebrew changes can introduce new warnings). **Mitigation:** acceptable cost for catching broken releases before users hit them; if audit flakiness becomes a problem, downgrade to `brew audit` without `--strict` in a follow-up. +- **Cost:** `macos-latest` minutes are free on public repositories (rune is public); the 10× macOS multiplier only applies to private repos. No billable cost from this job. +- **Assumption:** The owner will manually create the `ArjenSchwarz/homebrew-rune` repository with the placeholder formula above, add the `HOMEBREW_TAP_TOKEN` secret to the rune repo, and then cut `v1.3.1` to trigger the first automated update. Until this sequence completes, `brew install arjenschwarz/rune/rune` will fail. +- **Assumption:** Release tarball naming (`rune-v--.tar.gz`) and binary-at-archive-root layout remain stable — verified against the current v1.3.0 release assets and the `wangyoucao577/go-release-action@v1` configuration in `release.yml`. +- **Prerequisite:** `HOMEBREW_TAP_TOKEN` secret must exist on the rune repo before the first release fires the `homebrew` job. +- **Prerequisite:** `ArjenSchwarz/homebrew-rune` must exist with `Formula/rune.rb` present (placeholder) so the `git push` has a valid target branch. diff --git a/specs/homebrew-install/tasks.md b/specs/homebrew-install/tasks.md new file mode 100644 index 00000000..5d63223f --- /dev/null +++ b/specs/homebrew-install/tasks.md @@ -0,0 +1,19 @@ +--- +references: + - specs/homebrew-install/smolspec.md + - specs/homebrew-install/decision_log.md + - specs/homebrew-install/manual-steps.md +--- +# Homebrew Install + +- [x] 1. Release workflow publishes .sha256 sidecar files for every platform tarball (sha256sum: true enabled; verified by inspecting release assets of a test tag) + +- [x] 2. Release workflow supports manual re-run via workflow_dispatch with a tag input so the homebrew job can be exercised against an existing release without rebuilding binaries + +- [x] 3. Homebrew job renders a fresh Formula/rune.rb from the published sidecars and passes brew audit --strict --online and brew test on macos-latest before attempting any push + - Blocked-by: h92gaou (Release workflow publishes .sha256 sidecar files for every platform tarball (sha256sum: true enabled; verified by inspecting release assets of a test tag)) + +- [x] 4. Homebrew job commits the rendered formula to ArjenSchwarz/homebrew-rune idempotently, with concurrency group preventing parallel runs from clobbering each other + - Blocked-by: h92gaov (Homebrew job renders a fresh Formula/rune.rb from the published sidecars and passes brew audit --strict --online and brew test on macos-latest before attempting any push) + +- [x] 5. README documents brew install arjenschwarz/rune/rune alongside the existing install instructions