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.
| 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.ymlruns directly for PRs and is invoked byrelease.ymlas a reusable workflow for pushes tomain. PR runs and main runs execute the same jobs from the same file — there is nopr.yml/main.ymlpair that can drift apart. - Release only after everything passed.
release.ymlchainsci → release-please → publish-manifestwithneeds:. This is deliberate: tags created by release-please withGITHUB_TOKENnever 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 thecirun 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.ymltherefore splits the action: therelease-pleasejob cuts the release withskip-github-pull-request, and a separaterelease-please-prjob opens/refreshes the next PR withskip-github-releaseafterpublish-releasehas made the release public and visible. Same guarantee, correct changelog.
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
- 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 withIMAGE_DELIVERY_MODE=loadinstead 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-refreshe2e lane validates the local build → k3d load → rollout chain with a locally built image (PROJECT_IMAGEunset), 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.
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-reverserVerify 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.yamlThese 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.
- Every GitHub Action is pinned to a full commit SHA (with a
# vX.Y.Zcomment); Dependabot'sgithub-actionsecosystem bumps pin + comment together. - Every base image is pinned by digest (
golang,alpine,distrolessin Dockerfile,golang-bookwormin .devcontainer/Dockerfile); Dependabot'sdockerecosystem keeps the digests moving. - Trivy scans images in every run: on PRs,
image-scanscans the built project image (report to the job log); onmain,image-scan-releasescans 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.
| 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 |