Merging a pull request without automated checks is like deploying without tests — it works until it doesn't. Quality gates are the automated guardrails that ensure every PR meets a minimum bar before it reaches your main branch. They replace subjective "looks good to me" approvals with objective, repeatable checks.
This guide walks through setting up quality gates on GitHub, from basic linting and tests to advanced architecture-level checks. Whether you're starting from zero or looking to level up your existing pipeline, there's something here for you.
What Are Quality Gates?
A quality gate is an automated check that must pass before a pull request can be merged. If the check fails, the PR is blocked — no exceptions (unless you've configured emergency overrides).
Quality gates answer binary questions:
- Does the code compile? Yes or no.
- Do all tests pass? Yes or no.
- Is test coverage above the threshold? Yes or no.
- Are there any linting violations? Yes or no.
- Does this PR introduce architectural violations? Yes or no.
The power of quality gates is that they're non-negotiable. Unlike code review comments that can be ignored or deferred, a failing quality gate physically prevents the merge. This creates a consistent quality floor that the entire team respects.
Why Automate Instead of Relying on Code Review?
Code review is valuable, but it has limitations:
- Reviewers are human: They miss things, especially under time pressure. A reviewer might catch a logic bug but miss a subtle circular dependency.
- Inconsistency: Different reviewers have different standards. What one reviewer flags, another might approve.
- Fatigue: In teams with high PR volume, review quality degrades as reviewers get overwhelmed.
- Scope: Reviewers focus on the diff. They rarely check how a PR's changes affect the broader codebase structure.
Automated quality gates handle the repeatable, objective checks. This frees reviewers to focus on what humans do best: evaluating design decisions, naming choices, and business logic correctness.
Level 1: Basic Quality Gates
These are the table stakes. Every project should have these running on every PR.
Linting
A linter catches style issues, potential bugs, and bad practices. Set it up as a GitHub Action:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
For JavaScript/TypeScript projects, use Biome (fast, replaces ESLint + Prettier) or ESLint with a strict configuration. The key is that the linter runs the same rules locally and in CI — no surprises on push.
Type Checking
If you're using TypeScript, run the type checker in CI. It catches errors that linting doesn't:
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm tsc --noEmit
This ensures that nobody merges code with type errors, even if their local IDE didn't catch them.
Tests
Run your test suite on every PR. If a test fails, the PR is blocked:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test
For projects with slow test suites, consider running unit tests on every PR and E2E tests only on merges to main (or nightly).
Build Verification
A successful build is the ultimate integration test. If it fails, something is broken:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
Enforcing Required Status Checks
Having these jobs run isn't enough — you need to make them required. In GitHub:
- Go to Settings → Branches → Branch protection rules
- Click Add rule (or edit the existing rule for
main) - Enable Require status checks to pass before merging
- Search for and select your CI jobs:
lint,type-check,test,build - Enable Require branches to be up to date before merging (prevents merge conflicts from causing failures)
Now, no PR can be merged unless all four checks pass. The "Merge" button is grayed out until they're green.
Level 2: Coverage and Size Gates
Once you have the basics, add checks that track quality trends.
Test Coverage Thresholds
Tools like Vitest, Jest, or Codecov can report coverage and fail if it drops below a threshold:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
Configure Codecov (or your coverage tool) to fail the PR if:
- Overall coverage drops below a threshold (e.g., 70%)
- New code has less than 80% coverage
This prevents the gradual erosion of test coverage that happens when "just this one PR" skips tests.
Bundle Size Checks
For frontend projects, bundle size directly impacts user experience. Track it:
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
size-limit comments on every PR with the bundle size change. You can configure it to fail if the bundle grows beyond a threshold.
PR Size Limits
Large PRs are harder to review and more likely to contain hidden issues. While you can't easily block large PRs with a GitHub Action, you can add a warning:
pr-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check PR size
run: |
CHANGED=$(git diff --numstat origin/main...HEAD | wc -l)
if [ "$CHANGED" -gt 30 ]; then
echo "::warning::This PR touches $CHANGED files. Consider splitting it."
fi
Level 3: Code Ownership and Review Requirements
GitHub provides built-in features for controlling who can approve what.
CODEOWNERS
The CODEOWNERS file maps paths to required reviewers:
# .github/CODEOWNERS
# Global owners (fallback)
* @tech-lead
# Frontend
/src/components/ @frontend-team
/src/app/ @frontend-team
# Backend
/src/server/ @backend-team
/src/lib/db/ @backend-team @tech-lead
# Infrastructure
/.github/ @devops-team
/docker/ @devops-team
# Billing (sensitive)
/src/lib/stripe/ @tech-lead @billing-team
/src/server/actions/billing* @tech-lead @billing-team
When combined with Require review from Code Owners in branch protection rules, this ensures that changes to billing code are always reviewed by the billing team, changes to infrastructure are reviewed by devops, etc.
Required Approvals
In branch protection settings, you can require a minimum number of approvals (usually 1 or 2) before merging. Combined with CODEOWNERS, this creates a robust review process:
- At least one approval required
- The right people must approve changes to their areas
- Stale reviews are dismissed when new commits are pushed
Level 4: Architecture Quality Gates
This is where most teams stop. But the most impactful quality gates operate at the architecture level — checking not just whether the code is correct, but whether it respects the structural design of the codebase.
Preventing Circular Dependencies
Circular dependencies are one of the most common architectural problems. They make code harder to understand, test, and refactor. You can catch them in CI.
With dependency-cruiser, add a rule that fails on circular dependencies:
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "no-circular",
severity: "error",
from: {},
to: {
circular: true
}
}
]
};
Then add it to your CI pipeline:
dependency-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: npx depcruise --config .dependency-cruiser.cjs src
Enforcing Module Boundaries
Beyond circular dependencies, you can enforce that certain modules don't depend on others. For example, your data layer should never import from your presentation layer:
{
"forbidden": [
{
"name": "no-data-to-ui",
"comment": "Data layer must not depend on presentation layer",
"severity": "error",
"from": { "path": "^src/data" },
"to": { "path": "^src/(components|app)" }
},
{
"name": "no-ui-to-db",
"comment": "UI components must not access database directly",
"severity": "error",
"from": { "path": "^src/(components|app)" },
"to": { "path": "^src/lib/db" }
}
]
}
Architecture Checks as GitHub Status Checks
Tools like ReposLens integrate directly with GitHub as a status check. When a PR is opened, ReposLens analyzes the changes and reports:
- Whether new circular dependencies were introduced
- Whether module coupling increased
- Whether architectural boundaries were crossed
- A visual diff of the dependency graph (before vs. after)
This appears as a status check on the PR — green if the architecture is respected, red if it's violated. Combined with branch protection rules requiring this check to pass, it prevents architectural degradation at the source.
The advantage over DIY dependency-cruiser setups is that ReposLens requires no configuration file. It infers the architecture from your code and flags violations automatically. You can then add explicit rules if you want stricter control.
Emergency Overrides
No system should be impossible to bypass in a genuine emergency. GitHub provides two mechanisms:
Bypass for Repository Administrators
In branch protection settings, you can allow administrators to bypass required status checks. This should be used sparingly — only for production hotfixes or when a check is genuinely broken.
Ruleset Bypass Lists
GitHub's newer rulesets feature (replacing the older branch protection rules) allows you to create bypass lists for specific teams or users. This is more granular than the admin bypass.
The key is logging. If someone bypasses a quality gate, the team should know about it and review the skipped checks manually:
notify-bypass:
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
steps:
- name: Check for bypassed checks
run: |
# Query the PR's status checks and alert if any were bypassed
echo "Review manually if quality gates were skipped"
Best Practices for Quality Gates
Fail Fast
Order your checks from fastest to slowest. If linting fails in 10 seconds, there's no point waiting 5 minutes for the test suite to run:
jobs:
lint:
# Runs in ~15 seconds
type-check:
# Runs in ~30 seconds
test:
needs: [lint, type-check]
# Runs in ~3 minutes, only if lint and type-check pass
build:
needs: [test]
# Runs in ~2 minutes, only if tests pass
Using needs ensures that slow jobs don't start until fast jobs pass. This saves CI minutes and gives developers faster feedback.
Provide Clear Error Messages
When a quality gate fails, the developer needs to know exactly what to fix. Bad error messages cause frustration and wasted time.
Instead of:
Error: Quality check failed
Provide:
Error: Circular dependency detected
src/modules/billing/service.ts
→ src/modules/projects/queries.ts
→ src/modules/billing/utils.ts (cycle)
Fix: Move shared logic to src/shared/ or use an event-based pattern.
Most linters and testing frameworks do this well out of the box. For custom checks, invest in good error messages.
Keep Checks Fast
If your quality gates take 20 minutes to run, developers will context-switch while waiting — and context-switching kills productivity. Target:
- Linting: under 30 seconds
- Type checking: under 1 minute
- Unit tests: under 3 minutes
- Build: under 3 minutes
- E2E tests: under 10 minutes (run in parallel)
Use caching aggressively. GitHub Actions cache for node_modules, build artifacts, and test databases can cut CI times dramatically.
Don't Block on Warnings
There's a temptation to add "soft" quality gates that warn but don't block. Resist this temptation for important checks. Warnings are ignored. If something matters enough to check, make it a hard failure.
Reserve warnings for informational metrics like bundle size changes or PR size — things that are useful to know but don't have a clear pass/fail threshold.
Version Your Quality Gate Configuration
All quality gate configurations should live in the repository:
- Linter config (
.eslintrc,biome.json) - Test config (
vitest.config.ts,jest.config.ts) - Coverage thresholds
- Dependency rules (
.dependency-cruiser.cjs) - GitHub Actions workflows (
.github/workflows/) - CODEOWNERS file
This ensures that quality gates evolve with the code and that every branch uses the appropriate rules.
Putting It All Together
Here's a complete CI workflow that implements all the levels discussed:
name: PR Quality Gates
on:
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm tsc --noEmit
test:
runs-on: ubuntu-latest
needs: [lint, type-check]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- uses: codecov/codecov-action@v4
build:
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
dependency-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: npx depcruise --config .dependency-cruiser.cjs src
Add ReposLens as a GitHub App for the architecture-level check (no workflow configuration needed — it runs automatically on PRs once installed).
Then configure branch protection to require all of these checks to pass. The result is a multi-layered quality gate system that catches issues at every level — from style and types to tests and architecture.
Starting Small
If your project currently has no quality gates, don't try to implement everything at once. Start with:
- Week 1: Add linting and type checking as required checks
- Week 2: Add the test suite as a required check
- Week 3: Add build verification
- Week 4: Set up CODEOWNERS and required reviews
- Month 2: Add coverage thresholds and dependency checks
- Month 3: Add architecture-level checks
Each step gives you immediate value and builds the foundation for the next. Within three months, you'll have a comprehensive quality gate system that catches most issues before they reach your main branch.
The goal isn't perfection — it's a consistent, automated quality floor that the entire team can rely on. Every check you add reduces the burden on human reviewers and increases the reliability of your codebase.