Developer Tooling
GitHub Actions, one layer at a time: from six lines to a hardened pipeline
Start with the smallest workflow that does something useful, then add each concept only when the previous version breaks. By the end you’ve built — and actually understood — a real pipeline.
Scope: verified against GitHub’s docs, July 2026 — the OS billing multipliers (Linux ×1 / Windows ×2 / macOS ×10), the usage limits below, and the Node 24 action runtime (Node 20 is deprecated). Larger-runner pricing still moves; check the pricing page for exact rates.
The docs hand you every knob at once, which is why GitHub Actions feels bigger than it is. So we’ll do the opposite: build one pipeline from six lines up, and let each new idea show up exactly when the current version hits a wall — the same order you’d hit them for real. Watch the same ci.yml grow, and the concepts arrive already motivated.
TL;DR — the walls, in the order you hit them
- Six lines is already a real pipeline.
on: push, one job,pytest. Everything else is a layer you add when you hit a wall — not up front. - Wall 1 — a second job can’t see the first’s files. Each job is a fresh, isolated VM; data crosses only through artifacts, cache, and outputs.
- Wall 2 — you’re copy-pasting setup. The action is the reusable unit: composite actions share steps, reusable workflows share a whole pipeline.
- Wall 3 — it has to deploy without leaking a key. Scope
GITHUB_TOKENto read-only, then use OIDC so no static cloud keys are ever stored. - Wall 4 — a forked PR is a stranger’s code. Fork PRs get no secrets by design; the three real attacks are script injection,
pull_request_target, and unpinned actions. - Wall 5 — it’s slow and it costs. matrix / concurrency / cache, and the OS multiplier: Linux ×1 / Windows ×2 / macOS ×10.
Start here — six lines that actually work
You want your tests to run when you push. That’s the entire ambition, and this is the entire solution:
# .github/workflows/ci.yml — the whole thing, to start
on: [push] # WHEN: run on every push
jobs:
test: # WHAT: one job...
runs-on: ubuntu-latest # ...on a fresh Ubuntu VM
steps: # ...running these commands in order
- uses: actions/checkout@v4 # a prebuilt action: clone the repo
- run: pip install -r requirements.txt
- run: pytest
That is a real, working pipeline. Three keys carry the whole model: on: is when (the event), jobs: is what runs and where (each job gets its own machine), and steps: are the commands, in order — either a run: shell line or a uses: that pulls in a prebuilt action (here, checkout to clone your code). Push a commit and GitHub runs it, then reports pass or fail right on the commit and any PR.
Before we add anything, it’s worth seeing the full arc those six lines set in motion — from the event that fires to the result that lands back in the PR. Every layer after this just refines one part of this loop.
- A developer pushes a commit to the PR branch — the
pull_request(orpush) event fires. - GitHub matches the event against each workflow’s
on:filters (event, branches, paths) and creates a workflow run; a check shows “Queued” on the PR. - GitHub assigns each job to a runner (an ephemeral GitHub-hosted VM by label, or self-hosted) and mints a job-scoped
GITHUB_TOKEN. - The runner checks out the code (
actions/checkout), restores any cache, and sets up the toolchain. - Steps run in order —
run:commands anduses:actions (build, test, lint); logs stream live to the Checks tab. - Steps emit annotations, a job summary, and outputs; the check run shows “In progress.”
- The job completes with a conclusion (success / failure) and GitHub updates the check run.
- Results post to the PR conversation: the checks summary, inline annotations on the diff, and any comment a workflow left via the token.
- Required checks gate the merge button; the developer reads the result and pushes a fix — which starts the loop again.
The cast of characters — every piece, named (and how many you get)
Before we pile on more layers, here is the whole cast and how it nests — the vocabulary trips people more than the concepts do. A workflow is one .yml file; what other tools call a pipeline, GitHub simply calls a workflow (there is no separate “pipeline” object). An action is not a workflow — it is a single reusable building block that a step pulls in with uses:. And the file you write is distinct from a workflow run, which is one execution of it.
run: command or a uses: that plugs in an action. The confusions that trip people are called out below the diagram.“How many workflows can I run?”
Three different things are capped, so the answer depends on which you mean:
| What you’re counting | The limit |
|---|---|
| Workflow files in a repo | No hard limit — add as many .yml files as you like |
| Jobs running at once (across all runs) | Capped by plan: Free 20 · Pro 40 · Team 60 · Enterprise 500 (standard runners; larger-runner pools cap at 1,000). Support can raise it. |
| Jobs in one workflow run | 256 max (a matrix also tops out at 256) |
| New workflow runs queued | 500 per 10 seconds, per repository |
| One job’s run time | 6 hours on GitHub-hosted runners (5 days on self-hosted) |
| One workflow run’s total time | 35 days including time queued — then it’s cancelled |
| Reusable-workflow reach | 4 nesting levels deep, up to 20 unique reusable workflows per run |
GITHUB_TOKEN API calls | 1,000 requests per hour, per repository |
concurrency: key from the scaling layer, a choice you make, not a platform limit.How the YAML keys fit together — and where with goes
A workflow file has a lot of keys, and they feel scattered until you notice the rule: every key belongs to exactly one level — the workflow, a job, or a step — and its level tells you what it’s allowed to do. Read the file as three nested scopes and the keys sort themselves.
with: lives on a uses: step because it passes that action’s inputs; a run: step has no with: (it takes env: instead).The one that trips everyone is with:. It is the inputs to a uses: action, so it only appears on a uses: step, indented right under it. A run: step has no with: — you give it env: instead. The same pattern holds one level up: when you call a reusable workflow with uses:, you pass its inputs with with: too (and its secrets with secrets:).
| Level | The keys you’ll actually use |
|---|---|
| Workflow (top of file) | name · on · permissions · env · concurrency · defaults · jobs |
| Job (under a job id) | runs-on · needs · if · strategy/matrix · environment · permissions · env · outputs · timeout-minutes · steps |
Step (a - item) | name · id · if · uses · with · run · env · shell · working-directory · continue-on-error |
env:, permissions:, and if: can appear at workflow, job, or step level — the innermost one wins. So a workflow-wide permissions: contents: read can be widened by one job that needs more.“I need lint and a build too” — steps vs jobs
Real CI does more than one thing. Add a lint step and the shape of the problem appears: steps inside a job run one after another on the same machine, so a slow lint blocks your tests, and they can’t use different setups. When two things are independent, you don’t want them queued — you want them at once.
That’s the difference between a step and a job. Split independent work into separate jobs; jobs run in parallel by default, each on its own runner. When you do need order — build after tests pass — you say so with needs:.
jobs:
lint: # a SECOND job — runs in PARALLEL with test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: pytest
# Steps inside a job run in ORDER on one machine.
# Separate jobs run AT THE SAME TIME — unless you chain them with `needs:`.
So the hierarchy is just: a workflow wires an event to jobs (parallel machines), and each job runs steps (ordered commands) that call actions. Hold onto “each job is its own machine” — it’s about to bite.
“My deploy can’t find what build made” — jobs are isolated
You add a build job that produces ./dist, then a deploy job to ship it — and deploy fails: dist/ isn’t there. Nothing is broken. This is the single most important thing to internalize about GitHub Actions: every job runs on a brand-new VM, and its filesystem is thrown away when the job ends. Steps in one job share a disk; separate jobs share nothing.
- Job A does its work on the runner filesystem.
- To hand files to a later job, upload them as artifacts (
upload-artifact/download-artifact). - To reuse dependencies, save a cache keyed by a lockfile hash (
actions/cache). - To pass a small value, declare a job output and read it via
needs.build.outputs.*. - Job A's filesystem is destroyed; Job B starts empty and receives only what crossed the bridge.
So you move things across the gap deliberately. Files that a later job needs — a built package, a coverage report — go through artifacts (upload-artifact / download-artifact). Dependencies you want to reuse for speed go through a cache. Small string values (a version, an image tag) go through job outputs and are read via needs.build.outputs.*. Nothing else survives the crossing.
build:
needs: [lint, test] # runs only after BOTH lint and test pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python -m build # produces ./dist
- uses: actions/upload-artifact@v4 # hand ./dist to a later job
with: { name: dist, path: dist/ }
deploy:
needs: build
runs-on: ubuntu-latest # a BRAND-NEW VM — build's ./dist is already gone
steps:
- uses: actions/download-artifact@v4 # the only way to get it back
with: { name: dist }
- run: ./deploy.sh
“I’ve pasted the same setup four times” — actions and reuse
By now checkout + setup-python + pip install appears in every job, and a sister repo wants the exact same pipeline. This is where the value compounds — and where two different tools get confused. The reusable unit is the action: the thing uses: calls. Bundle your repeated steps into a composite action and each job shrinks to two lines. To share the whole pipeline across repos, publish a reusable workflow and let callers invoke it by ref.
- A step is the atom: either a
run:shell command or auses:action. - An action packages logic and is the reusable unit.
- Actions come in three kinds — composite (bundles steps), JavaScript (Node on the runner), Docker (isolated container, Linux-only). A composite action shares a step sequence inside one job.
- A reusable workflow shares whole jobs — a pipeline — across repos, called by ref or SHA.
# .github/actions/setup/action.yml — bundle the repeated steps ONCE
name: setup
runs:
using: composite
steps:
- uses: actions/setup-python@v4
with: { python-version: "3.12", cache: pip }
- run: pip install -r requirements.txt
shell: bash # composite run: steps MUST set a shell
# then every job shrinks to two lines:
# - uses: actions/checkout@v4
# - uses: ./.github/actions/setup
# .github/workflows/reusable-ci.yml — share a WHOLE pipeline across repos
on:
workflow_call: # this makes the workflow callable
inputs:
python-version: { type: string, default: "3.12" }
jobs:
test: { runs-on: ubuntu-latest, steps: [ ... ] }
# a caller in ANY repo runs the whole thing:
# jobs:
# ci:
# uses: my-org/ci-templates/.github/workflows/reusable-ci.yml@<sha>
# with: { python-version: "3.11" }
“It has to deploy — without me pasting a cloud key” — the token and OIDC
The deploy job needs credentials, and the instinct is to drop a long-lived AWS access key into repo secrets. That static key is exactly the thing that leaks. GitHub Actions gives you two better answers. First, every run already has an auto-generated GITHUB_TOKEN that expires when the job ends; its power is set by a permissions: block, per scope. Default it to read-only and grant up only where needed — the single highest-leverage hardening move.
Second, for the cloud, use OIDC: the job mints a short-lived token, and a trust policy in your cloud account swaps it for temporary credentials scoped to this repo and branch. No static keys live anywhere.
- pull_request from a fork — read-only token, no secrets: safe by default.
- pull_request_target — runs in the base context with a write token and secrets; safe only if it never checks out or runs PR code.
- OIDC — the job mints a short-lived, repo-scoped token and swaps it for temporary cloud credentials; no static keys in secrets.
pull_request_target shortcut, and OIDC as the modern keyless deploy.permissions:
contents: read # least privilege for the WHOLE run; grant up per job
jobs:
deploy:
permissions:
contents: read
id-token: write # let THIS job mint a short-lived OIDC token
steps:
- uses: aws-actions/configure-aws-credentials@v4 # swaps OIDC -> temp creds
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: us-east-1
- run: ./deploy.sh # deploys with TEMPORARY creds — nothing stored in secrets
“Don’t auto-ship to prod” — Environments and approvals
Now every merge deploys, and you want a human in the loop for production. Change control is native, and it lives in Environments. An environment (say production) carries its own secrets plus protection rules: required reviewers (a manual approval gate), a wait timer, and branch restrictions. Point the deploy job at it and the job simply pauses until someone approves.
deploy:
needs: build
environment: production # protection rules apply here:
runs-on: ubuntu-latest # required reviewers · wait timer · branch limits
steps:
- run: ./deploy.sh # this job PAUSES until a reviewer approves
staging and production can hold different values and different approvers, and a job only sees the one it targets.“Wait — my CI runs strangers’ code” — the fork trust line
Your workflow runs on pull requests, including from forks — that is, from people you’ve never met. The good news is the default is safe: a pull_request from a fork runs with a read-only token and no secrets, on purpose, so a drive-by PR can’t exfiltrate anything. The bad news is that almost every real compromise is one of three default-looking patterns.
- Script injection — untrusted input inlined into
run:is shell execution; pass it through anenv:var and reference the shell variable. - pull_request_target pwn request — write token + secrets plus a checkout of PR code runs the attacker as you; don't build or run untrusted PR code under it.
- Unpinned action — a moveable tag can be re-pointed; pin third-party actions to a full commit SHA and review updates.
They are: script injection — untrusted ${ github.event.* } interpolated into a run: block is shell execution; pull_request_target — it runs with a write token and secrets in the base repo’s context, so checking out and running the PR’s code hands your privileges to the author (the “pwn request”); and the unpinned action — a moveable tag can be re-pointed to new code. The fixes are cheap and worth making reflex:
# UNSAFE — a PR titled $(rm -rf ~) becomes a shell command on your runner:
# - run: echo "Title is ${{ github.event.pull_request.title }}"
# SAFE — pass untrusted input through an env var; the shell treats it as data:
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Title is $TITLE"
# And pin third-party actions to a full commit SHA — a tag can be re-pointed:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
tj-actions/changed-files action was compromised (CVE-2025-30066): attackers re-pointed its version tags to code that dumped runner secrets into build logs across 23,000+ repositories. Repos that had pinned it to a full commit SHA were unaffected — which is the whole argument for pinning.“It’s slow, and the bill is climbing” — matrix, concurrency, cache, cost
Three levers turn one job into fleet-scale CI without new machinery. A matrix fans a job across combinations (Python versions, OSes); set fail-fast: false to see every result instead of cancelling siblings on the first red. Concurrency cancels a run when newer commits land on the same PR (and, for deploys, does the opposite — one at a time, never cancel a live one). A cache skips repeated dependency installs.
test:
strategy:
fail-fast: false # let ALL legs finish; don't cancel siblings
matrix:
python: ["3.10", "3.11", "3.12"] # fan this job across versions
runs-on: ubuntu-latest
# top level — cancel a run when new commits land on the same PR:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true # (for deploys, do the OPPOSITE: never cancel)
Then the bill. On public repos, standard runners are free. On private repos you pay per minute, rounded up, with an OS multiplier that dwarfs every other knob — roughly Linux ×1, Windows ×2, macOS ×10. So the biggest cost lever is which runner you pick; after that, caching and cancellation. Larger runners and artifact/cache storage are billed on top.
Step back — the whole thing, in one picture
You’ve now met every piece by hitting the wall that needs it. Here they are as one map: an event fires a workflow, which wires it to isolated jobs on runners, each running steps and actions; data crosses between jobs only through the artifact/cache/output corridor; a scoped token authorizes them; and an Environment gate can hold a deploy.
- An event (push, pull_request, dispatch, schedule, workflow_call) starts a run.
- The workflow wires that event to jobs; build/test runs first, on its own runner, holding steps and actions.
- Nothing on a runner's disk survives — data reaches the next job only through the artifacts / cache / outputs corridor.
- Deploy runs on a fresh runner and pulls what it needs from the corridor.
- An Environment gate can hold the deploy for required reviewers before it runs.
- GITHUB_TOKEN / secrets / OIDC authorize both jobs — scope them least-privilege.
And four things stay true no matter how large your pipelines get, worth committing to memory:
- Jobs are isolated. Only artifacts, cache, and outputs cross between them.
- Fork PRs get no secrets — by design; don’t defeat it with
pull_request_target. - Actions triggered by
GITHUB_TOKENdon’t recursively trigger workflows — a push made with the default token won’t kick off another run (loop prevention; surprises people expecting a follow-on). - A tag is a moving target; a SHA is not. Pin third-party actions by commit.
The rest of the map — a reference shelf
The build above uses the pieces you reach for daily. The rest, kept compact so nothing’s lost:
Events beyond push / pull_request
| Event | Fires on | Note |
|---|---|---|
workflow_dispatch | a manual click / API call | takes typed inputs; the “Run workflow” button |
schedule | cron (UTC) | best-effort timing; runs the default branch’s file |
workflow_call | another workflow | makes this workflow reusable |
release / issues / … | repo activity | each has activity types and filters (branches, paths, tags) |
Where data and config live
| Mechanism | Use it for |
|---|---|
$GITHUB_OUTPUT | a step output (promote to a job output to cross jobs) |
$GITHUB_ENV / $GITHUB_PATH | env var / PATH for later steps in the same job |
$GITHUB_STEP_SUMMARY | markdown that renders in the Checks tab & PR |
| contexts & functions | github, needs, matrix… with hashFiles, fromJSON, contains, status checks |
Runners
GitHub-hosted = a clean ephemeral VM per run (Ubuntu / Windows / macOS, plus larger runners with more vCPU/RAM/GPU). Self-hosted = your machines, chosen by label, autoscalable (ARC on Kubernetes) — but never attach a self-hosted runner to a public repo: a forked PR can run arbitrary code on it. Keep public-repo CI on ephemeral hosted runners.
References
- GitHub — Understanding GitHub Actions and Workflow syntax for GitHub Actions (events, jobs, steps,
needs,permissions). docs.github.com/en/actions - GitHub — Events that trigger workflows (
push,pull_request,pull_request_target,workflow_dispatch,schedule,workflow_call). docs.github.com/en/actions/using-workflows/events-that-trigger-workflows - GitHub — Contexts and Expressions (
github/needs/matrixcontexts,hashFiles,fromJSON, status functions). docs.github.com/en/actions/learn-github-actions/contexts - GitHub — Using GitHub-hosted runners and About self-hosted runners (ephemeral VMs, labels, larger runners, public-repo warning). docs.github.com/en/actions/using-github-hosted-runners
- GitHub — Storing and sharing data from a workflow (artifacts) and Caching dependencies (
actions/cache, keys andrestore-keys). docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts - GitHub — Reusing workflows and Creating a composite action (
workflow_call, nesting/count limits,runs.using: composite). docs.github.com/en/actions/using-workflows/reusing-workflows - GitHub — Automatic token authentication and Assigning permissions to jobs (
GITHUB_TOKEN, per-scopepermissions). docs.github.com/en/actions/security-guides/automatic-token-authentication - GitHub — About security hardening with OpenID Connect (
id-token: write, cloud trust policies, temporary credentials). docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect - GitHub — Using environments for deployment (environment secrets, required reviewers, wait timers, branch policies). docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment
- GitHub — Security hardening for GitHub Actions (script injection,
pull_request_targetrisks, pinning third-party actions to a full SHA). docs.github.com/en/actions/security-guides/security-hardening-for-github-actions - GitHub Security Lab — Keeping your GitHub Actions and workflows secure: Preventing pwn requests. securitylab.github.com/research/github-actions-preventing-pwn-requests/
- GitHub — About billing for GitHub Actions (per-minute billing, OS multipliers, larger-runner rates, storage). docs.github.com/en/billing/managing-billing-for-github-actions
- GitHub — Actions limits (concurrent-job caps by plan, 256-job matrix, 6-hour job / 35-day run, 500 runs / 10 s queue, API rate limits). docs.github.com/en/actions/reference/limits
- GitHub — Actions runner pricing and About billing for GitHub Actions (per-minute rates; OS multipliers Linux ×1 / Windows ×2 / macOS ×10; larger-runner rates; storage). docs.github.com/en/billing
- GitHub Changelog — Deprecation of Node 20 on GitHub Actions runners (runners default to Node 24 from 2026-06-16; Node 20 EOL April 2026). github.blog/changelog
- CISA / GitHub Advisory Database — Supply-chain compromise of tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup (CVE-2025-30154), March 2025. cisa.gov · github.com/advisories