mirror of
https://github.com/enix/x509-certificate-exporter.git
synced 2026-08-19 03:56:21 +00:00
323 lines
15 KiB
YAML
323 lines
15 KiB
YAML
name: Release
|
||
|
||
# Triggered by pushing a vX.Y.Z tag (typically by release-please when
|
||
# its release PR is merged). Pre-release gates + three release jobs:
|
||
#
|
||
# 0. check — `task check` (lint + tests + security) on the tagged
|
||
# commit, by re-using the same three workflows that
|
||
# gate every PR (lint.yaml / security.yaml / test.yaml).
|
||
# Each gate runs on isolated runners with NO access to
|
||
# the `release` Environment, so a misbehaving check
|
||
# never sees the push secrets. The release jobs (1-3
|
||
# below) `needs:` every gate; a single failure
|
||
# short-circuits the whole workflow before any
|
||
# artifact moves.
|
||
# 1. goreleaser — builds binaries × OS/arch, archives, checksums,
|
||
# container images (busybox + scratch, multi-arch,
|
||
# pushed to ghcr/quay/docker.io), cosign-signs
|
||
# everything, attaches assets to the GitHub Release.
|
||
# Also produces a SLSA Build Level 3 provenance
|
||
# attestation over all binaries via GitHub's native
|
||
# `actions/attest-build-provenance`. Driven by
|
||
# .goreleaser.yaml + the attest step at the tail.
|
||
# 2. chart — packages the Helm chart, pushes as OCI artifact,
|
||
# cosign-signs.
|
||
#
|
||
# Container image SBOMs (CycloneDX via syft, attached as cosign
|
||
# attestations) are generated in a post-loop inside the goreleaser job,
|
||
# after GoReleaser has pushed the manifests.
|
||
#
|
||
# Verification by consumers (commands shown for the upstream repo;
|
||
# substitute the source-uri / certificate-identity / owner for forks):
|
||
# - Binary: gh attestation verify <archive> --owner enix
|
||
# - Image: cosign verify <ref> --certificate-identity-regexp '...' --certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||
# - SBOM: cosign verify-attestation <ref> --type cyclonedx --certificate-identity-regexp ...
|
||
# - Chart: cosign verify <chart-oci-ref> --certificate-identity-regexp '...' --certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||
#
|
||
# Manual setup required:
|
||
# - Repo environment "release" with required reviewers (Settings → Environments)
|
||
# - Environment variables on the `release` env (no fallbacks — workflow fails fast):
|
||
# - IMAGE_NAME — container image name (e.g. x509-certificate-exporter)
|
||
# - CHART_NAME — Helm chart name (often == IMAGE_NAME)
|
||
# - CHART_REGISTRY — OCI host/namespace where the chart is pushed,
|
||
# WITHOUT the `oci://` scheme (e.g. quay.io/enix/charts)
|
||
# - Repo secrets:
|
||
# - quay.io → QUAY_USERNAME, QUAY_TOKEN
|
||
# - docker.io → DOCKERHUB_USERNAME, DOCKERHUB_TOKEN
|
||
# - ghcr.io → no secret needed (uses workflow GITHUB_TOKEN)
|
||
# - Chart OCI registry credentials:
|
||
# - CHART_REGISTRY_USERNAME
|
||
# - CHART_REGISTRY_TOKEN
|
||
# - Branch protection on main configured separately
|
||
|
||
on:
|
||
push:
|
||
tags:
|
||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
||
|
||
permissions: {}
|
||
|
||
concurrency:
|
||
group: release-${{ github.ref }}
|
||
cancel-in-progress: false
|
||
|
||
jobs:
|
||
# ─── 0. Pre-release gates (`task check`) ─────────────────────────────────
|
||
# Three reusable workflows — same ones that gate every PR — re-run
|
||
# on the tagged commit. Each runs on its own runner (strict isolation
|
||
# from the build environment) and intentionally has NO access to the
|
||
# `release` Environment, so a misbehaving gate never sees the push
|
||
# secrets. The release jobs below `needs:` all three; a single failure
|
||
# aborts before anything moves to a registry.
|
||
#
|
||
# Ordering: `security` runs first; `lint` and `test` `needs:` it so a
|
||
# secret leak / vuln finding short-circuits the rest of the gate
|
||
# before consuming CI minutes on lint and tests.
|
||
security:
|
||
uses: ./.github/workflows/security.yaml
|
||
permissions:
|
||
contents: read
|
||
pull-requests: write # gitleaks-action posts on PRs; no-op on tag pushes
|
||
# `secrets: inherit` propagates GITLEAKS_LICENSE (a repo-level
|
||
# secret) into the called workflow. Scope is safe: this `security`
|
||
# job has no `environment:` set, and neither does any job inside
|
||
# security.yaml — so the `environment: release` secrets used by
|
||
# the goreleaser/chart jobs below remain out of reach throughout
|
||
# this chain. `inherit` propagates the calling job's context, not
|
||
# the entire workflow's.
|
||
secrets: inherit
|
||
lint:
|
||
needs: security
|
||
uses: ./.github/workflows/lint.yaml
|
||
permissions:
|
||
contents: read
|
||
with:
|
||
skip-renovate: true
|
||
test:
|
||
needs: security
|
||
uses: ./.github/workflows/test.yaml
|
||
permissions:
|
||
contents: read
|
||
with:
|
||
skip-e2e: true
|
||
|
||
# ─── 1. Build, push, sign everything (binaries + images) ─────────────────
|
||
goreleaser:
|
||
name: Build, push & sign
|
||
needs: [lint, security, test]
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 60
|
||
environment: release
|
||
permissions:
|
||
contents: write # create/append the GitHub Release
|
||
packages: write # push to ghcr.io
|
||
id-token: write # OIDC for cosign keyless + SLSA provenance signing
|
||
attestations: write # upload SLSA build provenance to the GitHub Attestations API
|
||
env:
|
||
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
|
||
steps:
|
||
- name: Validate environment variables
|
||
run: |
|
||
set -euo pipefail
|
||
[ -n "${IMAGE_NAME:-}" ] || {
|
||
echo "::error::Missing required Environment variable: IMAGE_NAME"
|
||
echo "::error::Set it at Settings → Environments → release → Variables"
|
||
exit 1
|
||
}
|
||
|
||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||
with:
|
||
# GoReleaser inspects git history for the changelog. Full clone
|
||
# avoids "shallow clone" warnings on large repos.
|
||
fetch-depth: 0
|
||
|
||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||
with:
|
||
go-version-file: go.mod
|
||
|
||
# buildx + qemu enable cross-arch image manifest building. The
|
||
# Dockerfiles only do COPY, so QEMU isn't strictly required for
|
||
# execution — but binfmt registration is needed so buildx
|
||
# recognises linux/arm64 and linux/riscv64 as valid platforms.
|
||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
|
||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||
|
||
# Logins for the three image registries. Quay/Docker Hub are
|
||
# skipped if their token is missing, mirroring the previous
|
||
# workflow's per-registry opt-in pattern.
|
||
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||
with:
|
||
registry: ghcr.io
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.GITHUB_TOKEN }}
|
||
- name: Login to quay.io
|
||
env:
|
||
QUAY_TOKEN: ${{ secrets.QUAY_TOKEN }}
|
||
if: env.QUAY_TOKEN != ''
|
||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||
with:
|
||
registry: quay.io
|
||
username: ${{ secrets.QUAY_USERNAME }}
|
||
password: ${{ secrets.QUAY_TOKEN }}
|
||
- name: Login to docker.io
|
||
env:
|
||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||
if: env.DOCKERHUB_TOKEN != ''
|
||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||
with:
|
||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||
|
||
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4
|
||
- uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0
|
||
|
||
- uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7
|
||
with:
|
||
version: latest
|
||
args: release --clean
|
||
# GITHUB_TOKEN is the credential goreleaser uses to attach
|
||
# archives + checksums + signatures to the GitHub Release stub
|
||
# release-please created. The action does NOT inject the
|
||
# token automatically — it has to be in the env. The job's
|
||
# `permissions.contents: write` is what gives this token the
|
||
# write-Release scope.
|
||
env:
|
||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
# SLSA Build Level 3 provenance for every binary listed in
|
||
# GoReleaser's dist/checksums.txt (sha256sum -c format). Signed
|
||
# via Sigstore (Fulcio + Rekor) using the workflow's OIDC token,
|
||
# uploaded to GitHub's native Attestations API. Verified by
|
||
# consumers with `gh attestation verify <archive> --owner <org>`.
|
||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4
|
||
with:
|
||
subject-checksums: dist/checksums.txt
|
||
|
||
# CycloneDX SBOMs on the multi-arch manifests, as cosign
|
||
# attestations. Iterates over the images GoReleaser pushed
|
||
# (read from dist/artifacts.json — its source of truth).
|
||
#
|
||
# dockers_v2 emits one "Docker Image" artifact per (registry, tag),
|
||
# where each tag is the multi-arch index manifest pushed by a single
|
||
# `docker buildx build --push --platform=...` invocation. There's no
|
||
# separate "Docker Manifest" artifact type anymore — those existed
|
||
# only for the legacy `dockers + docker_manifests` two-step flow.
|
||
#
|
||
# Dedup by digest before attesting: on stable releases, the same
|
||
# multi-arch index is pushed under multiple tags (e.g. `:VERSION`
|
||
# and `:latest`), which resolve to the SAME digest. Without
|
||
# dedup we'd run syft + cosign attest twice on the same content,
|
||
# doubling the work and the exposure to registry flakes.
|
||
#
|
||
# GODEBUG=http2client=0 forces HTTP/1.1 in the Go HTTP client used
|
||
# by cosign / go-containerregistry. Mitigates Docker Hub's flaky
|
||
# HTTP/2 stream resets ("INTERNAL_ERROR; received from peer") that
|
||
# have hit this step intermittently on the attestation upload.
|
||
- name: Generate + attest image SBOMs
|
||
env:
|
||
COSIGN_YES: "true"
|
||
GODEBUG: http2client=0
|
||
run: |
|
||
set -euo pipefail
|
||
# Resolve every pushed (registry, tag) to its digest, then
|
||
# dedup — the digest is the immutable identity, so attesting
|
||
# once per digest is enough. `${ref%:*}` strips the trailing
|
||
# `:tag` (shortest-match suffix removal).
|
||
while read -r ref; do
|
||
digest=$(docker buildx imagetools inspect "$ref" \
|
||
--format '{{ .Manifest.Digest }}')
|
||
echo "${ref%:*}@${digest}"
|
||
done < <(jq -r '.[] | select(.type == "Docker Image") | .name' dist/artifacts.json) \
|
||
| sort -u \
|
||
| while read -r ref_by_digest; do
|
||
echo "::group::SBOM for $ref_by_digest"
|
||
syft "$ref_by_digest" -o cyclonedx-json=/tmp/sbom.json
|
||
cosign attest --predicate /tmp/sbom.json --type cyclonedx "$ref_by_digest"
|
||
rm -f /tmp/sbom.json
|
||
echo "::endgroup::"
|
||
done
|
||
|
||
# ─── 2. Helm chart: package, push as OCI, cosign-sign ────────────────────
|
||
chart:
|
||
name: Chart publish & sign
|
||
needs: goreleaser # the chart references the just-pushed images
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 15
|
||
environment: release
|
||
permissions:
|
||
contents: read
|
||
id-token: write # OIDC for cosign keyless
|
||
env:
|
||
CHART_NAME: ${{ vars.CHART_NAME }}
|
||
CHART_REGISTRY: ${{ vars.CHART_REGISTRY }}
|
||
steps:
|
||
- name: Validate environment variables
|
||
run: |
|
||
set -euo pipefail
|
||
missing=()
|
||
[ -n "${CHART_NAME:-}" ] || missing+=("CHART_NAME")
|
||
[ -n "${CHART_REGISTRY:-}" ] || missing+=("CHART_REGISTRY")
|
||
if [ ${#missing[@]} -gt 0 ]; then
|
||
echo "::error::Missing required Environment variable(s): ${missing[*]}"
|
||
echo "::error::Set them at Settings → Environments → release → Variables"
|
||
exit 1
|
||
fi
|
||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||
- uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5
|
||
with:
|
||
version: "3.18.0"
|
||
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4
|
||
# Two separate logins because helm and cosign use different auth stores:
|
||
# - `helm registry login` writes to helm's own config (used by `helm push` below)
|
||
# - `cosign login` writes to ~/.docker/config.json (used by cosign when
|
||
# it uploads the signature blob as an OCI artifact in the final step)
|
||
# Without the cosign login, `cosign sign` fails with UNAUTHORIZED on the
|
||
# signature blob upload to the chart registry.
|
||
- name: Login to chart OCI registry
|
||
env:
|
||
CHART_REGISTRY_USERNAME: ${{ secrets.CHART_REGISTRY_USERNAME }}
|
||
CHART_REGISTRY_TOKEN: ${{ secrets.CHART_REGISTRY_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
host="${CHART_REGISTRY%%/*}"
|
||
echo "$CHART_REGISTRY_TOKEN" | helm registry login -u "$CHART_REGISTRY_USERNAME" --password-stdin "$host"
|
||
echo "$CHART_REGISTRY_TOKEN" | cosign login -u "$CHART_REGISTRY_USERNAME" --password-stdin "$host"
|
||
- name: Override chart name if needed
|
||
run: |
|
||
set -euo pipefail
|
||
source_name=$(awk '/^name:/ {print $2; exit}' chart/Chart.yaml)
|
||
if [ "$source_name" != "$CHART_NAME" ]; then
|
||
sed -i "s|^name:.*|name: $CHART_NAME|" chart/Chart.yaml
|
||
echo "Renamed chart from '$source_name' to '$CHART_NAME'"
|
||
fi
|
||
- name: Copy LICENSE into chart
|
||
run: cp LICENSE chart/LICENSE
|
||
- name: Helm package
|
||
run: |
|
||
set -euo pipefail
|
||
version="${GITHUB_REF_NAME#v}"
|
||
mkdir -p dist
|
||
helm package chart/ \
|
||
--destination dist/ \
|
||
--version "$version" \
|
||
--app-version "$version"
|
||
- name: Helm push to OCI registry
|
||
id: push
|
||
run: |
|
||
set -euo pipefail
|
||
version="${GITHUB_REF_NAME#v}"
|
||
chart_tgz="dist/${CHART_NAME}-${version}.tgz"
|
||
out=$(helm push "$chart_tgz" "oci://${CHART_REGISTRY}" 2>&1 | tee /dev/stderr)
|
||
digest=$(printf '%s\n' "$out" | grep -oE 'sha256:[a-f0-9]+' | head -1)
|
||
if [ -z "$digest" ]; then
|
||
echo "::error::Could not extract digest from helm push output"
|
||
exit 1
|
||
fi
|
||
ref="${CHART_REGISTRY}/${CHART_NAME}@${digest}"
|
||
echo "ref=$ref" >> "$GITHUB_OUTPUT"
|
||
- name: Sign chart with cosign keyless
|
||
env:
|
||
COSIGN_YES: "true"
|
||
run: cosign sign "${{ steps.push.outputs.ref }}"
|