SonarQube Scans on GitHub Actions
SonarQube scans on GitHub Actions need full git history, a warm analyzer cache, and heap for the scanner JVM. Workflow YAML, runner sizing, and cost math.
Last verified:
A SonarQube scan on GitHub Actions is one job that runs after build and test, checks the repository out with full history, restores the analyzer cache, and calls the scanner with a token held in secrets. On WarpBuild the same job runs on a warp- label with the vCPUs and memory the scanner JVM needs, billed per minute from $0.004, with the analyzer cache kept warm between runs.
The parts that decide wall-clock time are the checkout depth, the analysis inputs the scanner has to read, and the heap it gets. The sections below give the job shape for pull requests and the default branch, a working workflow, sizing against the Linux rates, and the failure modes that make scans slow.
Overview
A scan job has three inputs. The first is source plus git history: SonarQube reads blame to attribute issues and to separate new code from old code, so the scan job checks out with fetch-depth: 0 rather than the default shallow clone. The second is analysis material produced by earlier steps, which differs by language. Java analysis needs compiled classes through sonar.java.binaries, C and C++ analysis needs a compile_commands.json written by the build, and coverage reporting needs the coverage file the test job produced. The third is credentials: a SONAR_TOKEN secret, plus a host URL when analysis targets a SonarQube Server instance.
The scan runs in two shapes.
Pull request analysis. The scanner action reads the GitHub Actions context and sends pull request parameters with the analysis, so the server compares the branch against its base and decorates the pull request with new issues and the quality gate result. This is the incremental shape, and it is the one that runs on every push to a branch. It only analyzes what the pull request touches for reporting purposes, though the scanner still walks the file tree it is pointed at.
Default branch analysis. A push to the default branch runs the full analysis that sets the baseline every later pull request is measured against. This is the run that populates the project on the server, so it needs the complete input set: coverage from the whole suite, all modules, and the same analyzer versions the pull request runs use.
Keeping both shapes in one workflow with the same runner label and the same cache key is what keeps results comparable. When the two runs use different toolchains, the paths recorded in analysis inputs stop resolving and the server reports phantom changes.
SonarQube analysis usually sits alongside dependency and secret scanning in the same pipeline. This page covers the SonarQube job specifically; the scheduling, permissions, and result-upload patterns those jobs share are in the guide to security scanning jobs on GitHub Actions.
Scan jobs belong on Linux: the runner catalog lists Ubuntu images on x64 and ARM64 in sizes from 2 to 32 vCPUs with 150GB SSDs, and the images carry the same tooling as GitHub-hosted runners, so the Java runtime the scanner needs is already installed. Runners are ephemeral VMs, freshly allocated per job, which is why the analyzer cache step matters. Cache is enabled by default on all Linux runners; the cache documentation covers the mechanics.
Configuration
This workflow runs tests on 8 vCPUs, uploads coverage, then runs the scan on 4 vCPUs with the analyzer cache restored. The scan job is skipped for pull requests opened from forks, because those runs receive no secrets.
name: sonarqube
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/lcov.info
sonarqube:
needs: test
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: warp-ubuntu-latest-x64-4x
env:
SONAR_SCANNER_JAVA_OPTS: -Xmx6g
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
name: coverage
path: coverage
- uses: WarpBuilds/cache@v1
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar-${{ hashFiles('sonar-project.properties') }}
restore-keys: |
${{ runner.os }}-sonar-
- uses: SonarSource/sonarqube-scan-action@v5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- uses: SonarSource/sonarqube-quality-gate-action@v1
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}Four details carry the weight.
The cache path is the analyzer home. ~/.sonar/cache holds the analyzer plugin jars the scanner downloads from the server on first use, which on a multi-language project runs to hundreds of megabytes. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 with identical syntax. If the scanner runs inside a container step, set SONAR_USER_HOME to a path under the workspace and cache that path instead, since the home directory changes inside the container.
Credentials stay in env, never in with. The token is passed as an environment variable so it never appears in an input that gets echoed into the step summary. Keep SONAR_HOST_URL pointed at your SonarQube Server instance, or at the SonarQube Cloud endpoint when the project lives there, and keep the organization and project key in sonar-project.properties so both run shapes read the same values.
Fork pull requests need a second workflow. GitHub withholds secrets from workflows triggered by pull_request events on forks, so the if condition above skips the scan rather than failing it. To analyze fork contributions, have the first workflow upload the analysis inputs as an artifact and run the scan in a second workflow triggered by workflow_run, which executes in the base repository context and does receive secrets.
The quality gate wait is its own step with a timeout. sonarqube-quality-gate-action polls the server after analysis is submitted. Server-side processing time is outside the runner's control, so bound it with timeout-minutes rather than letting a queued server hold a runner.
For ARM64, swap the labels to sizes such as warp-ubuntu-latest-arm64-4x, which runs at $0.006 per minute. The scanner is a JVM application and the common analyzers ship as jars, so the workflow above needs no other change.
Sizing
The Linux x64 catalog, with per-minute rates from the pricing page and the full list on the Linux x64 runners page:
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 |
Size the scan job by memory first. The scanner loads the syntax tree of each file, holds the symbol table for the module under analysis, and keeps issue and measure data in memory until it uploads. On a repository of a few hundred thousand lines that fits inside a 6 GB heap, which warp-ubuntu-latest-x64-4x supports with room for the OS file cache. Analysis-heavy repositories, meaning several million lines, many languages in one project, or generated sources that inflate the file count, are where the 4 vCPU size starts hitting garbage collection pressure and warp-ubuntu-latest-x64-8x at $0.016 per minute pays for itself in reduced wall clock.
CPU matters in fewer places. Most analyzers process files sequentially inside one JVM, so extra cores idle during a JavaScript or Java scan. The exception is the C and C++ analyzer, which takes a thread count and scales across cores; a large C++ codebase is the case where 8 or 16 vCPUs shorten the scan directly. Run the build and test job on the larger size and let the scan job take the smaller one when the scan is single-threaded.
Two sizing rules follow from that split. Do not run the scan on the same oversized runner as the build unless the two share a job for artifact reasons, and do not size the scan job by repository size alone: a monorepo split into several SonarQube projects scans each one in its own smaller job, which parallelizes across runners instead of demanding one large machine.
Worked cost model
GitHub publishes list prices for its hosted runners: Linux larger runners meter at $0.022 per minute for the 8 vCPU size on private repositories, from the GitHub Actions minute multipliers reference, checked on 2026-08-13.
Take a repository whose scan job runs 6 minutes on 8 vCPU machines across 900 pipeline runs a month, or 5,400 runner-minutes, with 3 GB of analyzer cache and two cache operations per run:
| Line item | Rate | Volume | Monthly cost |
|---|---|---|---|
| GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 5,400 minutes | $118.80 |
| warp-ubuntu-latest-x64-8x | $0.016 per minute | 5,400 minutes | $86.40 |
| WarpBuild cache storage | $0.20 per GB-month | 3 GB | $0.60 |
| WarpBuild cache operations | $0.0001 per operation | 1,800 operations | $0.18 |
The WarpBuild total is $87.18 against $118.80 for the same minutes on GitHub-hosted larger runners, a difference of $31.62 per month. The shapes match: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), 27 percent lower list price, GitHub list price checked on 2026-08-13.
Every rate is on the pricing page. To turn that monthly figure into a per-review number, which is the one that moves when the team merges more often, the guide to GitHub Actions cost per pull request shows the division.
Bottlenecks
Full-history checkout on a large repository. fetch-depth: 0 is required for blame, and on a repository with a long history the clone alone can dominate the job. Partial clone filters look like the fix and are a trap: a blob:none filter leaves the scanner unable to read historical blobs, so blame comes back empty and every line looks new. Cache the git objects or accept the clone cost, and keep the fetch in the scan job only rather than in every job of the pipeline.
Cold analyzer cache. Without the cache step, each scan downloads the analyzer plugin set from the server before it reads a single file, and that transfer sits in the critical path of every pull request. The cache step above removes it. Cache entries expire after 7 days without use, so a rarely built branch pays the download again.
Scanner heap left to defaults. The JVM caps its default maximum heap at a quarter of physical memory. On a 16 GB runner that is 4 GB, which a mid-size project exceeds quietly: garbage collection consumes the cores while the scan crawls, and the job either runs long or ends with an out-of-memory error. SONAR_SCANNER_JAVA_OPTS: -Xmx6g in the workflow above sets it explicitly, and the value should follow the runner size when you change the label.
Missing analysis inputs. A scan that produces no coverage numbers or reports zero issues in a language is usually reading nothing. Java analysis without sonar.java.binaries pointed at compiled classes skips the bytecode rules, and coverage without the report file simply reports none. Both fail as a passing scan rather than an error, which is why the pattern of downloading the test job's artifacts into the scan job matters. On JVM projects the compile step that produces those classes is the expensive half of the pipeline; sizing and caching for it are covered on the Java solutions page.
Serial tail behind the test matrix. A scan job with needs on every shard of a test matrix starts only when the slowest shard finishes, and then adds its own clone, scan, and quality gate wait. Merging coverage reports in a small job before the scan, as the argent-x workflow cited below does, keeps that tail short.
Telling a memory-bound scan apart from a scan waiting on the server is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a job burning CPU in garbage collection looks visibly different from one idling on a quality gate poll. Snapshot runners, remote Docker builders, an MCP server, and the Action Debugger cover the rest of the pipeline around the scan.
Proof
Public repositories run SonarQube analysis on warp- labels, and the workflow files are open to read.
- libfn/functional runs
SonarSource/sonarqube-scan-actiononwarp-ubuntu-latest-arm64-4xinside a pinned GCC container, feeding the analyzer acompile_commands.jsonand gcov reports produced in the same job. Its pull request scan workflow is theworkflow_runpattern described above: the first workflow packages the analysis inputs, and the second runs the scan on the same runner label with access to the token, so pull requests from forks are analyzed too. - argentlabs/argent-x merges sharded Vitest coverage reports and runs the SonarQube scan step in a
merge-reports-unitjob onwarp-ubuntu-latest-x64-4x, downstream of a test matrix that runs on WarpBuild runners.
FAQ
Which runner size should a SonarQube scan job use?
Start the scan job on warp-ubuntu-latest-x64-4x at $0.008 per minute, which gives the scanner JVM 16 GB to work with. Move to warp-ubuntu-latest-x64-8x at $0.016 per minute when the scanner runs out of heap on a large codebase or when the C family analyzer is running with several threads.
How do I cache SonarQube analyzer downloads on WarpBuild runners?
Add WarpBuilds/cache@v1 with ~/.sonar/cache as the path, or set SONAR_USER_HOME to a workspace directory and cache that. Runners are ephemeral VMs, so without the cache step every scan re-downloads the analyzer plugins from the SonarQube server.
Why does the scan job need fetch-depth 0?
SonarQube reads git blame to attribute issues to authors and to decide which lines are new code. The default shallow checkout hides that history, so set fetch-depth to 0 on actions/checkout in the scan job.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.