Linting, Formatting, and Static Analysis Gates
Once a workflow builds and runs, the next gate is code quality: does the change follow the team’s style rules, does it type-check, and does static analysis flag anything dangerous before a human reviewer even looks at the diff? Linting, formatting, and static analysis gates turn these checks into required, automated steps that run on every pull request, giving fast, consistent feedback instead of relying on reviewers to catch style drift or common bugs by eye.
Overview / How it works
These three checks solve related but distinct problems, and separating them clarifies both your workflow design and your feedback speed:
- Linting (ESLint, Ruff, golangci-lint, RuboCop) analyzes source without executing it, flagging suspicious patterns such as unused variables, unreachable code, or banned APIs. Most linters can auto-fix trivial issues locally, but in CI you want them to only report, never silently rewrite code.
- Formatting (Prettier, Black, gofmt) enforces whitespace, quote style, and layout. In CI you never want the formatter to rewrite files – you want it to run in check mode and fail the build if the working tree doesn’t already match its output.
- Static analysis goes deeper: type checkers (mypy, TypeScript’s
tsc) verify structural correctness, and security-focused analyzers (CodeQL, Bandit, Semgrep) look for exploitable patterns like SQL string concatenation or hardcoded credentials.
Together these become quality gates: jobs whose pass/fail status is wired into branch protection so a pull request cannot merge until they succeed. They complement, but do not replace, the automated test suite covered elsewhere in this course – a change can be perfectly linted and still be functionally wrong.
Permissions matter here more than they first appear to. The default GITHUB_TOKEN permissions for a workflow depend on repository and organization settings, and many organizations still default to broad, mixed-write scopes. A lint or format job never needs to write to the repository, open issues, or touch packages, so it should declare permissions: contents: read explicitly at the workflow level. Only a job that uploads results to GitHub’s code scanning UI needs the additional security-events: write scope, and that scope should be granted to that single job, not the whole workflow.
Syntax or workflow structure
A quality-gate workflow generally follows this shape: a trigger on pull_request (and often push to the default branch), a top-level permissions block set to the minimum needed, a concurrency group so a new push cancels a stale, superseded run, and one job per category of check. Splitting lint, type-check, and security analysis into separate jobs lets them run in parallel on GitHub’s runners rather than in sequence inside one job, which shortens the time a contributor waits for feedback.
name: Lint and Format
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: lint-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint -- --max-warnings=0
- name: Check formatting
run: npm run format:check
Two details do most of the work here. npm ci installs exactly what the lockfile specifies instead of resolving fresh versions, so the linter and formatter that run in CI are the same versions a contributor has locally. And --max-warnings=0 turns every ESLint warning into a build failure – without it, warnings accumulate silently and the gate stops meaning anything.
Examples
Example 1: a Node.js lint and format gate. The workflow above runs on every pull request and every push to main. Expected behavior: a pull request that introduces an unused import fails the lint job with a nonzero exit code and an inline annotation on the offending line; a pull request with correctly formatted code produces a green check in under a minute thanks to the npm cache.
Run npm run lint -- --max-warnings=0
src/api/client.js
42:7 error 'token' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
Error: Process completed with exit code 1.
Example 2: static analysis with a scoped write permission. A Python service adds Ruff for linting, mypy for type checking, and Bandit for security patterns, then uploads the security findings as SARIF so they appear in GitHub’s Security tab. Only this job receives security-events: write; the workflow-level default stays read-only.
name: Static Analysis
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2 # v5.3.0
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements-dev.txt
- name: Run Ruff (lint)
run: ruff check . --output-format=github
- name: Run mypy (types)
run: mypy src/
- name: Run Bandit (security) and export SARIF
run: bandit -r src/ -f sarif -o bandit-results.sarif
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
sarif_file: bandit-results.sarif
Expected behavior: a Bandit finding such as a hardcoded password pattern shows up as a code scanning alert on the pull request, in addition to failing the job. ruff check‘s --output-format=github flag produces annotations that render directly on the diff.
Example 3: consolidating gates behind one required check. As the number of quality jobs grows, listing each one individually in branch protection becomes brittle – renaming a job breaks the required-checks configuration. A thin aggregator job that depends on all the others gives you a single, stable name to require.
name: Quality Gates
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run lint -- --max-warnings=0
- run: npm run format:check
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run typecheck
security:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
languages: javascript
- uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
quality-gate:
needs: [lint, typecheck, security]
runs-on: ubuntu-latest
steps:
- run: echo "All quality gates passed"
Expected behavior: three jobs run in parallel; quality-gate only starts once all three finish, and fails immediately if any of them failed. In branch protection you require only quality-gate, so adding a fourth check later never requires reconfiguring the repository settings.
Step by step
- Add a workflow file under
.github/workflows/that triggers onpull_requestand pushes to your default branch. - Pin every third-party action to a full commit SHA, with the version as a trailing comment, rather than a mutable tag.
- Cache the package manager’s directory keyed on the lockfile so repeat runs install in seconds.
- Run the linter in a mode that fails on warnings, and the formatter in check-only mode – never let CI auto-fix and commit on your behalf.
- Add a separate job for heavier static analysis, granting it only the extra permission it needs.
- Add an aggregator job that depends on every quality job, and require only that job’s name in branch protection.
- Confirm the gate actually blocks merges by opening a pull request with a deliberate lint error and watching the merge button disable.
You can reproduce the lint job locally before pushing, using the exact same commands the workflow runs:
npm ci && npm run lint -- --max-warnings=0 && npm run format:check
Common Mistakes
Mistake 1: silencing failures with continue-on-error. Adding this to “get the workflow green” while iterating quietly defeats the entire purpose of the gate – the job reports success no matter what the linter found.
# Bad: the job succeeds even when linting fails
- name: Run ESLint
run: npm run lint
continue-on-error: true
# Fixed: let the step fail the job, and fail on warnings too
- name: Run ESLint
run: npm run lint -- --max-warnings=0
Mistake 2: using pull_request_target to lint fork code. pull_request_target runs with the base repository’s context and a token that can carry write access, unlike pull_request, which runs with a read-only token for forked contributions. Checking out and executing a fork’s code under pull_request_target – for example to install dependencies and run a linter – hands an attacker-controlled npm ci or install script access to that privileged token and any configured secrets.
# Unsafe: runs untrusted fork code with a privileged token
on:
pull_request_target:
jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout PR head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm run lint
# Safe: pull_request uses a read-only token and no repo secrets
on:
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm ci && npm run lint -- --max-warnings=0
If you need to post a comment back onto the pull request with results – something that does require write access – do it from a second, separate workflow triggered by workflow_run that only reads the uploaded artifact from the first run; it never checks out or executes the fork’s code itself.
Best Practices
- Pin actions to a full commit SHA, not just a version tag – tags can be moved by the action’s maintainer (or an attacker who compromises their account), while a SHA is immutable. Keep the version number in a trailing comment for readability.
- Scope permissions per job, not per workflow, when only one job needs an elevated grant such as
security-events: write. - Fail on warnings, not just errors – a linter that only fails on errors lets warning counts grow until nobody reads them.
- Run formatters in check mode in CI; auto-fixing and committing from a bot account adds surprise commits and can be abused if the bot’s token is over-scoped.
- Use a
concurrencygroup per ref so a second push cancels the first run instead of both running to completion. - Keep linter and formatter versions declared in the repository (a lockfile or pinned dev dependency), not resolved to “latest,” so CI and local runs agree.
- Aggregate multiple quality jobs behind one required status check so branch protection configuration doesn’t need to change every time you add or rename a job.
- Never run untrusted pull request code with a privileged token or on a self-hosted runner; treat every fork contribution as potentially adversarial input.
Practice Exercises
- Take an existing project and add a lint job that fails on warnings. Confirm it goes green on clean code, then introduce an unused variable and confirm the job fails with an inline annotation.
- Add a formatter check step in check-only mode. Locally reformat one file so it no longer matches the formatter’s output, push it, and confirm the check fails without the formatter modifying anything in CI.
- Add a static analysis job that uploads SARIF output, scoped with
security-events: writeon that job only. Verify the workflow-levelpermissionsblock stays atcontents: readand that only the analysis job has the extra scope. - Create an aggregator job that depends on your lint, type-check, and security jobs, then configure branch protection to require only the aggregator’s check name.
Summary
Linting, formatting, and static analysis gates give a pull request fast, consistent feedback before a human reviewer or the test suite ever runs. Keep linting and formatting in check-only mode, fail builds on warnings instead of letting them accumulate, and separate cheap style checks from heavier static analysis so both can run in parallel. Grant each job only the permissions it needs – read-only by default, with security-events: write reserved for the job that uploads scan results – and never let a fork pull request’s code run under a trigger like pull_request_target that carries a privileged token. Consolidate the individual jobs behind one aggregator so branch protection stays stable as the pipeline evolves.
