CHAPTER 17

GitHub Actions

Vidha approved a PR she shouldn't have — not because she's careless, but because catching a missing semicolon by eyeball, every single time, was never a fair job for a human.

Two days after the issues board went live

Sahil's PR touches four files, four different kinds of changes, and somewhere in the third file there's a stray unused variable that the project's lint rules explicitly forbid. Vidha reviews it, reads the actual logic carefully, approves it — and doesn't catch the lint violation, because nobody catches every stylistic detail in a four-file diff by eye, every time, forever. It merges. A week later someone else copies the same bad pattern nearby, because it's now sitting in main looking approved and normal.

Nobody did anything wrong here, exactly. The actual failure is that "run the linter" was a step that existed only in people's heads, optional, easy to forget under any kind of time pressure — the same shape of problem as the group chat from last chapter, just for machine-checkable rules instead of decisions.

The fix isn't a better habit. It's removing the habit from the equation.

GitHub Actions runs code on GitHub's own machines, triggered automatically by things that happen in the repo — a push, a PR being opened, a schedule, a manual button. No one has to remember to run anything locally, because the check runs itself, on every single push, unconditionally.

THE SHAPE OF IT
event (push, PR opened, schedule...)   ← what triggers this
   └─ workflow  (a YAML file in .github/workflows/)
       └─ job  (runs on a fresh virtual machine)
          └─ step  (a shell command, or a reusable `uses:` action)

The workflow that closes the exact gap from Sahil's PR

# .github/workflows/lint.yml
name: lint

on:
  push:
    branches: [main]
  pull_request:

jobs:
  eslint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # clone the repo onto the runner
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint

Commit this file once, and from then on every PR — Sahil's, Ishaan's, anyone's — gets an automatic, unskippable lint pass. If the unused-variable rule would have failed here, this workflow fails loudly, right on the PR, before Vidha even opens the "Files changed" tab. Her review time goes back to what only a human can actually judge — does this logic make sense — instead of also being a manual style-linter.

Reading the YAML like a sentence

KeyMeaning
name:What shows up in the Actions tab
on:The trigger — push, pull_request, schedule, workflow_dispatch (a manual button), issues, release
jobs:One or more; run in parallel unless chained with needs:
runs-on:The runner's operating system
uses:A reusable, prebuilt action
run:A raw shell command
with:Inputs passed to an action

Triggers worth knowing well

on:
  push:
    branches: [main, release/*]
    paths: ["src/**", "package.json"]      # only fire if these paths changed

  pull_request:
    types: [opened, synchronize, reopened]

  schedule:
    - cron: "0 3 * * 1"                     # every Monday, 3 AM UTC

  workflow_dispatch:                        # a manual "Run workflow" button
    inputs:
      environment:
        type: choice
        options: [staging, production]

Matrix builds — the same check, many environments at once

Sahil's eventually going to want the site tested against more than one Node version, since not everyone on the club's laptops has the same one installed:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: ${{ matrix.node }} }
      - run: npm test

That's six jobs from one definition, running in parallel — every combination of OS and Node version shows up as its own status check on the PR.

Secrets — the values that never appear in a log

Once deployment (next chapter) needs an API key or a token, it goes in Settings → Secrets and variables → Actions, and is referenced as ${{ secrets.MY_SECRET }} — encrypted at rest, never printed to logs even if a workflow tries, and by default not exposed at all to workflows triggered from a fork's PR (which matters, because Ishaan's fork from Chapter 11 could otherwise have been a way to exfiltrate a club secret just by opening a PR).

Why Actions themselves are restricted, not just what they run

A workflow step that does uses: some-action@v3 is running someone else's code, on the club's own repo, with access to whatever secrets that job has. Accelerate restricts allowed Actions to GitHub-owned ones plus verified publishers — and pins every one to an exact commit SHA rather than a movable tag, since a tag like @v4 can be silently repointed by its maintainer (or an attacker who compromises their account) to different code than what was originally reviewed:

# Avoid — a movable tag, could point anywhere tomorrow
- uses: some-verified/action@v3

# Correct — this exact commit, forever, regardless of what the tag later points to
- uses: some-verified/action@a1b2c3d4e5f6789012345678901234567890abcd
CHECK
Why does pinning an Action to a commit SHA matter more than pinning it to a version tag like @v4?
A version tag is just a movable label — same idea as a branch from Chapter 7, applied to someone else's repo. If that repo's maintainer (or an attacker controlling their account) pushes different code and re-tags it v4, every workflow trusting @v4 silently starts running the new code with zero warning. A commit hash, per Chapter 3, is a fingerprint of exact content — it cannot be redirected to mean something else.

Lint runs automatically now. But "automatic checking" is only half the story Sahil actually needs solved — his real job, the one written right there on the dashboard as "CI/CD — Pages deploy," is getting the website itself onto the internet without anyone manually uploading files at 11 PM before an event.