From b7057f181a7c0e40941da7c52d89b620549fe224 Mon Sep 17 00:00:00 2001 From: A Tobey Date: Thu, 10 Sep 2026 19:30:01 -0400 Subject: [PATCH 1/4] feat(release): automate releases on tag push - binaries, image, brew 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 --- .github/dependabot.yml | 24 ++++ .github/release-body.md | 43 +++++++ .github/workflows/ci.yml | 88 ++++++++++++-- .github/workflows/release.yml | 209 ++++++++++++++++++++++++++++++++++ .goreleaser.yml | 187 +++++++++++++++++------------- CHANGELOG.md | 12 ++ README.md | 74 +++++++++--- release/Dockerfile | 28 +++-- 8 files changed, 551 insertions(+), 114 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/release-body.md create mode 100644 .github/workflows/release.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..256570a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +# Keeps the SHA-pinned actions, the digest-pinned base image, and the Go module +# graph moving. Pins without a bumper rot; this is the bumper. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + groups: + go-modules: + patterns: ["*"] + - package-ecosystem: docker + directories: + - / + - /release + schedule: + interval: weekly diff --git a/.github/release-body.md b/.github/release-body.md new file mode 100644 index 0000000..bea0a04 --- /dev/null +++ b/.github/release-body.md @@ -0,0 +1,43 @@ + +## Get it + +Grab your platform's archive or package below ([install notes](https://github.com/tobert/otel-cli#getting-started)), or: + +```sh +brew install tobert/otel-cli/otel-cli +go install github.com/tobert/otel-cli@{{ .Tag }} +docker pull ghcr.io/tobert/otel-cli:{{ .Version }} +``` + +(Pull version tags like `{{ .Version }}` — the `sha256-*` tags on the package page are +cosign signature/attestation artifacts riding alongside the image, not images.) + +## Verify it + +Run these from the folder holding your downloads. Every artifact carries SLSA +build provenance — any downloaded file, one command: + +```sh +gh attestation verify otel-cli_{{ .Version }}_linux_amd64.tar.gz -R tobert/otel-cli +gh attestation verify oci://ghcr.io/tobert/otel-cli:{{ .Version }} -R tobert/otel-cli +``` + +Or keyless-verify the signed checksum manifest with cosign ≥ 2.5 (covers every file it lists, works offline): + +```sh +cosign verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/tobert/otel-cli/.github/workflows/release.yml@refs/tags/{{ .Tag }}" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + checksums.txt +sha256sum -c --ignore-missing checksums.txt +``` + +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e42e885..4ad4503 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,87 @@ +# The correctness gate on every pull request and every push to main: build, +# vet, gofmt, tidy go.mod, and the functional suite (main_test.go execs the +# built binary, so the build comes first). A second job runs the release +# config as a snapshot so .goreleaser.yml can't rot between releases — +# release.yml is the real thing on a tag; this is quick feedback on every change. name: CI + on: - push: - branches: [ main ] pull_request: - branches: [ main ] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + test: + name: test runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 - - name: Setup - uses: actions/setup-go@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '>=1.20.0' - # otel-cli's main test needs the binary built ahead of time - # also this validates it can acutally build before we get there + go-version-file: go.mod + + # The release runs `go mod verify`, never `tidy` (a tidy at release time + # would ship a tree that differs from the tag), so tidiness is enforced + # here where a diff is cheap to fix. + - name: go.mod is tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: gofmt + run: | + out=$(gofmt -l .) + if [ -n "$out" ]; then + echo "gofmt needs to run on:" + echo "$out" + exit 1 + fi + + - name: go vet + run: go vet ./... + + # Same -s -w ldflags the release uses, so the build under test is the + # build that ships. - name: Build - # build with -s -w to reduce binary size and verify that build in test run: go build -v -ldflags="-s -w -X main.version=test -X main.commit=${{ github.sha }}" + - name: Test run: go test -v ./... + + release-snapshot: + name: goreleaser snapshot + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + # `goreleaser check` would be the cheap gate, but it exits non-zero on the + # deliberate `brews` deprecation (see .goreleaser.yml). A snapshot renders + # every artifact including the formula, which is the stronger check anyway. + - name: GoReleaser (snapshot) + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean --snapshot --skip=publish,sign,sbom + env: + HOMEBREW_TAP_GITHUB_TOKEN: "" + + - name: Smoke (run the binary) + run: | + bin=$(jq -r '.[] | select(.type == "Binary" and .goos == "linux" and .goarch == "amd64") | .path' dist/artifacts.json) + [ -n "$bin" ] || { echo "no linux/amd64 binary in dist/artifacts.json"; exit 1; } + "$bin" version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c8ae7ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,209 @@ +# Cut an otel-cli release: binaries, archives, Linux packages, checksums, SBOM, +# GitHub release, Homebrew formula, and a multiarch ghcr.io image. Triggered by +# pushing a `v*` tag (e.g. `v0.6.0`); also runnable by hand (workflow_dispatch) +# as a snapshot to smoke the whole matrix without publishing anything. +# +# GoReleaser (.goreleaser.yml) owns everything up to and including the GitHub +# release and the tap push. The image is assembled here from the Linux binaries +# it leaves in dist/, so the image can be pushed by digest, signed, attested, +# and only THEN tagged — see the comments on the image steps for why that order +# matters for the ghcr package page. +# +# Actions are pinned by commit SHA, not tag — a tag can be moved, a digest can't +# (supply-chain). Each pin carries a `# vX.Y.Z` comment naming the release it +# points at, for humans and Dependabot. +# +# Releases are born signed: cosign keyless-signs checksums.txt (the identity IS +# this workflow at this tag), SLSA build provenance is attached per artifact +# (`gh attestation verify`), and an SPDX SBOM is cataloged from go.mod. The image +# is signed and attested by the same machinery. Verification commands live in +# .github/release-body.md (rendered onto each release page) and the README. +# +# Publishing, signing, and attesting are all gated on the ref being a tag, so a +# dispatch run never publishes — but note that the job's permissions (including +# id-token) are granted on dispatch too; the gating is by step condition. +name: release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: {} + +jobs: + release: + name: goreleaser + image + runs-on: ubuntu-latest + permissions: + contents: write # create the release and upload assets + packages: write # push the image to ghcr (tag runs only) + id-token: write # mint the OIDC identity cosign + provenance sign with + attestations: write # store the build-provenance attestations + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # GoReleaser derives the version from tags and the previous tag for + # its git checks; a shallow clone has neither. + fetch-depth: 0 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + # Fail before anything is built or published rather than after the GitHub + # release exists and only the Homebrew push is left to fail. GITHUB_TOKEN + # cannot push to the tap repo; this is a fine-grained PAT with Contents: + # write on tobert/homebrew-otel-cli, stored as a repository secret. + - name: Preflight (tap token present) + if: github.ref_type == 'tag' + env: + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + run: | + if [ -z "$HOMEBREW_TAP_GITHUB_TOKEN" ]; then + echo "::error::HOMEBREW_TAP_GITHUB_TOKEN secret is not set; refusing to release without the Homebrew tap push" + exit 1 + fi + + - name: Install syft (SBOM) + uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + # Tag: the real thing, with the release page header rendered from + # .github/release-body.md. Dispatch: a snapshot — builds and packages + # everything, publishes nothing, and skips signing so no OIDC identity is + # minted for a build that ships nowhere. + - name: GoReleaser + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + distribution: goreleaser + version: "~> v2" + args: ${{ github.ref_type == 'tag' && 'release --clean --release-header-tmpl=.github/release-body.md' || 'release --clean --snapshot --skip=sign' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + + # Linking isn't running: a binary can build cleanly and still crash at + # startup, so execute the linux/amd64 binary the release will ship. + - name: Smoke (run the binary) + run: | + bin=$(jq -r '.[] | select(.type == "Binary" and .goos == "linux" and .goarch == "amd64") | .path' dist/artifacts.json) + [ -n "$bin" ] || { echo "no linux/amd64 binary in dist/artifacts.json"; exit 1; } + "$bin" version + + # CGO_ENABLED=0 must hold or the alpine image (musl) gets a glibc binary. + # ldd exits non-zero on a fully static binary, so that's the success case + # here — hence `|| true` plus a grep instead of a plain run. + - name: Smoke (assert fully static) + run: | + bin=$(jq -r '.[] | select(.type == "Binary" and .goos == "linux" and .goarch == "amd64") | .path' dist/artifacts.json) + out=$(ldd "$bin" 2>&1 || true) + echo "$out" + grep -q "not a dynamic executable" <<<"$out" + + # SLSA build provenance for every file checksums.txt lists (archives, + # packages, SBOM), stored in GitHub's attestation store (adds no release + # assets): `gh attestation verify -R tobert/otel-cli` is the + # one-command per-file verification path. + - name: Attest build provenance + if: github.ref_type == 'tag' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-checksums: dist/checksums.txt + + # release/Dockerfile COPYs binaries//otel-cli from the build + # context, so stage each Linux binary GoReleaser built under the docker + # arch name it maps to. artifacts.json is the contract, not dist/'s + # directory layout (which encodes goamd64/goarm64 and shifts between + # GoReleaser versions). + - name: Stage per-arch binaries + run: | + stage() { # + bin=$(jq -r --arg arch "$1" '.[] | select(.type == "Binary" and .goos == "linux" and .goarch == $arch) | .path' dist/artifacts.json) + [ -n "$bin" ] || { echo "no linux/$1 binary in dist/artifacts.json"; exit 1; } + install -D -m 0755 "$bin" "image/binaries/$2/otel-cli" + } + stage amd64 amd64 + stage arm64 arm64 + + - name: Set up buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + # Linking isn't running holds for images too: boot the amd64 image and make + # the binary speak before anything is pushed. + - name: Smoke (run the image) + run: | + docker buildx build --load --platform linux/amd64 -f release/Dockerfile -t otel-cli-smoke image + docker run --rm otel-cli-smoke version + + - name: Login to ghcr + if: github.ref_type == 'tag' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Tag policy: the full semver both v-prefixed (v0.6.0, what 0.5.0 and + # earlier published, so existing pulls keep working) and bare (0.6.0); + # major.minor and `latest` only for a stable release. metadata-action + # degrades every semver pattern to {{version}} on a prerelease and holds + # `latest` false — so an rc never becomes somebody's `latest` or `0.6`. The + # sha tag gives a dispatch run a well-formed (never-pushed) reference; it's + # disabled on tag runs so a release publishes version tags only. + - name: Image metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/tobert/otel-cli + tags: | + type=semver,pattern=v{{version}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,enable=${{ github.ref_type != 'tag' }} + + # Tag runs push BY DIGEST here and apply the version tags in the last step + # below, after signing. Order matters for the package page: GitHub's + # install box advertises the most recently published version, and + # cosign/provenance land as sha256-* tagged artifacts in the same package — + # publish the version tags last and "Latest" is always a pullable image, + # never a signature bundle. + - name: Build (and push by digest on a tag) + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: image + file: release/Dockerfile + platforms: linux/amd64,linux/arm64 + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=ghcr.io/tobert/otel-cli,name-canonical=true,push-by-digest=true,push=${{ github.ref_type == 'tag' }} + + # Same keyless machinery as the checksums signature, aimed at the manifest + # digest — the signature covers every tag later pointed at it. + - name: Sign image (cosign keyless) + if: github.ref_type == 'tag' + run: cosign sign --yes ghcr.io/tobert/otel-cli@${{ steps.build.outputs.digest }} + + - name: Attest image provenance + if: github.ref_type == 'tag' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ghcr.io/tobert/otel-cli + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + + # The finale, deliberately: point the version tags at the signed manifest + # list. imagetools create copies the existing list under each tag — no + # rebuild, no layer movement — and because this is the package's newest + # publish, the install box shows a real, pullable version. + - name: Publish version tags (last, so "Latest" is pullable) + if: github.ref_type == 'tag' + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + args=() + while IFS= read -r t; do [ -n "$t" ] && args+=(-t "$t"); done <<< "$TAGS" + docker buildx imagetools create "${args[@]}" "ghcr.io/tobert/otel-cli@$DIGEST" diff --git a/.goreleaser.yml b/.goreleaser.yml index 41ad4bd..ff21cfa 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,27 +1,33 @@ +# otel-cli release configuration for GoReleaser v2. +# +# .github/workflows/release.yml drives this on every `v*` tag (and runs it as a +# snapshot on workflow_dispatch to smoke the matrix without publishing). Locally: +# +# goreleaser release --snapshot --clean --skip=publish,sign,sbom +# +# GoReleaser owns the binaries, archives, Linux packages, checksums, SBOM, the +# keyless cosign signature over checksums.txt, the GitHub release, and the +# Homebrew formula. The container image is deliberately NOT built here: the +# workflow assembles it from the Linux binaries GoReleaser leaves in dist/, so +# that the image can be pushed by digest, signed, attested, and only THEN tagged +# — GoReleaser's docker pipeline signs after tagging, which leaves the ghcr +# package page advertising a signature artifact as "latest" (kaibo v0.2.0-rc.4 +# hit exactly that). Keep the image out of this file. version: 2 +project_name: otel-cli + before: hooks: - - go mod tidy - -checksum: - name_template: 'checksums.txt' - -snapshot: - version_template: 'SNAPSHOT-{{ .Commit }}' + # Verify, never tidy: a hook that rewrites go.mod would release a tree that + # differs from the tag. CI enforces tidiness on every pull request instead. + - go mod verify -changelog: - sort: asc - filters: - exclude: - - '^demos:' - - '^configs:' - - Merge pull request - - Merge branch - - go mod tidy +report_sizes: true builds: - - env: + - id: otel-cli + env: - CGO_ENABLED=0 goos: - linux @@ -31,17 +37,27 @@ builds: goarch: - amd64 - arm64 - - 386 - goarm: - - 7 + - "386" ignore: - goos: darwin - goarch: 386 + goarch: "386" - goos: freebsd goarch: arm64 + flags: + - -trimpath mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{ .CommitDate }} + - -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .CommitDate }} + +archives: + - id: archives + formats: ["tar.gz"] + format_overrides: + - goos: windows + formats: ["zip"] + builds_info: + group: root + owner: root nfpms: - package_name: otel-cli @@ -54,66 +70,79 @@ nfpms: - deb - rpm -archives: - - formats: [ "tar.gz" ] - format_overrides: - - goos: windows - formats: zip - builds_info: - group: root - owner: root +checksum: + name_template: "checksums.txt" + +# One SBOM for the whole release, cataloged from go.mod/go.sum (every target +# shares the same module graph), rather than one document per archive. syft is +# installed by the workflow; local snapshot runs pass --skip=sbom. +sboms: + - id: source + artifacts: any + documents: + - "{{ .ProjectName }}_{{ .Version }}_sbom.spdx.json" + cmd: syft + args: ["dir:.", "--exclude", "./dist/**", "--output", "spdx-json=$document"] +# Keyless cosign over checksums.txt only: verify the manifest once and it covers +# every file it lists. The .sigstore.json bundle is self-contained (certificate, +# signature, Rekor entry) so verification works offline. cosign v3 ignores the +# legacy --output-signature/--output-certificate pair in bundle mode, hence the +# single --bundle flag. The signing identity is the release workflow at the tag, +# minted from the job's id-token — there is no key to store or leak. +signs: + - id: checksums + artifacts: checksum + cmd: cosign + signature: "${artifact}.sigstore.json" + args: ["sign-blob", "--yes", "--bundle=${signature}", "${artifact}"] + +changelog: + # GitHub's auto-generated notes (grouped by PR, with contributors). The header + # above them comes from .github/release-body.md via --release-header-tmpl. + use: github-native + +release: + github: + owner: tobert + name: otel-cli + # A semver prerelease tag (v1.0.0-rc.1) becomes a GitHub prerelease and is + # never marked latest, so the Releases page always leads with a stable build. + prerelease: auto + make_latest: "{{ not .Prerelease }}" + # A re-run after a partial failure (say, the Homebrew push) must be able to + # finish the same release instead of tripping over assets it already uploaded. + replace_existing_artifacts: true + +snapshot: + version_template: "{{ incpatch .Version }}-SNAPSHOT-{{ .ShortCommit }}" + +# Formula, not cask, on purpose: GoReleaser deprecates `brews` in favor of +# `homebrew_casks`, but casks are macOS-only and the existing tap serves Linux +# users too. Moving to a cask would also make every current `brew install +# otel-cli` user reinstall. Tracked in the repo issues; revisit when GoReleaser +# v3 removes `brews`. This means `goreleaser check` reports a deprecation — CI +# validates the config with a snapshot build instead. brews: - # This means the repository must be tobert/homebrew-otel-cli - - name: "otel-cli" + - name: otel-cli + ids: ["archives"] url_template: "https://github.com/tobert/otel-cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + # The repository must be tobert/homebrew-otel-cli for `brew tap tobert/otel-cli`. + # GITHUB_TOKEN is scoped to this repo, so pushing the formula into the tap + # takes a separate fine-grained PAT with Contents: write on the tap repo. repository: - owner: "tobert" - name: "homebrew-otel-cli" - token: "{{ .Env.GITHUB_TOKEN }}" + owner: tobert + name: homebrew-otel-cli + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + directory: Formula commit_author: - name: "tobert" - email: "tobert@gmail.com" - homepage: "https://github.com/tobert/otel-cli" + name: tobert + email: tobert@gmail.com + commit_msg_template: "otel-cli {{ .Tag }}" + homepage: https://github.com/tobert/otel-cli description: "OpenTelemetry command-line tool for sending events from shell scripts & similar environments" - license: "Apache-2.0" - # If set to auto, the release will not be uploaded to the homebrew tap - # in case there is an indicator for prerelease in the tag e.g. v1.0.0-rc1 - skip_upload: "auto" - -dockers: - - image_templates: - - "ghcr.io/tobert/otel-cli:{{ .Tag }}-amd64" - dockerfile: release/Dockerfile - use: buildx - build_flag_templates: - - "--pull" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.name={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.source={{.GitURL}}" - - "--platform=linux/amd64" - - image_templates: - - "ghcr.io/tobert/otel-cli:{{ .Tag }}-arm64v8" - dockerfile: release/Dockerfile - use: buildx - build_flag_templates: - - "--pull" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.name={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.source={{.GitURL}}" - - "--platform=linux/arm64/v8" - -docker_manifests: - - name_template: "ghcr.io/tobert/otel-cli:{{ .Tag }}" - image_templates: - - "ghcr.io/tobert/otel-cli:{{ .Tag }}-amd64" - - "ghcr.io/tobert/otel-cli:{{ .Tag }}-arm64v8" - - name_template: "ghcr.io/tobert/otel-cli:latest" - image_templates: - - "ghcr.io/tobert/otel-cli:{{ .Tag }}-amd64" - - "ghcr.io/tobert/otel-cli:{{ .Tag }}-arm64v8" - use: docker + license: Apache-2.0 + test: | + system "#{bin}/otel-cli", "version" + # auto: a prerelease tag is released on GitHub but never pushed to the tap. + skip_upload: auto diff --git a/CHANGELOG.md b/CHANGELOG.md index b4efdae..a4c627b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ Begin agent-assisted development era. ### Changed +- Releases are cut by GitHub Actions on a `v*` tag: goreleaser publishes the + release and Homebrew formula, and the workflow builds, cosign-signs, attests + and tags the multiarch ghcr.io image (version tags land after signing so the + package page's "latest" is always pullable) +- Release artifacts are signed keylessly (cosign bundle over checksums.txt), + carry SLSA build provenance, and ship an SPDX SBOM; `gh attestation verify` + and `cosign verify-blob` instructions on each release page and in the README +- CI pins actions by commit SHA, checks gofmt/vet/tidy, and runs a goreleaser + snapshot on every pull request so the release config cannot rot +- Container image tags gain bare semver (`0.6.0`, `0.6`) alongside `v0.6.0` and + `latest`; base image is a digest-pinned Alpine 3.24 +- Dependabot keeps action pins, the base image digest, and Go modules current - Establish agent-assisted development practices - Emphasize functional testing and self-contained design - Documentation of project philosophy and structure for agents diff --git a/README.md b/README.md index 4b320d4..d99db25 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,42 @@ docker pull ghcr.io/tobert/otel-cli:latest docker run ghcr.io/tobert/otel-cli:latest status ``` -To use the brew tap e.g. on MacOS: +To use the brew tap on macOS or Linux: ```shell -brew tap tobert/otel-cli -brew install otel-cli +brew install tobert/otel-cli/otel-cli ``` +### Verify a download + +Every release is born signed in public CI: the signing identity *is* the release +workflow at that tag, witnessed by the Sigstore transparency log — no maintainer +key to steal or trust. Two independent checks; either one is sufficient. + +With the [`gh` CLI](https://cli.github.com/), SLSA build provenance is one command +against any file (or the image) from the release: + +```shell +gh attestation verify otel-cli_0.6.0_linux_amd64.tar.gz -R tobert/otel-cli +gh attestation verify oci://ghcr.io/tobert/otel-cli:0.6.0 -R tobert/otel-cli +``` + +With [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) ≥ 2.5 +(no GitHub tooling needed), verify the signed checksum manifest once and it +covers every file it lists. Grab `checksums.txt` and `checksums.txt.sigstore.json` +from the release, substituting the tag you downloaded in the identity: + +```shell +cosign verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/tobert/otel-cli/.github/workflows/release.yml@refs/tags/vX.Y.Z" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + checksums.txt +sha256sum -c --ignore-missing checksums.txt +``` + +Each release page carries the same commands with the tag filled in. + Alternatively, clone the repo and build it locally: ```shell @@ -313,25 +342,38 @@ We welcome contributions! This project uses agent-assisted development starting ## Releases -Releases are managed by goreleaser. Currently this is limited to @tobert due to rules in -the tobert organization. For now releases are not automated, but will be by the time -a v1.0 rolls out and the test suite is robust enough that we feel confident. +Releases are cut by GitHub Actions: pushing a `v*` tag runs +[release.yml](.github/workflows/release.yml), which drives goreleaser +([.goreleaser.yml](.goreleaser.yml)) to build every platform, publish the GitHub +release with archives, apk/deb/rpm packages, checksums and an SPDX SBOM, push the +Homebrew formula to [tobert/homebrew-otel-cli](https://github.com/tobert/homebrew-otel-cli), +and then builds, signs, attests and tags the multiarch `ghcr.io/tobert/otel-cli` image. +Everything is signed keylessly with cosign and carries SLSA build provenance; see +[Verify a download](#verify-a-download). -Testing the release: `goreleaser release --snapshot --rm-dist` +To cut a release: -To release, a GitHub personal access token is required. The release also needs to be tagged -in git. +```shell +git checkout main && git pull --rebase # release tags come off main +# update CHANGELOG.md for the new version and merge that first +git tag v0.6.0 # a -rc.N suffix makes a GitHub prerelease and skips the tap +git push origin v0.6.0 # the tag push triggers release.yml +gh run watch # optional: follow along +``` + +To smoke the whole matrix without publishing, run the workflow by hand from the +Actions tab (`workflow_dispatch`), or locally: ```shell -docker login ghcr.io # log into GitHub Docker repo -gh repo list # make sure GitHub PAT is working -git checkout main # release tags must be off the main branch -git pull --rebase # get the latest HEAD -git tag v0.1.1 # tag HEAD with the next version -git push --tags # push new tag up to GitHub -goreleaser release --rm-dist +goreleaser release --snapshot --clean --skip=publish,sign,sbom ``` +The workflow needs one repository secret, `HOMEBREW_TAP_GITHUB_TOKEN`: a +fine-grained personal access token with *Contents: read and write* on the tap +repository only. `GITHUB_TOKEN` covers the release and the container registry. +The workflow refuses to start a tagged release without it rather than fail after +the GitHub release is already published. + ## License Apache License 2.0 - Copyright (c) 2025 A. Tobey diff --git a/release/Dockerfile b/release/Dockerfile index 300fa7c..3954d90 100644 --- a/release/Dockerfile +++ b/release/Dockerfile @@ -1,9 +1,23 @@ -# While the top-level Dockerfile is set up for local development on otel-cli, -# this Dockerfile is only for release. +# otel-cli release image. The top-level Dockerfile builds from source for local +# development; this one only packages binaries that GoReleaser already built. # -# We use the Alpine base image to get the TLS trust store and not much else. -# The ca-certificates-bundle packet is pre-installed in the base so no -# additional packages are required. -FROM alpine:latest +# Built multiarch (amd64 + arm64) by .github/workflows/release.yml, which stages +# each Linux binary under binaries// — pure COPY, no RUN, so neither +# platform needs emulation to build. +# +# Alpine rather than scratch/distroless so the image carries the CA trust store +# (ca-certificates-bundle is in the base) and a shell for debugging inside the +# container, as the README has documented since 0.4.2. Pinned by manifest-list +# digest, same discipline as the workflow's action pins; Dependabot bumps it. +FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b + +# Links the ghcr package to the repo even for a manual build; the workflow's +# metadata-action adds the full OCI label set on top. +LABEL org.opencontainers.image.source="https://github.com/tobert/otel-cli" \ + org.opencontainers.image.description="OpenTelemetry command-line tool for sending events from shell scripts & similar environments" \ + org.opencontainers.image.licenses="Apache-2.0" + +ARG TARGETARCH +COPY binaries/${TARGETARCH}/otel-cli /otel-cli + ENTRYPOINT ["/otel-cli"] -COPY otel-cli / From 69547a2b4ea410aa3b5af2044a1e9dd89e75c1fb Mon Sep 17 00:00:00 2001 From: A Tobey Date: Thu, 10 Sep 2026 19:33:38 -0400 Subject: [PATCH 2/4] fix(release): gate publishing on the tag-push event, smoke before publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 42 +++++++++++++++++++++++------------ .goreleaser.yml | 4 +++- BOTS.md | 18 ++++++++------- Dockerfile | 2 +- README.md | 7 +++--- 5 files changed, 46 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8ae7ae..593c35e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,9 @@ # is signed and attested by the same machinery. Verification commands live in # .github/release-body.md (rendered onto each release page) and the README. # -# Publishing, signing, and attesting are all gated on the ref being a tag, so a +# Publishing, signing, and attesting are all gated on the EVENT being a tag +# push, not merely on the ref being a tag: `gh workflow run release.yml --ref +# v0.6.0` is a dispatch whose ref_type is 'tag', and must stay a snapshot. So a # dispatch run never publishes — but note that the job's permissions (including # id-token) are granted on dispatch too; the gating is by step condition. name: release @@ -56,7 +58,7 @@ jobs: # cannot push to the tap repo; this is a fine-grained PAT with Contents: # write on tobert/homebrew-otel-cli, stored as a repository secret. - name: Preflight (tap token present) - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' env: HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} run: | @@ -65,6 +67,16 @@ jobs: exit 1 fi + # Fail before anything is published: a binary that compiles but crashes at + # startup should stop the release here, not after GoReleaser has created + # the GitHub release and pushed the tap. The shipped binaries are smoked + # again below. + - name: Smoke (build and run before publishing) + run: | + go build -ldflags="-s -w -X main.version=prerelease-smoke -X main.commit=${{ github.sha }}" + ./otel-cli version + rm ./otel-cli + - name: Install syft (SBOM) uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 @@ -80,7 +92,7 @@ jobs: with: distribution: goreleaser version: "~> v2" - args: ${{ github.ref_type == 'tag' && 'release --clean --release-header-tmpl=.github/release-body.md' || 'release --clean --snapshot --skip=sign' }} + args: ${{ github.event_name == 'push' && github.ref_type == 'tag' && 'release --clean --release-header-tmpl=.github/release-body.md' || 'release --clean --snapshot --skip=sign' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} @@ -89,7 +101,7 @@ jobs: # startup, so execute the linux/amd64 binary the release will ship. - name: Smoke (run the binary) run: | - bin=$(jq -r '.[] | select(.type == "Binary" and .goos == "linux" and .goarch == "amd64") | .path' dist/artifacts.json) + bin=$(jq -r 'first(.[] | select(.type == "Binary" and .goos == "linux" and .goarch == "amd64") | .path)' dist/artifacts.json) [ -n "$bin" ] || { echo "no linux/amd64 binary in dist/artifacts.json"; exit 1; } "$bin" version @@ -98,7 +110,7 @@ jobs: # here — hence `|| true` plus a grep instead of a plain run. - name: Smoke (assert fully static) run: | - bin=$(jq -r '.[] | select(.type == "Binary" and .goos == "linux" and .goarch == "amd64") | .path' dist/artifacts.json) + bin=$(jq -r 'first(.[] | select(.type == "Binary" and .goos == "linux" and .goarch == "amd64") | .path)' dist/artifacts.json) out=$(ldd "$bin" 2>&1 || true) echo "$out" grep -q "not a dynamic executable" <<<"$out" @@ -108,7 +120,7 @@ jobs: # assets): `gh attestation verify -R tobert/otel-cli` is the # one-command per-file verification path. - name: Attest build provenance - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-checksums: dist/checksums.txt @@ -121,7 +133,7 @@ jobs: - name: Stage per-arch binaries run: | stage() { # - bin=$(jq -r --arg arch "$1" '.[] | select(.type == "Binary" and .goos == "linux" and .goarch == $arch) | .path' dist/artifacts.json) + bin=$(jq -r --arg arch "$1" 'first(.[] | select(.type == "Binary" and .goos == "linux" and .goarch == $arch) | .path)' dist/artifacts.json) [ -n "$bin" ] || { echo "no linux/$1 binary in dist/artifacts.json"; exit 1; } install -D -m 0755 "$bin" "image/binaries/$2/otel-cli" } @@ -139,7 +151,7 @@ jobs: docker run --rm otel-cli-smoke version - name: Login to ghcr - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io @@ -162,7 +174,7 @@ jobs: type=semver,pattern=v{{version}} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=sha,enable=${{ github.ref_type != 'tag' }} + type=sha,enable=${{ !(github.event_name == 'push' && github.ref_type == 'tag') }} # Tag runs push BY DIGEST here and apply the version tags in the last step # below, after signing. Order matters for the package page: GitHub's @@ -178,16 +190,16 @@ jobs: file: release/Dockerfile platforms: linux/amd64,linux/arm64 labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=ghcr.io/tobert/otel-cli,name-canonical=true,push-by-digest=true,push=${{ github.ref_type == 'tag' }} + outputs: type=image,name=ghcr.io/tobert/otel-cli,name-canonical=true,push-by-digest=true,push=${{ github.event_name == 'push' && github.ref_type == 'tag' }} # Same keyless machinery as the checksums signature, aimed at the manifest # digest — the signature covers every tag later pointed at it. - name: Sign image (cosign keyless) - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' run: cosign sign --yes ghcr.io/tobert/otel-cli@${{ steps.build.outputs.digest }} - name: Attest image provenance - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-name: ghcr.io/tobert/otel-cli @@ -199,11 +211,13 @@ jobs: # rebuild, no layer movement — and because this is the package's newest # publish, the install box shows a real, pullable version. - name: Publish version tags (last, so "Latest" is pullable) - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' env: TAGS: ${{ steps.meta.outputs.tags }} DIGEST: ${{ steps.build.outputs.digest }} run: | args=() - while IFS= read -r t; do [ -n "$t" ] && args+=(-t "$t"); done <<< "$TAGS" + # sort -u: on a prerelease every semver pattern degrades to the same + # full version, so metadata-action emits duplicate tags. + while IFS= read -r t; do [ -n "$t" ] && args+=(-t "$t"); done <<< "$(sort -u <<< "$TAGS")" docker buildx imagetools create "${args[@]}" "ghcr.io/tobert/otel-cli@$DIGEST" diff --git a/.goreleaser.yml b/.goreleaser.yml index ff21cfa..95b8ace 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -74,7 +74,9 @@ checksum: name_template: "checksums.txt" # One SBOM for the whole release, cataloged from go.mod/go.sum (every target -# shares the same module graph), rather than one document per archive. syft is +# shares the same module graph), rather than one document per archive. +# `artifacts: any` + one `documents` entry is the documented shape for that +# (verified on v2.18: exactly one SBOM, listed in checksums.txt). syft is # installed by the workflow; local snapshot runs pass --skip=sbom. sboms: - id: source diff --git a/BOTS.md b/BOTS.md index 7edd8ba..28c81ec 100644 --- a/BOTS.md +++ b/BOTS.md @@ -335,18 +335,20 @@ When creating a PR, write a good description - it becomes the squash commit mess - **MINOR**: New features, significant changes - **PATCH**: Bug fixes, small improvements -**Releases are managed by goreleaser:** +**Releases are cut by GitHub Actions on a tag push:** -- Version is set via git tags -- Release process documented in README.md -- CHANGELOG.md is updated with each release -- goreleaser handles building for multiple platforms +- Pushing a `v*` tag runs `.github/workflows/release.yml`, which drives goreleaser + (`.goreleaser.yml`) for binaries, packages, SBOM, signing, the GitHub release and the + Homebrew formula, then builds, signs, attests and tags the ghcr image +- A `-rc.N` tag is a GitHub prerelease: it skips the Homebrew tap and never becomes `latest` +- `workflow_dispatch` runs the same workflow as a snapshot that publishes nothing +- The full procedure and the one required secret are in README.md under "Releases" **When bumping versions:** -1. Update CHANGELOG.md with changes -2. Tag the release: `git tag v0.X.Y` -3. Let maintainers handle goreleaser +1. Update CHANGELOG.md with changes and merge that to main first +2. Tag the release off main: `git tag v0.X.Y && git push origin v0.X.Y` +3. Watch the `release` workflow; nothing else is manual ## 🎯 When Working on otel-cli diff --git a/Dockerfile b/Dockerfile index 3f49231..34d229a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:latest AS builder +FROM golang:1.26 AS builder WORKDIR /build COPY . . diff --git a/README.md b/README.md index d99db25..f39be1b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ We publish a number of package formats for otel-cli, including tar.gz, zip (wind apk (Alpine), rpm (Red Hat variants), deb (Debian variants), and a brew tap. These can be found on the repo's [Releases](https://github.com/tobert/otel-cli/releases) page. -On most platforms the easiest way is a go get: +On most platforms the easiest way is `go install`: ```shell go install github.com/tobert/otel-cli@latest @@ -209,8 +209,9 @@ otel-cli span --attrs 'item1=value1,"item2=value2,value3",item3=value4' ### Docker TLS Certificates -As of release 0.4.2, otel-cli containers are built off the latest Alpine base -image which contains the base CA certificate bundles. In order to override +Since release 0.4.2, otel-cli containers are built off an Alpine base image +(pinned by digest in [release/Dockerfile](release/Dockerfile) and bumped by +Dependabot) which contains the base CA certificate bundles. In order to override these for e.g. a self-signed certificate, the best bet is to volume mount your own /etc/ssl into the container, and it should get picked up by otel-cli and Go's TLS libraries. From 1e94e2ea3d0cae0900a5fb486779d68217cd1ccc Mon Sep 17 00:00:00 2001 From: A Tobey Date: Thu, 10 Sep 2026 19:41:14 -0400 Subject: [PATCH 3/4] fix(release): correct nfpm maintainer email to tobert@gmail.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 --- .goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 95b8ace..cb0ce2e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -62,7 +62,7 @@ archives: nfpms: - package_name: otel-cli homepage: https://github.com/tobert/otel-cli - maintainer: Amy Tobey + maintainer: Amy Tobey description: OpenTelemetry CLI Application (Server & Client) license: Apache 2.0 formats: From ccc932c73aeaa3a4d927b67948b037637e5a3aa3 Mon Sep 17 00:00:00 2001 From: A Tobey Date: Thu, 10 Sep 2026 19:47:40 -0400 Subject: [PATCH 4/4] fix(ci): keep the job id `build` - main's ruleset requires that check 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 --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ad4503..10aa614 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,9 @@ concurrency: cancel-in-progress: true jobs: - test: - name: test + # The job id is `build` because main's ruleset requires a status check with + # that context name; renaming it silently blocks every PR. + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1