Skip to main content
DevOps from zero · Lesson 14 of 15 · 12 min

GitHub Actions Quality Gates: Check Before You Deploy

Learn CI quality gates from a real workflow: typecheck, lint, tests and build, job dependencies with needs, fail-fast order, and branch protection.

By Matthews WongReviewed on

Key takeaway

A CI quality gate is a check whose failure stops the pipeline before the next stage. In GitHub Actions, steps run in order and stop at the first failure, a job with needs is skipped when the job it needs fails, and branch protection can require checks before a merge. Cheap checks go first so a bad commit fails fast.

You will be able to

  • Order typecheck, lint, tests and build into a pipeline where each gate protects the next.
  • Use needs so a failing job skips everything downstream, and explain what a skipped job means.
  • Say what this site's gates cover, what they do not, and where branch protection fits.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

How a failing check stops a deploy

A push runs typecheck, then lint, then the build, then the deploy. A failure at the first gate skips every later job, and a check that CI never runs protects nothing.

Step 1 of 6. One chain: push, typecheck, lint, build, deploy. Each job waits for the one before it. The two faded boxes at the bottom are the gaps we come back to.

Keys: ← → step · Home / End · K play or pause

  1. One chain: push, typecheck, lint, build, deploy. Each job waits for the one before it. The two faded boxes at the bottom are the gaps we come back to.

  2. The push triggers the workflow. Typecheck and lint are steps of the verify job, so they run in order: lint only starts if typecheck passed.

    Predict the next step Typecheck reports an error. What happens to the build and deploy jobs? (They run anyway · They are skipped · Only deploy is skipped)
  3. The type error fails the step, so the verify job fails. A job that needs it is skipped, and so is every job that needs that one. Nothing is built or deployed.

  4. Fix the error and push again. Typecheck passes, lint passes, and only then does the build job start. The deploy waits for the build in the same way.

    needs: verify
  5. This repo has an npm test script, but the workflow never calls it. A failing test would not stop the deploy. The gate has a gap exactly where no job runs.

    Predict the next step A branch rule requires a check, but that check ends up skipped. Can the pull request merge? (No, skipped counts as failed · Yes, skipped counts as satisfied)
  6. Required status checks are a GitHub setting, not workflow code. With one turned on, a pull request cannot merge until the check passes. A skipped check counts as satisfied.

What is a quality gate?

A quality gate is a check that must pass before the pipeline continues. The idea is simple: decide what must be true of every commit, run those checks automatically, and make failure stop the line. The typical candidates are a typecheck, a linter, automated tests and a build.

Read more (1)

The point is not ceremony. A check you run by memory gets skipped on the day you are in a hurry, which is the day you need it most. A gate does not forget.

How do steps and needs turn checks into gates?

Inside one job, steps run in order. Unless a condition says otherwise, a step runs only if the previous steps succeeded, because the default condition is success(). The runner decides pass or fail from the command's exit code, so a failing typecheck or linter is enough to stop the job.

Read more (1)

Between jobs, needs names the jobs that must finish first. If a job fails or is skipped, every job that needs it is skipped too, unless a condition such as always() overrides that. That is what makes a chain of jobs a gate: build needs verify, deploy needs build.

Which checks go first, and why fail fast?

Put the cheapest, most informative checks first. A typecheck or linter reports a mistake long before a full build would, so a bad commit is rejected early and the slow, expensive jobs never start. Failing fast saves runner time and, more usefully, gets the signal to you sooner.

Read more (1)

A matrix adds a related switch. With fail-fast on, a failure in a matrix job that is not allowed to fail cancels the matrix jobs still in progress or queued. This site has no matrix, but the rule is the same: stop spending time on a run that cannot succeed.

Where do this site's gates stop?

The verify job runs npm ci, then a typecheck, then the linter. The build job needs verify and the deploy job needs the build job, so no image is built from a commit whose typecheck or lint failed. Next's own build deliberately skips typechecking and linting, because they already ran once, and the config says to remove that skip if verify ever stops gating the build.

Read more (1)

One honest gap and one extra gate. The gap: the repo has an npm test script, but neither workflow calls it, so tests are not a gate. The extra: the build also triggers the prebuild script, which runs the site's parity, placement, import and message-key checks, so those do gate the image. A separate smoke workflow builds the site and runs browser tests on pull requests, but the deploy does not wait for it.

What does branch protection add?

A workflow reports a result; branch protection is what makes the result binding. With required status checks on, all required checks must pass before changes can be merged into the protected branch. A check with a successful, skipped or neutral status counts as satisfied, so a gate that is skipped by accident is a gate that is open.

Read more (1)

Two details trip people up. Job names should be unique across workflows, since a reused name can give ambiguous results and block merges. And this deploy workflow runs on pushes to main, not on pull requests, so to gate a pull request you need a workflow that runs on the pull_request event. The repo files cannot show whether branch protection is enabled; that lives in the repository settings.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

.github/workflows/deploy.yml:38-44
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run lint

  build-and-push:
    name: Build & push image
    needs: verify

The whole gate. Three steps run in order, and a failure in any of them fails the verify job. The build job names it in needs, so a failed verify skips the build and, through the build, the deploy.

next.config.ts:36-40
  // Run `npx tsc --noEmit` yourself before pushing, as CLAUDE.md already says.
  // If the `verify` job is ever removed or stops gating the build, these two
  // lines must come out in the same change.
  typescript: { ignoreBuildErrors: true },
  eslint: { ignoreDuringBuilds: true },

A deliberate trade: the Docker build does not typecheck or lint again, because the gate already did. The comment is the safety valve, naming the exact condition under which these two lines must go. A gate you depend on should always be written down like this.

package.json:12
"prebuild": "node scripts/generate-blog-sources.mjs --check && node scripts/game/check-placements.mjs && node scripts/game/check-imports.mjs && node scripts/check-message-keys.mjs",

npm runs a script named prebuild automatically before build. Every check is joined with &&, so the first one that exits non-zero stops the chain and fails the build. That makes it a gate inside the build step, with no workflow change.

.github/workflows/game-smoke.yml:20
  pull_request:

The one line that makes this workflow a pull-request check. The deploy workflow has no such trigger, which is why a gate for pull requests needs its own workflow or its own trigger.

What breaks

Mistakes that cost real time

  • A check job nobody waits for

    needs is what orders jobs. If the build job does not name verify, nothing makes it wait for verify, and a failed check no longer stops it. Whenever you add a gate, also add it to the needs of the job it should protect.

  • Turning checks off downstream without a gate upstream

    This site's build skips typecheck and lint because verify runs them. Remove verify, or let it stop gating the build, and type errors ship silently. The config comment names this coupling; copy that habit whenever one check replaces another.

  • Tests exist but CI never runs them

    A script in package.json protects nothing until a job calls it. Here npm test is not in the verify job, so a failing test does not stop a deploy. List what your pipeline actually runs, then compare it with what your package.json says.

  • A required check that is skipped

    Branch protection treats a skipped check as satisfied. If a path filter or an upstream failure skips the required job, the merge button turns green. Make sure the required check runs on every pull request, or accept that a skip means pass.

Which check catches what
TypecheckWrong types and broken contractsYes, verify job
LintSuspicious code and style rulesYes, verify job
BuildCode that cannot compile or bundleYes, build job (prebuild checks first)
TestsBehaviour that changed by accidentNo, npm test is not called
Try it yourself

Do it on your own machine

Any small repo with GitHub Actions works. You will build a two-job gate, break it on purpose, and watch the second job get skipped.

  1. Run the checks locally first, in the order you want CI to run them. Fix anything that fails before you push.

    npx tsc --noEmit && npm run lint
  2. Create .github/workflows/gate.yml with a verify job and a build job that needs it.

    name: gate
    on:
      push:
        branches: [main]
      pull_request:
    
    jobs:
      verify:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: 20
              cache: npm
          - run: npm ci
          - run: npx tsc --noEmit
          - run: npm run lint
    
      build:
        needs: verify
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: 20
              cache: npm
          - run: npm ci
          - run: npm run build
  3. Push it and confirm both jobs pass. Then add a deliberate type error, such as assigning a string to a number, and push again.

    const count: number = 'three';
  4. Open the run. The verify job should be red and the build job should show as skipped, not run. Fix the error and push to turn it green.

  5. Optional: in the repository settings, add a branch protection rule for main that requires the verify check, then open a pull request and see the merge blocked while it fails.

Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.A workflow has jobs verify and build, but build does not declare needs. verify fails. What happens to build?
  2. 2.The repo has an npm test script and the deploy workflow's verify job runs only npm ci, a typecheck and lint. A test starts failing. What happens on the next push to main?
  3. 3.A branch rule requires the verify check, but a path filter makes that job skip on a documentation-only pull request. Can it merge?
Think it through

Answer in your head, then check

  1. 1.Which jobs must pass before this site's deploy job can start?Show the answer

    Answer:Build and push, which itself needs verify (typecheck and lint). The deploy needs the build job, so a failed verify skips both.

  2. 2.Why does the Docker build set ignoreBuildErrors and ignoreDuringBuilds?Show the answer

    Answer:Typecheck and lint already ran once in the verify job, so repeating them inside the image build only costs time. The comment names the exit condition: if verify stops gating the build, remove both lines.

  3. 3.What does branch protection add that a workflow alone does not?Show the answer

    Answer:It makes a check binding: with required status checks on, the pull request cannot merge until the check passes. Without it, a red check is only a warning.

Go deeper

Related deep dives on this site