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.

PhaseWhat runsScales with the diffFix
Runner start and checkoutVM boot, actions/checkoutNoSmaller checkout, warm runner
Scanner image pullContainer image for the scanner, often several hundred MBNoCache or preload the image
Vulnerability database refreshAdvisory database download on every jobNoCache the database, refresh on a schedule
AnalysisTree walk, parsing, rule evaluationYesRestrict to changed paths, size the runner
Result uploadSARIF upload, dependency submissionNoLeave 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.

PhaseMinutes in the example run
Runner start and checkout2
Scanner image pull3
Vulnerability database refresh2
Full-tree analysis10
Result upload1
Total18

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.sarif

Scheduled 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.sarif

Both 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 labelOSvCPURAMStoragePrice per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4xUbuntu 24.04416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8xUbuntu 24.04832 GB150GB SSD$0.016

Cost or Time Model

Assumptions

InputValueSource
Repository size900,000 lines, 12,000 files, 40 packagesYour repository
Pull request pushes per weekday40Your workflow run history
Weekdays per month22Calendar
Scheduled full scans per month30One per day
Full-tree scan duration18 minutesThe breakdown above
Changed-paths scan duration4 minutesThe breakdown above, analysis scoped to one package
Cached database and image size3 GBYour cache metrics
warp-ubuntu-latest-x64-4x$0.008 per minutepricing
warp-ubuntu-latest-x64-8x$0.016 per minutepricing
Cache storage$0.20 per GB-monthpricing
Cache write or restore$0.0001 per operationpricing
GitHub 8-core Linux larger runner$0.022 per minuteGitHub 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 runRate per minuteMonthly 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

JobRuns per monthMinutes eachMinutesRateCost
Changed-paths scan on 4x88043,520$0.008$28.16
Scheduled full scan on 8x3018540$0.016$8.64
Cache storage, 3 GB---$0.20 per GB-month$0.60
Cache operations, 910---$0.0001$0.09
Total4,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.