Releasing
binder is released with two interlocking pieces of automation:
- release-please owns
versioning: it reads Conventional Commits
on
main, maintains a “release PR” that bumps the version and updatesCHANGELOG.md, and, when that PR is merged, creates thevX.Y.Zgit tag and a GitHub Release shell. - goreleaser (v2) owns building & publishing:
triggered by the
vX.Y.Ztag, it builds reproducible cross-platform binaries and publishes them to the GitHub Release and the Homebrew tap.
The boundary between them is the git tag: release-please owns the tag, the tag is the only trigger for goreleaser. Nobody hand-edits a version string.
The one secret you must configure: RELEASE_TOKEN
Section titled “The one secret you must configure: RELEASE_TOKEN”Create a single fine-grained Personal Access Token and store it as the repo
Actions secret RELEASE_TOKEN. Every cross-repo/cross-workflow token is wired
to this one name:
| Consumer | Env / input | Value |
|---|---|---|
release-please action |
token: |
${{ secrets.RELEASE_TOKEN }} |
| goreleaser Homebrew publish | HOMEBREW_TAP_GITHUB_TOKEN |
${{ secrets.RELEASE_TOKEN }} |
| GitHub Release upload | GITHUB_TOKEN |
built-in ${{ secrets.GITHUB_TOKEN }} (not RELEASE_TOKEN) |
Why a PAT and not the default GITHUB_TOKEN for release-please? A tag pushed
using the default GITHUB_TOKEN does not trigger another workflow. If
release-please used it, the tag would land but release.yml (goreleaser) would
never fire. The PAT breaks that loop. (This is the classic release-please +
goreleaser gotcha.)
The token needs write access to:
- this repo (
ghchinoy/binder) — create branches, PRs, tags, releases; ghchinoy/homebrew-tap— commit the updated formula.
The release flow
Section titled “The release flow”- Merge Conventional-Commit PRs to
main(feat:,fix:,feat!:/BREAKING CHANGE:…). release-please.ymlopens/updates a release PR with the next version andCHANGELOG.md.- Merge the release PR. release-please tags
vX.Y.Zand creates the GitHub Release shell. - The tag triggers
release.yml→goreleaser release --clean, which:- builds
linux/darwin/windows × amd64/arm64binaries, uploads archives +checksums.txtto the GitHub Release; - updates the Homebrew formula in
ghchinoy/homebrew-tap.
- builds
What a release publishes
Section titled “What a release publishes”Seven assets, from goreleaser’s default name template with the version
v-stripped. The v0.2.1 release, verified with gh release view v0.2.1:
binder_0.2.1_darwin_amd64.tar.gz binder_0.2.1_linux_amd64.tar.gz binder_0.2.1_windows_amd64.zipbinder_0.2.1_darwin_arm64.tar.gz binder_0.2.1_linux_arm64.tar.gz binder_0.2.1_windows_arm64.zipchecksums.txtSo the predictable pattern for an install script is
binder_<version>_<os>_<arch>.tar.gz, .zip on Windows. Each archive also
carries LICENSE and README.md (archives.files in .goreleaser.yaml).
The tap update lands as Formula/binder.rb in ghchinoy/homebrew-tap, which
means the user-facing install command is:
brew install ghchinoy/tap/binderThat is the one thing worth smoke-testing after a release. ghchinoy/tap is
Homebrew’s shorthand for the ghchinoy/homebrew-tap repo, and the formula’s own
test block runs binder --version.
Versioning posture (pre-1.0)
Section titled “Versioning posture (pre-1.0)”- SemVer with
v-prefixed tags. The series opened atv0.1.0(seeded by"initial-version": "0.1.0"inrelease-please-config.json); the current released version is always whatever.release-please-manifest.jsonholds. Never read a version out of this document. - While
0.x:feat:→ minor,fix:→ patch, breaking changes → minor (not major). Configured viabump-minor-pre-major: trueandbump-patch-for-minor-pre-major: falseinrelease-please-config.json.v1.0.0is reserved for the first stability commitment (frozen converter output + trust-stamp format). - The OKF spec level binder targets is a separate axis: advertise it in the README/CHANGELOG, never encode it in binder’s SemVer.
How the version reaches the binary (single-source: the tag)
Section titled “How the version reaches the binary (single-source: the tag)”cmd/root.go declares var Version = "dev", and two different sources can fill
it in:
- goreleaser injects the tag at build time:
-ldflags "-X github.com/ghchinoy/binder/cmd.Version={{ .Version }}". Note that goreleaser’s.Versionis the tag with itsvstripped. go install github.com/ghchinoy/binder@vX.Y.Zgets no ldflags, so aninit()fallback recovers the module version fromdebug.ReadBuildInfo(). That value isv-prefixed.
Left alone those two disagree about the leading v, and the disagreement is not
cosmetic: the version is stamped into every converted concept’s trust provenance
as generated.by: "binder/<version>", so two install methods would write two
different trust stamps for one release.
They are therefore reconciled in code. init() routes both sources through a
single funnel, normalizeVersion, which strips exactly one leading v when what
follows is a digit and leaves everything else (dev, (devel), the empty
string) untouched. It never fabricates a version — it only removes a prefix from
a value that is already there.
The canonical form therefore has no leading v: binder/0.3.0, not
binder/v0.3.0. That is what binder --version prints, what the --json
envelope’s binder field contains, and what lands in generated.by — identical
across the goreleaser, go install, and go build paths. What normalization
does not do is invent a version. A plain go build — and make build, which
is go build -o bin/binder . — passes no -ldflags, so nothing sets
cmd.Version and the init() fallback reports the Go module pseudo-version
instead (e.g. binder/0.2.2-0.20260816074947-7f4ca6b4c816). This has nothing to
do with whether the clone is tagged: a clone sitting on v0.2.1-12-gdd8c35e
still reports the pseudo-version, because the tag only reaches the binary
through -ldflags. That is why the release path must inject it.
To rehearse the stamp locally, build the way the release builds:
go build -ldflags "-X github.com/ghchinoy/binder/cmd.Version=0.3.0" -o /tmp/binder ./tmp/binder --version # binder/0.3.0Reproducible build invariants
Section titled “Reproducible build invariants”The release build preserves binder’s core invariants (design §7):
- Pinned deps: modules are fetched from the Go module proxy at build time,
pinned via
go.mod/go.sumand verified against thego.sumhashes. There is nogo mod tidy/downloadhook; the compile-sanity before-hook is a plaingo build -o /dev/null .. The build needs network access to the proxy. - Reproducible:
-trimpath,-s -w,mod_timestamp={{.CommitTimestamp}}, andrelease.ymlexportsSOURCE_DATE_EPOCH= commit time so timestamps agree.
You can rehearse the release build locally without publishing:
goreleaser check # validate .goreleaser.yamlgoreleaser release --snapshot --clean # build all targets, no publish./dist/binder_linux_amd64_v1/binder --versionDifferential-validation exit gate
Section titled “Differential-validation exit gate”make check (gofmt + go vet + go test ./...) is the toolchain-only gate. The
project’s full exit gate additionally cross-checks binder’s own verdicts
against the vendor-neutral okfcli/okf
validator (v0.3.0) in both directions:
make okf-install # go install github.com/okfcli/okf/cmd/okf@v0.3.0make gate # local checks + external differential validationmake gate runs scripts/interop.sh, which compares binder’s and okf’s
verdicts in both directions and fails on any unexpected disagreement. It is a
maintainer/CI gate: installing or running binder never requires okf.
Deferred (Phase 4)
Section titled “Deferred (Phase 4)”cosign signatures and SBOMs are intentionally not configured yet (no
signs:/sboms: blocks). Checksums (checksums.txt) are emitted today.
winget is temporarily unavailable. The winget publisher was removed in
#46 until the cross-repo PAT can
open a PR to microsoft/winget-pkgs (the fine-grained token 403’d on the
upstream PR). There is no winget: block in .goreleaser.yaml today. Tracked in
#40; Windows users should take
the release .zip in the meantime.