Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions .github/release-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!--
Rendered onto every release page by GoReleaser (--release-header-tmpl in
release.yml), above GitHub's auto-generated notes. GoReleaser templates it, so
.Tag is the git tag (v-prefixed) and .Version the same without the v — how the
image is tagged. This is the one surface where every placeholder is concrete —
the commands below carry the exact tag, ready to copy — so keep it that way: no
angle-bracket stand-ins. HTML comments don't render on GitHub.
-->
## 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
```

---
87 changes: 76 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
223 changes: 223 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 <file> -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/<TARGETARCH>/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() { # <goarch> <docker-arch>
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"
Loading