Skip to content

feat(ci): Publish a main and an experimental Homebrew channel - #229

Draft
ananos wants to merge 1 commit into
mainfrom
feat/homebrew-channels
Draft

ananos wants to merge 1 commit into
mainfrom
feat/homebrew-channels

Conversation

@ananos

@ananos ananos commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

There was no way to install a build that is not a release. Trying a feature
before it reached a tag meant building from source, which is a different ask
from brew install, and it is the difference between somebody testing a branch
and somebody saying they will.

Two casks, kept separate rather than one channel with two sources. brig@main
follows the tip of main and is rebuilt on every merge, so brew upgrade
follows what is coming. brig@experimental moves only when one of us promotes
a ref to it, which is how an unmerged branch reaches a tester. Folding them
into one would mean a merge to main silently replacing the branch build a
tester was halfway through checking.

Each pulls the matching hull, from brig-sh/hull#75, because a feature usually
spans both. The port publishing in #228 is exactly that case: it needs a hull
change, so no brig-only channel could have carried it.

Related issues

Pairs with brig-sh/hull#75.

Changes

  • .github/workflows/channel.yml: builds and publishes a channel, on push to
    main and on workflow_dispatch with a ref.
  • Each channel publishes to a moving tag -- channel-main,
    channel-experimental -- whose assets are replaced in place, so a year of
    merges leaves one release rather than one per commit. Neither matches v*,
    so neither starts the release workflow and skip_upload: auto on the stable
    cask is untouched.
  • script/render-cask.py renders the cask. goreleaser's cask pipe renders for
    the version it is releasing, and a channel has no version of its own. It
    takes the archive list explicitly, because brig ships four platforms and hull
    ships one, and it emits what brew style wants, so no --fix pass.
  • snapshot.version_template honours CHANNEL_VERSION when set. Homebrew
    decides there is something to upgrade to from the version string alone, so
    two builds of a moving channel must not share one. A local --snapshot is
    unchanged.
  • docs/install.md and docs/releasing.md.

Checklist

  • make all passes (vet, test, build)
  • script/smoke.sh passes
  • I have added or updated tests covering the change -- a release
    workflow has no unit test here; what could be run locally is below
  • I have run go test ./... -race -- no Go code changed
  • I have exercised the change against a real runtime -- nothing in the
    run, exec or credential path changed
  • I have updated the affected docs (see docs/README.md for the map)

What was checked locally, since the workflow itself cannot be. goreleaser release --snapshot with CHANNEL_VERSION set built all four archives under
the channel version, and without it still produced the old 0.2.1-snapshot
spelling, so a local snapshot is unaffected. The renderer was run against those
real archives: brew style reported no offences, and the sha256 in the cask
matches shasum -a 256 of the built tarball. actionlint is clean on the new
workflow.

What I could not check

The workflow has never executed: it needs the tap token and a merge to main
to fire. The step most worth watching on the first run is the tap push, which
uses HOMEBREW_TAP_GITHUB_TOKEN directly rather than minting an App token the
way the release does. That was deliberate -- a channel that cannot reach the
tap should fail loudly, and there is no release beside it to quietly go green
-- but it does mean the secret has to exist for the channel to work at all.

Two things worth your opinion

Naming. These are brig@main and brig@experimental. You offered
"current", "main" or "rolling" for the first; I took @main because it names
the thing it follows and matches the branch, so there is nothing to look up.
Easy to change now, annoying once someone has it installed.

The tap gets a push, not a pull request. The stable cask opens a PR and a
maintainer merges it, which is right: it is what people install. A channel cask
is regenerated on every merge to main, and a PR per merge is noise nobody would
read, so this pushes. If you would rather have the review even here, the change
is small, but expect a PR per merge.

@ananos
ananos marked this pull request as ready for review September 15, 2026 22:47
@ananos

ananos commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

@claude-fleet review

@claude-fleet

claude-fleet Bot commented Sep 15, 2026

Copy link
Copy Markdown

Review task queued.

Task: cd0209f1-64db-4ba8-b46b-ba8c3305ea04
Commit: b0f8d10
Model: executor default (effort high)
Executor: rp1
Status: done. Review posted.

@claude-fleet claude-fleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment review. CI/docs only, no Go touched. Reviewed at b0f8d10 against merge-base ef4aa8b: read channel.yml, .goreleaser.yaml, render-cask.py, and both docs; ran the renderer against dummy archives to confirm output shape.

What I verified holds

  • Archive name coupling lines up. goreleaser's archive name_template (brig-{{.Version}}-{{.Os}}-{{.Arch}}) and the --archive brig-$V-... names passed to render-cask.py both resolve to the same CHANNEL_VERSION string, so the cask's url/sha256 point at the assets that actually get uploaded. In --snapshot, .Version is the rendered snapshot.version_template, and that template returns CHANNEL_VERSION verbatim via envOrDefault. A local --snapshot with the env unset keeps {{ incpatch .Version }}-snapshot, so the existing snapshot path is unchanged.
  • Moving-tag / no-release-trigger claim. channel-main and channel-experimental don't match v*, so release.yml isn't started and skip_upload: auto on the stable cask is untouched.
  • Upgrade-on-any-difference premise. Homebrew treats a cask as outdated on version inequality, not greater-than, so two same-day builds differing only in short SHA still upgrade. The date/SHA scheme is sufficient.
  • Renderer output. conflicts_with lists stable + the other channel (not itself); depends_on emits hull@channel + cosign; completions reordered bash/fish/zsh; on_macos/on_linux in order. Renders cleanly.
  • fetch-depth: 0 is needed for goreleaser tag history and for git rev-parse --short HEAD in the version step. VERSION is 0.2.0, read correctly.

Gates

No Go changed; no unit surface. I did not run actionlint, goreleaser, or brew style (not installed here) — see not_checked. Author reports all three clean.

Findings

One should-fix robustness issue on concurrent cross-channel publishes; details inline. Smaller: the goreleaser before hook re-runs go mod tidy + go test ./... on every merge to main (already covered by ci.yml), adding minutes per merge — intentional-looking, but worth a note.

Happy to approve once you've weighed the tap-push race; the rest is solid.

Not checked:

  • brew style / brew audit on the rendered cask, and Homebrew's acceptance of @ cask tokens and nested on_macos/on_linux + on_arm/on_intel stanzas on target platforms (no brew available; author reports brew style clean)
  • actual goreleaser release --snapshot run with CHANNEL_VERSION set, and that emitted archive filenames equal CHANNEL_VERSION (author reports verified; confirmed only by template inspection)
  • actionlint on the new workflow (author reports clean)
  • first live run: HOMEBREW_TAP_GITHUB_TOKEN presence/permissions and the tap push against the real homebrew-brig repo
  • hull-side casks (hull@main / hull@experimental) that the depends_on references, from brig-sh/hull#75

Task cd0209f1-64db-4ba8-b46b-ba8c3305ea04 on rp1, model executor default, effort high.

Comment thread .github/workflows/channel.yml Outdated
git add "Casks/brig@$CHANNEL.rb"
git diff --cached --quiet && { echo "the cask is unchanged"; exit 0; }
git commit -m "chore(cask): brig@$CHANNEL $V"
git push

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should_fix: Concurrent main + experimental publishes can race on the tap push. The concurrency group is scoped per channel (channel-main vs channel-experimental, line 36), so a push-to-main run and a workflow_dispatch (experimental) run execute at the same time. Both git clone --depth 1 the tap's default branch (line 159) and git push here with no fetch/rebase/retry. If the two tap-update steps overlap, the first push wins and the second is rejected non-fast-forward; that job fails and leaves its channel's cask stale, with an error that points at git internals rather than the cause. The stable cask sidesteps this by opening PRs. Fix: put the tap write under a single shared concurrency group, or git pull --rebase origin <branch> and retry before pushing.

@asapranidis

Copy link
Copy Markdown
Member

This is great improvement, can I ask to hold a bit for this PR until we sort out #230 which I feel will simplify this PR as well by removing the need to encode the version and that when we have the snapshot it would report the proper version including the build commit.

@ananos
ananos force-pushed the feat/homebrew-channels branch from ff802cd to 8a98351 Compare September 16, 2026 11:12
@ananos

ananos commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Fixed. The tap push now rebases and retries rather than pushing once:

for attempt in 1 2 3 4 5; do
  git push && exit 0
  echo "the tap moved under this push; rebasing (attempt $attempt)"
  git pull --rebase --quiet origin main
  sleep $((attempt * 2))
done

A shared concurrency group would not have covered it: brig and hull each publish two channels into the one tap, and a concurrency group is per workflow -- these are four workflows in two repositories, so there is no group they can all join. Rebase is the thing that works across all four, and they touch different files so it always applies. brig-sh/hull#75 got the same fix.

The same push also carries the renderer fixes from the hull review: the DO NOT EDIT banner now names the path the script was invoked by, so it resolves in both repositories, and a cask rendered with no depends_on no longer trips brew style on stanza grouping.

@ananos

ananos commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Held, and that is a fair ask -- #230 is the better-shaped fix for half of this, so waiting costs nothing. I have put this and brig-sh/hull#75 back to draft so nobody merges them past you.

One thing worth separating before you design #230, because I think it removes less of this PR than it looks like it does.

The version string here does two jobs, and they come apart:

Diagnostics -- what commit is this binary. #230 removes this entirely, and better than encoding it in a token: vcs.revision is already in the binary, so brig version can report the commit whether or not anything stamped it. A channel build would then identify itself without the version string carrying the SHA at all. Agreed, and this is the half worth waiting for.

Homebrew upgrade detection -- is there something newer. This one #230 does not reach. brew upgrade compares the version stanza in the cask against the installed one; it never runs the binary. Two channel builds sharing a version string are the same cask to Homebrew, so brew upgrade offers nothing and a tester stays on last week's build with no sign of it. goreleaser also names the archive after the version, so two builds would collide on the asset name in the channel release.

So the string can get shorter but not go away. Once #230 lands I would expect this to drop to something like 0.2.0-main.<shortsha> -- unique per build, which is all Homebrew needs -- and stop pretending to be a source of truth about the commit, because brig version would be that. The date is in there today only because it reads better in brew info; it can go.

If #230 ends up making snapshot.version_template unnecessary in some way I have not seen, even better -- tell me and I will rework this on top rather than defend what is here.

Unrelated, and already pushed: the tap-push race claude-fleet raised is fixed (rebase and retry, since a concurrency group cannot span two repositories), as are the renderer nits from the hull review. So this is waiting only on #230, not on review comments.

#228 and brig-sh/hull#74 are the port publishing itself and do not depend on any of this -- they are green and not held.

@ananos
ananos marked this pull request as draft September 16, 2026 13:20

@asapranidis asapranidis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#230 is fixed on main by #231. VERSION is removed. brig version reports the version the Go toolchain embeds: the tag on a tagged commit, otherwise a pseudo-version (v0.2.1-0.<commit time>-<commit>), with +dirty on a modified tree, plus the commit.

Steps to update this PR

  1. Rebase onto main; do not merge main in. Expected conflicts:
    • .goreleaser.yaml: main removed -X main.version={{.Version}} from both builds. Take main's ldflags; keep the snapshot.version_template change.
    • docs/releasing.md: main rewrote the start of the file. Keep the new "The prerelease channels" section.
  2. Replace "Compose the version" with the version the binary reports, after actions/setup-go (inline, L72).
  3. Check that go mod tidy leaves the tree clean, and skip goreleaser's before hooks (inline, L89).
  4. Upload the new assets before deleting the old ones (inline, L109).
  5. Checkout credentials and experimental refs (inline, L60), inputs.ref injection (inline, L49), cask conflicts (inline, docs/install.md L56).
  6. Update text that describes the version as last release + channel + date + short SHA: the comment at channel.yml L67-71, and docs/releasing.md if it describes the format.

Version string

Requirements from the earlier thread: a new string for every build, so brew upgrade detects it, and unique asset names. The pseudo-version meets both. It contains the commit and orders by commit time. It is also what brig version prints, so brew info brig@main and brig version show the same value.

  • brig@main and brig@experimental built from the same commit get the same version. They are different casks, with assets under different tags, so nothing collides.
  • Re-promoting the same commit does not trigger an upgrade. The code is the same.
  • Promoting a tagged commit gives the tag, e.g. 0.3.0.

hull#75

Steps 3-5 apply there as well. Its version step reads hull's own VERSION, which #231 does not touch.

Comment thread .github/workflows/channel.yml Outdated
Comment on lines +72 to +78
- name: Compose the version
id: version
run: |
set -euo pipefail
BASE="$(cat VERSION)"
SHORT="$(git rev-parse --short HEAD)"
echo "value=${BASE}-${{ steps.channel.outputs.name }}.$(date -u +%Y%m%d).${SHORT}" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERSION was removed in #231, so cat VERSION fails. Read the version from the binary instead. This needs Go, so move the step after actions/setup-go and after the tidy check (L89):

      - name: Read the version
        id: version
        run: |
          set -euo pipefail
          go build -o "$RUNNER_TEMP/brig" ./cmd/brig
          v="$("$RUNNER_TEMP/brig" version --json | jq -r '.data.version | ltrimstr("v")')"
          echo "value=$v" >> "$GITHUB_OUTPUT"

On main this gives 0.2.1-0.20260916131706-003e6d17deb7. goreleaser names the archives from CHANNEL_VERSION, so the asset names, the cask version and brig version all show this one string.

The comment at L67-71 needs updating: the string no longer has the channel name or the build date.

Comment on lines +89 to +94
- uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: release --snapshot --clean --skip=sbom,sign
env:
CHANNEL_VERSION: ${{ steps.version.outputs.value }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

goreleaser checks the git tree before it runs the before hooks. If the go mod tidy hook changes go.mod or go.sum, the binaries embed +dirty and stop matching CHANNEL_VERSION. A snapshot build does not fail on a dirty tree, so nothing catches it.

Add the check release.yml now runs, before the version step:

      - name: Check the tree stays clean after go mod tidy
        run: |
          go mod tidy
          git diff --exit-code

Then skip the hooks here. go test ./... already runs in CI on every merge to main:

          args: release --snapshot --clean --skip=before,sbom,sign

Comment on lines +109 to +126
if gh release view "$TAG" >/dev/null 2>&1; then
# The old assets carry the previous build's version in their name,
# so --clobber does not replace them and they would accumulate
# until the release listed every build ever made.
gh release view "$TAG" --json assets -q '.assets[].name' | while read -r asset; do
[ -n "$asset" ] && gh release delete-asset "$TAG" "$asset" --yes
done
gh release edit "$TAG" --prerelease --target "$(git rev-parse HEAD)" \
--title "$TITLE" --notes "$NOTES"
else
gh release create "$TAG" --prerelease --target "$(git rev-parse HEAD)" \
--title "$TITLE" --notes "$NOTES"
fi
# checksums.txt as well as the archives: the tap's CI asks each
# release whether it really published the sha256 its cask claims,
# and a channel cask has to answer that the same way a stable one
# does.
gh release upload "$TAG" dist/*.tar.gz dist/checksums.txt --clobber

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old assets are deleted before the new ones are uploaded, and the tap names the old files until "Update the tap" finishes. During that window:

  • brew install --cask brig@main gets a 404.
  • A tap CI run started by a hull channel push fails the checksum job for brig@main.

Order:

  1. Upload the new assets.
  2. Update the tap.
  3. In a step after "Update the tap", delete the assets whose names are not in dist/.

checksums.txt keeps its name and is replaced in place, so a short window remains until the tap push lands.

Comment on lines +60 to +65
- uses: actions/checkout@v7
with:
ref: ${{ steps.channel.outputs.ref }}
# goreleaser reads the tag history to derive the version this build
# is the successor to, so a shallow clone gives it the wrong answer.
fetch-depth: 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • fetch-depth: 0 is still needed: the Go toolchain derives the pseudo-version from the nearest tag. Update the comment to give that reason.
  • On workflow_dispatch this checks out the promoted ref. script/render-cask.py and .goreleaser.yaml, including any hooks it defines, then come from that ref rather than main:
    • A ref cut before this PR has neither the renderer nor the CHANNEL_VERSION template, so it cannot be promoted.
    • Code from a contributor's ref runs in a job with contents: write, and by default checkout leaves that token in the git config.

Suggested:

  • persist-credentials: false. The tag push at L105-106 then needs the token passed explicitly.
  • Check out the workflow's own commit (github.workflow_sha) to a separate path. Run render-cask.py from there and pass goreleaser --config from there.
  • Move "Update the tap" to a second job that receives only the rendered cask as an artifact.

Comment thread .github/workflows/channel.yml Outdated
Comment on lines +49 to +50
echo "ref=${{ github.event.inputs.ref }}"
echo "source=${{ github.event.inputs.ref }}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

${{ github.event.inputs.ref }} is substituted into the script before bash runs, so a ref containing " or $(...) runs as shell. Pass it through env: and validate it:

[[ "$REF" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "not a ref: $REF" >&2; exit 1; }

The same value reaches the cask via --source (L144) inside a Ruby string, where a " breaks the file.

Comment thread docs/install.md Outdated
Comment on lines +56 to +57
were asked to install one. Only one of the three casks can be installed at a
time; `brew uninstall --cask brig@main` and install the one you want.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching between casks fails in two cases:

  • Homebrew checks only the conflicts_with of the cask being installed (Cask::Installer#check_conflicts). brig@main declares the conflict; the stable brig cask does not. brew install --cask brig over brig@main is therefore not blocked, and it fails later when Homebrew links the binaries. Add to homebrew_casks in .goreleaser.yaml:

        conflicts:
          - cask: brig@main
          - cask: brig@experimental
  • brew uninstall --cask brig@main leaves hull@main installed. The stable brig then pulls hull, which conflicts with hull@main. The command needs both casks: brew uninstall --cask brig@main hull@main.

There was no way to install a build that is not a release. Trying a
feature before it reached a tag meant building from source, which is a
different ask from `brew install`, and it is the difference between
somebody testing a branch and somebody saying they will.

Two casks, and they are deliberately separate rather than one channel
with two sources. `brig@main` follows the tip of main and is rebuilt on
every merge, so `brew upgrade` follows what is coming. `brig@experimental`
moves only when a maintainer promotes a ref to it, which is how an
unmerged branch reaches a tester. Folding them together would mean a
merge to main silently replacing the branch build a tester was halfway
through checking.

Each publishes to a moving tag whose assets are replaced in place, so a
year of merges leaves one release rather than one per commit. Neither
tag matches `v*`, so neither starts the release workflow, and
`skip_upload: auto` on the stable cask is untouched.

The cask is rendered by script/render-cask.py rather than by goreleaser.
goreleaser's cask pipe renders for the version it is releasing, and a
channel has no version of its own. The renderer takes the archive list
explicitly, because brig ships four platforms and hull ships one, and it
emits what `brew style` wants -- verified against real archives, with
`brew style` reporting no offences and the rendered sha256 matching the
built tarball.

The channel cask is pushed to the tap rather than opened as a pull
request: it is regenerated on every merge, and a PR per merge is noise
nobody reads. The stable cask keeps its review.

`snapshot.version_template` now honours CHANNEL_VERSION when it is set,
so a channel build carries a version no other build shares -- Homebrew
decides there is something to upgrade to from that string alone. A local
`--snapshot` sets nothing and keeps the old spelling.

Signed-off-by: Anastassios Nanos <ananos@nofire.ai>
@ananos
ananos force-pushed the feat/homebrew-channels branch from 8a98351 to a79e24c Compare September 16, 2026 18:59
@ananos

ananos commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

All six fixed, and #230 landing changed the shape of the first one for the better. Rebased onto current main.

1. cat VERSION after #231. Taken as written. The version is now read off a binary -- brig version --json | jq -r '.data.version | ltrimstr("v")' -- after setup-go and the tidy check. That gives 0.2.1-0.20260916184007-ebffd502094a, so the asset names, the cask version and what the binary says about itself are one string instead of three that can drift. The comment about the channel name and the build date is gone with them.

I did check the claim rather than take it: a plain go build in my worktree reported dev, which would have made every channel build the same version and broken brew upgrade silently. That turned out to be Go not stamping VCS info in a linked worktree. From a real clone it stamps the pseudo-version, which is what CI has. Verified end to end: goreleaser named the archives from it, the cask carries it, and the sha256 in the cask matches the built tarball.

2. go mod tidy dirtying the tree after goreleaser reads it. Added the same check release.yml runs, before the version step, and the build now runs --skip=before,sbom,sign. go test ./... already runs in CI on every merge to main, so nothing is lost.

3. Assets deleted before the new ones are up. Reordered as you laid it out: upload, update the tap, then a final step removes the assets no longer in dist/. During the upload the cask still names the previous build's files and those files are still there, so an install mid-run gets the old build rather than a 404. checksums.txt keeps its name and is replaced in place, so the window you mention is down to the tap push.

4. The promoted ref, and the token. All three suggestions:

  • persist-credentials: false on the build checkout. The tag push is given a token explicitly instead.
  • A second checkout of github.workflow_sha at .workflow. The renderer runs from there and goreleaser gets --config .workflow/.goreleaser.yaml, so promoting a branch does not run that branch's copy of either -- which is also what lets a ref cut before this workflow existed be promoted at all.
  • The fetch-depth: 0 comment now gives your reason: the Go toolchain derives the pseudo-version from the nearest tag.

I did not split the tap update into a second job. With the config and the renderer pinned to the workflow commit and no credentials persisted, the remaining exposure is goreleaser's before hooks from the promoted ref -- and those are now skipped. If you would still rather have the job boundary, say so and I will add it; it is a bigger change than the rest and I would rather you chose it deliberately.

5. Script injection on inputs.ref. Fixed, and thank you -- this was the one that mattered. The input reaches bash through env: and is checked against [A-Za-z0-9._/-] before anything uses it. --source gets the same value, so the cask's Ruby string is covered by the same check.

6. conflicts_with only on one side. Both halves:

  • .goreleaser.yaml gains conflicts: [cask: brig@main, cask: brig@experimental] on the stable cask, so brew install --cask brig over a channel build is refused rather than failing later at the link step.
  • docs/install.md now says brew uninstall --cask brig@main hull@main, and why: removing brig@main alone leaves hull@main, and the stable brig then asks for hull.

Checked: goreleaser check, actionlint, make all, script/smoke.sh, every step script under /bin/bash 3.2, and brew style on a cask rendered from the real archives.

@pmoust

pmoust commented Sep 18, 2026

Copy link
Copy Markdown
Member

@claude-fleet review

@claude-fleet

claude-fleet Bot commented Sep 18, 2026

Copy link
Copy Markdown

Cannot enqueue the review task (HTTP 400: bad request: spec contains an unexpanded shell substitution "$("$RUNNER_TEMP/brig" version --json | jq -r '.data.version | ltrimstr("v")" on line 490. A shell substitution placed in a JSON parameter never expands -- JSON does no shell processing, so this reaches the executor as literal text and ). No task enqueued.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants