PR Checks That Matter — Shift-Left Without Noise

Fast feedback on pull requests accelerates delivery. Learn how to design PR checks that catch real issues early, use path filters to skip irrelevant checks, and balance required vs. optional checks.

This post is part 5 of the CI/CD Quality series. Part 4 covered reports and failure triage — making CI failures actionable.


Introduction — Shift-Left Is About Speed, Not Volume

“Shift-left” means catching issues earlier in the development cycle. The earlier you find a defect, the cheaper it is to fix.

But shift-left is not “run every check on every PR.” That’s how you get:

  • 20-minute CI runs that block merges
  • Developers waiting for unrelated checks to pass
  • False positives from checks that don’t apply to the changed code

Smart PR checks are fast, relevant, and blocking only when necessary.

This post covers how to design PR checks that provide fast feedback without creating bottlenecks, use path filters to skip irrelevant checks, and balance required vs. optional checks.


Fast Feedback vs. Slow Suites

The goal of PR checks is fast feedback. If a developer waits 20 minutes for CI, they context-switch to another task. When CI completes, they’ve already moved on.

Target CI Times

Check TypeTarget TimePurpose
Lint< 30 secondsCatch style violations
Build< 2 minutesEnsure code compiles
Unit tests< 3 minutesValidate business logic
Smoke tests< 5 minutesCatch critical regressions
Full E2E suite10–20 minutesComprehensive validation (run post-merge)

Principle: Block merges on fast checks (< 5 minutes). Run slow suites post-merge or on schedules.

:::warning[The 5-Minute Rule] If your required PR checks take longer than 5 minutes, developers will start bypassing them. Fast feedback keeps CI trusted. :::


Path Filters — Run Only Relevant Checks

Not every PR needs every check. If you change documentation, you don’t need to run E2E tests.

GitHub Actions supports path filters to trigger workflows only when specific files change.

Example: Skip E2E Tests for Docs Changes

name: E2E Tests

on:
  pull_request:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'playwright.config.ts'

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e

Result: E2E tests run only when src/, tests/, or test config changes. Docs PRs skip this workflow entirely.

Example: Separate Workflows for Frontend and Backend

# .github/workflows/frontend-ci.yml
name: Frontend CI

on:
  pull_request:
    paths:
      - 'frontend/**'
      - 'package.json'

jobs:
  frontend-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
# .github/workflows/backend-ci.yml
name: Backend CI

on:
  pull_request:
    paths:
      - 'backend/**'
      - 'requirements.txt'

jobs:
  backend-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest

Result: Frontend changes only run frontend tests. Backend changes only run backend tests. No wasted CI minutes.

:::tip[Combining Path Filters] Use paths-ignore to exclude specific files. Example: paths-ignore: ['docs/**', '*.md'] skips workflows for documentation changes. :::


Required vs. Optional Checks

GitHub allows you to mark specific checks as required before a PR can be merged.

Repository Settings → Branches → Branch protection rules → Require status checks to pass before merging

Build — Code must compile
Lint — Code style must pass
Unit tests — Business logic must be correct
Smoke tests — Critical paths must work

ℹ️ Full E2E suite — Informational; failures investigated post-merge
ℹ️ Coverage report — Trends tracked; no hard threshold
ℹ️ Performance benchmarks — Regressions flagged but not blocked

Rationale: Required checks are fast, deterministic, and block obvious defects. Optional checks provide context without blocking delivery.


Conditional Checks Based on Labels

Use GitHub labels to trigger optional checks on-demand.

Example: Run Full E2E Suite When Labeled

name: Full E2E (On-Demand)

on:
  pull_request:
    types: [labeled]

jobs:
  e2e-full:
    if: contains(github.event.pull_request.labels.*.name, 'run-e2e-full')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:e2e

Usage: Add the run-e2e-full label to a PR to trigger the full E2E suite. Remove the label to skip it.

Why this works: Full E2E suites are slow. Run them only when needed — high-risk changes, pre-release validation, or investigating regressions.


Failing Fast — Stop on First Failure

If the build fails, there’s no point running tests. If lint fails, there’s no point running E2E.

Use job dependencies to fail fast.

Example: Sequential Checks with Dependencies

name: CI

on: [pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
  
  lint:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint
  
  unit-tests:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
  
  smoke-tests:
    runs-on: ubuntu-latest
    needs: [build, lint, unit-tests]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e:smoke

Flow:

  1. build runs first
  2. If build succeeds, lint and unit-tests run in parallel
  3. If all succeed, smoke-tests runs
  4. If any job fails, dependent jobs are skipped

Result: Fast failure. If the build fails in 1 minute, CI stops immediately. No wasted time running tests against broken code.


Practical Example — Optimised PR Checks

Combine path filters, required checks, optional checks, and fast failure.

name: PR Checks

on:
  pull_request:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'playwright.config.ts'

jobs:
  build:
    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
  
  lint:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
  
  unit-tests:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm test
  
  smoke-tests:
    runs-on: ubuntu-latest
    needs: [build, lint, unit-tests]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e:smoke
      
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: smoke-test-report
          path: playwright-report/
  
  e2e-full:
    if: contains(github.event.pull_request.labels.*.name, 'run-e2e-full')
    runs-on: ubuntu-latest
    needs: [build, lint, unit-tests]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:e2e
      
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: e2e-full-report
          path: playwright-report/

Features:

  • Path filters skip workflow for docs changes
  • build runs first; other jobs depend on it
  • smoke-tests is required and runs in < 5 minutes
  • e2e-full is optional and runs only when labeled
  • All reports uploaded as artifacts

Typical PR: 3–4 minutes to complete required checks. Full E2E runs only when explicitly requested.


Auto-Merge for Trusted PRs

For low-risk PRs (dependency updates, automated refactors), enable auto-merge when all checks pass.

Dependabot Auto-Merge

name: Auto-Merge Dependabot

on:
  pull_request_target:

jobs:
  auto-merge:
    if: github.actor == 'dependabot[bot]'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Enable auto-merge
        run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Result: Dependabot PRs auto-merge when all checks pass. No manual approval required for low-risk dependency updates.

:::warning[Security Consideration] Only auto-merge PRs from trusted sources (Dependabot, renovate). Never auto-merge external contributor PRs without review. :::


Conclusion — Fast, Relevant, Trusted

PR checks are the front line of quality. They catch issues before merge, provide fast feedback, and keep the main branch stable.

The principles:

  • Run fast checks (< 5 minutes) as required checks
  • Use path filters to skip irrelevant workflows
  • Fail fast with job dependencies
  • Make slow suites optional or label-triggered
  • Auto-merge trusted PRs to reduce manual overhead

When PR checks are fast and relevant, developers trust them. When developers trust CI, they ship faster.

Action for this week: Measure the P50 and P95 times for your PR checks. If P95 > 10 minutes, identify the slowest job and either optimise it, move it to post-merge, or make it optional.


Next in this series: Part 6 — Continuous Testing in Continuous Delivery