The CI Pipeline (GitHub Actions)
We have an image. Now we need a machine that builds it, proves it,
scans it, and signs it — automatically, the same way every time, on every push. That machine is the CI
pipeline, and this page is the complete .github/workflows/ci.yml for Snip, with every stage justified
by the manual step it removes.
The shape is the one from Pipelines & Stages: a sequence of stages, each a gate. If any gate fails, the pipeline stops and nothing downstream runs. The whole design rests on one principle — stop the line — so that a broken change is caught within minutes of being written instead of weeks later when someone tries to ship it.
The whole workflow
Section titled “The whole workflow”name: ci
on: pull_request: # run on every PR: prove the change before merge push: branches: [main] # run again on merge: build the artifact to ship
# Least-privilege token: read code, write packages (registry), and# request an OIDC token for keyless signing. Nothing else.permissions: contents: read packages: write id-token: write # required for cosign keyless signing
env: REGISTRY: ghcr.io IMAGE: ghcr.io/acme/snip
jobs: # ---- Stage 1: prove the code (fast, runs on every PR) ---------------- test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- uses: actions/setup-go@v5 with: go-version: "1.22" cache: true # cache modules across runs → faster
- name: Lint uses: golangci/golangci-lint-action@v6 with: version: latest
- name: Unit tests (with race detector + coverage) run: go test -race -coverprofile=coverage.out ./...
# ---- Stage 2: build, scan, sign, push (only after tests pass) -------- image: needs: test # GATE: do not build an image unless tests are green runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
# The immutable tag is the git SHA. One commit ⇒ one image, forever. - name: Compute image tag id: tag run: echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
# Build the image but DON'T push yet — we scan before anything leaves. - name: Build image (load locally, do not push) uses: docker/build-push-action@v6 with: context: . load: true tags: ${{ env.IMAGE }}:${{ steps.tag.outputs.sha }} cache-from: type=gha cache-to: type=gha,mode=max
# GATE: fail the build on HIGH/CRITICAL vulnerabilities. - name: Trivy vulnerability scan uses: aquasecurity/trivy-action@master with: image-ref: ${{ env.IMAGE }}:${{ steps.tag.outputs.sha }} severity: HIGH,CRITICAL ignore-unfixed: true # don't block on CVEs with no fix available exit-code: "1" # non-zero exit ⇒ pipeline stops here
# Only now, with a green scan, push the exact image we scanned. - name: Log in to registry if: github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image if: github.ref == 'refs/heads/main' run: docker push ${{ env.IMAGE }}:${{ steps.tag.outputs.sha }}
# Sign the pushed image so consumers can verify it came from this # pipeline. Keyless: identity from the GitHub OIDC token, no private key. - name: Install cosign if: github.ref == 'refs/heads/main' uses: sigstore/cosign-installer@v3
- name: Sign the image if: github.ref == 'refs/heads/main' run: cosign sign --yes ${{ env.IMAGE }}:${{ steps.tag.outputs.sha }}That is a complete, working pipeline. Read it as four ideas: prove, then build; scan before you push; tag immutably; sign what you ship.
Triggers: PR vs main
Section titled “Triggers: PR vs main”The on: block runs the pipeline at two distinct moments, and they have different jobs:
pull_request— run the proving stages (lint, test) on the change before it merges, so a reviewer (and the author) sees a green check and a broken change never lands onmain. This is continuous integration in its purest form: every change is integrated and validated continuously, not in a big batch.pushtomain— the change has merged; now build the real artifact to ship and push/sign it. Theif: github.ref == 'refs/heads/main'guards mean PRs build and scan an image (proving it’s buildable and clean) but never publish one — only merges produce a registry artifact.
Stop-the-line gates
Section titled “Stop-the-line gates”Each needs: and each non-zero exit-code is a gate. The order is deliberate — cheapest, fastest checks
first, so feedback is fast and expensive work only runs on changes that already passed the cheap checks.
lint ──► unit tests ──► build image ──► Trivy scan ──► push ──► sign │ │ │ │ fail? fail? fail? CVE? ─── STOP. nothing downstream runs. └──────────┴──────────────┴──────────────┴──► the bad change never becomes a pushed, deployable artifact.The immutable tag: one commit, one image
Section titled “The immutable tag: one commit, one image”The image is tagged with the git SHA (snip:a1b2c3d4e5f6), not latest or a hand-typed version.
This is the single most important property of the artifact, and it’s the foundation of
Artifacts & Registries:
- Immutable — a SHA tag is never reused or overwritten.
snip:a1b2c3means exactly the bytes built from commita1b2c3, today and forever.latestmeans “whatever was pushed most recently,” which is a moving target you can’t roll back to or reason about. - Traceable — given a running pod, the tag tells you the exact commit in production. Given a commit, you know its image. The link between “what’s deployed” and “what’s in git” is exact, both directions.
- Promotable — the same SHA-tagged image moves from staging to prod unchanged. Nothing is rebuilt per environment, so “it passed in staging” is a statement about the exact bytes going to prod.
Signing: proving provenance
Section titled “Signing: proving provenance”The last step signs the image with cosign in keyless mode. Signing answers a question scanning can’t: did this image really come from our pipeline, unmodified? The signature binds the image’s digest to the identity of the workflow that built it (via the GitHub OIDC token — no private key to manage or leak). Downstream, an admission policy can refuse to run any image that isn’t signed by this pipeline, which is the heart of supply-chain security: you don’t just trust that an image is clean, you verify it’s yours.
| Stage | Question it answers | Failure means |
|---|---|---|
| Lint | Is the code well-formed and consistent? | Stop — fix style/obvious bugs |
| Unit tests | Does the logic do what we claim? | Stop — the change is broken |
| Trivy scan | Does the image carry known vulnerabilities? | Stop — don’t publish vulnerable bytes |
| Sign (cosign) | Did our pipeline produce these exact bytes? | Consumers can reject unsigned images |
The thread
Section titled “The thread”The manual, error-prone steps this pipeline removes are the ones a human used to do on release day and forget half of: building on the right branch, running “the tests I remember,” eyeballing dependencies for known CVEs, hand-typing a version tag, and copying an unverified artifact to a server. Each is now a stage that runs identically every time and refuses to proceed when it fails. Production gets safer because a broken, vulnerable, or unsigned change physically cannot become a deployable artifact — the gates stop it while it’s still cheap to stop, and the SHA tag plus signature make whatever does ship exactly traceable and verifiable. The cost is up-front: writing and maintaining the workflow, and occasionally a red build blocking a merge — which is the system working, not failing.
We now produce a trusted, signed, immutably-tagged image on every merge. Before we deploy it, we make it observable, so that when it’s live we can see — on real signals — whether it’s healthy: The Observability Stack.
Check your understanding
Section titled “Check your understanding”- The pipeline runs on both
pull_requestandpushtomain, but does different things in each. What runs in each case, and why don’t PRs push an image? - Why does the workflow build and scan the image before pushing it, rather than push then scan? What bad state does that ordering prevent?
- Explain three concrete advantages of tagging the image with the git SHA instead of
latest. What goes wrong withlateston rollback? - Signing and scanning answer different questions. State each question, and describe an attack that a clean scan would miss but a signature check would catch.
- The stages are ordered lint → test → build → scan → push → sign. Why this order, and what is the “stop-the-line” principle buying you at each gate?
Show answers
- On a PR, lint and tests run to prove the change before merge (and the image is built+scanned to
prove it’s buildable and clean) — but the push/sign steps are guarded by
if: github.ref == 'refs/heads/main', so no artifact is published. On merge tomain, the real artifact is built, pushed, and signed. PRs don’t push because an unmerged change shouldn’t produce a deployable image. - So a known-vulnerable image never exists in the registry. If you push first, you’ve already published bytes that something downstream could pull before the scan result lands. Build-scan-then-push means the gate runs on a local image, and only a clean image is ever published.
- The SHA tag is immutable (never overwritten, so it always means the same bytes), traceable
(exact commit ↔ exact image, both directions), and promotable (the same image moves staging→prod
unchanged). With
latest, “roll back to whichlatest?” is unanswerable — the tag is a moving target, so you can’t reliably return to a known-good state. - Scanning asks does this image carry known vulnerabilities? Signing asks did our pipeline produce these exact bytes, unmodified? A clean scan misses an image that was swapped or tampered with after the build (e.g. a malicious image pushed to the same tag from elsewhere) — the signature check catches it because the bytes weren’t signed by our workflow’s identity.
- Cheapest/fastest checks run first so feedback is fast and expensive work only runs on changes that already passed cheap ones. “Stop the line” means any failing gate halts everything downstream, so a broken change is caught while it’s cheap to fix and cannot advance to becoming a pushed, deployable, signed artifact.