A CI/CD pipeline without quality gates is just a fast way to ship broken software. The pipeline is the last line of automated defence between your code and your users — and if it is not enforcing quality at every stage, you are effectively deploying on hope.
In this article, I will walk you through building a quality-first CI/CD pipeline from scratch using GitHub Actions, covering every stage from code commit to production deployment. This is the pipeline architecture I have refined across multiple teams and products.
The Quality Gate Philosophy
A quality gate is a checkpoint that code must pass before it can proceed to the next stage. Gates should be fast, automated, and non-negotiable. If a gate fails, the pipeline stops. No exceptions. No "we'll fix it later." A failing gate is a signal, and ignoring that signal is how you accumulate technical debt and production incidents.
The gates I will cover, in order of execution:
- Static analysis (lint + type check)
- Unit tests with coverage threshold
- Integration tests
- E2E tests with Playwright
- Visual regression (snapshot tests)
- Performance budget check
- Security scan
Stage 1 — Static Analysis Gate
This is the cheapest gate to run and it catches a surprising number of issues. Lint rules enforce code style and catch obvious errors. TypeScript compilation catches type errors before runtime. This gate should run in under 60 seconds.
name: Quality Pipeline on: push: branches: [main, develop] pull_request: branches: [main] jobs: static-analysis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - name: Lint check run: npm run lint - name: Type check run: npm run type-check
Stage 2 — Unit Test Gate with Coverage
Unit tests verify individual functions and components in isolation. More importantly, the coverage threshold ensures your team cannot merge code without adequate test coverage. I recommend starting at 70% and increasing to 80%+ over time.
unit-tests: runs-on: ubuntu-latest needs: static-analysis steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci - name: Run unit tests with coverage run: npm run test:coverage - name: Upload coverage report uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage/
In your jest.config.ts, enforce coverage thresholds:
export default { coverageThreshold: { global: { branches: 70, functions: 80, lines: 80, statements: 80, }, }, };
Stage 3 — E2E Tests with Playwright
E2E tests are the most expensive gate to run but the most representative of real user behaviour. I run them in parallel across Chromium and Firefox to save time. Critical user flows — login, checkout, key workflows — must all pass before deployment proceeds.
e2e-tests: runs-on: ubuntu-latest needs: unit-tests steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps chromium firefox - name: Run E2E tests run: npx playwright test env: BASE_URL: ${{ secrets.STAGING_URL }} - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/
Stage 4 — Visual Regression Gate
Visual regression tests catch unintended UI changes — a CSS change that accidentally breaks a layout, a font that loaded differently, a component that shifted position. These are the bugs that pass all functional tests but still make users confused or angry.
Stage 5 — Deploy Gate (Production Only)
Only after all previous gates pass does the pipeline proceed to deployment. The deploy job uses needs to depend on all quality jobs, creating a hard dependency chain:
deploy: runs-on: ubuntu-latest needs: [static-analysis, unit-tests, e2e-tests] if: github.ref == 'refs/heads/main' environment: production steps: - uses: actions/checkout@v4 - name: Deploy to production run: ./scripts/deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Making the Pipeline Fast
The biggest resistance to quality gates is speed. If the pipeline takes 45 minutes, developers will start bypassing it. Keep it fast by:
- Parallelising independent jobs — static analysis, unit tests, and security scans can all run simultaneously.
- Caching aggressively — cache node_modules, Playwright browsers, and build artifacts between runs.
- Running E2E tests in parallel — use Playwright's sharding feature to split tests across multiple machines.
- Failing fast — run the fastest gates first. Static analysis failing in 30 seconds is better than E2E tests failing after 20 minutes.
// Key Takeaways
- Quality gates must be non-negotiable — a failing gate always stops the pipeline, no exceptions.
- Run gates in order of speed: static analysis first, E2E tests last.
- Enforce coverage thresholds in Jest config so they cannot be bypassed.
- Upload test reports and visual diffs as CI artifacts so failures are always visible.
- Target a pipeline completion time of under 15 minutes through parallelism and caching.