Anatomy of a CI pipeline
CI (continuous integration) means a server builds and tests every change automatically, so a broken commit is caught in minutes instead of on release day. This is a GitHub Actions workflow; GitLab CI, CircleCI, and Jenkins use different key names for the same handful of ideas.
File extensions: .yml, .yaml
Every part of the example below is labelled and explained. This page is one of 55 annotated tours on AnatomyOf, a free, open-source project by LunarWerx Studios.
What is inside a CI pipeline
Workflow name
The label shown in the Actions tab and on every commit's status.
name: is the human-facing title of the whole pipeline. It appears in the repository's Actions tab, in the checks list on a pull request, and in the emails you get when a run fails. If you omit it, GitHub falls back to the file path, which is legal and unhelpful. A separate run-name: key can title each individual *run*, and it accepts expressions, so a workflow can label runs with the branch or the person who triggered them. The file itself must live in .github/workflows/ in the default branch to be picked up at all.
Trigger (`on:`)
The events that start a run: pushes, pull requests, a clock, a button.
on: answers "when does this run?". push and pull_request are the everyday pair, each optionally narrowed by branches:, tags:, or paths: filters so that a docs-only change does not spend ten minutes compiling. schedule: takes cron expressions and runs on GitHub's clock in UTC. workflow_dispatch: adds a **Run workflow** button in the UI and can declare typed inputs:. One trap is worth knowing early: pull_request runs the workflow *as defined in the pull request* but denies it secrets and write access, precisely because the code came from a stranger. pull_request_target grants those, running the
Permissions
How much the automatic GITHUB_TOKEN is allowed to touch.
Every run is handed a short-lived GITHUB_TOKEN that expires when the job ends. permissions: decides what that token can do, scope by scope (contents, packages, issues, pull-requests, id-token, and so on). Declaring contents: read at the top of the file is the least-privilege default: anything not listed is set to none. This matters because a workflow runs third-party code from the internet by design. A compromised or merely careless action inherits whatever the token can do, so a pipeline that only needs to read the repo should not be holding a token that can push to it or publish a release.
Concurrency
Limits runs to one per group, optionally cancelling the older ones.
Without this, pushing five commits in a minute starts five full pipelines that all race to tell you about the same code. concurrency: puts runs into a named group: where only one may be in progress, and cancel-in-progress: true kills the older run the moment a newer one arrives. The usual group expression combines the workflow and the branch, so different branches still build in parallel while a single branch only ever has one live run. The one place to be careful is deployment: cancelling a half-finished deploy is worse than queueing it, so production jobs normally set cancel-in-progress: fal
Job
A named unit of work. Jobs run in parallel on separate machines.
Everything under jobs: is a job, and by default they all start at once on independent, freshly created machines. That isolation is the reason a pipeline is fast, and also the reason nothing carries over: two jobs share no disk, no installed packages, and no environment. Anything one job produces for another has to travel as an artifact or a job outputs: value. A job is also the unit of retry. When you click "Re-run failed jobs" after a flaky test, this is what gets re-run. timeout-minutes: caps how long one may hang before GitHub stops paying for it, which defaults to 360.
Runner (`runs-on:`)
Which machine executes the job.
runs-on: picks the hardware and operating system. GitHub-hosted labels like ubuntu-latest, windows-latest, and macos-latest give you a clean virtual machine that is created for this job and destroyed afterwards, preloaded with common toolchains. The -latest labels move: ubuntu-latest currently means Ubuntu 24.04, and GitHub migrates the label to newer images on a published schedule, so pinning an explicit ubuntu-24.04 is the way to avoid being surprised by that. The alternative is a self-hosted runner, your own machine registered with the repo, used when a job needs particular hardware, a priv
Matrix strategy
Runs one job many times over a set of values.
A strategy: matrix: expands a single job definition into one run per combination of the values you list. Three Node versions becomes three parallel jobs; three versions across two operating systems becomes six. Inside the job, ${{ matrix.node }} holds that run's value, which is also how you give each one a distinct name. fail-fast: true is the default and cancels every sibling the moment one fails, which is right when you just want a red light quickly. Setting it to false lets them all finish, which is what you want when the interesting question is *which* combinations broke. max-parallel: thr
Dependency (`needs:`)
Makes one job wait for another, turning the pipeline into stages.
needs: test holds a job back until the named job has finished successfully. That single key is what turns a flat pile of parallel jobs into the classic build, test, deploy shape, and it accepts a list, so a job can wait on several at once. If one of the jobs it needs fails or is skipped, the dependent job is skipped too. Because a matrix expands into many runs of the same job, depending on it waits for *all* of them. A dependent job can also read the earlier job's declared outputs: through the needs context, which is the supported way to pass a value (a version number, an image tag) from one m
Step
One ordered task inside a job. Steps share the same machine.
A job's steps: run one after another on the same runner, so unlike jobs they *do* share a working directory and any environment changes made along the way. Each step can carry a name: for the log, an id: so later steps can read its outputs, and its own env:, if:, working-directory:, or continue-on-error:. The first step of almost every workflow is a checkout, because the runner starts empty: it has your repository's *name*, not its files. A step that fails stops the job at that point, which is why ordering matters and why the cheapest checks are usually put first.
Action (`uses:`)
Pulls in a reusable, packaged step from another repository.
uses: actions/checkout@v7 runs someone else's published step. The part before @ is a repository, and the part after is a git ref: a tag, a branch, or a full commit SHA. with: passes that action its inputs, which are declared in the action's own action.yml. This is the reuse mechanism that makes workflows short, and it is also the supply chain. @v7 is a moving tag the author can repoint at any time, so security-sensitive pipelines pin the full commit SHA instead and let a bot propose the bumps. A handful of first-party actions (checkout, setup-*, cache, upload-artifact) cover most of what a nor
Shell command (`run:`)
Executes commands directly on the runner, exactly as you would locally.
run: is the escape hatch and the workhorse. It hands a string to the runner's shell (bash on Linux and macOS, PowerShell on Windows, overridable per step with shell:). A block scalar written with | lets one step hold several lines of script. The rule that governs everything is the exit code: zero passes, anything else fails the step and therefore the job. That is why npm test needs no special integration to work here. Bash steps run with set -eo pipefail by default, so a failure partway through a multi-line script stops it rather than sailing on to report success.
Secrets and variables
${{ secrets.X }} injects a stored credential without printing it.
Secrets are encrypted values stored on the repository, environment, or organization, and read back through the secrets context. Their values are masked in the logs, so an accidental echo prints *** rather than your deploy key. The vars context is the same mechanism for values that are merely configuration and not sensitive, and env: sets plain environment variables at the workflow, job, or step level. Scope them as tightly as they will go. Attaching a secret to an environment: means only jobs targeting that environment can read it, and an environment can additionally demand a human approval be
Condition (`if:`)
Runs a step or job only when an expression is true.
if: takes an expression and skips the step or job when it evaluates false. Inside an if: the surrounding ${{ }} is optional, which is why you see both forms in the wild. Common uses are branch guards (github.ref == 'refs/heads/main') and event guards (github.event_name == 'push'). The status functions matter here. By default every step carries an implicit success(), meaning a step is skipped once something earlier has failed. if: always() runs the step regardless, which is how test reports and artifacts still get uploaded from a failing build, and if: failure() runs one only when something bro
Official CI site · All languages on AnatomyOf
The interactive tour needs JavaScript. Enable it to hover a callout and trace it into the code.