Skip to content

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.

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.

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 on main. This is continuous integration in its purest form: every change is integrated and validated continuously, not in a big batch.
  • push to main — the change has merged; now build the real artifact to ship and push/sign it. The if: 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.

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 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:a1b2c3 means exactly the bytes built from commit a1b2c3, today and forever. latest means “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.

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.

StageQuestion it answersFailure means
LintIs the code well-formed and consistent?Stop — fix style/obvious bugs
Unit testsDoes the logic do what we claim?Stop — the change is broken
Trivy scanDoes 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 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.

  1. The pipeline runs on both pull_request and push to main, but does different things in each. What runs in each case, and why don’t PRs push an image?
  2. Why does the workflow build and scan the image before pushing it, rather than push then scan? What bad state does that ordering prevent?
  3. Explain three concrete advantages of tagging the image with the git SHA instead of latest. What goes wrong with latest on rollback?
  4. 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.
  5. 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
  1. 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 to main, the real artifact is built, pushed, and signed. PRs don’t push because an unmerged change shouldn’t produce a deployable image.
  2. 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.
  3. 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 which latest?” is unanswerable — the tag is a moving target, so you can’t reliably return to a known-good state.
  4. 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.
  5. 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.