Week 6: CI/CD with GitHub Actions

You can now build, tag, scan and push a container image by hand — but "by hand" means a human has to remember every step, in order, every single time. This week you automate that entirely: GitHub Actions runs your build-test-push sequence on every push, on infrastructure GitHub manages for you, using encrypted secrets instead of credentials typed into a terminal. The registry login and image tag you practiced manually in Week 5 become workflow steps here, and the pipeline you finish today is exactly what Week 7 extends with environments, approvals and a real deployment strategy.

Module 5 of 22 Week 6 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Read and write a GitHub Actions workflow's triggers, jobs and steps
  • Choose between GitHub-hosted and self-hosted runners and store credentials as encrypted secrets
  • Build a complete build-test-push pipeline for a containerized app that runs on every push

1. Workflow, Job & Step Anatomy

A GitHub Actions workflow is a YAML file in .github/workflows/ that describes what should run and when. Each workflow contains one or more jobs, and each job runs a sequence of steps on its own fresh virtual machine. Jobs run in parallel by default; steps within a job run in order, on the same machine, sharing its filesystem.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

A step is either uses:, which runs a reusable, published action (a packaged unit of automation — actions/checkout clones your repo onto the runner, since a fresh VM starts with no code on it at all), or run:, which executes a shell command directly. Steps share files and environment variables within a job but nothing carries over between separate jobs unless you explicitly pass it with outputs or an uploaded artifact.

Pin actions to a version

uses: actions/checkout@v4 pins a major version, not an exact commit. For anything security-sensitive, pin to a full commit SHA (actions/checkout@8e5e7e5...) instead — a compromised or unexpectedly changed tag on a third-party action is a real supply-chain risk, the same class of problem you'll formalize in Week 14.

2. Triggers & Runners

The on: block decides what causes a workflow to fire. The two you'll use constantly are push (code landed on a branch) and pull_request (a PR was opened or updated) — and it's common to run a different subset of jobs for each, since a PR needs feedback before merge but only main should ever trigger a deploy.

.github/workflows/ci.yml — triggers
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:      # adds a manual "Run workflow" button in the UI

runs-on: chooses where a job's steps actually execute. GitHub-hosted runners (ubuntu-latest, windows-latest, macos-latest) are fresh VMs GitHub provisions, runs your job on, and destroys — zero maintenance, generous free minutes on public repos, but capped CPU and no access to anything inside your private network. A self-hosted runner is a machine you register yourself (on-prem, in your own VPC, or even a beefy build server) — necessary when a job needs GPU hardware, a private network resource, or simply costs less to run continuously than paying per-minute for hosted compute.

terminal — registering a self-hosted runner
# From: repo Settings > Actions > Runners > New self-hosted runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.319.1.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.319.1/actions-runner-linux-x64-2.319.1.tar.gz
tar xzf actions-runner-linux-x64-2.319.1.tar.gz
./config.sh --url https://github.com/<org>/<repo> --token <REGISTRATION_TOKEN>
./run.sh

Then reference it with a label instead of ubuntu-latest: runs-on: self-hosted.

Self-hosted runners on public repos

Never attach a self-hosted runner to a public repository unless you tightly control who can open pull requests against it. Anyone can open a PR with a workflow that runs arbitrary code on your runner — on a hosted runner that's a disposable throwaway VM, but on a self-hosted one it's a machine with access to whatever network it sits on.

3. Encrypted Secrets

A registry token or cloud credential should never appear as literal text in a workflow file — anyone who can read the repository can read the file. GitHub Actions secrets solve this: values stored encrypted at rest, readable only inside a running workflow through the secrets context, and automatically redacted (replaced with ***) if a step accidentally prints one to the log.

terminal — adding a secret
# Repo > Settings > Secrets and variables > Actions > New repository secret
# Name: DOCKERHUB_TOKEN   Value: <paste the token>

# Or via the GitHub CLI
gh secret set DOCKERHUB_TOKEN --body "dckr_pat_xxxxxxxxxxxxx"
.github/workflows/ci.yml — using a secret
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

Secrets aren't passed to workflows triggered from a fork's pull request by default — a deliberate protection, since anyone could otherwise open a PR from a fork with a workflow that exfiltrates your secrets to an external URL. Non-sensitive configuration (a bucket name, an AWS region) belongs in variables (vars.) instead of secrets — it keeps the Actions log readable and avoids "why is this masked" confusion for values that were never sensitive.

Environment-scoped secrets

Beyond repository-level secrets, GitHub lets you scope a secret to a specific Environment (like production) so only jobs deploying to that environment can read it — the mechanism you'll use directly in Week 7 to gate a production AWS credential behind a required reviewer.

4. A Build-Test-Push Pipeline

Putting it together: a single workflow that runs your test suite, then — only if tests pass and the change landed on main — builds the Docker image, logs in with a secret, and pushes it tagged with the commit SHA, exactly as you did by hand in Week 5.

.github/workflows/build-test-push.yml
name: Build, Test & Push

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm test

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build image
        run: docker build -t ${{ vars.DOCKERHUB_USERNAME }}/orders-api:${{ github.sha }} .

      - name: Scan image
        run: |
          docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
            aquasec/trivy image --severity HIGH,CRITICAL --exit-code 1 \
            ${{ vars.DOCKERHUB_USERNAME }}/orders-api:${{ github.sha }}

      - name: Push image
        run: docker push ${{ vars.DOCKERHUB_USERNAME }}/orders-api:${{ github.sha }}

needs: test makes build-and-push wait for the test job and skip entirely if it fails — a broken build never reaches the registry. The if: condition restricts the push itself to direct pushes on main, so opening a pull request still runs the full test suite for feedback without ever pushing an image. github.sha gives you the exact same immutable, git-SHA-based tagging scheme from Week 5, generated automatically instead of typed by hand.

Fail fast, fail cheap

Order steps from cheapest/fastest to most expensive: linting and unit tests before a Docker build, a Docker build before a slow integration test, a scan before a push. A workflow that fails in 30 seconds on a lint error, instead of 8 minutes into a build, saves real CI minutes and gives faster feedback on every PR.

5. Hands-on Exercise

Hands-on

Automate your Week 5 registry push

Turn last week's manual build-scan-push sequence into a GitHub Actions workflow that runs itself on every push.

Requirements:

  1. Push your Week 5 project to a GitHub repository (create one if you haven't) and add DOCKERHUB_USERNAME as a repository variable and a Docker Hub access token as the DOCKERHUB_TOKEN secret.
  2. Create .github/workflows/build-test-push.yml with a test job that checks out the code and runs your project's test command.
  3. Add a second job, gated with needs: test, that only runs on pushes to main, and that logs in to Docker Hub with docker/login-action.
  4. In that job, build the image tagged with ${{ github.sha }}, scan it with Trivy set to fail on HIGH/CRITICAL findings, then push it.
  5. Push a commit to a feature branch and open a pull request — confirm the test job runs but build-and-push does not.
  6. Merge the PR to main and confirm in the Actions tab that both jobs ran and the image landed in your Docker Hub repository tagged with the merge commit's SHA.
Hint

If a step's output looks wrong, click into the specific step in the Actions run log rather than re-running the whole workflow — each step's expanded log shows exactly which command ran and its full output, which is almost always faster than guessing and pushing another commit.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why do steps within one job share state, but two jobs in the same workflow don't?

Each job is scheduled onto its own fresh virtual machine, so its steps naturally share that one machine's filesystem and environment as they run in sequence. A different job gets a completely separate VM with none of that state, which is why passing data between jobs requires explicitly using outputs or uploading/downloading an artifact.

Q2

Why does GitHub withhold secrets from workflows triggered by a pull request from a fork?

Anyone can fork a public repository and open a pull request containing a modified workflow file. If secrets were available to fork PR workflows, an attacker could add a step that prints or exfiltrates your registry tokens or cloud credentials to a URL they control, with no special access needed beyond opening a PR.

Q3

What does needs: test on the build-and-push job actually guarantee?

It makes build-and-push wait until the test job finishes, and skip entirely if test fails. Without it, GitHub Actions runs independent jobs in parallel by default, which would let a broken build get pushed to the registry at the same time the test job is still failing.

Q4

When would a self-hosted runner be the right choice over a GitHub-hosted one?

When a job needs something a disposable hosted VM can't provide — access to a private network resource, specialized hardware like a GPU, or heavier and more consistent compute than hosted runners offer — or when continuous high-volume usage makes owning the machine cheaper than paying per minute. The tradeoff is you now own patching, security and uptime for that machine yourself.