Table of Contents#
- Where GitHub Fits in This Series
- GitHub Plans and Actions Pricing at a Glance
- GitHub as a Git Host — What's Actually Different
- The
.github/Directory - Anatomy of a Workflow File
- Events — What Triggers a Workflow
- Jobs, Steps, and Runners
- A Minimal Workflow, Built Up Step by Step
- Matrix Builds — Testing Across Many Configurations
- Artifacts and Caching
- Composite Actions — Reusable Steps
- Reusable Workflows — Reusable Jobs
- Composite Action vs Reusable Workflow — When to Use Which
- Environments and Manual Approval Gates
- Self-Hosted Runners
- Contexts, Expressions, and Conditional Steps
- Job Outputs — Passing Small Data Between Jobs
- Service Containers — Databases for Integration Tests
- Concurrency Control
- A Full Realistic Multi-Stage Pipeline
- Common Mistakes
- Worked Practice Problems
- Summary and What's Next
Where GitHub Fits in This Series#
Part 1 of this series built a tool-agnostic mental model of CI/CD: pipeline anatomy, deployment strategies, the DORA metrics. Part 2 covered Infrastructure as Code, and Part 3 covered GitOps — again, tool-agnostic. Starting with this chapter, the series gets concrete: how does a real team, on a real platform, actually build the pipeline Part 1 described?
GitHub is the natural starting point because it's the single most widely used Git hosting platform in the industry, and — critically for this series — it ships its own first-party CI/CD system, GitHub Actions, built directly into the same product. You don't add a separate CI tool on top of GitHub the way older stacks bolted Jenkins onto a GitHub repo; the pipeline definition lives in the same repository as the code, triggered natively by the same events (a push, a pull request, a release) that GitHub already tracks.
Diagram
This chapter and the next (Part 5) cover GitHub end to end: this one focuses on the mechanics of building a working pipeline — workflow syntax, reusability, matrix builds, environments. Part 5 focuses on securing and governing that pipeline once it exists — token permissions, OIDC, branch protection, and GitHub's built-in security tooling. Splitting it this way mirrors a real adoption curve: teams get a pipeline running first, then harden it.
GitHub Plans and Actions Pricing at a Glance#
Before writing a single workflow, it's worth understanding what a team is actually signing up for — GitHub Actions billing is usage-based on top of whichever GitHub plan a team is already on, not a separate product with its own subscription.
| Plan | Included Actions minutes/month (Linux) | Included storage | Concurrent jobs (hosted) | Typical fit |
|---|---|---|---|---|
| Free | 2,000 | 500 MB | Up to 20 | Personal projects, open-source (public repos get unlimited minutes on standard runners) |
| Team | 3,000 | 2 GB | Up to 60 | Small-to-mid paid teams needing private-repo CI |
| Enterprise | 50,000 | 50 GB | Up to 180 (higher on request) | Large orgs, needs SSO/SAML, audit log, custom runner policies |
Two multipliers that catch teams off guard when a bill arrives higher than expected:
- Minutes are billed per-OS at different rates on private repos — a Linux minute costs 1×, a Windows minute costs 2×, and a macOS minute costs 10× the same wall-clock minute, because GitHub's underlying hosted-runner costs differ that much across operating systems. A macOS matrix leg that seems "just one more OS" in a workflow file can dominate a team's entire monthly Actions bill.
- Public repositories get genuinely unlimited Actions minutes on standard GitHub-hosted runners — this is a major reason so much of the open-source ecosystem runs GitHub Actions specifically, versus a competing platform with a hard included-minutes cap even for public projects.
Larger, more powerful hosted runners (more vCPU/RAM, up to 96-core, and GPU-enabled runners) are available as a separate paid add-on on top of any plan, billed per-minute at a higher rate than the standard 2-core runner — relevant for the self-hosted-runner tradeoff discussed later in this chapter, since a larger hosted runner is often a simpler first step than standing up self-hosted infrastructure.
GitHub as a Git Host — What's Actually Different#
Everything this course has assumed about Git so far (branches, commits, pull requests) is generic — GitHub didn't invent any of it. What GitHub actually adds on top of plain Git is a set of platform features that CI/CD pipelines hook into directly:
| Feature | What it is | Why it matters for CI/CD |
|---|---|---|
| Pull Requests | A reviewable proposal to merge one branch into another | Workflows can trigger on PR open/update, post status checks back onto the PR, and block merge until checks pass |
| Branch protection / rulesets | Rules that restrict what can happen to a branch (e.g. no direct pushes to main) | Pipelines are often the enforcement mechanism for these rules — a required status check IS a pipeline job |
| Environments | Named deployment targets (staging, production) with their own secrets and approval rules | This is GitHub's implementation of the "manual approval gate" from Part 1's pipeline diagram |
| Releases | A tagged, versioned snapshot of the repo, with attached binary artifacts | Workflows commonly trigger on release creation to build and publish final packages |
| Issues / Projects | Native issue tracking and lightweight project boards | Not CI/CD directly, but workflows can auto-comment, auto-label, or auto-close issues as part of a pipeline |
A concrete example of the difference this makes: with a bolted-on CI tool, "block merge until tests pass" requires configuring the CI tool to post a webhook back to GitHub's status API, and configuring GitHub's branch protection to require that named status check. With GitHub Actions, the workflow run is already a native GitHub check — no webhook plumbing required, it's a first-class citizen of the platform from the start.
The .github/ Directory#
GitHub reserves a specific top-level directory in every repository for platform configuration. The parts relevant to this chapter:
.github/
├── workflows/ # Every CI/CD pipeline definition lives here
│ ├── ci.yml
│ ├── deploy-prod.yml
│ └── nightly-scan.yml
├── actions/ # Local composite actions (repo-private, not published)
│ └── setup-env/
│ └── action.yml
├── CODEOWNERS # Covered in Part 5 — enforces review ownership
├── dependabot.yml # Covered in Part 5 — automated dependency updates
└── ISSUE_TEMPLATE/
The one rule that matters most right now: every file directly inside .github/workflows/ ending in .yml or .yaml is treated as an independent, top-level pipeline definition. There is no single master pipeline file the way some other platforms use one .gitlab-ci.yml — a GitHub repo commonly has many small, focused workflow files (one for CI on every PR, one for a nightly security scan, one for production deploys), each triggered by its own set of events.
Anatomy of a Workflow File#
A workflow file has four structural layers, and understanding the nesting is the single biggest unlock for reading (and writing) GitHub Actions YAML correctly:
Diagram
- Workflow — the entire file. Has a
name:, anon:trigger definition, and one or more jobs. - Job — a unit of work that runs on its own fresh runner (virtual machine or container), in parallel with other jobs by default, unless you explicitly declare a dependency with
needs:. - Step — an ordered instruction inside a job. A step is either
run:(a raw shell command) oruses:(invoke a reusable "Action" — a packaged, shareable unit someone else, or you, already wrote). - Action — the smallest reusable unit; a piece of packaged automation referenced by
uses:, either from the public GitHub Marketplace (actions/checkout@v4), a private repo, or a local path (./.github/actions/setup-env).
Events — What Triggers a Workflow#
The on: key defines what makes a workflow run at all — this is GitHub's version of the generic "code committed/pushed" trigger from Part 1's pipeline diagram, but with far more event types than a simple push:
on: push: branches: [main] paths: ['src/**'] # only run if files under src/ changed pull_request: branches: [main] release: types: [published] schedule: - cron: '0 2 * * *' # nightly at 02:00 UTC workflow_dispatch: # manual "Run workflow" button in the UI inputs: environment: description: 'Target environment' required: true type: choice options: [staging, production] workflow_call: # THIS workflow can be called BY another workflow
Why paths: filtering matters at scale: in a monorepo with a frontend and a backend, without paths: filtering, every commit — even a docs typo fix in the backend — triggers the (possibly expensive) frontend test suite too. Scoping triggers to the paths a workflow actually cares about is a direct, practical application of the "fail fast, don't waste time/money on irrelevant work" principle already established in this series.
workflow_dispatch is worth calling out specifically — it's what turns a workflow into something a human can trigger on demand from the GitHub UI or API, with typed inputs, rather than only ever firing automatically off a Git event. This is commonly how a manual production deploy button is implemented in GitHub Actions.
Jobs, Steps, and Runners#
jobs: build: runs-on: ubuntu-latest # GitHub-hosted runner: a fresh Ubuntu VM steps: - name: Check out the repo uses: actions/checkout@v4 # Action: clones the repo onto the runner - name: Set up Node uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies run: npm ci # raw shell command - name: Run tests run: npm test
Two facts about jobs that trip people up constantly:
- Every job starts from a completely clean runner. There is no filesystem state shared between jobs by default — a file created in job
builddoes not exist in jobdeployunless it's explicitly passed viaactions/upload-artifact/actions/download-artifact(covered below), or the two steps are inside the same job. - Jobs run in parallel unless you say otherwise. To force one job to wait for another — e.g.
deploymust not start untilbuildandtestboth succeed — useneeds::
jobs: build: runs-on: ubuntu-latest steps: [...] test: runs-on: ubuntu-latest steps: [...] deploy: needs: [build, test] # waits for BOTH to succeed first runs-on: ubuntu-latest steps: [...]
Diagram
This needs: graph is directly how GitHub Actions implements the layered pipeline-stage ordering from Part 1 — except unlike a single linear pipeline, jobs without a needs: relationship genuinely run concurrently, which is why a real-world workflow's build and lint jobs typically run side by side rather than one after another.
A Minimal Workflow, Built Up Step by Step#
Starting from the smallest possible useful workflow and adding one real-world concern at a time:
Step 1 — the bare minimum that does something:
name: CI on: push jobs: test: runs-on: ubuntu-latest steps: - run: echo "hello"
Step 2 — actually checking out and testing code:
name: CI on: push jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - run: npm ci - run: npm test
Step 3 — scoping the trigger and adding least-privilege permissions (permissions are covered in depth in Part 5, but every workflow should set this from day one):
name: CI on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - run: npm ci - run: npm test
Step 4 — failing fast with a lint job that runs in parallel, plus caching (caching covered next section):
name: CI on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: lint: 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 run lint 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
This progression is deliberately incremental — a real team almost never designs a workflow file in one sitting; it grows exactly like this as new requirements (linting, caching, deployment) show up.
Matrix Builds — Testing Across Many Configurations#
A matrix runs the same job definition multiple times, once per combination of variables you supply — the direct GitHub Actions implementation of "test this across every Node version / OS / database version we support" without hand-writing a separate job for each combination.
jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] node-version: ['18', '20', '22'] fail-fast: false # let ALL combinations finish, don't cancel siblings on first failure steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '${{ matrix.node-version }}' } - run: npm ci - run: npm test
This single job definition expands into 9 real jobs (3 operating systems × 3 Node versions), running in parallel, each reported back as its own separate check.
Diagram
fail-fast: false is a genuinely important, easy-to-miss setting. The default (true) cancels every other matrix combination the instant any one combination fails — which sounds efficient, but means a failure specific to, say, Windows + Node 18 might cancel the macOS + Node 22 job before it ever reports its own, potentially different, result. For a compatibility matrix specifically, you almost always want fail-fast: false so you see the complete picture of what's broken where in a single run.
You can also exclude specific, known-bad combinations without removing an entire row or column:
strategy: matrix: os: [ubuntu-latest, windows-latest] node-version: ['18', '20', '22'] exclude: - os: windows-latest node-version: '18' # this one combination is skipped
Artifacts and Caching#
Two related but distinct mechanisms for moving data between jobs and speeding up repeated runs — mixing them up is a common source of confusion.
| Cache | Artifact | |
|---|---|---|
| Purpose | Speed up future runs (skip re-downloading dependencies) | Pass a file from one job to another, or preserve a build output |
| Lifetime | Best-effort — GitHub may evict old caches; treat as disposable | Explicit retention period (default 90 days), guaranteed available for that window |
| Typical content | node_modules, pip/Maven/Go module caches | Compiled binaries, test reports, coverage output, build logs |
| Action | actions/cache@v4 | actions/upload-artifact@v4 + actions/download-artifact@v4 |
# Caching dependencies (many setup-* actions like setup-node have this built in via `cache:`) - uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: npm-${{ runner.os }}- # Passing a build output from one job to another # In job "build": - uses: actions/upload-artifact@v4 with: name: dist path: dist/ # In job "deploy" (which has `needs: build`): - uses: actions/download-artifact@v4 with: name: dist path: dist/
The cache key pattern above (hashFiles('package-lock.json')) is worth understanding, not just copying: the cache key changes automatically whenever the lockfile changes, so a dependency bump correctly invalidates the old cache instead of silently reusing stale, now-wrong dependencies — the same "invalidate on real change, reuse otherwise" idea underlying Terraform's plan/apply model from Part 2.
Composite Actions — Reusable Steps#
A composite action packages a sequence of steps into one reusable, named unit — the fix for the same 5-10 lines of setup steps being copy-pasted at the top of every workflow file in a repo.
# .github/actions/setup-env/action.yml name: 'Set up environment' description: 'Checks out code and installs Node dependencies with caching' inputs: node-version: description: 'Node version to install' default: '20' runs: using: 'composite' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} cache: 'npm' - run: npm ci shell: bash # required for every `run:` step inside a composite action
Used from any workflow in the same repo:
jobs: test: runs-on: ubuntu-latest steps: - uses: ./.github/actions/setup-env with: node-version: '20' - run: npm test
The one syntax detail that catches people out: every run: step inside a composite action must explicitly declare shell: bash (or whichever shell) — unlike a normal workflow step, composite actions don't infer a default shell.
Reusable Workflows — Reusable Jobs#
Where a composite action reuses steps within one job, a reusable workflow reuses an entire job definition — including its own runs-on, its own strategy.matrix, and multiple jobs if needed. It's invoked with uses: at the job level (not inside steps:), and the source workflow must declare workflow_call as one of its triggers.
# .github/workflows/reusable-deploy.yml name: Reusable Deploy on: workflow_call: inputs: environment: required: true type: string secrets: DEPLOY_TOKEN: required: true jobs: deploy: runs-on: ubuntu-latest environment: ${{ inputs.environment }} steps: - uses: actions/checkout@v4 - run: ./deploy.sh --env ${{ inputs.environment }} env: TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Called from another workflow, once per target environment via a matrix — the same "deploy across many environments" pattern this series has already covered conceptually in Part 1's deployment-strategies discussion, now expressed concretely:
# .github/workflows/deploy-all.yml name: Deploy All Environments on: push: branches: [main] jobs: deploy: strategy: matrix: target: [staging, production] uses: ./.github/workflows/reusable-deploy.yml with: environment: ${{ matrix.target }} secrets: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Diagram
A reusable workflow can also be called from a completely different repository (uses: my-org/shared-workflows/.github/workflows/reusable-deploy.yml@v1), which is how larger organizations centralize a "golden path" deploy pipeline that every product repo calls into, rather than each team maintaining its own copy. GitHub caps this at 10 levels of nesting (the top-level caller plus up to 9 reusable workflows) and 50 unique reusable workflows callable from a single workflow file — generous limits that only matter at real organizational scale.
Composite Action vs Reusable Workflow — When to Use Which#
A table worth memorizing, because this exact question ("should this be a composite action or a reusable workflow?") comes up in every non-trivial GitHub Actions codebase:
| Composite Action | Reusable Workflow | |
|---|---|---|
| Reuses | A sequence of steps | One or more entire jobs |
| Invoked from | Inside a job's steps: list | A job's uses: key directly (no steps: needed) |
Can define its own runs-on? | No — runs on whatever runner the calling job already picked | Yes — each reusable job has its own runs-on |
Can use strategy.matrix? | Only the calling job's matrix (it has none of its own) | Yes, independently |
| Typical use case | "Set up my environment" boilerplate shared across many jobs | "Deploy to an environment" — a full, self-contained unit of work |
| Secrets | Inherits whatever the calling job already has | Must be explicitly passed via secrets: (or secrets: inherit) |
Rule of thumb: if what you're deduplicating is setup steps that still run inside a bigger job, use a composite action. If what you're deduplicating is a self-contained unit of work with its own runner and possibly its own matrix (most commonly: a deploy job), use a reusable workflow.
Environments and Manual Approval Gates#
GitHub Environments (staging, production, etc.) are GitHub's concrete implementation of the "manual approval?" decision diamond from Part 1's pipeline-anatomy diagram — and this is also where GitHub-native Continuous Delivery (Part 1's distinction from Continuous Deployment) actually gets enforced, not just described.
An environment, configured in repo Settings → Environments, can carry:
- Required reviewers — one or more specific people (or teams) who must click "Approve" before a job referencing this environment proceeds — the workflow run pauses, genuinely blocked, until approval.
- Wait timer — a mandatory delay (e.g. 10 minutes) before the job runs, even with no human involved — useful as a "cool-down" window to catch an obviously-bad deploy just landed elsewhere.
- Deployment branch/tag rules — restrict which branches are even allowed to deploy to this environment (e.g. only
maincan deploy toproduction). - Environment-scoped secrets — a secret defined on the
productionenvironment is invisible to a job running againststaging, even in the same workflow file.
jobs: deploy-prod: runs-on: ubuntu-latest environment: name: production url: https://app.example.com steps: - run: ./deploy.sh env: API_KEY: ${{ secrets.PROD_API_KEY }} # only visible because job targets "production"
Diagram
This is the exact mechanism that turns a GitHub Actions pipeline from Continuous Deployment (fully automatic) into Continuous Delivery (automatic up to a human-gated release) as covered conceptually in Part 1 — the only difference between the two, in GitHub terms, is whether the production environment has required reviewers configured.
Self-Hosted Runners#
GitHub-hosted runners (runs-on: ubuntu-latest) are fresh, ephemeral VMs GitHub provisions and tears down for every job — zero maintenance, but limited to GitHub's provided hardware/OS images and (on the free tier) limited monthly minutes. A self-hosted runner is a machine (physical, VM, container, or Kubernetes pod via the Actions Runner Controller) that a team registers to their own repo, organization, or enterprise, and that GitHub Actions dispatches jobs to instead.
jobs: build: runs-on: [self-hosted, linux, gpu] # labels: match any runner tagged with ALL of these steps: - uses: actions/checkout@v4 - run: ./build-with-gpu.sh
Why a team reaches for self-hosted runners, in rough order of frequency:
- Hardware GitHub doesn't offer — GPU-accelerated builds, specialized ARM hardware, more RAM/CPU than the largest GitHub-hosted tier.
- Network access to private infrastructure — the runner needs to reach an internal database or private VPC that a GitHub-hosted (public-cloud, ephemeral) runner cannot reach.
- Cost at high volume — GitHub-hosted minutes are billed per-minute past the included allowance; a team running thousands of build-minutes daily may find dedicated self-hosted capacity cheaper.
- Compliance — some regulatory environments require build infrastructure to stay entirely within the organization's own network boundary.
The security tradeoff, stated plainly (expanded fully in Part 5): a self-hosted runner attached to a public repository is a genuine security risk — anyone who can open a pull request can potentially get arbitrary code executed on that runner via a workflow triggered by pull_request_target or similar. Self-hosted runners are broadly considered safe for private/internal repos with a trusted contributor set, and require real hardening (ephemeral, single-job runners; no persistent secrets on the box) for anything public-facing.
Contexts, Expressions, and Conditional Steps#
Every value inside ${{ }} in a workflow file is an expression, evaluated against one of several built-in contexts — structured data GitHub makes available about the run, the event that triggered it, the repo, and the job's own state so far.
| Context | Holds | Example |
|---|---|---|
github | The triggering event, repo, actor, ref | github.event_name, github.actor, github.ref |
env | Environment variables defined in the workflow/job/step | env.NODE_ENV |
secrets | Configured secrets (never printed to logs, auto-masked) | secrets.DEPLOY_TOKEN |
matrix | The current matrix combination | matrix.node-version |
needs | Outputs and results from jobs this job depends on | needs.build.outputs.version, needs.build.result |
steps | Outputs from previous steps in the same job | steps.get-version.outputs.value |
runner | Info about the runner executing the job | runner.os, runner.temp |
if: conditions are how a step or an entire job decides whether to run at all — the GitHub Actions equivalent of a branch in code, and the mechanism behind "only deploy from main, never from a feature branch" or "only run this cleanup step even if an earlier step failed":
jobs: deploy: if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest steps: - run: ./deploy.sh - name: Notify on failure if: failure() # only runs if a PRECEDING step in this job failed run: ./notify-slack.sh "Deploy failed" - name: Always clean up if: always() # runs regardless of success/failure/cancellation run: ./cleanup.sh
The four status-check functions — success() (default, implicit if if: is omitted), failure(), cancelled(), and always() — are worth memorizing precisely, because a genuinely common mistake is adding a "notify on failure" step and having it silently never run, because by default GitHub skips every remaining step in a job the instant one step fails, and only if: failure() or if: always() override that default skip behavior.
Job Outputs — Passing Small Data Between Jobs#
Artifacts (covered earlier) move files between jobs. Job outputs move small, individual values — a version string, a computed flag, a generated ID — without the overhead of uploading and downloading a file for a single piece of data.
jobs: build: runs-on: ubuntu-latest outputs: version: ${{ steps.get-version.outputs.value }} steps: - id: get-version # the step needs an `id:` to be referenced later run: echo "value=$(cat VERSION)" >> "$GITHUB_OUTPUT" deploy: needs: build runs-on: ubuntu-latest steps: - run: echo "Deploying version ${{ needs.build.outputs.version }}"
The >> "$GITHUB_OUTPUT" pattern is the current, correct way to set an output — writing to this special environment-file path GitHub provides to the runner, rather than the older ::set-output:: workflow command syntax, which GitHub deprecated for security reasons (it was vulnerable to log injection from untrusted input). Any tutorial or Stack Overflow answer still showing ::set-output:: is describing a removed, no-longer-functional pattern.
Service Containers — Databases for Integration Tests#
A service container runs a real dependency — Postgres, Redis, RabbitMQ — as a sidecar container alongside a job's main container, network-reachable by the job's steps for the job's entire duration. This is how a GitHub Actions job runs genuine integration tests against a real database, rather than mocking it out, directly reusing the "cheap unit tests first, then real integration tests against real dependencies" stage-ordering principle from Part 1.
jobs: integration-test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: testpass POSTGRES_DB: testdb ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run test:integration env: DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
The options: health-check block is not optional boilerplate — it's what prevents a race condition. Without it, the job's steps can start running before Postgres has finished initializing inside its container, causing intermittent, hard-to-debug "connection refused" failures that look like a flaky test but are actually a startup-ordering bug. GitHub Actions waits for the service container to report healthy (via the configured health check) before starting the job's own steps.
Concurrency Control#
By default, pushing three commits to the same branch within a minute of each other queues (or runs in parallel) three separate, full workflow runs — wasteful, and for a deploy workflow specifically, actively dangerous (two deploys to the same environment racing each other). concurrency: groups workflow runs by a key and controls what happens when a new run starts while an older one in the same group is still active.
concurrency: group: deploy-production # runs sharing this exact group name are serialized cancel-in-progress: false # let the current run finish; queue the new one behind it jobs: deploy: runs-on: ubuntu-latest environment: production steps: [...]
A very common pattern uses the branch/PR reference to build a dynamic group name, so every branch or PR gets its own independent concurrency lane — stale runs on an old commit within the same PR get cancelled, but that never affects a different PR's runs:
concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true # a new push to the SAME pr/branch cancels the old, now-stale run
The two settings genuinely mean opposite things and picking the wrong one has real consequences: cancel-in-progress: true is correct for CI checks on a PR (there's no value in finishing a test run against code that's already been superseded by a newer push) but would be actively dangerous for a production deploy job (cancel-in-progress: false, as shown above) — you never want to cancel a deploy that's already partway through applying changes to production; you want the next one to wait its turn instead.
A Full Realistic Multi-Stage Pipeline#
Tying every mechanism from this chapter together into one realistic pipeline — build → test (matrix) → security scan → deploy to staging → manual approval → deploy to production, directly reusing Part 1's full pipeline-anatomy diagram, now expressed as real GitHub Actions YAML:
name: Full Pipeline on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-env - run: npm run build - uses: actions/upload-artifact@v4 with: { name: dist, path: dist/ } test: needs: build strategy: matrix: node-version: ['18', '20', '22'] fail-fast: false runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '${{ matrix.node-version }}', cache: 'npm' } - run: npm ci - run: npm test security-scan: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm audit --audit-level=high deploy-staging: needs: [test, security-scan] runs-on: ubuntu-latest environment: staging steps: - uses: actions/download-artifact@v4 with: { name: dist, path: dist/ } - run: ./deploy.sh --env staging smoke-test: needs: deploy-staging runs-on: ubuntu-latest steps: - run: curl -f https://staging.example.com/healthz deploy-production: needs: smoke-test runs-on: ubuntu-latest environment: production # required reviewers configured here = the manual gate steps: - uses: actions/download-artifact@v4 with: { name: dist, path: dist/ } - run: ./deploy.sh --env production
Diagram
Notice this single file demonstrates every stage-ordering principle from Part 1: cheap/fast checks (test, security-scan) run before expensive ones (an actual deploy); the production deploy is the last, most gated step; and the needs: graph enforces exactly the same left-to-right dependency order as the generic pipeline diagram this series opened with.
Common Mistakes#
| Mistake | Why it's a problem | Fix |
|---|---|---|
Forgetting permissions: entirely | GITHUB_TOKEN defaults to broad permissions on many repos, violating least privilege | Set permissions: { contents: read } at the workflow level, escalate only in the specific job that needs it (full detail in Part 5) |
Using actions/checkout@v4 but never pinning further | A tag like @v4 can be moved by the action's author (or, in a compromise, an attacker) | Pin third-party actions to a full commit SHA for anything security-sensitive (full detail in Part 5) |
fail-fast: true (the default) on a compatibility matrix | Cancels other combinations before you see their results, hiding the full picture of what's actually broken | Set fail-fast: false when the goal is genuinely seeing every combination's outcome |
| Treating a cache as guaranteed | Caches are best-effort and can be silently evicted | Never rely on cache for anything correctness-critical — only for speed; use artifacts for anything that must exist |
| Copy-pasting the same 5 setup steps into every workflow file | Drift — a fix applied to one copy doesn't reach the others | Extract into a composite action (or reusable workflow if it's a full job) |
No environment protection on production | A single bad merge to main can auto-deploy straight to real users with no human check | Configure required reviewers on the production environment |
| Self-hosted runner attached to a public repo with no hardening | A malicious PR can potentially execute code on your infrastructure | Use GitHub-hosted runners for public repos, or heavily hardened ephemeral self-hosted runners (Part 5) |
Worked Practice Problems#
Problem 1: A team's test job takes 12 minutes because it reinstalls all npm dependencies from scratch on every run. What's the single highest-leverage fix, and why?
Answer: Add dependency caching via actions/cache@v4 (or setup-node's built-in cache: 'npm' option), keyed on the lockfile hash. This is the single highest-leverage fix because dependency installation is almost always the largest fixed cost in a JS pipeline, and unlike test parallelization or matrix tuning, caching requires no change to the actual test suite — it's pure infrastructure speedup with no risk to correctness, since the cache key is invalidated automatically the moment the lockfile changes.
Problem 2: Three product repos in an organization each maintain their own near-identical 40-line "build, scan, and push a Docker image" workflow, and they've already drifted slightly out of sync — one has an extra security scan step the others are missing. Which GitHub Actions mechanism fixes this, and how would you migrate?
Answer: A reusable workflow (workflow_call) in a shared, central repository, since what's being deduplicated is a full self-contained job (build+scan+push), not just a handful of setup steps. Migration: extract the common logic into shared-workflows/.github/workflows/build-scan-push.yml with workflow_call inputs for anything genuinely repo-specific (image name, Dockerfile path), then replace each product repo's local job with uses: my-org/shared-workflows/.github/workflows/build-scan-push.yml@v1. Going forward, a fix to the shared workflow (like adding that missing security scan step) reaches all three repos the next time they pull @v1, instead of requiring three separate manual edits.
Problem 3: A workflow needs to deploy to staging, qa, and production — same deploy logic, different target and different secrets per environment — with production requiring manual approval but the other two not. Sketch the job structure.
Answer: One reusable workflow (reusable-deploy.yml, workflow_call, taking environment as an input and DEPLOY_TOKEN as a required secret) called three times from a caller workflow — either via strategy.matrix: { target: [staging, qa] } for the two non-gated environments, plus a separate explicit production job with needs: on the first two so it only starts after both succeed. The manual-approval behavior needs no extra workflow logic at all — it's entirely a property of the production GitHub Environment having required reviewers configured in repo settings, which the reusable workflow's environment: ${{ inputs.environment }} line automatically picks up whenever it's called with environment: production.
Summary and What's Next#
GitHub ships its pipeline system directly inside the same product as the code it builds — workflow files live at .github/workflows/, triggered by native platform events (push, PR, release, schedule, or a manual button), and structured as jobs (parallel by default, ordered via needs:) made of steps. Matrix builds fan a single job definition out across many configurations; composite actions deduplicate steps within a job, while reusable workflows deduplicate entire jobs (and are how larger organizations centralize a golden-path pipeline). Environments are GitHub's concrete implementation of the manual approval gate that separates Continuous Delivery from Continuous Deployment, and self-hosted runners trade GitHub's zero-maintenance hosted infrastructure for custom hardware, network access, or cost control — at the price of real security responsibility.
Part 5 picks up exactly where the security tradeoffs in this chapter left off: GITHUB_TOKEN least privilege in depth, pinning actions to a commit SHA against supply-chain attacks, OIDC to eliminate long-lived cloud credentials entirely, branch protection rules and rulesets, CODEOWNERS, Dependabot, and GitHub Advanced Security (CodeQL and secret scanning).