Security Scanning Jobs Without the Wait
Scan jobs run long because image pulls, database refreshes, and full-tree analysis repeat on every pull request. Split the work and cache the database.
Security scanning jobs in GitHub Actions run long because most of the wall clock goes to work that repeats on every run: pulling the scanner image, refreshing the vulnerability database, and walking the whole tree even when the pull request touched four files. Split that into a scheduled full-tree scan and a pull request scan restricted to changed paths, keep the scanner image and the database in a cache between runs, and the pull request gate returns in minutes while full coverage keeps running on its own timetable.
This guide covers where the minutes actually go, the workflow YAML for both halves of the split, and a monthly cost model for a stated repository size that you can rerun with your own numbers.
Diagnosis
A scan job is five phases, and only one of them has anything to do with the size of your diff. Open a recent scan run, expand the step timings in the log, and write down the seconds each phase took before changing any configuration.
| Phase | What runs | Scales with the diff | Fix |
|---|---|---|---|
| Runner start and checkout | VM boot, actions/checkout | No | Smaller checkout, warm runner |
| Scanner image pull | Container image for the scanner, often several hundred MB | No | Cache or preload the image |
| Vulnerability database refresh | Advisory database download on every job | No | Cache the database, refresh on a schedule |
| Analysis | Tree walk, parsing, rule evaluation | Yes | Restrict to changed paths, size the runner |
| Result upload | SARIF upload, dependency submission | No | Leave it alone, it is seconds |
An example breakdown
The numbers below come from one scan job on a monorepo of roughly 900,000 lines across 12,000 tracked files in 40 packages. Substitute the step timings from your own workflow run history.
| Phase | Minutes in the example run |
|---|---|
| Runner start and checkout | 2 |
| Scanner image pull | 3 |
| Vulnerability database refresh | 2 |
| Full-tree analysis | 10 |
| Result upload | 1 |
| Total | 18 |
Eight of those 18 minutes are fixed cost. They are paid identically by a one-line documentation fix and by a 200-file refactor, and they are paid again on every push to the branch. At 40 pull request pushes a weekday, the fixed phases alone consume 320 minutes a day.
The analysis phase is the other half of the problem. A full-tree walk on a repository this size reads every file in every package to answer a question about two of them.
Fix
Three changes, in the order that returns the most minutes.
Move the full tree onto a schedule
Coverage of the whole repository is a daily concern rather than a per-push concern. New advisories land against dependencies nobody edited, so the scan that finds them has to run on a clock rather than on a diff. Run it once a day on a schedule with workflow_dispatch alongside it, and let it use a larger runner, because nobody is waiting on it.
Restrict the pull request scan to changed paths
Compute the merge base, take the changed files, collapse them to the packages that contain them, and hand the scanner only those paths. A pull request that touches one package then analyses one package. Skip the job entirely when the diff contains no scannable paths.
Filesystem and dependency scanners take path arguments directly. For SAST that builds its own database, scope the build to the affected project rather than the workspace root.
Stop redownloading the database and the image
The advisory database is the phase teams forget, and it is the one that also hits upstream rate limits during a busy afternoon. Restore it from cache with WarpBuilds/cache@v1, which is a drop-in replacement for actions/cache@v4, and let the scheduled job own the refresh and the save. Pull request jobs then read a database written that morning instead of downloading their own copy 40 times a day.
The scanner image is the same argument. Pin it by digest and cache it, or bake it into a snapshot runner so the image is already on disk when the job starts. Runner images run on ephemeral VMs that are created on demand and destroyed after each build, described in the runner security documentation, so the cache is what carries state between them rather than a reused machine.
Size the runner for the analysis phase
Analysis is CPU and IO bound. The scheduled full-tree job earns a larger size because it is the only job that reads everything; the pull request job usually does not, because the fixed phases dominate once the tree walk is scoped down. Measure both before committing to a size. The full label set is in the cloud runners documentation.
Configuration
Pull request scan, changed paths only
name: security-scan-pr
on:
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
changed-paths:
runs-on: warp-ubuntu-latest-x64-2x
outputs:
packages: ${{ steps.filter.outputs.packages }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: filter
run: |
BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
git diff --name-only "$BASE" HEAD > changed.txt
PACKAGES=$(cut -d/ -f1-2 changed.txt | sort -u | paste -sd' ' -)
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
scan-diff:
needs: changed-paths
if: needs.changed-paths.outputs.packages != ''
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/cache/restore@v1
with:
path: ~/.cache/vuln-db
key: vuln-db-
restore-keys: vuln-db-
- name: Scan the changed packages
run: |
scanner fs \
--db-path ~/.cache/vuln-db \
--skip-db-update \
--format sarif \
--output diff.sarif \
${{ needs.changed-paths.outputs.packages }}
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: diff.sarifScheduled full-tree scan
This job owns the database refresh, so it writes the cache entry that every pull request job reads.
name: security-scan-full
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
contents: read
security-events: write
jobs:
full-tree:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- name: Refresh the vulnerability database
run: scanner db update --db-path ~/.cache/vuln-db
- name: Scan the full tree
run: |
scanner fs \
--db-path ~/.cache/vuln-db \
--skip-db-update \
--format sarif \
--output full.sarif .
- uses: WarpBuilds/cache/save@v1
with:
path: ~/.cache/vuln-db
key: vuln-db-${{ github.run_id }}
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: full.sarifBoth workflows upload SARIF, so findings land in the same code scanning view whichever job produced them. If the same pipeline also emits a component inventory, the mechanics are in how to generate an SBOM during a GitHub Actions build and the term itself is defined in the SBOM glossary entry.
Labels and rates used above
Per-minute rates from the pricing page:
| Runner label | OS | vCPU | RAM | Storage | Price per minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 |
Cost or Time Model
Assumptions
| Input | Value | Source |
|---|---|---|
| Repository size | 900,000 lines, 12,000 files, 40 packages | Your repository |
| Pull request pushes per weekday | 40 | Your workflow run history |
| Weekdays per month | 22 | Calendar |
| Scheduled full scans per month | 30 | One per day |
| Full-tree scan duration | 18 minutes | The breakdown above |
| Changed-paths scan duration | 4 minutes | The breakdown above, analysis scoped to one package |
| Cached database and image size | 3 GB | Your cache metrics |
| warp-ubuntu-latest-x64-4x | $0.008 per minute | pricing |
| warp-ubuntu-latest-x64-8x | $0.016 per minute | pricing |
| Cache storage | $0.20 per GB-month | pricing |
| Cache write or restore | $0.0001 per operation | pricing |
| GitHub 8-core Linux larger runner | $0.022 per minute | GitHub Actions billing |
GitHub list prices checked on 2026-08-13.
Full scan on every push
Monthly scan runs: 40 pushes times 22 weekdays equals 880.
Monthly minutes: 880 times 18 equals 15,840.
| Where those minutes run | Rate per minute | Monthly total |
|---|---|---|
| warp-ubuntu-latest-x64-8x | $0.016 | $253.44 |
| GitHub 8-core Linux larger runner | $0.022 | $348.48 |
At the same 8 vCPU shape, $0.016 per minute against $0.022 per minute is a 27 percent lower list price (GitHub pricing, checked on 2026-08-13).
The split model
| Job | Runs per month | Minutes each | Minutes | Rate | Cost |
|---|---|---|---|---|---|
| Changed-paths scan on 4x | 880 | 4 | 3,520 | $0.008 | $28.16 |
| Scheduled full scan on 8x | 30 | 18 | 540 | $0.016 | $8.64 |
| Cache storage, 3 GB | - | - | - | $0.20 per GB-month | $0.60 |
| Cache operations, 910 | - | - | - | $0.0001 | $0.09 |
| Total | 4,060 | $37.49 |
The monthly bill for security scanning moves from $253.44 to $37.49, and the minute total moves from 15,840 to 4,060. The number engineers feel is the other one: the gate on a pull request returns in about 5 minutes including the changed-paths job, rather than 18.
Two of those savings are independent. Restricting the analysis phase cuts minutes on any runner you use, and the per-minute rate decides what each remaining minute costs. Apply both. The wider version of this arithmetic across a whole GitHub Actions bill is in how to reduce GitHub Actions costs, and the update traffic that feeds these scans is covered in Dependabot and Renovate jobs in GitHub Actions.
Where WarpBuild fits
The full rate table is on the pricing page.
For the review that usually accompanies this work, WarpBuild is SOC 2 Type 2 certified, with the report available through the trust center. The isolation and storage answers a security team asks for are in the runner security documentation, and the longer questionnaire is walked through in a security review checklist for GitHub Actions runners.
FAQ
Does scanning only changed paths miss vulnerabilities?
It misses anything that changes outside your diff, such as a new advisory published against a dependency nobody touched this week. That is why the scheduled full-tree scan stays in place. The pull request job is the fast gate, and the scheduled job is the coverage.
Why does a scan take 18 minutes when the pull request changed four files?
Three of the five phases in a scan job are fixed cost. Runner start and checkout, scanner image pull, and vulnerability database refresh all run at the same length whether the diff is four files or four hundred. Only the analysis phase scales with what you feed it.
Is a cached vulnerability database safe to scan against?
It is as fresh as the job that last wrote it. A scheduled job that refreshes the database and saves it back every morning leaves pull request scans reading a database at most 24 hours old. Shorten the schedule if your policy needs a tighter window.
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.