From 26aff0c211a26c046ec6a98ee57239ad606ce436 Mon Sep 17 00:00:00 2001 From: Arjen Schwarz Date: Sun, 19 Apr 2026 23:55:31 +1000 Subject: [PATCH 1/5] T-824: Plan Homebrew install via automated tap updates Add smolspec, decision log, task list, and manual-steps checklist for publishing rune via Homebrew. The design appends a homebrew job to release.yml with needs: releases-matrix so it runs only after all four platform tarballs are uploaded, enables sha256sum: true on the existing release step so sidecar checksums can be consumed without redownloading tarballs, validates the rendered formula with brew audit --strict and brew test on macos-latest, and pushes to ArjenSchwarz/homebrew-rune via a fine-grained PAT (HOMEBREW_TAP_TOKEN). Manual prerequisites (create tap repo, generate PAT, configure secret, cut v1.3.1 to bootstrap) are tracked in specs/homebrew-install/manual-steps.md to keep tasks.md focused on coding work. Follow-up tickets T-872 (Rune) and T-873 (Fog) cover macOS binary signing/notarisation, which is out of scope here. --- CHANGELOG.md | 4 + specs/homebrew-install/decision_log.md | 171 +++++++++++++++++++++++++ specs/homebrew-install/manual-steps.md | 20 +++ specs/homebrew-install/smolspec.md | 104 +++++++++++++++ specs/homebrew-install/tasks.md | 19 +++ 5 files changed, 318 insertions(+) create mode 100644 specs/homebrew-install/decision_log.md create mode 100644 specs/homebrew-install/manual-steps.md create mode 100644 specs/homebrew-install/smolspec.md create mode 100644 specs/homebrew-install/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6798483a..0cf9a5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Homebrew Install Spec** (T-824): Planning docs for publishing rune via Homebrew with an automated formula-update job appended to the release workflow, sha256 sidecars emitted by the release matrix, `brew audit`/`brew test` validation on macOS, and cross-repo push to a companion `homebrew-rune` tap via a fine-grained PAT (`specs/homebrew-install/`) + ### Changed - **Configuration**: `.rune.yml` now rejects unknown fields (`KnownFields` enforcement). Config files with extra or misspelled keys that were previously silently ignored will now produce an error. Remove any unsupported fields from your `.rune.yml` to resolve. diff --git a/specs/homebrew-install/decision_log.md b/specs/homebrew-install/decision_log.md new file mode 100644 index 00000000..f8d1017a --- /dev/null +++ b/specs/homebrew-install/decision_log.md @@ -0,0 +1,171 @@ +# Decision Log: Homebrew Install + +## Decision 1: Append homebrew job to release.yml instead of a separate workflow + +**Date**: 2026-04-19 +**Status**: accepted + +### Context + +The formula-update step depends on the release matrix having uploaded all four platform tarballs. GitHub fires `release: published` once, triggering any workflow subscribed to that event concurrently. The release matrix takes several minutes to finish, so a separate workflow on the same trigger would race against it. + +### Decision + +Append a new `homebrew` job to the existing `.github/workflows/release.yml` with `needs: releases-matrix`, rather than creating a separate `homebrew.yml`. + +### Rationale + +`needs:` gives deterministic ordering with zero extra machinery: the formula job only starts once every matrix leg has uploaded its asset. No polling, no `workflow_run`, no race window. + +### Alternatives Considered + +- **Separate `homebrew.yml` with `workflow_run: release.yml`**: Keeps release.yml untouched, but `workflow_run` does not fire for workflow-triggered workflows, runs on both success and failure (needs gating), and decouples related logic across two files. +- **Separate `homebrew.yml` on `release: published` with polling**: Simple to reason about but racy. A 5-minute poll may not cover a matrix build that can exceed 10 minutes. + +### Consequences + +**Positive:** +- Deterministic execution order — formula updates run only after all tarballs exist. +- Single place to understand the release pipeline. +- No wasted macOS minutes polling for assets that aren't ready. + +**Negative:** +- Modifies release.yml (the spec originally listed this as out-of-scope; that constraint was dropped as arbitrary). +- Homebrew job is tightly coupled to the release workflow — future refactors touch both. + +--- + +## Decision 2: Use a fine-grained PAT (`HOMEBREW_TAP_TOKEN`) for cross-repo push + +**Date**: 2026-04-19 +**Status**: accepted + +### Context + +The default `GITHUB_TOKEN` cannot push to a different repository, so the homebrew job needs a cross-repo credential to commit the updated formula to `ArjenSchwarz/homebrew-rune`. + +### Decision + +Use a fine-grained Personal Access Token scoped to `ArjenSchwarz/homebrew-rune` with `contents:write`, stored as the repo secret `HOMEBREW_TAP_TOKEN`. + +### Rationale + +Simplest viable credential for a single-maintainer personal project. Fine-grained PATs let us narrow the blast radius to the tap repo only. Expiry risk is acceptable given the maintainer can rotate on a yearly cadence. + +### Alternatives Considered + +- **Deploy key on the tap repo**: No expiry, but requires SSH-based `git push`, private key in secret, and is still tied to the tap repo only. Marginal benefit over PAT for more operational complexity. +- **GitHub App**: Most robust and rotatable, supports auditable installations. Heavier setup (app creation, installation on both repos, token minting at runtime via `actions/create-github-app-token`). Over-engineered for a personal project with one maintainer. + +### Consequences + +**Positive:** +- Minimal setup; one secret, one PAT. +- Scope-limited to the tap repo. + +**Negative:** +- Max 1-year lifetime; rotation is manual and easy to forget. +- Tied to a personal account — if the account is compromised or disabled, automation breaks. + +--- + +## Decision 3: Bootstrap tap with a placeholder formula, trigger a v1.3.1 release immediately + +**Date**: 2026-04-19 +**Status**: accepted + +### Context + +The tap repo must exist (with a `Formula/rune.rb`) before the automation can push to it. There is a window between tap creation and the first automated release during which `brew install` could fail. + +### Decision + +Seed the tap with a placeholder (non-functional) `Formula/rune.rb`. Immediately after the tap repo and `HOMEBREW_TAP_TOKEN` secret are in place, cut a `v1.3.1` release; the new workflow populates the real formula on first run. + +### Rationale + +Keeps the bootstrap step mechanical — no manual sha256 computation for the current v1.3.0 tarballs. The broken-install window is tiny (minutes between tap creation and v1.3.1 publish), and no user is relying on the tap before it's announced anyway. + +### Alternatives Considered + +- **Seed with a real v1.3.0 formula (manual sha256 computation)**: `brew install` works from the moment the tap is public, before any release is cut. Requires computing four sha256s manually for a one-time bootstrap — not worth the effort given how quickly v1.3.1 can be cut. + +### Consequences + +**Positive:** +- Bootstrap is mechanical; no hand-computed checksums. +- Exercises the automation on the very first release, proving it works. + +**Negative:** +- Tap is non-functional between creation and first automated release — must not be announced publicly before v1.3.1 lands. + +--- + +## Decision 4: Validate formula with `brew audit --strict` and `brew test` in CI + +**Date**: 2026-04-19 +**Status**: accepted + +### Context + +A broken formula (wrong URL, wrong sha256, missing binary, malformed Ruby) reaches every `brew install` user before anyone notices. Homebrew provides `brew audit` (static checks) and `brew test` (installs + runs the formula's test block) that catch most such problems locally. + +### Decision + +Run `brew audit --strict --online Formula/rune.rb` and `brew test Formula/rune.rb` on `macos-latest` in the homebrew job, before pushing to the tap repo. Fail the job on any error. + +### Rationale + +The "MUST pass brew audit" requirement is only meaningful if the workflow actually enforces it. The cost is one `macos-latest` runner for ~2 minutes per release — acceptable for a release-only job. + +### Alternatives Considered + +- **Skip CI validation, rely on downstream user reports**: Cheaper and faster, but the failure mode (silent broken install for every user) is unacceptable. Would require dropping the audit requirement to be honest. +- **Run audit on `ubuntu-latest` with Homebrew on Linux**: Works but is slower to spin up (Homebrew is slower on Linux runners) and diverges from the platform most users install on. + +### Consequences + +**Positive:** +- Broken formulas cannot reach the tap repo. +- `brew test` actually installs the real tarball and runs `rune --version`, so URL and sha256 errors surface in CI. + +**Negative:** +- macOS runner minutes cost more than Linux minutes. +- `brew audit` warnings can change upstream and cause occasional CI flakes unrelated to rune. + +--- + +## Decision 5: Emit sha256 sidecars from the release matrix and consume them in the homebrew job + +**Date**: 2026-04-19 +**Status**: accepted + +### Context + +The homebrew job needs a sha256 digest per platform tarball to write into the formula. The naive implementation downloads each tarball and computes `shasum -a 256` locally. Tarballs are multi-MB; downloading four of them on every release is wasteful when the matrix already has the bytes in hand. + +### Decision + +Enable `sha256sum: true` on the existing `wangyoucao577/go-release-action` step so each matrix leg publishes a `.sha256` sidecar alongside its tarball. The homebrew job fetches only the sidecars (~80 bytes each) and parses the digest with `awk`. + +### Rationale + +The action already computes and publishes checksums (`.md5` sidecars exist today). Flipping `sha256sum: true` is a one-line change with no extra scripting, gives end users a published sha256 for manual verification, and avoids several MB of redundant traffic on every release. + +### Alternatives Considered + +- **Download tarballs in the homebrew job and compute sha256 locally**: Works with zero changes to the matrix step, but redownloads what was just built and uploaded. No user-facing checksum benefit. +- **Add a custom step to each matrix leg that runs `shasum -a 256` and uploads via `gh release upload`**: Needed only if the action didn't support sha256sum natively. Since it does, the custom step is strictly more code. +- **Pass sha256 via matrix job outputs**: Matrix job outputs collapse into a single value per key across legs, making this awkward without per-leg artifact uploads. Sidecars are simpler. + +### Consequences + +**Positive:** +- One-line change in the release step. +- Homebrew job is fast (fetches four small sidecars, not four multi-MB tarballs). +- End users gain published sha256 sidecars for manual verification. + +**Negative:** +- `brew test` still downloads the macOS tarball during install validation — unavoidable without dropping the test gate, and this is genuine validation rather than redundant work. + +--- diff --git a/specs/homebrew-install/manual-steps.md b/specs/homebrew-install/manual-steps.md new file mode 100644 index 00000000..efb208bc --- /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 + +- [ ] 1. Tap repo ArjenSchwarz/homebrew-rune exists with Formula/rune.rb committed using the placeholder content specified in smolspec.md + +- [ ] 2. Fine-grained PAT scoped to ArjenSchwarz/homebrew-rune with contents:write is generated; expiry date noted on calendar for rotation + +- [ ] 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..8baa2dcf --- /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 + +- [ ] 1. Release workflow publishes .sha256 sidecar files for every platform tarball (sha256sum: true enabled; verified by inspecting release assets of a test tag) + +- [ ] 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 + +- [ ] 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)) + +- [ ] 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) + +- [ ] 5. README documents brew install arjenschwarz/rune/rune alongside the existing install instructions From 128cb6d2d6ff957a2e445227cf75335d56f6ad06 Mon Sep 17 00:00:00 2001 From: Arjen Schwarz Date: Mon, 20 Apr 2026 12:00:47 +1000 Subject: [PATCH 2/5] [chore]: Completed manual steps --- specs/homebrew-install/manual-steps.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/homebrew-install/manual-steps.md b/specs/homebrew-install/manual-steps.md index efb208bc..80f76af2 100644 --- a/specs/homebrew-install/manual-steps.md +++ b/specs/homebrew-install/manual-steps.md @@ -7,11 +7,11 @@ references: ## Before first automated release -- [ ] 1. Tap repo ArjenSchwarz/homebrew-rune exists with Formula/rune.rb committed using the placeholder content specified in smolspec.md +- [x] 1. Tap repo ArjenSchwarz/homebrew-rune exists with Formula/rune.rb committed using the placeholder content specified in smolspec.md -- [ ] 2. Fine-grained PAT scoped to ArjenSchwarz/homebrew-rune with contents:write is generated; expiry date noted on calendar for rotation +- [x] 2. Fine-grained PAT scoped to ArjenSchwarz/homebrew-rune with contents:write is generated; expiry date noted on calendar for rotation -- [ ] 3. HOMEBREW_TAP_TOKEN secret is configured on ArjenSchwarz/rune using the PAT +- [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 From 5247a4b87e7d8fc6d4a07e65b2386312f2c8e338 Mon Sep 17 00:00:00 2001 From: Arjen Schwarz Date: Mon, 20 Apr 2026 12:06:26 +1000 Subject: [PATCH 3/5] T-824: Automate Homebrew formula updates on release - Enable sha256sum: true on the go-release-action step so the matrix publishes .sha256 sidecars for every platform tarball. - Add a workflow_dispatch trigger with a `tag` input and gate releases-matrix to release events only, allowing manual re-runs of the homebrew job against an existing release without rebuilding binaries. - Add a macos-latest homebrew job that downloads the sha256 sidecars, parses the digests, renders Formula/rune.rb via heredoc, and validates with `brew audit --strict --online`, `brew install`, and `brew test rune` before touching the tap. - Check out ArjenSchwarz/homebrew-rune with HOMEBREW_TAP_TOKEN, commit the rendered formula idempotently (no-op when unchanged), and push. Serialise runs with a `homebrew-` concurrency group so parallel releases cannot clobber each other. - Document `brew install arjenschwarz/rune/rune` in README. --- .github/workflows/release.yml | 139 +++++++++++++++++++++++++++++++- CHANGELOG.md | 2 +- README.md | 8 ++ specs/homebrew-install/tasks.md | 12 +-- 4 files changed, 153 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6776b032..038af7ad 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 < +- [x] 1. Release workflow publishes .sha256 sidecar files for every platform tarball (sha256sum: true enabled; verified by inspecting release assets of a test tag) -- [ ] 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] 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 -- [ ] 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] 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)), sidecar, tarball, release, sidecar, tarball, release, sidecar, tarball, release, sidecar, tarball, release, sidecar, tarball, release -- [ ] 4. Homebrew job commits the rendered formula to ArjenSchwarz/homebrew-rune idempotently, with concurrency group preventing parallel runs from clobbering each other +- [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) -- [ ] 5. README documents brew install arjenschwarz/rune/rune alongside the existing install instructions +- [x] 5. README documents brew install arjenschwarz/rune/rune alongside the existing install instructions From b6b00f73585b3687825f8408e6f7b86e76e82bc1 Mon Sep 17 00:00:00 2001 From: Arjen Schwarz Date: Mon, 20 Apr 2026 12:12:50 +1000 Subject: [PATCH 4/5] T-824: Address pre-push review on Homebrew release job - Push explicit branch (HEAD:main) to the tap so tap default-branch renames or detached-like checkout state cannot silently redirect the formula commit. - Clean up garbled Blocked-by metadata on tasks.md task 3 left over from an earlier rune edit. - Add specs/homebrew-install/implementation.md with beginner, intermediate, and expert explanations plus a completeness assessment mapping every MUST/SHOULD requirement to its implementation site. --- .github/workflows/release.yml | 2 +- specs/homebrew-install/implementation.md | 129 +++++++++++++++++++++++ specs/homebrew-install/tasks.md | 2 +- 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 specs/homebrew-install/implementation.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 038af7ad..ce199446 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -168,5 +168,5 @@ jobs: echo "Formula already up-to-date for $TAG; nothing to commit." else git commit -m "rune $TAG" - git push origin HEAD + git push origin HEAD:main fi diff --git a/specs/homebrew-install/implementation.md b/specs/homebrew-install/implementation.md new file mode 100644 index 00000000..d7f6bcd5 --- /dev/null +++ b/specs/homebrew-install/implementation.md @@ -0,0 +1,129 @@ +# Homebrew Install — Implementation Explanation + +## Beginner Level + +### What Changed + +Users can now install rune with `brew install arjenschwarz/rune/rune`. Every time a new rune release is cut on GitHub, a Homebrew formula is automatically updated in a separate "tap" repository so the `brew install` command keeps working against the newest version. + +### Why It Matters + +Before this change, installing rune required `go install` (assumes the user has Go installed) or building from source. Homebrew is the standard package manager on macOS and popular on Linux, so supporting it lowers the barrier to entry. + +### Key Concepts + +- **Homebrew formula**: A Ruby file (`Formula/rune.rb`) that tells Homebrew where to download the binary and how to verify it with a sha256 checksum. +- **Tap**: A GitHub repo that hosts Homebrew formulas. `ArjenSchwarz/homebrew-rune` is rune's tap. +- **Release workflow**: The GitHub Actions workflow that runs when a new tag is published. It already builds the binaries; now it also updates the formula. +- **sha256 sidecar**: A tiny companion file (e.g., `rune-v1.3.1-darwin-arm64.tar.gz.sha256`) containing the checksum of a tarball. Used so the formula can verify downloads are intact. + +--- + +## Intermediate Level + +### Changes Overview + +Three production files changed: + +- `.github/workflows/release.yml`: adds `sha256sum: true` to the existing binary-matrix step (so sidecars are published), plus a new `homebrew` job that renders the formula, validates it, and commits it to the tap. +- `README.md`: adds a Homebrew subsection under Installation. +- `CHANGELOG.md`: adds an Unreleased/Added entry. + +Plus spec files in `specs/homebrew-install/` (smolspec, tasks, decision log, manual steps). + +### Implementation Approach + +The `homebrew` job runs on `macos-latest`, gated with `needs: releases-matrix` so it only starts after all four platform tarballs are published. It then: + +1. Resolves the tag from `GITHUB_REF_NAME` (release event) or `github.event.inputs.tag` (manual dispatch). +2. Downloads only the four `.sha256` sidecars (~80 bytes each), not the multi-MB tarballs, using `gh release download`. +3. Parses each sidecar with `awk` and validates that each digest is exactly 64 hex chars. +4. Renders `Formula/rune.rb` inline via an unquoted heredoc, substituting the version and four sha256 values. Ruby `#{version}` interpolations pass through untouched because they contain no shell metacharacters. +5. Runs `brew audit --strict --online` and `brew install --formula ./Formula/rune.rb && brew test rune` to validate the formula before it reaches users. +6. Checks out `ArjenSchwarz/homebrew-rune` using a fine-grained PAT (`HOMEBREW_TAP_TOKEN`), copies the formula over, and commits + pushes to `main`. The commit is skipped when the formula is byte-identical (`git diff --cached --quiet`), making re-runs idempotent. + +A `concurrency` group serializes all tap writes, and `workflow_dispatch` with a `tag` input allows manual recovery without rebuilding binaries. On manual dispatch, `releases-matrix` is skipped (via `if: github.event_name == 'release'`); the homebrew job's gate `if: always() && result != 'failure' && result != 'cancelled'` allows `'skipped'` through. + +### Trade-offs + +- **One workflow vs two**: Chose to append the job to `release.yml` rather than use a separate `homebrew.yml` triggered by `workflow_run`. `needs:` gives deterministic ordering; `workflow_run` is racy and doesn't fire for workflow-created workflows. Documented as Decision 1. +- **Fine-grained PAT vs GitHub App**: PAT is simpler for a one-maintainer personal project. Yearly rotation is the downside. Decision 2. +- **Placeholder formula bootstrap**: The tap ships with a non-functional formula so `brew tap` works; the first real `v1.3.1` release populates the genuine one. Avoids hand-computing sha256s for the bootstrap. Decision 3. +- **Sidecars over re-download**: `wangyoucao577/go-release-action` natively supports `sha256sum: true`, so flipping it is one line. Alternative (download tarballs and shasum locally) wastes bandwidth. Decision 5. + +--- + +## Expert Level + +### Technical Deep Dive + +**Tag resolution dual-mode**: The `tag` step is the load-bearing bridge between `release: published` and `workflow_dispatch`. On a release event, `GITHUB_REF_NAME` is the tag name (e.g., `v1.3.1`). On dispatch, `github.event.inputs.tag` is the user-supplied string. Both are normalized to `version = tag#v` for the formula `version` line, while the literal `v` stays in the URL template so asset filenames match (`rune-v1.3.1-darwin-amd64.tar.gz`). + +**Heredoc safety**: The heredoc is unquoted (`< 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/tasks.md b/specs/homebrew-install/tasks.md index f016761e..5d63223f 100644 --- a/specs/homebrew-install/tasks.md +++ b/specs/homebrew-install/tasks.md @@ -11,7 +11,7 @@ references: - [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)), sidecar, tarball, release, sidecar, tarball, release, sidecar, tarball, release, sidecar, tarball, release, sidecar, tarball, release + - 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) From e2ab001d80dd101d2c670267b78f716036c76332 Mon Sep 17 00:00:00 2001 From: Arjen Schwarz Date: Mon, 20 Apr 2026 14:58:39 +1000 Subject: [PATCH 5/5] [doc]: Update agent notes with known gaps and testing guidance - batch-command: document phase marker adjustment bug (T-787) and the phase detection gap that drops markers on plain remove operations (T-820) - config-discovery: note that home config tests must isolate HOME (T-812) - dependencies: document that remove paths bypass RemoveTaskWithDependents and the title-output ordering issue (T-801) - phase-add: new note on AddTaskToPhase dropping extended fields (T-836) - testing: new note on shared command state leakage (T-857) and current known test failures (T-856, T-859) --- docs/agent-notes/batch-command.md | 9 +++++++++ docs/agent-notes/config-discovery.md | 1 + docs/agent-notes/dependencies.md | 2 ++ docs/agent-notes/phase-add.md | 7 +++++++ docs/agent-notes/testing.md | 10 ++++++++++ 5 files changed, 29 insertions(+) create mode 100644 docs/agent-notes/phase-add.md create mode 100644 docs/agent-notes/testing.md diff --git a/docs/agent-notes/batch-command.md b/docs/agent-notes/batch-command.md index fcee1068..81e44752 100644 --- a/docs/agent-notes/batch-command.md +++ b/docs/agent-notes/batch-command.md @@ -34,6 +34,15 @@ The block-based approach matters because users may intentionally interleave remo 3. When adding new validatable fields to `Operation`, update `validateOperation` to include content validation for both add and update cases The `validateDetailsAndReferences` helper in batch.go centralises detail/reference content validation for use in `validateOperation`. + +## Phase Marker Adjustment + +Phase-aware batch adds use `addTaskWithPhaseMarkers` in `internal/task/batch.go`. When inserting a top-level task into an earlier phase, the immediate next phase marker must move to the new task and every later marker must be shifted to account for renumbered top-level tasks. T-787 tracks a bug where the batch path only updates the immediate next marker, which can render later phase headers before the wrong task in files with three or more phases. + ## Testing Gotcha: Cobra Flag State Cobra flag values and `Changed` bits persist across `Execute()` calls in the same process. This matters in tests where multiple tests share `rootCmd`. The `resetBatchFlags()` helper in `batch_test.go` resets `batchInput` and the flag's `Changed` bit. Call it at the start of any batch test that does NOT use `--input` to avoid false positives from stale state. + +## Known Gap: Phase Detection for Plain Operations + +`cmd/batch.go` currently routes to `ExecuteBatchWithPhases` only when an operation has a `phase` field or type `add-phase`. If the target file already has phase markers but the batch contains only plain operations such as `remove`, it uses `ExecuteBatch` and then `WriteFile`, which reuses original phase markers without adjusting them for removed top-level tasks. T-820 tracks this; the command should detect existing phase markers before choosing the execution path. diff --git a/docs/agent-notes/config-discovery.md b/docs/agent-notes/config-discovery.md index 279fe7c0..3b967cac 100644 --- a/docs/agent-notes/config-discovery.md +++ b/docs/agent-notes/config-discovery.md @@ -28,6 +28,7 @@ Uses `exec.CommandContext` with a timeout context to prevent hangs. Key details: - The timeout test (`TestGetCurrentBranchTimeout`) uses a 200ms timeout with a mock git that sleeps 10s, verifying the function returns within the computed bound - `TestDiscoverFileFromBranch` tests mock both `getCurrentBranch` and `getRepoRoot` (returning the temp dir) since the temp dirs are not real git repos - `TestDiscoverFileFromBranchSubdirectory` initializes a real git repo and tests from a subdirectory without mocking `getRepoRoot` +- Home config tests should isolate `HOME` with a temp directory. T-812 tracks `TestConfigPrecedence` writing to the developer's real `~/.config/rune/config.yml`, which breaks restricted sandboxes and can mutate real local config. ## Gotchas diff --git a/docs/agent-notes/dependencies.md b/docs/agent-notes/dependencies.md index de64a6af..4fb047bf 100644 --- a/docs/agent-notes/dependencies.md +++ b/docs/agent-notes/dependencies.md @@ -18,6 +18,8 @@ The dependents map registers tasks even if they lack a StableID (using hierarchi - Returns warnings listing how many tasks had references cleaned up. - After cleanup, delegates to `removeTaskRecursive` + `RenumberTasks`. +Known gap: the user-facing `remove` command goes through `RemoveTaskWithPhases`, and batch remove operations call `RemoveTask` directly. Those paths currently bypass `RemoveTaskWithDependents`, so removing a blocker can leave stale `BlockedBy` references behind. The `remove` command also keeps a `*Task` pointer for output before mutating the slice; deleting an earlier task can make the success message report the shifted task's title instead of the removed task's title. The title-output issue is tracked as T-801. + ## StableID Assignment Tasks get StableIDs in two ways: diff --git a/docs/agent-notes/phase-add.md b/docs/agent-notes/phase-add.md new file mode 100644 index 00000000..7c516a2b --- /dev/null +++ b/docs/agent-notes/phase-add.md @@ -0,0 +1,7 @@ +# Phase-Aware Add + +`cmd/add.go` uses a separate path when `--phase` is provided: it calls `task.AddTaskToPhase(filename, addParent, addTitle, addPhase)` instead of building `AddOptions` and calling the normal extended add path. + +`AddTaskToPhase` in `internal/task/operations.go` currently accepts only file path, parent ID, title, and phase name. It preserves phase markers and inserts top-level tasks at the end of the target phase, but it does not know about stream, owner, blocked-by, requirements, or requirements-file. + +T-836 tracks the resulting bug: `rune add --phase ... --stream/--owner/--blocked-by/--requirements` silently creates the phased task while dropping those extended fields. diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.md new file mode 100644 index 00000000..7f9e1654 --- /dev/null +++ b/docs/agent-notes/testing.md @@ -0,0 +1,10 @@ +# Testing + +## Shared Command State + +Command tests often call `runX` helpers directly and share package-level globals such as `format` and `dryRun`. Tests that expect default table output must set and restore `format` explicitly, otherwise earlier JSON/markdown tests can leak state. T-857 tracks the current `TestRunCompleteDryRun` failure. + +## Current Known Test Failures + +- T-856: `internal/task/phase_test.go` has a stale two-argument call to `RenderMarkdownWithPhases`; the production function now requires a `phaseSource *TaskList`. +- T-859: `cmd.TestRenumberPreservesAllPhaseMarkers` shows `runRenumber` misplacing phase markers for files with gapped/non-sequential top-level IDs. `ExtractPhaseMarkers` already returns sequential IDs, but `cmd/renumber.go` still maps markers through raw file task IDs.