What You'll Build
In this exercise you will build a complete, production-grade CI/CD pipeline for the cricket scorecard API using GitHub Actions, bringing together everything from Module 5. The pipeline will trigger on every push and pull request, build the image once with BuildKit layer caching, run unit and integration tests against a real database, scan the image for vulnerabilities and fail on fixable critical issues, and — only on the main branch — push an immutably tagged image to a registry. You will tag with the commit SHA for traceability, gate the deployment-ready push behind all checks passing, and structure the workflow so the exact artifact that was tested is the one published. By the end you will have a pipeline that turns every code change into a verified, traceable, deployable image automatically, which is the backbone of how professional teams ship containerised software.
Prerequisites
- A GitHub repository you can push to, with GitHub Actions enabled and permission to create workflow files under .github/workflows.
- Completion of Module 5 lessons on CI/CD, BuildKit, production patterns, resource limits and debugging.
- The cricket scorecard API from earlier modules — a Flask app with a Dockerfile, a test suite, and a /healthz endpoint.
- Basic familiarity with git branches and pull requests, since the pipeline behaves differently for PRs versus main.
- Access to a container registry (GitHub Container Registry ghcr.io works out of the box with the built-in token).
Setup & Project Structure
You will add a workflows directory and a couple of test-support files to the existing API project. The workflow YAML defines the pipeline, a Compose file describes the test dependencies, and the application already carries its Dockerfile and tests. Keeping the pipeline definition in the repository means it is versioned and reviewed exactly like the application code, which is the whole point of pipeline-as-code. Create the layout below before writing the workflow.
scorecard-cicd/
├── .github/
│ └── workflows/
│ └── ci.yml # the pipeline definition
├── api/
│ ├── Dockerfile # hardened, exec-form CMD
│ ├── requirements.txt # flask, psycopg2-binary, pytest
│ ├── app.py # scorecard API + /healthz
│ └── test_app.py # unit + integration tests
└── compose.test.yaml # spins up a real Postgres for tests
# Create the skeleton:
mkdir -p scorecard-cicd/.github/workflows scorecard-cicd/api
cd scorecard-cicd
# compose.test.yaml — a throwaway database for integration tests
# services:
# match-db:
# image: postgres:16
# environment: { POSTGRES_PASSWORD: test, POSTGRES_DB: cricket }
# healthcheck: { test: ['CMD-SHELL','pg_isready -U postgres'], interval: 3s, retries: 5 }
Step 1 — Foundation
Step 1 establishes the trigger and the build stage with caching, the foundation every later stage depends on. The workflow runs on pushes and pull requests, checks out the code, sets up Buildx to enable BuildKit layer caching, and builds the image once into the runner. Building a single artifact here — rather than rebuilding at each stage — is the decision that guarantees the tested image and the shipped image are identical. Caching keeps this fast on every run.
# .github/workflows/ci.yml — trigger + cached build (foundation)
name: scorecard-cicd
on:
push:
branches: [main]
pull_request:
jobs:
pipeline:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # needed to push to ghcr.io
steps:
- uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Build the image ONCE (with layer cache)
uses: docker/build-push-action@v6
with:
context: ./api
load: true # load into the runner for testing
tags: cricket/scorecard:ci
cache-from: type=gha
cache-to: type=gha,mode=max
Step 2 — Core Logic
Step 2 adds the verification gates: unit tests, integration tests against a real Postgres, and a vulnerability scan. Unit tests run inside the built image so they exercise exactly what will ship. Integration tests bring up a throwaway database with Compose so the code is tested against a real dependency, not a mock. The Trivy scan fails the job on fixable high or critical CVEs. Every gate runs against the single image built in Step 1, and any failure halts the pipeline so a broken or vulnerable change never advances.
# Append to ci.yml steps: the verification gates (run against cricket/scorecard:ci)
- name: Unit tests inside the built image
run: docker run --rm cricket/scorecard:ci python -m pytest -q test_app.py -k unit
- name: Integration tests against a real Postgres
run: |
docker compose -f compose.test.yaml up -d match-db
# wait for the db healthcheck to pass
until [ "$(docker inspect -f '{{.State.Health.Status}}' \
$(docker compose -f compose.test.yaml ps -q match-db))" = healthy ]; do sleep 2; done
docker run --rm --network host \
-e DATABASE_URL=postgres://postgres:test@localhost:5432/cricket \
cricket/scorecard:ci python -m pytest -q test_app.py -k integration
docker compose -f compose.test.yaml down -v
- name: Vulnerability scan (fail on fixable HIGH/CRITICAL)
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image --exit-code 1 --ignore-unfixed \
--severity HIGH,CRITICAL cricket/scorecard:ci
Step 3 — Integration & Enhancement
Step 3 adds the publish stage, gated so it runs only on the main branch and only after every check has passed. It logs in to the registry using the run-scoped built-in token, then pushes the image tagged with the immutable commit SHA plus a moving latest tag, reusing the build cache. Because this step is guarded by the branch condition and sits after all the gates, pull requests are fully verified but never publish, and only a merge to main produces a traceable, deployable artifact. This completes the build-test-scan-push spine.
# Append to ci.yml steps: gated publish (main only, after all gates pass)
- name: Log in to the registry
if: github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # run-scoped, masked
- name: Push immutably tagged image (main only)
if: github.ref == 'refs/heads/main'
uses: docker/build-push-action@v6
with:
context: ./api
push: true
tags: |
ghcr.io/${{ github.repository }}/scorecard:${{ github.sha }}
ghcr.io/${{ github.repository }}/scorecard:latest
cache-from: type=gha
# Pull requests reach here but skip both steps -> verified, never published.
Step 4 — Testing & Verification
Now verify the pipeline behaves correctly in both scenarios it must handle. Push a feature branch and open a pull request, and confirm the pipeline builds, tests and scans but does not publish. Then merge to main and confirm the same checks run and an image tagged with the commit SHA appears in the registry. Also confirm a deliberately failing test or a vulnerable dependency halts the pipeline before any push. Each check proves one guarantee the pipeline is supposed to make.
# Verify both paths of the pipeline
# 1) PR path: build + test + scan, but NO publish
git checkout -b feature/run-rate-fix
git commit -am 'tweak run rate'; git push -u origin feature/run-rate-fix
# Open a PR -> Actions runs build, unit, integration, scan; push steps are skipped.
# 2) main path: same checks, then an SHA-tagged image is published
git checkout main; git merge feature/run-rate-fix; git push
# Actions runs all gates, then pushes:
# ghcr.io/<owner>/<repo>/scorecard:<commit-sha>
# ghcr.io/<owner>/<repo>/scorecard:latest
# 3) Confirm the published image is traceable to the exact commit:
docker pull ghcr.io/<owner>/<repo>/scorecard:$(git rev-parse HEAD)
# 4) Prove a gate blocks a bad change: break a test and push to a branch
# -> the pipeline fails at the test step and the publish steps never run.
# Check the Actions run: the job is marked failed before 'Push' executes.
Warning: Do not move the registry login or push steps above the test and scan steps, and do not remove the 'if: github.ref == refs/heads/main' guard. If the push runs before the gates, or runs on every branch, you will publish images that were never verified — or publish from pull requests, including ones from untrusted forks. The push must always come last and be gated on the main branch so only fully checked code on the trunk is ever released.
Extension Challenge: Harden and extend the pipeline. First, add a deploy job with 'needs: [pipeline]' and 'environment: production' so it runs only after CI is green and requires a manual approval, deploying the SHA-tagged image. Second, pin the deployment to the image digest rather than the SHA tag for supply-chain integrity, capturing the digest from the push step's output. Third, add an image-signing step with cosign and a matching verification step before deploy, so only signed images can be released.
- A pipeline-as-code workflow lives in the repository, so the build process is versioned and reviewed exactly like the application code.
- Build the image once with BuildKit layer caching, then reuse that single artifact for testing, scanning and publishing to avoid drift.
- Run unit tests inside the built image and integration tests against a real database spun up with Compose, so you test exactly what ships.
- Scan the built image and fail the job on fixable high/critical CVEs, making security an enforced gate rather than an optional step.
- Guard the publish steps with a main-branch condition so pull requests are fully verified but only merges to the trunk release an artifact.
- Tag published images with the immutable commit SHA for traceability and authenticate with a run-scoped, masked token, never a hard-coded secret.