Events, matched against files
A push, a pull request or a schedule is an event. GitHub reads the workflow files in that commit and starts a run for each one whose on: block matches, filters included.
GitHub turns the push into an event, finds every workflow that listens for it, and builds a graph of jobs. Each job waits for the ones it needs, then gets a brand-new machine that's thrown away when it finishes.
One workflow file, three runners at most. A tick stands in for roughly a minute of a real job.
One push, one run, four jobs. The two test jobs are a matrix and run side by side. build waits for both, deploy waits for build, and every job starts on a fresh machine.
Waiting for an event. Workflows don't run on a schedule of their own here. Push something.
(no runs yet)
A push, a pull request or a schedule is an event. GitHub reads the workflow files in that commit and starts a run for each one whose on: block matches, filters included.
Jobs run in parallel unless needs: says otherwise. A job starts when everything it needs has succeeded, and is skipped if any of it didn't, or if its if: is false.
Each job gets a fresh virtual machine, which is destroyed afterwards. Steps share a disk, jobs don't. Caches and artifacts are how anything gets from one to another.
Live: the lines deciding what happens now.github/workflows/ci.yml
on: push: { branches: [main], paths-ignore: ['docs/**'] } pull_request:concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }jobs: test: strategy: { matrix: { node: [20, 22] }, fail-fast: true } runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: ${{ matrix.node }}, cache: npm } - run: npm ci - run: npm test build: needs: test steps: [checkout, setup-node, npm ci, npm run build, upload-artifact] deploy: needs: build if: github.ref == 'refs/heads/main' steps: [download-artifact, ./deploy.sh]Read it as a set of rules, not a script. on: decides whether a run happens at all. needs: and if: decide which jobs run and in what order. steps: are the only part that executes in sequence.
The matrix turns one job definition into one job per value, here one per Node version, each on its own runner. concurrency makes sure only one run per branch is alive, so deploys can't overtake each other.
Live: what GitHub decided
No installed packages, no environment variables, no files you forgot to commit. Every job proves the repository builds from scratch, which is the point.
Without a cache, every job reinstalls every dependency. Key the cache on the lockfile, so it's reused until dependencies really change and rebuilt the moment they do.
Push twice quickly and two runs can deploy in either order. A concurrency group with cancel-in-progress keeps only the newest alive.
The build job runs npm run build, and the deploy job needs its output in dist/. Without an upload and download step, what does deploy find?
A pull request's tests pass. What happens to the deploy job, which has if: github.ref == 'refs/heads/main'?
Runners you host yourself are long-lived machines, so files can survive between jobs unless you clean up. Everything here is GitHub-hosted, where each job really does get a new VM.
A cache key can't be overwritten, which is why only the first matrix leg saves. Real caches also fall back on restore-keys, are scoped to branches, and are evicted after seven days unused or when the repository passes its storage limit.
Hosted runner minutes are billed per job, rounded up to the minute, at different rates per operating system. Public repositories use standard runners free. The concurrency limit depends on the plan.
Real deploy jobs use an environment with protection rules and secrets, and a scoped GITHUB_TOKEN. Pull requests from forks don't get secrets at all, another reason deploy is limited to main.