MSBuild Caching on GitHub Actions Windows Runners
MSBuild rebuilds every project on GitHub Actions because runners are ephemeral. Cache NuGet, keep incremental output inside one job, and skip idle analyzers.
Last verified:
MSBuild has a working incremental build system, and it gives you nothing on GitHub Actions, because every job starts on a fresh machine with empty obj and bin directories and no previous timestamps to compare against. On a Windows runner the practical answer is to cache the NuGet global packages folder with actions/cache, preserve incremental output inside a single job rather than trying to carry it between jobs, and stop analyzers from rerunning on projects nobody touched.
WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. This guide is about the Windows fleet and about one constraint that shapes every decision below: WarpBuild cache is enabled by default on Linux runners and is documented as unsupported on Windows runners, so Windows caching goes through actions/cache or a build-server strategy.
Diagnosis
Three separate mechanisms make a Windows MSBuild job repeat work it already did. They look identical in the GitHub Actions timing panel, and they have different fixes, so separate them before changing anything.
No incremental state survives between jobs
Runners are ephemeral. The virtual machine is created for the job and destroyed at the end, so the NuGet global packages folder under the runner profile, the obj intermediate outputs, and the bin outputs are all empty when the job starts. A solution with 300 package references downloads and extracts 300 packages on every run. A solution with 60 projects compiles 60 projects on every run.
The signal is a restore step that takes minutes and a build step whose duration barely moves between a one-line change and a thousand-line change.
Checkout timestamps force a full solution rebuild
This is the part that surprises people who try to fix the first problem by caching obj and bin. MSBuild decides whether a project is up to date by comparing input timestamps against output timestamps. Git stores no modification times, so actions/checkout writes every source file with the current clock. Every source file is therefore newer than any output you restored from a cache, and MSBuild rebuilds the project.
Restoring build output across jobs also opens a correctness hole. A stale obj folder from a different compiler version or a different property set produces a build that succeeds locally and fails to reproduce, which costs more time than it saves.
Analyzers and source generators rerun on unchanged projects
Roslyn analyzers and source generators run as part of compilation. When the project rebuilds, they run again, on every syntax tree, including the projects whose sources are byte-identical to the last run. A large rule set on a 60-project solution can spend more time in analyzers than in code generation.
Prove it before acting. Build with -p:ReportAnalyzer=true -v:n and read the analyzer execution summary the compiler prints, then capture a binary log with -bl and inspect which targets ran and why MSBuild judged each project out of date. WarpBuild's CI observability collects OpenTelemetry system metrics from the runner agent and correlates them with GitHub Actions job logs, which is how you tell a CPU-bound analyzer pass from an IO-bound package extraction without guessing.
What is worth caching on Windows
| Artifact | Path | Cache key | Reuse across jobs |
|---|---|---|---|
| NuGet global packages | C:\nuget | hash of packages.lock.json | Yes, this is the main win |
| MSBuild intermediate output | per-project obj | none | No, checkout timestamps defeat it |
| Compiled assemblies | per-project bin | none | No, and stale output risks a bad build |
| Analyzer and generator results | inside obj | none | No, they are tied to the same timestamps |
Fix
Work down the list in order. The first four cost nothing per minute and usually return more time than a larger runner does.
1. Cache the NuGet global packages folder. Point NUGET_PACKAGES at a short path such as C:\nuget, which also keeps package paths clear of the Windows path length limit, and key an actions/cache entry on the hash of your lock files. Turn on RestorePackagesWithLockFile so the lock files exist, and restore with -p:RestoreLockedMode=true so a drifted lock file fails the job instead of silently re-resolving against the feed. WarpBuild cache is unsupported on Windows runners, and actions/cache writes to GitHub's own Actions cache service, which a WarpBuild runner reaches with no extra configuration because it receives the same cache credentials any GitHub Actions job receives. Key design and lock file handling are covered in detail in caching NuGet packages on GitHub Actions.
2. Restore once, then build without restoring. Run -t:Restore as its own step with -p:RestoreUseStaticGraphEvaluation=true so the project graph is evaluated once, then run -t:Build on its own. Merging the two makes MSBuild walk the graph twice per run.
3. Preserve incremental output inside the job. The job is the only scope where MSBuild incrementality is trustworthy, so build the solution exactly once and let every later step consume that output. Pass --no-build to dotnet test and -p:NoBuild=true to a publish or pack target. Never run a clean target between them. On a solution with several test projects and a publish step, this alone removes two or three full compilations per run.
4. Turn on parallel project builds. msbuild.exe uses a single node unless you pass -m, so a 60-project solution on an 8 vCPU runner compiles one project at a time while seven cores idle. Pass -m explicitly rather than relying on an SDK default that moves between versions, and set MSBUILDDISABLENODEREUSE=1, because reused worker processes buy nothing on a machine that is about to be destroyed and occasionally hold file locks that break later steps.
5. Move analyzers off the pull request path. Set -p:RunAnalyzersDuringBuild=false on the build that gates pull requests, and run one job on merges to main that builds with analyzers on and -p:ReportAnalyzer=true. Rule violations still block the branch, and the per-pull-request wait drops by whatever the analyzer pass was costing.
6. Consider a build-server strategy for the parts that do not need Windows. Cross-platform .NET code often runs its whole pipeline on Windows out of habit. Compilation and test for platform-neutral projects can run on warp-ubuntu-latest-x64-8x at $0.016 per minute, where WarpBuild cache is on by default and snapshot runners are available, and Windows keeps the targets that genuinely need Windows toolsets. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, so this option applies to the Linux side of the pipeline. The toolchain detail for splitting a pipeline this way lives in running .NET builds on GitHub Actions.
7. Size the runner last. Move up one size at a time and read the metrics after each move. When the build stops scaling with cores, split work across jobs instead. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps.
Configuration
The workflow
This is the whole fix as a workflow. It restores packages from the Actions cache, restores and builds as separate steps, builds projects in parallel, and keeps the build output alive for the test and publish steps.
name: windows-msbuild
on:
pull_request:
push:
branches: [main]
env:
DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
MSBUILDDISABLENODEREUSE: "1"
NUGET_PACKAGES: C:\nuget
jobs:
build:
runs-on: warp-windows-latest-x64-8x
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Restore the NuGet packages folder from the Actions cache
uses: actions/cache@v4
with:
path: C:\nuget
key: nuget-windows-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
nuget-windows-
- uses: microsoft/setup-msbuild@v2
- name: Restore once, with static graph evaluation
run: >
msbuild Contoso.sln
-t:Restore
-m
-p:RestoreUseStaticGraphEvaluation=true
-p:RestoreLockedMode=true
- name: Build every project in parallel, keeping obj and bin in place
run: >
msbuild Contoso.sln
-t:Build
-m
-p:Configuration=Release
-p:ContinuousIntegrationBuild=true
-p:RunAnalyzersDuringBuild=false
-bl:artifacts/build.binlog
- name: Test against the output the build step produced
run: >
dotnet test Contoso.sln
--configuration Release
--no-build
--logger trx
- name: Publish from the same output
run: >
msbuild src/Contoso.Web/Contoso.Web.csproj
-t:Publish
-m
-p:Configuration=Release
-p:NoBuild=true
-p:PublishDir=artifacts/web
- uses: actions/upload-artifact@v4
if: always()
with:
name: build-binlog
path: artifacts/build.binlogTwo details are easy to get wrong. NUGET_PACKAGES is set at the workflow level so the cache path, the restore, and the build all agree on one folder. And the binary log is uploaded even on failure, because the run you most want to inspect is the one that broke.
The analyzer job
Analyzers still gate the default branch. This job runs on merges only and prints where the analyzer time goes.
analyzers:
if: github.ref == 'refs/heads/main'
runs-on: warp-windows-latest-x64-8x
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: microsoft/setup-msbuild@v2
- name: Build with analyzers and report their cost
run: >
msbuild Contoso.sln
-t:Restore;Build
-m
-p:Configuration=Release
-p:RunAnalyzersDuringBuild=true
-p:ReportAnalyzer=true
-v:nImages and labels
warp-windows-latest-x64-8x runs Windows Server 2022 with Visual Studio 2022. Use warp-windows-2025-x64-8x for the Windows Server 2025 base, and warp-windows-2025-vs2026-x64-8x when the solution needs Visual Studio 2026 toolsets. Pin an explicit version label rather than latest when the build depends on a specific toolset. Image detail lives on Visual Studio images on WarpBuild Windows runners, and cache behavior is documented on the WarpBuild caching page.
Cost or Time Model
Windows rates are linear in vCPU: every Windows vCPU-minute lists at $0.004, so the catalog runs from $0.016 per minute at 4 vCPU to $0.128 per minute at 32 vCPU. That linearity makes sizing arithmetic instead of opinion.
| WarpBuild label | vCPU | RAM | Storage | Per minute | GitHub-hosted Windows larger runner | GitHub per minute | Lower list price |
|---|---|---|---|---|---|---|---|
warp-windows-latest-x64-4x | 4 | 16 GB | 256GB SSD | $0.016 | 4-core, 4 vCPU, 16 GB | $0.022 | 27 percent |
warp-windows-latest-x64-8x | 8 | 32 GB | 256GB SSD | $0.032 | 8-core, 8 vCPU, 32 GB | $0.042 | 24 percent |
warp-windows-latest-x64-16x | 16 | 64 GB | 256GB SSD | $0.064 | 16-core, 16 vCPU, 64 GB | $0.082 | 22 percent |
warp-windows-latest-x64-32x | 32 | 128 GB | 256GB SSD | $0.128 | 32-core, 32 vCPU, 128 GB | $0.162 | 21 percent |
WarpBuild rates and shapes come from the cloud runners documentation and the pricing page. GitHub rates and shapes come from the Actions billing reference, checked on 2026-08-13. The same four sizes exist on the Windows Server 2025 and Visual Studio 2026 label families at identical rates, and the full matrix with aliases and regions is on WarpBuild Windows runners for GitHub Actions. The smallest Windows size is 4 vCPU, because Windows 2vCPU runners were removed on June 8, 2026.
A worked model
Replace these inputs with numbers from your own run history.
- 800 Windows solution builds per month, roughly 36 per weekday.
- A baseline build of 18 minutes on
warp-windows-latest-x64-4x, of which 4 minutes is NuGet restore. - 200 of those 800 runs are merges to
main.
Step one, same shape, lower rate. 800 builds at 18 minutes is 14,400 Windows minutes. At $0.016 that is $230.40 per month, against $316.80 for the same minutes on the 4-core GitHub-hosted Windows larger runner at $0.022. Nothing about the build changed.
Step two, cache the packages folder. A warm NuGet cache plus static graph evaluation takes restore from 4 minutes to 1 minute, so the build is 15 minutes. That is 12,000 minutes, or $192.00. Cache entries live in GitHub's Actions cache service, so this step adds no WarpBuild line item.
Step three, move analyzers to merges. Say the analyzer pass is 3 minutes. Pull request builds drop to 12 minutes: 800 times 12 is 9,600 minutes, or $153.60. The dedicated analyzer job runs on 200 merges at 8 minutes each, which is 1,600 minutes, or $25.60. Monthly total is $179.20, and every pull request check returns 3 minutes sooner.
Step four, decide whether more cores pay. An 8 vCPU minute costs $0.032, exactly twice a 4 vCPU minute, so break-even is half the wall clock: 12 times 0.016 divided by 0.032 is 6 minutes. With -m on a 60-project solution, suppose the build lands at 8 minutes. The pull request line goes from $153.60 to $204.80 and buys 4 minutes on every run. 800 runs times 4 minutes is 3,200 minutes, about 53 hours per month of engineers waiting on a check, for $51.20.
If your Windows fleet is large enough that compute economics dominate, BYOC runs on AWS, GCP, and Azure with the compute billed to your own cloud account.
FAQ
Can I cache the obj and bin folders between GitHub Actions jobs?
You can restore them, but MSBuild will still rebuild. Up-to-date checks compare source timestamps against output timestamps, and actions/checkout writes every source file with the checkout time, which is newer than any output you restored. The reliable cross-job cache on Windows is the NuGet global packages folder. Incremental output is worth preserving inside one job, across the restore, build, test, and publish steps.
Does WarpBuild caching work on Windows runners?
No. WarpBuild cache is available on Linux runners and is enabled by default there, and the documentation lists it as unsupported for Windows runners. On a WarpBuild Windows runner, use actions/cache, which writes to GitHub's own Actions cache service and needs no extra configuration.
Which MSBuild switches actually reduce build time in GitHub Actions?
Pass -m so MSBuild builds independent projects in parallel instead of one node at a time, set MSBUILDDISABLENODEREUSE=1 because worker reuse buys nothing on a machine that is destroyed after the job, run -t:Restore once with -p:RestoreUseStaticGraphEvaluation=true, and then run -t:Build without restoring again. Use --no-build for the test step so the test host reuses the output the build step produced.
How do I find out how much time analyzers cost my build?
Build with -p:ReportAnalyzer=true and -v:n. The log prints total analyzer execution time and a per-analyzer breakdown, so you can see which rules dominate. Capture a binary log with -bl at the same time and read it to confirm which targets ran and why MSBuild considered each project out of date.
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.