Skip to content

Latest commit

 

History

History
154 lines (126 loc) · 9.39 KB

File metadata and controls

154 lines (126 loc) · 9.39 KB

CI/CD Overview

The pipeline is split by trust level, not by topic. One rule drives the whole design:

Untrusted (pull-request) code may be built and tested, but it never meets a write token, a secret, or publishing credentials.

Everything else follows from that rule. The checks themselves come from the same Task graph you run locally — CI is just task lint, task test, and the e2e suite executed inside the same CI container the devcontainer is built from, so local and CI results cannot drift apart.

The four trust zones

Zone Workflow Trigger Secrets Writes Purpose
Untrusted validation ci.yml pull_request none none Full contributor validation: containers, lint, unit, e2e, image scan
Trusted validation release.yml → calls ci.yml push to main GITHUB_TOKEN packages (CI base container + release-grade image digests) The same jobs, plus building the release-grade multi-arch digests (per-arch, by digest) the release tail retags. The instrumented test image stays artifact-only.
Release & publish release.yml tail jobs after trusted validation is green GITHUB_TOKEN + OIDC packages, releases, attestations Version (release-please), retag the CI-built multi-arch digests to semver + latest (zero rebuilds), publish chart, sign, attest
Hygiene scorecard.yml weekly + main none security-events OpenSSF Scorecard supply-chain checks

Three properties are worth calling out:

  • One copy of the validation pipeline. ci.yml runs directly for PRs and is invoked by release.yml as a reusable workflow for pushes to main. PR runs and main runs execute the same jobs from the same file — there is no pr.yml/main.yml pair that can drift apart.
  • Release only after everything passed. release.yml chains ci → release-please → publish-manifest with needs:. This is deliberate: tags created by release-please with GITHUB_TOKEN never trigger other workflows (GitHub's recursion guard), so a separate tag-triggered release workflow would either silently not run or need a PAT. Chaining in one run keeps the guarantee structural: nothing can be published from a commit that did not pass the full pipeline first. The release tail builds nothing — the multi-arch image digests it publishes were built and scanned by the ci run of the same commit; the release only retags, signs, and attests them.
  • release-please runs in two passes, on purpose. A GitHub release is created as a draft so every signed asset can be attached before it goes public (immutable releases reject post-publish uploads — see Release artifacts). But a draft is invisible to release-please's own "latest release" lookup, so if the invocation that cut the draft also opened the next release PR, that PR would be computed against the whole history and propose a bogus "release everything" version. release.yml therefore splits the action: the release-please job cuts the release with skip-github-pull-request, and a separate release-please-pr job opens/refreshes the next PR with skip-github-release after publish-release has made the release public and visible. Same guarantee, correct changelog.

How fork PRs work

GitHub gives fork PRs a read-only GITHUB_TOKEN and no secrets. The pipeline adapts by changing the delivery of images, never the checks. The instrumented project (test) image now travels the fork-safe artifact path on every run — PR and main alike — so the battle-tested path is the only path. Only the CI base container and the release-grade digests are pushed, and only on main:

flowchart LR
  classDef pr fill:#e8ecff,stroke:#5566aa,color:#000;
  classDef trusted fill:#e6f7e6,stroke:#33aa33,color:#000;

  subgraph BOTH["instrumented test image — PR and main alike"]
    B1["build locally<br/>GOCOVER=1, push: false"]:::pr --> A1["docker save → artifact"]:::pr
    A1 --> C1["later jobs docker load<br/>e2e imports into k3d"]:::pr
  end

  subgraph MAIN["push to main only (trusted)"]
    B2["build-release amd64 + arm64<br/>clean, semver, push by digest"]:::trusted --> R2["ghcr.io candidate digests"]:::trusted
    R2 --> S2["Trivy-scanned;<br/>retagged at release"]:::trusted
  end
Loading
  • The CI base container and the project image are built from the PR's own code, so a PR that changes the toolchain is validated against its own toolchain.
  • The instrumented image travels between jobs as an artifact (docker save/docker load) on every run, and e2e imports it into k3d with IMAGE_DELIVERY_MODE=load instead of pulling. It is never pushed, so no instrumented digest can be mistaken for a promotable release candidate.
  • Nothing is published from a PR, regardless of origin. Same-repo PRs follow the exact same path so the two flavors can't diverge.
  • The image-refresh e2e lane validates the local build → k3d load → rollout chain with a locally built image (PROJECT_IMAGE unset), so it works identically on fork PRs.

First-time contributors additionally need a maintainer to approve the workflow run — that is a GitHub Actions repository setting, the last line of defense for CI-minute abuse, not something the workflow files control.

Release artifacts and how to verify them

On a release (release-please PR merged to main), release.yml publishes. The image bytes are not rebuilt at release time: build-release-amd64/-arm64 in the ci run built and pushed the per-arch digests (and image-scan-release scanned them), and the release step merges those exact digests into a multi-arch manifest tagged with the semver:

Artifact Where Integrity
Multi-arch image (linux/amd64, linux/arm64) ghcr.io/configbutler/gitops-reverser cosign keyless signature, SLSA build provenance attestation, SPDX SBOM attestation
Helm chart oci://ghcr.io/configbutler/charts/gitops-reverser cosign keyless signature
install.yaml, sbom.spdx.json GitHub release assets each signed directly (<asset>.sigstore.json) and SLSA-attested (<asset>.intoto.jsonl), also uploaded as release assets

Verify an image (also embedded in every release's notes):

cosign verify \
  --certificate-identity-regexp '^https://github.com/ConfigButler/gitops-reverser/\.github/workflows/release\.yml@refs/heads/main$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/configbutler/gitops-reverser:<version>

gh attestation verify oci://ghcr.io/configbutler/gitops-reverser:<version> \
  --repo ConfigButler/gitops-reverser

Verify a release asset (e.g. install.yaml) from its downloaded .sigstore.json bundle:

cosign verify-blob \
  --bundle install.yaml.sigstore.json \
  --certificate-identity-regexp '^https://github.com/ConfigButler/gitops-reverser/\.github/workflows/release\.yml@refs/heads/main$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  install.yaml

These per-asset signatures/attestations exist specifically so release assets carry their own verifiable integrity, independent of the OCI registry — the surface OpenSSF Scorecard's Signed-Releases check actually inspects.

Signing is keyless (Sigstore): there is no private key to store or leak. The signature certifies "built by the release.yml workflow on main of this repository", issued via GitHub's OIDC identity and logged in the public Rekor transparency log.

Supply-chain hygiene

  • Every GitHub Action is pinned to a full commit SHA (with a # vX.Y.Z comment); Dependabot's github-actions ecosystem bumps pin + comment together.
  • Every base image is pinned by digest (golang, alpine, distroless in Dockerfile, golang-bookworm in .devcontainer/Dockerfile); Dependabot's docker ecosystem keeps the digests moving.
  • Trivy scans images in every run: on PRs, image-scan scans the built project image (report to the job log); on main, image-scan-release scans both shipped arches (amd64 + arm64) by digest and uploads SARIF to code scanning — so the exact bytes a release retags are scanned before the release. Either way the job fails on CRITICAL vulnerabilities that have a fix available.
  • Minimal token permissions per job; the workflow default is contents: read.
  • OpenSSF Scorecard runs weekly and on every push to main.

Where things are defined

Concern Lives in
What gets checked (lint, unit, e2e, packaging) Taskfile-build.yml, test/e2e/Taskfile.yml — see tasks-overview.md
Tool versions .devcontainer/Dockerfile (single source for devcontainer and CI)
Validation pipeline .github/workflows/ci.yml
Release pipeline .github/workflows/release.yml
Hygiene .github/workflows/scorecard.yml, .github/dependabot.yml
Release process details .github/RELEASES.md