Making the Robot Do the Boring Part
A workflow is a YAML file in one exact directory, and once it exists your tests run on somebody else's computer every time anyone pushes. Watch a run break into jobs and steps, and see where its verdict shows up on a pull request.
Worth reading first: The Pull Request, From Branch to Button
A workflow is a YAML file in one exact directory, and once it exists your tests run on somebody else's computer every time anyone pushes. Watch a run break into jobs and steps, and see where its verdict shows up on a pull request.
A workflow is a YAML file in one exact directory
There is no configuration screen. GitHub Actions reads .github/workflows/*.yml from your repository — that path, exactly — and any file it finds there is a workflow. Commit one and it is live.
name: CI on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm testThat file is a complete, working continuous integration setup. Every push to main and every pull request now gets a fresh Ubuntu machine that checks out your code, installs Node, installs dependencies, and runs four commands. If any of them exits non-zero, the run fails.
Which events trigger this workflow. push, pull_request, schedule, release, workflow_dispatch, and about thirty more.
Named units of work. They run in parallel by default, each on its own machine.
The machine. ubuntu-latest is free for public repositories and much cheaper than Windows or macOS for private ones.
Run in order, on one machine, sharing a filesystem. Either uses (a prebuilt action) or run (a shell command).
Events decide when a workflow runs
The on block is where most of the design happens, because running the wrong workflow at the wrong time is how teams end up waiting eleven minutes for a documentation typo.
on: # Only pull requests targeting main, and only when code changes pull_request: branches: [main] paths-ignore: ["docs/**", "**.md"] # Every night at 03:00 UTC — cron, in UTC, always schedule: - cron: "0 3 * * *" # A button in the Actions tab, with an input workflow_dispatch: inputs: environment: type: choice options: [staging, production] # When a release is published release: types: [published]Two settings are worth adding to almost every workflow. Concurrency cancels the previous run when you push again — otherwise pushing three times in five minutes means three full runs, two of which are already irrelevant. And permissions narrows what the automatic token can do.
concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: readJobs run in parallel, on fresh machines
Each job gets its own clean virtual machine. Nothing is shared between jobs — not the checkout, not the installed dependencies, not the files one of them wrote. Steps within a job share everything, because they are on the same machine.
.github/workflows/ci.yml · on: pull_request · run #241
Jobs — all started at once
170s of machine time, but the run took as long as the slowest job — they ran side by side on three separate throwaway virtual machines.
Steps in typecheck — strictly in order
- Set up job2s
- actions/checkout@v43s
- actions/setup-node@v46s
- npm ci25s
- npm run typecheck11s
- npm run buildskipped
- Upload artifactskipped
src/lib/users.ts:14:22 - error TS2532: Object is possibly 'undefined'.
14 return res.body.id;
~~~~
Found 1 error in src/lib/users.ts:14
Error: Process completed with exit code 2.On the pull request: “Some checks were not successful — 1 failing, 2 successful”
If typecheck is a required check in the branch protection rules, the merge button is disabled until it is green. If it is not required, the red X is a suggestion and anyone can merge straight past it — which is the difference between having CI and having CI that means something.
That is why actions/checkout is the first step of nearly every job. The machine starts empty; without it there is no code to run anything against.
Two mechanisms shape how jobs relate to each other:
jobs: lint: runs-on: ubuntu-latest steps: [...] test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] node: [20, 22] steps: [...] # This one job definition produces FOUR parallel runs deploy: needs: [lint, test] # waits for both, and is skipped if either fails if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: [...]fail-fast: false is worth setting on a matrix. The default cancels every other combination the moment one fails, so you learn that Node 20 broke and not whether Windows also did — which is usually the thing you needed to know.
A failing check blocks the merge, which is the point
Every job appears on the pull request as a check, with a tick or a cross and a link to its logs. That is the visible half of Actions and the reason most teams adopt it.
But a red cross on its own blocks nothing. Anyone can merge straight past it. The check only becomes a gate when it is named as required in the branch protection rules — which is the connection between this chapter and the last one.
Making CI mean something
- ✓Name the jobs that must pass as required checks in branch protection
- ✓A required check that is flaky is worse than none — people learn to re-run rather than read
- ✓Keep it fast. Under five minutes gets read; twenty minutes gets ignored and worked around
- ✓Cache dependencies (setup-node's cache: npm, or actions/cache) — it is usually most of the runtime
- ✓Fail loudly and specifically. "Exit code 1" with 4,000 lines of log above it is not a message
- ✓Run the same commands CI runs, locally, before pushing. CI should confirm, not discover
$ gh run list --limit 5 # recent runs$ gh run watch # follow the current one$ gh run view --log-failed # just the failing step's output$ gh workflow run deploy.yml -f environment=staging # trigger a workflow_dispatchSecrets are how a workflow gets a password safely
A deploy workflow needs credentials, and the workflow file is committed to the repository. Secrets are the way out: encrypted values stored in the repository's settings, injected at run time, and never shown again after you save them.
- name: Deploy env: API_TOKEN: ${{ secrets.DEPLOY_TOKEN }} run: ./scripts/deploy.sh$ gh secret set DEPLOY_TOKEN # prompts, and never echoes$ gh secret listGitHub masks secret values in logs, so a token that gets printed appears as ***. Treat that as a safety net rather than a guarantee: a value that is transformed before printing — base64-encoded, split, interpolated into a URL — is not recognised and not masked.
Created automatically for every run. Enough for most tasks, expires when the run ends, and its scope is set by the permissions block.
For that one repository. Where a deploy key or an API token belongs.
Scoped to a named environment like production, and can require a human to approve the deployment before the job proceeds.
Shared across repositories, with a list of which ones may use them.
Key takeaways
- A workflow is a YAML file in .github/workflows. Commit it and it is live; there is no configuration screen.
- on decides when it runs — push, pull_request, schedule, workflow_dispatch, release.
- Jobs run in parallel on separate fresh machines; steps run in order on one machine and share a filesystem.
- Every job starts empty, which is why actions/checkout is almost always the first step.
- needs makes one job wait for another; matrix turns one job definition into several parallel runs.
- Set fail-fast: false on a matrix, or one failure hides every other result.
- concurrency with cancel-in-progress stops three pushes from paying for three full runs.
- A red check blocks nothing until it is marked required in branch protection.
- Slow or flaky CI gets worked around. Under five minutes and deterministic is the target.
- Secrets are encrypted repository settings injected at run time and masked in logs — but only when printed verbatim.
- Fork pull requests run without secrets on purpose. pull_request_target removes that protection.
Quick check
Answer these to unlock the next chapter — 3 of 4 to pass. You can retake it anytime.
Answer every question to check.
Make a free account to read on
Every chapter is free — an account is how your progress, XP, and streak follow you from your laptop to your phone, and how you show up on the leaderboard. No payment, no trial.