How to Add Semgrep and CodeQL to GitHub Actions (2026 Guide)

Oct 31, 2026 · 13 min · Ajay Kumar

This guide is for engineers who want static analysis on every pull request in GitHub Actions without drowning the team in legacy alerts. By the end you will have CodeQL running in advanced setup with a config file, Semgrep Community Edition running in a container and reporting only findings introduced by the PR, both tools uploading SARIF to the GitHub Security tab under separate categories, a custom Semgrep rule with tests, a suppression strategy, and a ruleset that blocks merges on new high-severity alerts. Versions were checked against the release pages on 31 October 2026: github/codeql-action v4.39.1 (bundling CodeQL CLI 2.27.3), Semgrep v1.183.0 and actions/checkout v7.0.1. You need admin access to a repository; for private repositories, code scanning needs a GitHub Code Security licence. If you want the background on what SAST does and does not catch compared with DAST and SCA, read the companion explainer, SAST vs DAST vs SCA vs IAST in 2026.

Step 1: Choose CodeQL default setup or advanced setup

GitHub offers two ways to run CodeQL. Default setup is a toggle under Settings, Code security: GitHub generates the configuration, picks languages, scans on pushes and pull requests plus a weekly schedule, and GitHub's docs recommend starting there. C/C++, C#, Java and Rust use the none build mode, so nothing has to compile. Default setup moves to new Action versions on its own.

Advanced setup is a workflow file you own. Choose it when you need a config file, custom query packs, a specific build, path filtering, or the analysis to run on self-hosted runners with your own build tooling.

The two do not mix. Enabling default setup disables any existing CodeQL workflow and "block[s] any CodeQL analysis API uploads", per the same page. Semgrep uploads are not CodeQL uploads, so Semgrep works alongside either. If you are not sure, start with default setup and come back to Step 2 when it stops being enough.

If you already have an advanced workflow on @v3, move it now. GitHub announced that CodeQL Action v3 will be deprecated in December 2026, alongside GHES 3.19. v4 runs on the Node.js 24 runtime; the migration is changing @v3 to @v4 on every init, autobuild, analyze and upload-sarif reference.

# Find every CodeQL Action reference still on v3
grep -rn "github/codeql-action/.*@v3" .github/workflows/

Step 2: Create the CodeQL advanced setup workflow

Pin every third-party action to a full commit SHA with the version in a comment. Tags can be moved; SHAs cannot, which is the lesson of the tag hijacks covered in GitHub Actions security in 2026. The SHAs below are the commits for codeql-action v4.39.1 and checkout v7.0.1 as resolved through the GitHub API.

# .github/workflows/codeql.yml
name: CodeQL

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  merge_group:            # needed if you use a merge queue
  schedule:
    - cron: "27 3 * * 1"  # weekly full scan, Monday 03:27 UTC

permissions:
  contents: read

jobs:
  analyze:
    name: Analyze (${{ matrix.language }})
    runs-on: ubuntu-24.04
    timeout-minutes: 60
    permissions:
      security-events: write   # upload results
      contents: read
      actions: read            # only needed in private repos
      packages: read           # only needed for private CodeQL packs
    strategy:
      fail-fast: false
      matrix:
        include:
          - language: actions               # scans your workflow files
            build-mode: none
          - language: javascript-typescript
            build-mode: none
          - language: python
            build-mode: none
          - language: java-kotlin
            build-mode: none                # no compile step needed
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
        with:
          languages: ${{ matrix.language }}
          build-mode: ${{ matrix.build-mode }}
          config-file: ./.github/codeql/codeql-config.yml

      - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
        with:
          category: "/language:${{ matrix.language }}"

Delete the matrix entries for languages you do not have. For compiled languages that do not support none (Go and Swift), or C/C++ projects where it is not accurate enough, set build-mode: manual and add your real build commands between init and analyze.

GitHub's docs note that scanning the pull request merge commit gives more accurate results than scanning the branch head on every push, which is why the trigger is pull_request rather than push on all branches. On pull requests the Action also applies diff-informed analysis automatically, reporting only alerts on lines the PR added or changed.

Step 3: Add a CodeQL config file for query suites and paths

The config file controls which queries run and which paths are analysed. Start with security-extended, which GitHub describes as the default suite plus lower-severity and lower-precision queries. The query suite docs say the default suite is "highly precise", so if the team is new to SAST, staying on default for the first month is a reasonable choice.

# .github/codeql/codeql-config.yml
name: "repo CodeQL config"

queries:
  - uses: security-extended      # default suite + lower precision security queries

paths-ignore:
  - "**/node_modules"
  - "**/vendor"
  - "**/*.test.js"
  - "docs"

query-filters:
  # Drop a query the team has agreed is noise for this codebase
  - exclude:
      id: js/missing-rate-limiting

paths and paths-ignore only take effect for interpreted languages and for compiled languages scanned with build-mode: none (C/C++, C#, Java and Rust). When code is built, CodeQL analyses whatever the build compiles, so you restrict scope by changing the build. Note also that paths-ignore on the workflow trigger is a different thing: it decides whether the workflow runs, not which files are analysed.

Step 4: Run Semgrep CE on pull requests and upload SARIF

Semgrep's docs say the preferred Community Edition command is semgrep scan, and that it uses only the LGPL-2.1 CLI with no account. (semgrep ci is the AppSec Platform command and needs a token.) Two flags do the heavy lifting: --baseline-commit makes Semgrep report only results "not found in this commit", and --sarif-output writes a SARIF copy alongside the normal output, per the CLI reference.

# .github/workflows/semgrep.yml
name: Semgrep

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  schedule:
    - cron: "41 4 * * 1"   # weekly full scan

permissions:
  contents: read

jobs:
  semgrep:
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    permissions:
      contents: read
      security-events: write
    container:
      image: semgrep/semgrep:1.183.0   # pin; weekly releases change rule behaviour
    if: github.actor != 'dependabot[bot]'
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0               # baseline needs the base commit in history
          persist-credentials: false

      - name: Semgrep scan
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          git config --global --add safe.directory "$GITHUB_WORKSPACE"
          ARGS="--config p/default --config .semgrep/ --metrics=off --error"
          if [ -n "$BASE_SHA" ]; then
            # PR: report only findings introduced by this change
            semgrep scan $ARGS --baseline-commit "$BASE_SHA" --sarif-output=semgrep.sarif
          else
            # push or schedule: full scan
            semgrep scan $ARGS --sarif-output=semgrep.sarif
          fi

      - name: Upload SARIF
        if: always()                    # upload even when --error fails the scan
        uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
        with:
          sarif_file: semgrep.sarif
          category: semgrep

A few choices worth explaining. The base SHA goes through an environment variable rather than being pasted into the script, which is the habit that keeps you safe when the value is attacker-controlled (a branch name or PR title). --metrics=off stops usage telemetry; note that --config auto requires metrics, so use named rulesets instead. p/default comes from the Semgrep Registry under the Semgrep Rules License, which permits internal use; if that matters to you, Opengrep is a drop-in fork with open rules. Finally, --error makes the step exit 1 when there are findings, and with --baseline-commit that means new findings only.

On a pull request the scan log includes the line "Scan was limited to files changed since baseline commit", which is how you confirm the diff scan is active rather than a full scan.

The category matters. GitHub replaces earlier results from the same tool and category, and since 22 July 2025 it rejects SARIF files with multiple runs for the same tool and category rather than merging them. Separate uploads that share a category replace each other, so if you run Semgrep in a matrix, add the matrix value to the category.

Pull request opened or updated CodeQL job init + config file, analyze diff-informed on PRs Semgrep job scan --baseline-commit base SHA new findings only, SARIF out Code scanning categories: /language:x, semgrep Merge blocked new high or critical alert Merge allowed no new alerts above threshold The gate is a branch ruleset with Require code scanning results, set per tool (Step 7).
How the two workflows from Steps 2 and 4 feed GitHub code scanning and the merge gate. Behaviour follows GitHub's code scanning and Semgrep CLI documentation.

Step 5: Write a custom Semgrep rule and test it

Registry rules catch generic bugs. The rules that pay for themselves are the ones that encode your own mistakes: the internal HTTP client that must always set a timeout, the ORM method that must never receive raw strings. Rules need id, message, severity, languages and a pattern; current severities are LOW, MEDIUM, HIGH and CRITICAL, with the older ERROR, WARNING and INFO still accepted, per the rule syntax reference.

# .semgrep/requests-timeout.yaml
rules:
  - id: requests-without-timeout
    languages: [python]
    severity: HIGH
    message: >-
      requests call without timeout can hang a worker forever.
      Pass timeout=(connect, read).
    patterns:
      - pattern: requests.$METHOD(...)
      - metavariable-regex:
          metavariable: $METHOD
          regex: ^(get|post|put|patch|delete|head)$
      - pattern-not: requests.$METHOD(..., timeout=$T, ...)
    metadata:
      cwe: "CWE-400: Uncontrolled Resource Consumption"

Tests live next to the rule with the same base name. ruleid: marks a line that must match, ok: a line that must not, as described in the testing docs.

# .semgrep/requests-timeout.py
import requests

# ruleid: requests-without-timeout
requests.get("https://example.com")

# ok: requests-without-timeout
requests.get("https://example.com", timeout=(3, 10))
docker run --rm -v "$PWD:/src" -w /src semgrep/semgrep:1.183.0 semgrep --test .semgrep/
# Expected:
# 1/1: ✓ All tests passed

Add that command as a step in the Semgrep job so a rule edit that breaks its own tests fails CI. The test fixture deliberately contains a bad call, so add .semgrep/ to .semgrepignore (Step 6) or the main scan will report it as a finding.

Step 6: Suppress false positives without losing the audit trail

There are four layers, from narrowest to widest. Prefer the narrowest one that works.

Inline, Semgrep. Put a nosemgrep comment with the full rule ID on the line of the match or the line before it. Semgrep's ignore docs ask for rule IDs with their namespace and comma-separated lists for several rules; Semgrep matches the end of the ID, so a local rule's short ID works too. A bare nosemgrep with no ID silences every rule on that line, which is how real bugs get hidden; reject it in review.

# Streaming endpoint: the timeout is set on the session adapter, not per call.
# nosemgrep: requests-without-timeout
resp = requests.get(url)

Paths, Semgrep. A .semgrepignore uses .gitignore syntax. Without one, Semgrep uses a default template that skips node_modules/, vendor/, build output and common test paths, and it also reads your .gitignore. Tracked files are still scanned even if .gitignore lists them.

# .semgrepignore
:include .gitignore
.semgrep/
testdata/
**/*_generated.go

Alert dismissal, both tools. In the Security tab, dismiss an alert with a reason (false positive, used in tests, won't fix) and a comment. GitHub's alert docs note that a dismissed alert is dismissed in all branches and stays in the Closed list where it can be reopened. This is the right tool for a single CodeQL false positive, because CodeQL has no inline suppression comment by default.

Query or rule removal. If the same query is wrong across the codebase, exclude it with query-filters (Step 3) or drop the Semgrep rule, and write down why in the config file. Do this deliberately and rarely.

Step 7: Block merges on new high-severity alerts

Reporting without a gate tends to become decoration. Under Settings, Rules, Rulesets, create a branch ruleset for main, enable Require code scanning results, and add each tool. GitHub's merge protection docs list two thresholds per tool: "Alerts" (None, Errors, Errors and Warnings, All) and "Security alerts" (None, Critical, High or higher, Medium or higher, All). I start with Security alerts at "High or higher" and Alerts at "Errors" for CodeQL, and the same for the Semgrep tool once it has uploaded at least once (the tool has to exist before you can select it).

The same rule can be set through the REST API as the code_scanning ruleset rule, which is the version I keep in Terraform or a script so every repository gets the same gate. Combined with the --error flag in Step 4, a new Semgrep finding both fails the job and blocks the merge; you can drop --error if you prefer the ruleset to be the only gate.

Troubleshooting

Symptom Likely cause Fix
Resource not accessible by integration on upload Job lacks security-events: write, or the run is a Dependabot or fork PR with a read-only token Set security-events: write at job level; skip or separately handle Dependabot runs (as the if: in Step 4 does)
CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled Default setup and an advanced workflow are both on Pick one: disable default setup, or delete the workflow file
Two CodeQL workflows appear in the Actions tab An old workflow file that default setup disabled Delete the disabled file from .github/workflows/
Upload fails with multiple runs for the same tool and category One SARIF file (or a directory uploaded at once) holds several runs with the same tool and category, rejected since July 2025 Upload each run separately with its own category, for example ${{ matrix.* }} in a matrix
Semgrep baseline scan fails with a git error, or warns git could not find a single branch-off point Shallow checkout, so the base SHA is not in history Set fetch-depth: 0 on actions/checkout
Semgrep: detected dubious ownership in repository Container user differs from the workspace owner Run git config --global --add safe.directory "$GITHUB_WORKSPACE" before scanning
Semgrep aborts with Cannot create auto config when metrics are off --config auto needs metrics, which --metrics=off disables Use named rulesets such as p/default plus local rules
CodeQL: No source code was seen during the build Compiled language with a build that did not compile the code Use build-mode: none where supported, or manual with real build commands
Old alerts stay open after renaming a category GitHub tracks results per tool and category Delete the stale configuration from the tool status page
Out of disk or memory on large repos Big database on a standard runner Use a larger runner, narrow paths, or split languages into separate jobs

FAQ

Should I use CodeQL default setup or advanced setup?

Use default setup unless you need a config file, custom queries, specific build steps or path filters. It updates itself, picks languages automatically and needs no workflow file. Switch to advanced setup when one of those needs appears, and remember the two cannot run CodeQL on the same repository at once.

Can I run Semgrep and CodeQL together in GitHub Actions?

Yes. They upload SARIF under different tool names and categories, and both appear in the same Security tab and pull request annotations. Running both is common: CodeQL for deep cross-file data flow, Semgrep for fast custom rules specific to your codebase.

Does Semgrep Community Edition need a login or token?

No. semgrep scan with registry rulesets or local rules runs without an account. You need a token only for semgrep ci on the Semgrep AppSec Platform, which adds cross-file analysis, Pro rules, PR comments and finding tracking.

When does CodeQL Action v3 stop working?

GitHub said v3 will be deprecated in December 2026, at the same time as GHES 3.19, after which it stops receiving updates. On github.com, change every @v3 reference to @v4; default setup users are moved automatically.

When I add this to a repository, the first thing I check is the tool status page under Security, because it tells me whether both tools uploaded, for which languages and when, before anyone starts arguing about alerts. Then I look at the first week's dismissals: if more than a handful are "false positive" for the same query, that query goes in query-filters with a comment. I keep secret scanning separate, with pre-commit hooks as covered in the Gitleaks guide. Official references: CodeQL Action releases, GitHub code scanning docs, SARIF upload docs, Semgrep CE in CI and the Semgrep CLI reference.

Related: GitHub Actions Security in 2026: 76 Hijacked Tags and the Fixes That Work, How to Stop Secrets Leaking Into Git: Gitleaks, Pre-commit, CI (2026) and How to Scan Container Images with Trivy and Grype (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related