Skip to content

feat(release): automate releases on tag push - binaries, image, brew - #39

Merged
tobert merged 4 commits into
mainfrom
feat/release-automation
Sep 10, 2026
Merged

feat(release): automate releases on tag push - binaries, image, brew#39
tobert merged 4 commits into
mainfrom
feat/release-automation

Conversation

@tobert

@tobert tobert commented Sep 10, 2026

Copy link
Copy Markdown
Owner

What

Releases are now cut by GitHub Actions on a v* tag push, ported from kaibo's pipeline and adapted to a Go project that already had goreleaser.

  • .github/workflows/release.yml (new): goreleaser builds binaries, archives, apk/deb/rpm, checksums, an SPDX SBOM (syft over go.mod), cosign keyless-signs checksums.txt as a .sigstore.json bundle, publishes the GitHub release and pushes the Homebrew formula. The workflow then stages the linux amd64/arm64 binaries from dist/artifacts.json, builds a multiarch alpine image, pushes by digest, signs, attests SLSA provenance, and applies the version tags last so the ghcr package page's "latest" is always a pullable image. workflow_dispatch runs the whole thing as a snapshot that publishes nothing (gated on the event, not the ref, so a dispatch against a tag ref stays a snapshot).
  • .goreleaser.yml: docker sections removed (the workflow owns the image), SBOM + cosign + github-native changelog + release header template added, make_latest off for prereleases, replace_existing_artifacts for re-runs.
  • .github/workflows/ci.yml: SHA-pinned actions, contents: read, gofmt/vet/tidy gate, plus a goreleaser snapshot job so the release config can't rot between releases.
  • .github/dependabot.yml: actions, gomod, docker.
  • release/Dockerfile: digest-pinned Alpine 3.24, COPY binaries/${TARGETARCH}/otel-cli.
  • .github/release-body.md: per-release "Get it / Verify it" header with the tag filled in.
  • README: brew install tobert/otel-cli/otel-cli, a "Verify a download" section, a rewritten "Releases" section. BOTS.md and CHANGELOG updated.

Decisions worth a look

  • Homebrew formula, not cask. goreleaser v2 deprecates brews in favour of homebrew_casks, but casks are macOS-only and the tap serves Linux; a cask would also make every current brew install otel-cli user reinstall. Kept the formula, so goreleaser check exits non-zero by design and CI validates with a snapshot instead. Tracked in Homebrew: goreleaser deprecates brews (formula) in favour of homebrew_casks #40.
  • The formula now lands at Formula/otel-cli.rb in the tap; the old root otel-cli.rb becomes dead once Formula/ exists and can be deleted after the first release.
  • Image tags: v0.6.0 (continuity with 0.5.0) + 0.6.0 + 0.6 + latest. Still Alpine, still root, as the README documents; nonroot is a follow-up.

Before the first tagged release

  1. Repo secret HOMEBREW_TAP_GITHUB_TOKEN: fine-grained PAT, Contents read/write on tobert/homebrew-otel-cli only. The workflow refuses to start a tag run without it.
  2. ghcr.io/tobert/otel-cli is currently private; flip it to public in the package settings or docker pull fails for everyone.
  3. Run release.yml by hand once (snapshot) and cut a v0.6.0-rc.1 before v0.6.0: the rc exercises the release header, attestations and image tag policy without touching the tap or latest.
  4. CHANGELOG's 0.6.0 date (2025-11-09) predates the actual release.

Verification

  • goreleaser check: valid (only the deliberate brews deprecation)
  • goreleaser release --snapshot --clean --skip=publish,sign,sbom: full matrix, formula rendered, linux/amd64 binary static and version runs
  • SBOM stanza exercised with a syft shim: exactly one document, listed in checksums.txt
  • actionlint clean on both workflows
  • kaibo review (crusoe cast) caught the dispatch-on-tag-ref gap; its two goreleaser semantics findings were checked against the v2.18 schema and a run and turned out to be false positives.

🤖 Reviewed by kaibo (crusoe: zai-org/GLM-5.3 synth + deepseek-ai/Deepseek-V4-Flash explorer)

🤖 Generated with Claude Code

tobert and others added 4 commits September 10, 2026 19:30
Why: releases were a manual goreleaser run on Amy's laptop; kaibo already has
a tag-triggered, signed, attested pipeline and otel-cli should flow the same.
Approach: keep goreleaser for binaries/archives/nfpm/checksums/SBOM/cosign
bundle/GitHub release/Homebrew formula; build the ghcr image in the workflow
from dist/artifacts.json binaries so it can be pushed by digest, signed,
attested, and tagged LAST (goreleaser's docker pipeline signs after tagging,
leaving a signature artifact as the package page's "latest"). SHA-pinned
actions, dependabot, CI snapshot gate, release page header template.
Learned: goreleaser v2 deprecates `brews` for `homebrew_casks`, but casks are
macOS-only and the tap serves Linux, so the formula stays and `goreleaser
check` is non-zero by design. Snapshot build + actionlint pass locally.
Next: kaibo review; Amy creates HOMEBREW_TAP_GITHUB_TOKEN secret and makes
the ghcr package public; workflow_dispatch smoke; then tag v0.6.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lish

Why: kaibo review (crusoe cast: GLM-5.3 synth, DeepSeek-V4-Flash explorer)
found that `gh workflow run release.yml --ref v0.6.0` is a dispatch whose
ref_type is 'tag', so every step gated on ref_type alone would publish for
real from a hand-triggered run.
Approach: gate on `github.event_name == 'push' && github.ref_type == 'tag'`
everywhere; add a go build + `version` smoke before GoReleaser so a
crash-at-startup binary fails before the GitHub release and tap push exist;
jq first() so a second build id can't split $bin; sort -u the image tags
(prerelease semver patterns collapse to duplicates). Docs: README Alpine
note no longer says "latest", BOTS.md points at the tag-push flow, dev
Dockerfile pins golang:1.26 so Dependabot can bump it.
Learned: the review's other two GoReleaser findings (`sboms.artifacts: any`
invalid, `$document` unsubstituted) were false — verified against the v2.18
JSON schema and a snapshot run with a syft shim: exactly one SBOM, listed in
checksums.txt. The CI snapshot does render the formula under --skip=publish.
Next: push, PR, Amy sets HOMEBREW_TAP_GITHUB_TOKEN + ghcr visibility, dispatch
smoke, then a -rc tag before the stable one.

🤖 Reviewed by kaibo (crusoe: zai-org/GLM-5.3 + deepseek-ai/Deepseek-V4-Flash)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Why: the packaging metadata carried tobert@github.com, an address that
existed once long ago; Amy's email has been tobert@gmail.com for decades.
Approach: one-line change in the nfpms block; commit_author already used
the right address.
Next: nothing; ships with the release automation PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Why: PR #39 was unmergeable: the branch ruleset on main requires a status
check named `build`, and renaming the job to `test` meant it never reported.
Approach: rename the job id back and leave a comment so the next rename
doesn't repeat this. The goreleaser snapshot job is not required by the
ruleset; adding it is a repo-settings change for Amy.
Next: merge #39.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@tobert
tobert merged commit 3cd6f9c into main Sep 10, 2026
2 checks passed
@tobert
tobert deleted the feat/release-automation branch September 10, 2026 23:49
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.

1 participant