Mechanism 06Shipping SoftwareInteractive · 5 min

What happens in the minutes after you push a commit?

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 eventWorkflows don't run on a schedule of their own here. Push something.

Waiting for an event. Workflows don't run on a schedule of their own here. Push something.

tick 0
Push to main
From a branch
Runners available at once
Latest runs✓ done ▸ running · to do – skipped

(no runs yet)

Counters
0Runs
0Runner ticks billed
0Jobs queued
0Cache hits
0Cache misses
0Jobs cancelled
Sheet 06 · One workflow, from event to runnersGitHub-hosted runners, one job eachTicks stand in for minutes
The model

Three ideas explain most of it

Idea 1

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.

Idea 2

Jobs form a graph

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.

Idea 3

Every job starts from nothing

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.

Under the hood

The workflow file decides everything

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 events yet)
When it breaks

Most confusion comes from the clean machine

“It works locally”

The runner has none of your state

No installed packages, no environment variables, no files you forgot to commit. Every job proves the repository builds from scratch, which is the point.

Scenario 5 · slow runs

Downloading the internet every time

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.

Scenario 4 · races

Two deploys, wrong order

Push twice quickly and two runs can deploy in either order. A concurrency group with cancel-in-progress keeps only the newest alive.

Test yourself

Two predictions

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'?

Fine print

What this model leaves out

Self-hosted runners

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.

Cache behaviour

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.

Billing and limits

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.

Secrets, environments and permissions

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.

Keep going

Connected mechanisms