How to Add Semgrep and CodeQL to GitHub Actions (2026 Guide)
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.
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
How to Sign and Verify Container Images with Cosign Keyless (2026)
Step-by-step: sign container images with cosign v3.1.4 keyless in GitHub Actions, verify with certificate identity flags, attach SBOM and SLSA attestations, enforce signatures in Kubernetes with Kyverno 1.19 ImageValidatingPolicy or Sigstore policy-controller, and fix common errors.
11 minOct 20, 2026How to Scan Container Images with Trivy and Grype (2026)
Step-by-step guide to scanning container images with Trivy v0.76 and Grype v0.121: verified installs after the March 2026 Trivy compromise, image and SBOM scans, CI gates on fixable criticals, .trivyignore, VEX, and fixes for DB download errors.
12 minOct 31, 2026How to Encrypt Kubernetes Secrets in Git with SOPS and age (2026)
Step-by-step SOPS 3.13.3 and age 1.3.2 setup: generate age keys, write .sops.yaml creation rules, encrypt and edit Secrets, decrypt with Flux kustomize-controller or Argo CD via KSOPS, rotate keys safely, and fix MAC mismatch and no identity matched errors.
11 min