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.
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.
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.
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.
# 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.
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.
# 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"
- 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.
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.
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.
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
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:
- Push your Week 5 project to a GitHub repository (create one if you haven't) and add
DOCKERHUB_USERNAMEas a repository variable and a Docker Hub access token as theDOCKERHUB_TOKENsecret. - Create
.github/workflows/build-test-push.ymlwith atestjob that checks out the code and runs your project's test command. - Add a second job, gated with
needs: test, that only runs on pushes tomain, and that logs in to Docker Hub withdocker/login-action. - In that job, build the image tagged with
${{ github.sha }}, scan it with Trivy set to fail on HIGH/CRITICAL findings, then push it. - Push a commit to a feature branch and open a pull request — confirm the
testjob runs butbuild-and-pushdoes not. - Merge the PR to
mainand 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.
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?
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?
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?
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 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.