From 4f045b21c99b9298d49cb3f3aeab04ccdbae83cc Mon Sep 17 00:00:00 2001 From: Frank O'Hara Date: Sat, 8 Aug 2026 10:11:52 -0600 Subject: [PATCH 1/4] Publish a Homebrew formula on release Enables cargo-dist's homebrew installer targeting fixture-dev/homebrew-tap, so macOS and Linux users can brew install fixture-dev/tap/lash. --- .github/workflows/release.yml | 49 ++++++++++++++++++++++++++++++++++- CHANGELOG.md | 6 +++++ CONTRIBUTING.md | 17 ++++++++++++ Cargo.toml | 1 + README.md | 15 ++++++++++- crates/lash-cli/Cargo.toml | 3 ++- devlog.md | 30 +++++++++++++++++++++ dist-workspace.toml | 7 ++++- 8 files changed, 124 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f48e57..a77ec2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -279,14 +279,61 @@ jobs: gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + repository: "fixture-dev/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch homebrew formulae + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + announce: needs: - plan - host + - publish-homebrew-formula # 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.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.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..f76d182 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -791,6 +791,23 @@ 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. +#### 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-homebrew-formula` +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. + ### Security **If you discover a security vulnerability**: 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..0c944fa 100644 --- a/devlog.md +++ b/devlog.md @@ -2326,3 +2326,33 @@ 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. diff --git a/dist-workspace.toml b/dist-workspace.toml index 89cef8b..d9cc140 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -8,7 +8,12 @@ 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 jobs to run in CI +publish-jobs = ["homebrew"] # 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 From 9755193143f42a7a67a25ce9004c772c76d19a5d Mon Sep 17 00:00:00 2001 From: Frank O'Hara Date: Sat, 8 Aug 2026 10:31:07 -0600 Subject: [PATCH 2/4] Work around cargo-dist#29 in Homebrew publish job dist 0.28.5+ generates persist-credentials: false on the tap checkout, but the job's final git push needs them. Flip that one line and document it. --- .github/workflows/release.yml | 8 +++++++- CONTRIBUTING.md | 22 +++++++++++++++++++++- devlog.md | 17 +++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a77ec2f..5d79188 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -293,7 +293,13 @@ jobs: steps: - uses: actions/checkout@v4 with: - persist-credentials: false + # LOCAL PATCH — not produced by `dist generate`. + # Upstream astral-sh/cargo-dist#29: dist 0.28.5+ sets this to false, + # but the "Commit formula files" step below ends in a bare `git push` + # that needs these credentials, so the job dies with + # "could not read Username for 'https://github.com/'". + # Re-apply this after every `dist generate` until #29 is fixed. + persist-credentials: true repository: "fixture-dev/homebrew-tap" token: ${{ secrets.HOMEBREW_TAP_TOKEN }} # So we have access to the formula diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f76d182..bee899f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -789,7 +789,8 @@ 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. +regenerate the workflow and commit the result — do not edit `release.yml` by hand, +with one documented exception (see the Homebrew tap section below). #### Homebrew tap @@ -808,6 +809,25 @@ 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. +**One hand-patch lives in `release.yml`.** dist 0.28.5+ generates +`persist-credentials: false` on the tap checkout, but the job ends in a bare +`git push` that needs those credentials, so the job fails with +`could not read Username for 'https://github.com/'` +([astral-sh/cargo-dist#29](https://github.com/astral-sh/cargo-dist/issues/29), +open as of 0.28.7). We flip that single line to `true`, marked with a +`LOCAL PATCH` comment in the file. + +`dist generate` silently reverts it. After any regeneration or dist upgrade, +re-apply it and confirm with: + +```bash +grep -A3 'fixture-dev/homebrew-tap' .github/workflows/release.yml +``` + +The failure mode is loud rather than silent — if the patch is lost, the publish +job fails and blocks `announce`, so you will not ship a release with a stale +formula. Drop the patch once #29 is fixed upstream. + ### Security **If you discover a security vulnerability**: diff --git a/devlog.md b/devlog.md index 0c944fa..e3ace30 100644 --- a/devlog.md +++ b/devlog.md @@ -2356,3 +2356,20 @@ 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. Options were: pin dist back to +0.28.3 (last known-good, but reverts four patch releases across the whole +pipeline), own the publish job via a custom reusable workflow, or hand-patch the +one line. Took the hand-patch — the revert risk is loud rather than silent, +since losing it fails the job and blocks `announce` instead of shipping a stale +formula. Documented in CONTRIBUTING with a re-check grep. + +**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. From cc9bdf95b35a7de127dba6cf7a37ab93a18f6dfb Mon Sep 17 00:00:00 2001 From: Frank O'Hara Date: Sat, 8 Aug 2026 10:44:05 -0600 Subject: [PATCH 3/4] Own the Homebrew publish job instead of patching release.yml dist plan rejects hand-edits to the generated workflow, and allow-dirty would disable that drift check entirely. Publish via ./.github/workflows/homebrew-tap.yml. --- .github/workflows/homebrew-tap.yml | 99 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 60 ++++-------------- CONTRIBUTING.md | 43 +++++++------ devlog.md | 25 ++++++-- dist-workspace.toml | 8 ++- 5 files changed, 160 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/homebrew-tap.yml diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml new file mode 100644 index 0000000..ea38f60 --- /dev/null +++ b/.github/workflows/homebrew-tap.yml @@ -0,0 +1,99 @@ +# 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 + +permissions: + contents: read + +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 5d79188..2418f3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -279,67 +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/* - publish-homebrew-formula: + custom-homebrew-tap: needs: - plan - host - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PLAN: ${{ needs.plan.outputs.val }} - GITHUB_USER: "axo bot" - GITHUB_EMAIL: "admin+bot@axo.dev" if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} - steps: - - uses: actions/checkout@v4 - with: - # LOCAL PATCH — not produced by `dist generate`. - # Upstream astral-sh/cargo-dist#29: dist 0.28.5+ sets this to false, - # but the "Commit formula files" step below ends in a bare `git push` - # that needs these credentials, so the job dies with - # "could not read Username for 'https://github.com/'". - # Re-apply this after every `dist generate` until #29 is fixed. - persist-credentials: true - repository: "fixture-dev/homebrew-tap" - token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - # So we have access to the formula - - name: Fetch homebrew formulae - uses: actions/download-artifact@v4 - with: - pattern: artifacts-* - path: Formula/ - merge-multiple: true - # This is extra complex because you can make your Formula name not match your app name - # so we need to find releases with a *.rb file, and publish with that filename. - - name: Commit formula files - run: | - git config --global user.name "${GITHUB_USER}" - git config --global user.email "${GITHUB_EMAIL}" - - for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do - filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) - name=$(echo "$filename" | sed "s/\.rb$//") - version=$(echo "$release" | jq .app_version --raw-output) - - export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" - brew update - # We avoid reformatting user-provided data such as the app description and homepage. - brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true - - git add "Formula/${filename}" - git commit -m "${name} ${version}" - done - git push + 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 - - publish-homebrew-formula + - 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' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.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/CONTRIBUTING.md b/CONTRIBUTING.md index bee899f..acfbf80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -789,16 +789,17 @@ 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, -with one documented exception (see the Homebrew tap section below). +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-homebrew-formula` -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. +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 @@ -809,24 +810,30 @@ 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. -**One hand-patch lives in `release.yml`.** dist 0.28.5+ generates -`persist-credentials: false` on the tap checkout, but the job ends in a bare -`git push` that needs those credentials, so the job fails with +**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), -open as of 0.28.7). We flip that single line to `true`, marked with a -`LOCAL PATCH` comment in the file. +still open as of 0.28.7 and unfixed on upstream `main`). -`dist generate` silently reverts it. After any regeneration or dist upgrade, -re-apply it and confirm with: +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. -```bash -grep -A3 'fixture-dev/homebrew-tap' .github/workflows/release.yml -``` +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. -The failure mode is loud rather than silent — if the patch is lost, the publish -job fails and blocks `announce`, so you will not ship a release with a stale -formula. Drop the patch once #29 is fixed upstream. +If #29 is ever fixed, delete `homebrew-tap.yml` and set +`publish-jobs = ["homebrew"]`. ### Security diff --git a/devlog.md b/devlog.md index e3ace30..266f0d2 100644 --- a/devlog.md +++ b/devlog.md @@ -2363,12 +2363,25 @@ Note the macos-14 runner pins from commit 23c7e8f are not literals in `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. Options were: pin dist back to -0.28.3 (last known-good, but reverts four patch releases across the whole -pipeline), own the publish job via a custom reusable workflow, or hand-patch the -one line. Took the hand-patch — the revert risk is loud rather than silent, -since losing it fails the job and blocks `announce` instead of shipping a stale -formula. Documented in CONTRIBUTING with a re-check grep. +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 diff --git a/dist-workspace.toml b/dist-workspace.toml index d9cc140..1440005 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -12,8 +12,12 @@ 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 jobs to run in CI -publish-jobs = ["homebrew"] +# 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 From 50a6e7ba62cd4313d1ddaeef5d41268ad0930b25 Mon Sep 17 00:00:00 2001 From: Frank O'Hara Date: Sat, 8 Aug 2026 10:47:42 -0600 Subject: [PATCH 4/4] Drop permissions block from Homebrew publish workflow dist's caller job grants only id-token and packages, so requesting contents: read exceeded the grant and failed the run at startup. --- .github/workflows/homebrew-tap.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index ea38f60..bdafafe 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -25,8 +25,15 @@ on: required: true type: string -permissions: - contents: read +# 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: