What pre-merge checks actually are
Most engineers, when asked about their pre-merge checks, will describe their CI pipeline. A list of GitHub Actions jobs, a required status check or two, maybe a branch protection rule. That's not wrong — but it's about a third of the actual surface. The full picture is wider, and the parts people systematically underinvest in are never the ones on the CI dashboard.
Let me be precise about what I mean by a pre-merge check. It's any automated or semi-automated signal that runs before code reaches your main branch and changes what happens next — either blocking the merge, demanding a fix, or forcing a human to make a deliberate decision. Under that definition you get a lot more than just test jobs.
You get linting and formatting — ESLint, golangci-lint, Prettier, rustfmt — the unglamorous work of keeping a codebase consistent enough that diffs stay readable. You get type checking: tsc --noEmit, go vet, the things that catch a whole class of bugs before they ever get a chance to run. You get unit and integration tests, which everyone counts, plus build verification, which fewer people think to separate out. Then you get the human-layer checks: required reviewer approvals, CODEOWNERS assignments, deploy previews that someone is actually supposed to look at.
And then, floating above all of it, you get AI code review — which is neither purely automated nor purely human, sits in its own tier, and changes the economics of the whole stack in ways people are still figuring out.
The reason it matters to name all of these together is that they fail in different ways and at different points. A lint error fails in two seconds on your laptop. A flaky integration test fails in twenty minutes on a CI runner, intermittently, in a way that teaches the team to ignore red signals. A required review from someone who's on vacation fails indefinitely. Understanding what you're actually stacking — and why each layer exists — is the prerequisite to a check stack that does its job without becoming the reason deploys are slow.
Three tiers of checks
The most useful mental model I've found is three tiers, separated by where they run, how fast they are, and what they're actually protecting against. The diagram below lays it out; the discussion after it is the part that matters.
Tier 1: fast and local. Lint, type check, format. These should run in under thirty seconds, ideally under ten, and they should run on every save or at worst on every commit. If your Tier 1 checks take two minutes you have a configuration problem, not a complexity problem. The whole point of this tier is that feedback loops this tight change developer behavior — you fix the thing immediately, while the context is still loaded, rather than getting a red CI signal forty minutes after you've moved on to something else.
In TypeScript projects I run tsc --noEmit and eslint --cache together in a pre-commit hook via Husky. The cache flag is not optional — without it you're type-checking the entire project on every commit instead of just changed files. In Go projects, go vet ./... plus golangci-lint run --fast gives you the same class of fast local signal. The key word in both cases is fast. If a developer starts skipping a hook because it's slow, that hook is worse than no hook — it's just friction with no protection.
Tier 2: CI. Unit tests, integration tests, build. This tier runs in your GitHub Actions workflow, not on a developer's machine, and it takes minutes rather than seconds. That's fine. The tradeoff you're making is thoroughness for speed — you can run a full suite against a real database, spin up services, test cross-platform builds. The failure mode here isn't slowness; it's flakiness. A test that fails fifteen percent of the time for no deterministic reason is not a test. It's a noise machine that trains your team to re-run until green and merge anyway. If you have flaky tests, fixing them is higher ROI than any other CI investment you could make.
Tier 3: gates. Required reviewers, CODEOWNERS, AI code review, deploy preview sign-off. This tier is where human judgment enters the pipeline and where most of the interesting design decisions are. The question you need to answer for every gate in this tier is: who is actually reading the output of this check, and what are they empowered to do about it? A required review from a reviewer who rubber-stamps everything is not a gate. A deploy preview that nobody looks at is not a gate. A CodeRabbit review that sits on a PR and gets dismissed without being read is not a gate. Gates only exist where the output changes behavior.
When checks slow you down vs protect you
This is the conversation most teams avoid having, because it requires admitting that some of the checks you've accumulated are theater. Here are the failure modes I've watched happen repeatedly.
Flaky tests treated as required checks. You already know this one. The test that fails on Mondays because of a timezone edge case. The integration test that times out when the CI runner is under load. The moment you start re-running jobs to get green instead of investigating why they're red, the check has failed at its job. Required checks need a reliability budget — something like 99.5% pass rate on unchanged code, or it gets removed from the required list until it's fixed.
Over-required reviews. Requiring two senior engineer approvals on every PR sounds thorough. In practice it means PRs sit for two days on trivial changes while your senior engineers become a bottleneck they resent. The right answer is scope-based requirements: CODEOWNERS for high-risk paths (authentication, payment flows, core data models), single approval for everything else, and no approval required for documentation and dependency bumps. Treat your reviewers' attention as a scarce resource, because it is.
Checks nobody reads. This is the subtlest failure mode and the most common. A security scanner that produces four hundred warnings, of which three are genuine findings and the rest are false positives. A code coverage report that CI uploads somewhere and no reviewer ever opens. An AI review that gets a thumbs down and a dismiss without the findings being evaluated. In every one of these cases, you've built infrastructure that imposes cost on every merge and protects nothing. The fix is not to remove the tool — it's to fix the signal-to-noise ratio until the output is actually worth reading, then make reading it part of the merge process.
Slow checks on the critical path. If your end-to-end test suite takes forty-five minutes and it's a required check, every PR has a forty-five minute minimum merge time, even the one-line bug fix. The solution is parallelization, test splitting, or — in some cases — moving the slow suite off the required path onto a post-merge job that pages you if it regresses. Not every check needs to block merge. Some checks are better run continuously on main with an alert on regression than as a merge gate that stretches cycle time to hours.
Building a check stack your team actually respects
A check stack your team respects is not the same as a check stack your team follows. You want the former. The latter is just compliance under pressure.
The most important principle is fast failures first. Order your checks so the cheapest, fastest, most-likely-to-catch-this-class-of-bug runs before the expensive ones. Run linting and type checking before tests. Run unit tests before integration tests. Run integration tests before end-to-end tests. This sounds obvious but most CI configurations I've seen in the wild do the opposite — they run everything in parallel, which means a type error that could be caught in ten seconds doesn't surface until the forty-minute E2E suite finishes. Parallel is good for independent checks. Sequential is good when earlier checks catch failures that would invalidate later ones.
Below is a GitHub Actions workflow that puts this structure into practice. It's not a template to copy wholesale — your project will have different dependencies — but the shape is right: fast tier runs first and gates the slow tier, and the gate layer only runs when CI passes.
# .github/workflows/pre-merge.yml
name: Pre-merge checks
on:
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Tier 1: fast, local-equivalent ─────────────────────────
typecheck:
name: Type check
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
lint:
name: Lint
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 eslint . --cache --max-warnings 0
# ── Tier 2: CI suite (needs Tier 1) ────────────────────────
unit-tests:
name: Unit tests
needs: [typecheck, lint]
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 test -- --coverage
integration-tests:
name: Integration tests
needs: [typecheck, lint]
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:test@localhost/test
build:
name: Build
needs: [typecheck, lint]
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
# ── Tier 3: gates (needs full CI suite) ────────────────────
# Required review, CODEOWNERS, and AI review are enforced
# via branch protection — not represented here, but gated
# on this final job passing.
ci-complete:
name: CI complete
needs: [unit-tests, integration-tests, build]
runs-on: ubuntu-latest
steps:
- run: echo "All checks passed."
A few things worth calling out in that config. The concurrency block with cancel-in-progress: true means that pushing a second commit while CI is running cancels the first run — no queuing up stale check runs. The needs chains enforce the fast-first ordering without making you think about it on every PR. And the ci-complete job gives branch protection a single required status to key off, so adding a new CI job later doesn't require touching the branch protection rules.
On the gates side: make failures actionable, not just red. A red light with no message is not useful. A red light that says "integration tests failed — see job output for the specific assertion and the service logs" is useful. Every check should produce output that a developer who wasn't watching it can use to understand and fix the problem without opening a terminal on the CI runner. This sounds like table stakes, but most CI pipelines I've reviewed produce output that assumes you were already watching. Write your checks for the developer who picks up your PR at 9am and needs to understand why it's blocked.
The one check everyone forgets
Every check I've described so far is structural. Does it compile? Does it pass the test suite? Does it satisfy the linter? These are necessary but they answer a different question than the one that actually matters to users.
The check almost nobody has is a semantic or behavioral diff. Does this change what users actually experience?
This is not the same as a test. A test says "given these inputs, the function returns this output." A behavioral diff says "here is what the product did before this change, and here is what it does after — are those two things the same experience, intentionally different, or accidentally different?" The distinction matters because you can have a fully green test suite and still ship a regression that degrades the user experience in a way none of the tests covered.
In practice this looks different depending on what you're building. For a UI, it's visual regression testing — screenshot diffs on component stories, or a deploy preview with a structured checklist of user flows to verify. For an API, it's contract testing or a comparison of response shapes against a snapshot. For an AI feature, it's an eval suite that measures output quality on a held-out set of prompts, not just whether the code runs. For a CLI tool, it's golden-output testing against a set of representative invocations.
None of these are hard to add. The reason they get skipped is not technical — it's that they require you to define what "correct behavior" means in user terms, not just in code terms. That definition is harder to write than a unit test, and it feels less concrete, so it goes in the backlog indefinitely.
The teams I've seen get this right treat the behavioral diff as a first-class artifact of the PR, not an afterthought. Before you merge a change to a core user flow, you produce evidence — a screenshot, a response diff, an eval result — that shows the change does what it says it does and doesn't break what it says it doesn't. Not because a rule requires it. Because "does it build and pass tests" is a question about your code, and "does it work for users" is a different question, and somebody has to answer the second one.
If I had to pick one thing to add to most pre-merge stacks, it wouldn't be another linting rule or a higher coverage threshold. It would be a consistent answer to: how do we know, for this specific change, that what users experience after the merge is what we intended?
Everything else is easier to fix after the fact. That one, you don't find out until the support tickets arrive.
— Hendrik