diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml new file mode 100644 index 0000000..bdafafe --- /dev/null +++ b/.github/workflows/homebrew-tap.yml @@ -0,0 +1,106 @@ +# Publishes the dist-generated Homebrew formula to the tap repo. +# +# This replaces dist's built-in `publish-homebrew-formula` job, which is broken +# upstream: it checks out the tap with `persist-credentials: false` and then +# ends in a bare `git push` that needs those credentials, so it fails with +# "could not read Username for 'https://github.com/'" +# (astral-sh/cargo-dist#29, open as of dist 0.28.7). +# +# Working around that in the generated release.yml would mean hand-patching a +# generated file and setting `allow-dirty = ["ci"]`, which switches off dist's +# check that release.yml matches dist-workspace.toml. Owning this one job keeps +# release.yml fully generated and that check enabled. +# +# dist calls this via `publish-jobs = ["./homebrew-tap"]`, passing the release +# plan and inheriting secrets. Drop it and revert to the built-in job if #29 is +# ever fixed. + +name: Publish Homebrew formula + +on: + workflow_call: + inputs: + plan: + description: "The dist release plan, as JSON" + required: true + type: string + +# No `permissions:` block here on purpose. dist's generated caller job declares +# its own set ("id-token": write, "packages": write), and for a reusable workflow +# that set is the complete grant — anything unlisted, including `contents`, is +# `none`. Declaring `contents: read` here would request more than the caller +# grants, which GitHub rejects before any job starts ("startup_failure"). +# +# Nothing below needs `contents` on this repo anyway: the tap checkout +# authenticates with HOMEBREW_TAP_TOKEN, and download-artifact reads same-run +# artifacts via the runtime token. + +jobs: + publish-homebrew-formula: + runs-on: "ubuntu-22.04" + env: + PLAN: ${{ inputs.plan }} + steps: + - name: Check out the tap + uses: actions/checkout@v4 + with: + repository: "fixture-dev/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # Deliberately persisted — the push step below relies on them. + persist-credentials: true + + # Pulls every release artifact, but only the .rb files are ever staged. + - name: Fetch generated formulae + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + + # No `brew style --fix` here, unlike dist's built-in job: the formula is + # machine-generated and consistent, so the only effect would be to pay for + # a `brew update` on every release. + - name: Commit and push formulae + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + found=0 + committed=0 + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + found=$((found + 1)) + filename=$(echo "$release" | jq --raw-output '.artifacts[] | select(endswith(".rb"))') + version=$(echo "$release" | jq --raw-output '.app_version') + name="${filename%.rb}" + + if [ ! -f "Formula/${filename}" ]; then + echo "::error::${filename} is in the release plan but was not among the build artifacts" + exit 1 + fi + + git add "Formula/${filename}" + + if git diff --cached --quiet -- "Formula/${filename}"; then + echo "Formula/${filename} is already at ${version}; nothing to commit" + continue + fi + + git commit -m "${name} ${version}" + committed=$((committed + 1)) + done + + if [ "$found" -eq 0 ]; then + echo "::error::No formula found in the release plan. Is \"homebrew\" still in \`installers\` in dist-workspace.toml?" + exit 1 + fi + + if [ "$committed" -eq 0 ]; then + echo "Tap is already up to date; nothing to push." + exit 0 + fi + + git push + echo "Pushed ${committed} formula update(s) to the tap." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f48e57..2418f3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -279,14 +279,29 @@ jobs: gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + custom-homebrew-tap: + needs: + - plan + - host + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + uses: ./.github/workflows/homebrew-tap.yml + with: + plan: ${{ needs.plan.outputs.val }} + secrets: inherit + # publish jobs get escalated permissions + permissions: + "id-token": "write" + "packages": "write" + announce: needs: - plan - host + - custom-homebrew-tap # use "always() && ..." to allow us to wait for all publish jobs while # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! - if: ${{ always() && needs.host.result == 'success' }} + if: ${{ always() && needs.host.result == 'success' && (needs.custom-homebrew-tap.result == 'skipped' || needs.custom-homebrew-tap.result == 'success') }} runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a818a3..37e6419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ While the major version is 0, minor version bumps may contain breaking changes. ## [Unreleased] +### Added + +- Homebrew installs via `brew install fixture-dev/tap/lash`. Each release + publishes a generated formula to the `fixture-dev/homebrew-tap` repo, covering + macOS and Linux on both x86_64 and arm64. + ## [0.1.0] - 2026-08-07 Initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbd8136..acfbf80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -790,6 +790,50 @@ To cut a release: After changing dist settings in `dist-workspace.toml`, run `dist generate` to regenerate the workflow and commit the result — do not edit `release.yml` by hand. +The `plan` job runs `dist plan`, which fails the build if `release.yml` has +drifted from `dist-workspace.toml`, so a hand-edit is caught on the next PR. + +#### Homebrew tap + +Each release also publishes a formula to the +[fixture-dev/homebrew-tap](https://github.com/fixture-dev/homebrew-tap) repo, +which backs `brew install fixture-dev/tap/lash`. The publish job commits +`Formula/lash.rb` to that repo using a `HOMEBREW_TAP_TOKEN` secret — a personal +access token with `contents: write` on the tap repo only. + +The formula is generated, not maintained by hand: it points at the prebuilt +release tarballs for both macOS architectures and both Linux architectures, so +`brew install` downloads a binary rather than compiling. Nothing needs doing per +release. + +If a release succeeds but the formula does not update, `HOMEBREW_TAP_TOKEN` has +most likely expired — reissue it and re-run the failed job. The publish job is +skipped for prereleases unless `publish-prereleases` is enabled. + +**We own the publish job.** dist's built-in `publish-homebrew-formula` is broken +in 0.28.5+: it checks out the tap with `persist-credentials: false` and then ends +in a bare `git push` that needs those credentials, failing with +`could not read Username for 'https://github.com/'` +([astral-sh/cargo-dist#29](https://github.com/astral-sh/cargo-dist/issues/29), +still open as of 0.28.7 and unfixed on upstream `main`). + +So `dist-workspace.toml` sets `publish-jobs = ["./homebrew-tap"]`, and dist +generates a caller job that invokes our own +[`.github/workflows/homebrew-tap.yml`](.github/workflows/homebrew-tap.yml) with +the release plan and `secrets: inherit`. `release.yml` stays fully generated, so +dist's drift check stays enabled. + +The alternative — hand-patching the generated file — requires +`allow-dirty = ["ci"]`, which disables that drift check for the whole release +workflow and lets future config changes silently fail to reach `release.yml`. +That is why it was rejected. + +Our version also skips `brew style --fix` (the formula is generated, so the only +effect is paying for a `brew update` each release) and is safe to re-run: an +unchanged formula is a no-op instead of a "nothing to commit" failure. + +If #29 is ever fixed, delete `homebrew-tap.yml` and set +`publish-jobs = ["homebrew"]`. ### Security diff --git a/Cargo.toml b/Cargo.toml index 5511e06..0649333 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ edition = "2021" rust-version = "1.75" license = "Apache-2.0" repository = "https://github.com/fixture-dev/lash" +homepage = "https://github.com/fixture-dev/lash" [workspace.dependencies] # Internal crates diff --git a/README.md b/README.md index 627c2ff..10675f3 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,15 @@ cargo clippy --workspace -- -D warnings ## Installation -### Prebuilt binaries (recommended) +### Homebrew (macOS / Linux) + +```bash +brew install fixture-dev/tap/lash +``` + +Upgrade with `brew upgrade lash`, remove with `brew uninstall lash`. + +### Prebuilt binaries Download a prebuilt binary for Linux, macOS, or Windows from the [latest release](https://github.com/fixture-dev/lash/releases/latest), or use @@ -92,6 +100,11 @@ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/fixture-dev/lash/releas powershell -ExecutionPolicy Bypass -c "irm https://github.com/fixture-dev/lash/releases/latest/download/lash-installer.ps1 | iex" ``` +> **Pick one method.** The installer script and `cargo install` place `lash` in +> `~/.cargo/bin`, while Homebrew uses its own prefix. Installing both leaves two +> binaries on your PATH, and whichever comes first wins — which makes version +> mismatches confusing. Run `which -a lash` if output looks stale. + ### From source Install Lash globally using the install script: diff --git a/crates/lash-cli/Cargo.toml b/crates/lash-cli/Cargo.toml index 2a8dc7e..add11c6 100644 --- a/crates/lash-cli/Cargo.toml +++ b/crates/lash-cli/Cargo.toml @@ -6,7 +6,8 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Command-line interface for Lash" +homepage.workspace = true +description = "Minimalist, Markdown-native task tracker for devs and agents" [[bin]] name = "lash" diff --git a/devlog.md b/devlog.md index 5a915ba..266f0d2 100644 --- a/devlog.md +++ b/devlog.md @@ -2326,3 +2326,63 @@ output — Windows CI only, since Linux/macOS default to 8 MiB and release builds have small frames. Diagnosed by adding child status/stdout/stderr to the subprocess test assertions (commit a2b2caf). Fixed by reserving an 8 MiB stack for Windows targets in `.cargo/config.toml`. + +--- + +## Homebrew tap installer (Path A) + +Added `brew install fixture-dev/tap/lash` by turning on cargo-dist's Homebrew +installer rather than hand-maintaining a formula. Config-only change in +`dist-workspace.toml`: `"homebrew"` added to `installers`, plus `tap` and +`publish-jobs`. `dist generate` added a `publish-homebrew-formula` job that +commits the formula to the tap repo with a `HOMEBREW_TAP_TOKEN` secret, and +wired `announce` to wait on it. + +`dist` warned that the Homebrew installer needs a `homepage`, which the +workspace never set — added to `[workspace.package]` and inherited by +`lash-cli`. Also replaced the self-referential crate description ("Command-line +interface for Lash") since it becomes the formula's `desc` and shows up in +`brew info`. + +The generated formula downloads the prebuilt release tarballs for both macOS +arches and both Linux arches, so installs are a download rather than a source +build, and Linuxbrew works for free. Verified locally with +`dist build --artifacts=global` and `ruby -c` on the emitted `lash.rb`. + +Homebrew-core (bare `brew install lash`) was considered and deferred: it gates +on notability, rejects binary-only formulae so the generated file would not +transfer, and `lash` is a contested name in a global namespace. + +Note the macos-14 runner pins from commit 23c7e8f are not literals in +`release.yml` — the build matrix is computed at runtime by the `plan` job from +`dist-workspace.toml`, so regenerating the workflow does not disturb them. + +### Two blockers found before the first tagged release + +**1. Upstream dist bug (astral-sh/cargo-dist#29).** dist 0.28.5+ emits +`persist-credentials: false` on the tap checkout (PR #18), but the publish job +ends in a bare `git push` that depends on those credentials, so it dies with +`could not read Username for 'https://github.com/'`. Still unfixed on upstream +main, so 0.30.1-prerelease is affected too. + +First attempt was a one-line hand-patch of the generated `release.yml`. CI +rejected it: the release workflow's own `plan` job runs `dist plan`, which +verifies `release.yml` matches `dist-workspace.toml` and failed with "has out of +date contents and needs to be regenerated" (PR #30, run 31267186110). Keeping the +patch would have required `allow-dirty = ["ci"]`, which disables that drift check +for the entire release workflow — future config changes would silently fail to +reach `release.yml`. Worse footgun than the bug being worked around. + +Settled on owning the job instead: `publish-jobs = ["./homebrew-tap"]` makes dist +generate a caller that invokes our `.github/workflows/homebrew-tap.yml` with the +plan and `secrets: inherit`. `release.yml` stays fully generated and the drift +check stays on. Ours also drops `brew style --fix` (pure cost on a generated +formula) and is re-run safe — an unchanged formula is a no-op rather than a +"nothing to commit" failure, which the built-in job gets wrong. Verified the +publish logic locally against a real `dist plan` JSON across four cases: fresh +formula commits with the right message and stages only the `.rb`; unchanged +formula no-ops; missing artifact and missing-formula-in-plan both fail loudly. + +**2. Empty tap repo.** `actions/checkout` cannot check out a repository with no +commits (actions/checkout#1477, #746), so the tap needs at least one commit +before the first release runs. diff --git a/dist-workspace.toml b/dist-workspace.toml index 89cef8b..1440005 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -8,7 +8,16 @@ cargo-dist-version = "0.28.7" # CI backends to support ci = "github" # The installers to generate for each app -installers = ["shell", "powershell"] +installers = ["shell", "powershell", "homebrew"] +# Homebrew tap that receives the generated formula on each release. Publishing +# needs a HOMEBREW_TAP_TOKEN repo secret with contents:write on the tap repo. +tap = "fixture-dev/homebrew-tap" +# Publish via our own .github/workflows/homebrew-tap.yml rather than dist's +# built-in homebrew job, which is broken upstream (astral-sh/cargo-dist#29): +# it sets persist-credentials: false on the tap checkout but then relies on +# those credentials for its final `git push`. Owning the job keeps release.yml +# fully generated, so dist's up-to-date check stays enabled. +publish-jobs = ["./homebrew-tap"] # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] # Path that installers should place binaries in