Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/homebrew-tap.yml
Original file line number Diff line number Diff line change
@@ -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."
17 changes: 16 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion crates/lash-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
60 changes: 60 additions & 0 deletions devlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 10 additions & 1 deletion dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading