diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2f436d8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,92 @@ +name: Release (artifacts + signing) +# Builds release artifacts and signs them when a `v*` tag is pushed. +# +# The tag itself is cut by scripts/release.sh: it builds the React UI, makes a +# DETACHED commit embedding internal/ui/dist, tags it, and pushes ONLY the tag +# (main stays source-only). This workflow runs on that tag push and owns +# release ARTIFACTS + SIGNING — GoReleaser builds the cross-platform binaries, +# emits checksums + SBOMs, and cosign keyless-signs the checksums file so a +# .sig + .pem pair attaches to the release. That satisfies Scorecard's +# Packaging (a publishing workflow is detected) and Signed-Releases checks. +# +# release.sh embeds internal/ui/dist into the detached tagged commit, but this +# workflow rebuilds the UI (npm ci && npm run build) BEFORE invoking goreleaser +# anyway: `go build` only recompiles Go (never runs vite), so rebuilding here +# guarantees the embedded dist is present and current regardless of how the tag +# was produced. +# +# All third-party actions are SHA-pinned per Scorecard Pinned-Dependencies; +# pins are reused verbatim from security.yml / scorecard.yml where they overlap. +on: + push: + tags: + - 'v*' + +# Workflow-level default: minimum needed for actions/checkout. The release job +# escalates to contents: write (create/append the GitHub release + upload +# assets) and id-token: write (cosign keyless OIDC). +permissions: + contents: read + +jobs: + goreleaser: + name: GoReleaser (build · sbom · sign) + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Harden runner egress + # step-security/harden-runner v2.19.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 + with: + egress-policy: audit + + - name: Checkout tag + # actions/checkout v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + # Full history so goreleaser's changelog/version detection works. + fetch-depth: 0 + persist-credentials: false + + - name: Set up Go + # actions/setup-go v6.2.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 + with: + go-version-file: go.mod + cache: true + + - name: Set up Node + # actions/setup-node v6.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: '24' + + - name: Build UI (embedded into the binary) + # The Go binary embeds internal/ui/dist via //go:embed; build it before + # goreleaser compiles. `npm run build` emits into internal/ui/dist. + run: | + cd ui + npm ci + npm run build + test -f ../internal/ui/dist/index.html + + - name: Install cosign + # sigstore/cosign-installer v3.9.2 — provides `cosign` for the keyless + # signing step in .goreleaser.yaml (signs: cmd: cosign). + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 + + - name: Run GoReleaser + # goreleaser/goreleaser-action v6.2.1 + uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 + with: + version: '~> v2' + args: release --clean + env: + # Pure-Go SQLite driver → cgo off (matches .goreleaser.yaml builds.env). + CGO_ENABLED: '0' + # Default token: goreleaser uses it to create/append the release. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # cosign keyless reads the OIDC token from these (set by id-token: write). + COSIGN_EXPERIMENTAL: '1' diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..4e170d4 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,83 @@ +# GoReleaser config — release ARTIFACTS + signing for OtelContext. +# Triggered by the `release.yml` workflow on a `v*` tag push (the tag is cut by +# scripts/release.sh, which also builds + embeds the React UI into the tagged +# commit). See CLAUDE.md "Security & Supply Chain" for the split of duties. +# +# OSS-only (cost $0): GoReleaser OSS v2, cosign (keyless OIDC), syft for SBOMs. +# No Pro-only keys are used. Validate locally with `goreleaser check`. +version: 2 + +project_name: otelcontext + +# The UI must already be built into internal/ui/dist before this runs — the +# binary embeds it via `//go:embed all:dist`. release.yml runs `npm ci && +# npm run build` in ui/ before invoking goreleaser; this hook just fails loudly +# if that step was skipped (e.g. a local `goreleaser release` without the UI). +before: + hooks: + - sh -c 'test -f internal/ui/dist/index.html || { echo "internal/ui/dist not built — run (cd ui && npm ci && npm run build) first" >&2; exit 1; }' + +builds: + - id: otelcontext + binary: otelcontext + main: . + # Pure-Go SQLite driver → no cgo; required for cross-compilation here. + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - -s -w + +archives: + - id: otelcontext + formats: [tar.gz] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + files: + - LICENSE.md + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +sboms: + - id: archive-sbom + artifacts: archive + +# Sign the checksums file with cosign keyless (Sigstore OIDC). Signing the +# checksums file transitively covers every archive listed in it, so a single +# .sig + .pem pair attaches to the release and Scorecard's Signed-Releases +# check is satisfied. id-token: write must be granted to the release job. +signs: + - cmd: cosign + certificate: "${artifact}.pem" + args: + - sign-blob + - --yes + - --output-signature + - "${signature}" + - --output-certificate + - "${certificate}" + - "${artifact}" + artifacts: checksum + output: true + +release: + # scripts/release.sh --release may already have created the GitHub release for + # this tag (with notes, no artifacts). `append` adds goreleaser's artifacts to + # that existing release instead of failing on "release already exists". If the + # release does not yet exist, goreleaser creates it. + mode: append + +changelog: + # release.sh owns the human-facing release notes; keep goreleaser's autogenerated + # changelog out of the way so the two don't fight over the release body. + disable: true diff --git a/CLAUDE.md b/CLAUDE.md index 6d649be..7a58b81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,6 +292,14 @@ All actions are SHA-pinned per Scorecard `Pinned-Dependencies`. Top-level `permi - **Baseline:** to be measured after first push to `main`. Track via the Scorecard dashboard linked from the README badge. - **Stretch target:** ≥ 8.0/10. Best-effort — Scorecard does **not** gate merge per the board ruling. The `passing` Best Practices badge is the only hard supply-chain gate. +### Release artifacts & signing (`.github/workflows/release.yml` + `.goreleaser.yaml`) + +Triggered by a `v*` tag push (the tag is cut by `scripts/release.sh`). GoReleaser OSS v2 builds cross-platform binaries (linux/darwin × amd64/arm64, `CGO_ENABLED=0` for the pure-Go SQLite driver, `-trimpath -s -w`), emits `tar.gz` archives (incl. LICENSE.md + README.md), a sha256 `checksums.txt`, and per-archive SBOMs via syft. **cosign keyless** (Sigstore OIDC; the release job grants `id-token: write`) signs the checksums file, attaching `checksums.txt.sig` + `checksums.txt.pem` to the release. This is what makes Scorecard's **Packaging** (a publishing workflow is detected) and **Signed-Releases** checks score. + +Division of labour: `scripts/release.sh` builds the UI + pushes the tag (and, with `--release`, creates the GitHub release shell with notes but **no artifacts**); `release.yml` then runs GoReleaser with `release.mode: append`, which adds the signed artifacts to that same release without racing on "release already exists". All third-party actions are SHA-pinned per Scorecard `Pinned-Dependencies` (pins reused from `security.yml`/`scorecard.yml` where they overlap). $0/OSS-only: GoReleaser OSS, cosign (Apache-2.0), syft (Apache-2.0) — no Pro-only config keys. + +> Caveat: Packaging/Signed-Releases only **score** after the next `v*` tag-push actually runs `release.yml` and produces a signed release. + ### Vulnerability reporting See [`SECURITY.md`](SECURITY.md). Preferred channel: GitHub Security Advisories at `https://github.com/RandomCodeSpace/otelcontext/security/advisories/new`. Email fallback: `ak.nitrr13@gmail.com` with subject prefix `[otelcontext security]`. diff --git a/scripts/release.sh b/scripts/release.sh index 169beda..d89e6f7 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -15,9 +15,23 @@ # the release commit is reachable solely through the tag, so no build artifact # ever lands on the branch. # +# Division of labour with .github/workflows/release.yml +# ----------------------------------------------------- +# Pushing the tag (below) triggers release.yml, which owns release ARTIFACTS + +# SIGNING: GoReleaser builds the cross-platform binaries, emits checksums + +# SBOMs, and cosign keyless-signs the checksums file (.sig + .pem). This script +# only builds the UI and pushes the tag; it does NOT upload any binaries. +# +# `--release` here creates the GitHub release shell EARLY (title + install +# notes, NO artifacts) so humans see notes immediately. GoReleaser is configured +# with `release.mode: append` (.goreleaser.yaml), so when its run finishes it +# ADDS its signed artifacts to this same release rather than failing on +# "release already exists" — no race. Omit `--release` and GoReleaser creates +# the release itself from scratch. +# # Usage: -# scripts/release.sh vX.Y.Z[-pre] # build + push tag -# scripts/release.sh vX.Y.Z[-pre] --release # also create a GitHub pre-release +# scripts/release.sh vX.Y.Z[-pre] # build + push tag (release.yml does the rest) +# scripts/release.sh vX.Y.Z[-pre] --release # also create the GitHub release shell (notes only) # set -euo pipefail @@ -69,7 +83,10 @@ echo "✓ pushed tag $VER -> $(git rev-parse --short "$VER")" # (the EXIT trap now restores main to the source-only state) -# --- Optional GitHub pre-release -------------------------------------------- +# --- Optional GitHub pre-release (shell only; release.yml appends artifacts) - +# Creates the release with title + notes and NO artifacts. The tag push above +# triggers .github/workflows/release.yml, whose GoReleaser run (release.mode: +# append) attaches the signed binaries, checksums, and SBOMs to this release. if [ "$MAKE_RELEASE" = "--release" ]; then prev="$(git describe --tags --abbrev=0 "${VER}^" 2>/dev/null || true)" range="${prev:+${prev}..}${VER}"