Continuous Integration Fundamentals

Static Analysis & Quality Gates

22 min Lesson 5 of 28

Static Analysis & Quality Gates

Every CI pipeline runs tests — but tests only cover the paths you thought to test. Static analysis examines your source code without executing it, catching an entirely different class of problems: style violations, type errors, security anti-patterns, dead code, and complexity debt. Quality gates are the policy layer on top: a set of thresholds that must all pass before a pipeline is allowed to merge. Together they are the enforcement mechanism that keeps a large codebase from degrading as hundreds of engineers commit daily.

At Google, Meta, and Amazon, static analysis is not optional or advisory — it is a hard pipeline gate. A commit that drops test coverage below the agreed threshold, or that introduces a known-bad security pattern, is automatically blocked. No human review required to say no; the pipeline says no.

Key idea: Static analysis finds defects at the cheapest possible moment — before the code runs, before it is reviewed, before it ships. The cost of fixing a bug in CI is roughly 100x cheaper than fixing it after it reaches production. Quality gates enforce that no one can skip this step under schedule pressure.

Linters: Enforcing Style and Correctness

A linter parses your code and flags deviations from a defined set of rules. Rules fall into two categories: stylistic (indentation, line length, naming conventions) and correctness (use of deprecated APIs, unused variables, unreachable code). Running a linter in CI — not just in an IDE — guarantees that the rules are enforced for every engineer, not just the conscientious ones.

Common linter choices by ecosystem:

  • JavaScript/TypeScript: eslint with a team ruleset (Airbnb, Google, or custom)
  • Python: ruff (fast Rust-based replacement for flake8 + isort + pyupgrade)
  • Go: golangci-lint — a meta-linter that runs 50+ linters in parallel
  • Java: Checkstyle + SpotBugs
  • Infrastructure: tflint for Terraform, hadolint for Dockerfiles

In big-tech environments, lint failures are blocking, not warnings. The CI job exits non-zero and the PR cannot merge. Any rule you allow to be "advisory" will eventually be ignored by half the team.

# .github/workflows/ci.yml — lint job (Node.js project example) name: CI on: pull_request: push: branches: [main] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci # ESLint — exits 1 on any violation (--max-warnings 0 = no advisory warnings) - run: npx eslint . --ext .ts,.tsx --max-warnings 0 # TypeScript type check (tsc emits no JS; just validates types) - run: npx tsc --noEmit # Dockerfile lint - uses: hadolint/hadolint-action@v3.1.0 with: dockerfile: Dockerfile failure-threshold: warning
Pro practice: Pin your linter versions in package.json or requirements.txt and cache the install in CI. A floating latest linter version that gains a new rule breaks your build on a Friday afternoon with no code change — a classic false-alarm that erodes trust in the pipeline.

Formatters: Eliminating Style Debates

A formatter is stronger than a linter for style — it rewrites the code to a canonical form rather than just flagging it. The most impactful effect of adopting a formatter is that style debates disappear from code review. No one argues about brace placement when the formatter is the only authority.

Popular formatters: prettier (JS/TS/CSS/HTML), black (Python), gofmt (Go, built into the toolchain), rustfmt (Rust). In CI you run the formatter in check mode (it exits non-zero if any file would change) rather than rewrite mode:

# Check formatting without modifying files — safe for CI # Prettier (JavaScript/TypeScript) npx prettier --check "src/**/*.{ts,tsx,css}" # Black (Python) black --check --diff . # gofmt (Go) — no native --check; use this idiom test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) # Terraform terraform fmt -check -recursive

Coverage Thresholds: Enforcing Test Investment

Test coverage measures what percentage of your codebase is exercised by your test suite. Tracking it is table-stakes; enforcing a minimum threshold as a quality gate is what actually prevents coverage from eroding over time.

The gate works like this: if a PR would drop aggregate coverage below the threshold (say 80%), the CI job fails. The author must add tests before the PR can merge. This creates a virtuous cycle — new code is always shipped with tests, and the coverage floor can only stay flat or rise.

Production pitfall: Line coverage is easy to game — you can reach 100% line coverage without testing any behaviour. At big-tech scale, coverage policy typically requires branch coverage (every if/else arm tested) and, for critical paths, mutation testing (the tests must actually catch injected bugs). Do not treat a coverage number as proof of quality; treat it as a floor below which quality is almost certainly bad.

Coverage enforcement examples per ecosystem:

# Jest (JavaScript) — enforce 80% across all dimensions # jest.config.js module.exports = { coverageThreshold: { global: { branches: 80, functions: 85, lines: 85, statements: 85, }, // Per-file threshold for critical modules './src/payments/**': { branches: 95, lines: 95, }, }, }; # Run in CI: npx jest --coverage --ci # Jest exits 1 if any threshold is not met # -------------------------------------------------- # pytest + pytest-cov (Python) # pytest.ini [tool:pytest] addopts = --cov=src --cov-report=term-missing --cov-fail-under=80 # Run: pytest # Exits 1 if coverage < 80% # -------------------------------------------------- # Go (built-in coverage tool) go test ./... -coverprofile=coverage.out go tool cover -func=coverage.out | tail -1 # Enforce threshold in CI script: COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%') if [ "$(echo "$COVERAGE < 80" | bc)" -eq 1 ]; then echo "Coverage ${COVERAGE}% is below 80% threshold" exit 1 fi

SAST: Security in the Pipeline

Static Application Security Testing (SAST) applies security-specific rules to your source code. It catches patterns like SQL injection, hardcoded credentials, unsafe deserialization, command injection, and use of deprecated cryptographic APIs — before any of them ever reach a running environment.

SAST tools by language:

  • Multi-language: Semgrep (rule-based, very fast, GitHub-native SARIF output), CodeQL (GitHub Advanced Security, deep dataflow analysis)
  • Python: Bandit
  • Java/Kotlin: SpotBugs + Find Security Bugs plugin, Semgrep Java rules
  • JavaScript: npm audit (dependency CVEs) + Semgrep
  • Containers: Trivy (image + filesystem scan for CVEs and secrets)

GitHub provides CodeQL free for public repos and as part of GitHub Advanced Security for private repos. Semgrep Community is free for any repo. At minimum, every pipeline should run dependency CVE scanning (npm audit --audit-level=high, pip-audit, trivy fs .) and one SAST tool.

# GitHub Actions — SAST job using Semgrep and Trivy sast: runs-on: ubuntu-latest permissions: security-events: write # required to upload SARIF results steps: - uses: actions/checkout@v4 # Semgrep — open-source SAST, 1000+ rules - uses: returntocorp/semgrep-action@v1 with: config: >- p/owasp-top-ten p/secrets p/ci # auditOn: push to main # generateSarif: uploads to GitHub Security tab generateSarif: '1' - uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: semgrep.sarif # Trivy — container + filesystem vulnerability scan - uses: aquasecurity/trivy-action@master with: scan-type: 'fs' scan-ref: '.' format: 'sarif' output: 'trivy.sarif' severity: 'HIGH,CRITICAL' exit-code: '1' # fail the job on HIGH/CRITICAL - uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: trivy.sarif
SARIF (Static Analysis Results Interchange Format) is the standard JSON format for security tool output. GitHub, GitLab, and Azure DevOps all consume SARIF natively and surface findings as annotations on the diff — reviewers see exactly which line has a potential SQL injection, without leaving the PR interface.

Assembling a Quality Gate Pipeline

A quality gate is only as good as its enforcement. The pattern at big-tech companies is: run all static analysis jobs in parallel, require every one to pass before merge is allowed, and configure branch protection rules so that no one — not even repo owners — can bypass them.

Quality Gate Pipeline — parallel static analysis jobs feeding a merge gate Pull Request push / sync Lint eslint / ruff / golangci-lint Format Check prettier --check black --check Coverage Gate jest --coverage threshold >= 80% SAST Semgrep / CodeQL HIGH/CRIT = fail Dep Audit trivy / npm audit known CVEs Quality Gate ALL jobs must pass Merge allowed branch protection PASS PR blocked fix & re-push FAIL All five jobs run in parallel — total wall-clock time = slowest job (~2-4 min) Branch protection rules enforce: required status checks + no admin bypass
A quality gate pipeline: five static analysis jobs run in parallel; the merge button is locked until every required check is green.

Common Failure Modes and How to Avoid Them

1. Treating lint warnings as advisory. If your linter exits 0 with warnings, engineers learn to ignore them. Configure --max-warnings 0 or the equivalent from day one. A warning that is silently ignored for six months is not a warning — it is technical debt with no owner.

2. A global coverage threshold that rewards padding. Teams under pressure write tests that call functions without asserting anything, inflating coverage numbers while adding no value. Counter this by combining coverage with mutation score, and by doing coverage review on critical modules rather than just the aggregate.

3. SAST noise causing alert fatigue. A SAST tool with a 30% false-positive rate will be suppressed by engineers within a week. Start with a small, high-precision ruleset (OWASP Top 10, hardcoded secrets) and expand it incrementally. Every rule you add should be vetted: run it against your repo's history and measure the false-positive rate before enforcing it.

4. Quality gates that can be bypassed. Branch protection rules are only useful if the admin bypass option is disabled — or if bypasses are logged and reviewed. "We will just merge directly under pressure" is how every quality gate eventually collapses. Automate the exception process: a break-glass workflow that requires a second approver and sends an alert to the engineering manager.

Big-tech standard — pre-commit hooks as a first line of defence: Run linters and formatters locally via pre-commit (Python tool that manages hooks for any language). This catches the majority of violations before a push, so CI only needs to enforce rather than discover. Engineers get faster feedback, and CI time is not wasted on formatting failures.
# .pre-commit-config.yaml — local hook runner (install: pip install pre-commit) repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: detect-private-key # catches accidentally committed secrets - repo: https://github.com/psf/black rev: 24.4.2 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.4.4 hooks: - id: ruff args: [--fix] - repo: https://github.com/returntocorp/semgrep rev: 1.73.0 hooks: - id: semgrep args: ['--config', 'p/secrets', '--error'] # Install: pre-commit install # Run manually: pre-commit run --all-files

In Lesson 6 you will move from code quality to artifact management — what you build in CI, how you version it, and how you store it so downstream deployment stages can consume it reliably.