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..10aa614 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,88 @@ +# 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: + # 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: - - 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..593c35e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,223 @@ +# 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 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 + +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.event_name == 'push' && 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 + + # 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 + + - 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.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 }} + + # 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 '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 + + # 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 '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" + + # 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.event_name == 'push' && 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" '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" + } + 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.event_name == 'push' && 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.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 + # 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.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.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.event_name == 'push' && 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.event_name == 'push' && github.ref_type == 'tag' + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + args=() + # 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 41ad4bd..cb0ce2e 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,22 +37,32 @@ 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 homepage: https://github.com/tobert/otel-cli - maintainer: Amy Tobey + maintainer: Amy Tobey description: OpenTelemetry CLI Application (Server & Client) license: Apache 2.0 formats: @@ -54,66 +70,81 @@ 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. +# `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 + 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/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/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/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 4b320d4..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 @@ -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 @@ -180,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. @@ -313,25 +343,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 /