Skip to content

ci: cache NuGet packages and npm downloads#4525

Open
dbrattli wants to merge 4 commits into
mainfrom
repo-assist/eng-ci-nuget-npm-caching-2026-04-de479ba7cc732133
Open

ci: cache NuGet packages and npm downloads#4525
dbrattli wants to merge 4 commits into
mainfrom
repo-assist/eng-ci-nuget-npm-caching-2026-04-de479ba7cc732133

Conversation

@dbrattli

@dbrattli dbrattli commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator

Caches ~/.nuget/packages and the npm download cache across CI job runs. This eliminates redundant package downloads on every run — the repo pulls 40 distinct NuGet packages across 69 project files, re-downloaded per job today.

All 11 setup-dotnet steps now go through a composite action at .github/actions/setup-dotnet, and all 5 setup-node steps get cache: npm with a key covering every committed lockfile.

Composite action

The 11 setup-dotnet blocks were byte-identical, comment included, so they live in one file rather than eleven copies that drift apart on the next change:

- name: Setup .NET
  uses: ./.github/actions/setup-dotnet

This adds an ordering constraint: the composite action requires actions/checkout to have run first. Every job already checks out as its first step, but a future job that sets up .NET before checking out would fail with a confusing "action not found".

NuGet cache key

The key hashes:

cache-dependency-path: |
  **/*.fsproj
  **/*.props
  **/*.targets
  !**/obj/**

.props and .targets are included deliberately, not just .fsproj. Version numbers are not all declared in project files: FSharp.Compiler.Service.fsproj interpolates $(SystemCollectionsImmutableVersion), $(FSharpCoreShippedPackageVersionValue) and six others that are defined in .props. Bumping one of those changes the resolved package graph without touching any .fsproj, so a .fsproj-only key would not move.

obj/ is excluded because restore generates *.nuget.g.props/.targets there. Those don't exist at setup-dotnet time in a clean CI job, so this is a no-op today — but without it the key depends on whether the workspace had been built before, which stops being hypothetical if a job ever gains a restore step ahead of setup, or on a self-hosted runner with a reused workspace.

npm cache key

setup-node's default hashes only the root package-lock.json, but build.sh runs npm install in src/fable-standalone and src/fable-compiler-js as well (Standalone.fs:112, CompilerJs.fs:33), each with its own committed lockfile. The root-only key left those tarballs out of the cache entirely — most visibly in the build-standalone job. The key is **/package-lock.json, excluding node_modules for the same workspace-cleanliness reason as obj/.

On NuGet lock files

An earlier review raised that this style of key can't capture transitive version drift, and that the correct setup is RestorePackagesWithLockFile + committed packages.lock.json + --locked-mode.

That's a fair description of what lock files give you, but it isn't a prerequisite for this change, because the cache cannot produce a wrong build:

setup-dotnet's cache: true restores ~/.nuget/packages before dotnet restore runs — it does not short-circuit restore. Restore still resolves the graph against the feed, and for each resolved package either finds it already present locally or downloads it. For a cached package to be used, restore must have independently resolved to that exact version, in which case it is the correct one by construction.

So a mis-keyed cache costs hit rate, not correctness. The failure mode is extra downloads, and it is self-correcting. That is why the keys here are widened rather than the change being blocked.

Lock files buy something real but orthogonal — reproducibility, i.e. a guarantee that this commit resolves to the same transitive graph months from now, with --locked-mode failing loudly if it wouldn't. That's worth doing on its own merits, but it costs 69 committed packages.lock.json files plus churn on every dependency bump, and it shouldn't gate a change whose entire benefit is not re-downloading packages. Better filed separately.

Notes

  • The ! exclusions are belt-and-braces, not load-bearing. @actions/glob's hashFiles supports negation, but even if it didn't, an unsupported pattern matches nothing and contributes nothing to the hash — identical behavior either way, since obj/ and node_modules/ are absent at that point regardless.
  • Caching can't fail a build, so a green run doesn't say anything about hit rates. What this run does prove is that the composite action resolves in all 11 jobs. Hit rates are only observable from the run after this one, since this one populates under new keys.
  • Jobs on the same runner OS compute an identical key and race to save at the end of the run. Only one wins; the rest log a benign "Cache already exists" warning. That's the expected shape.
  • Rebased on current main; the five setup-node conflicts were resolved keeping main's @v7.

Closes #4486

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

github-actions Bot and others added 2 commits April 11, 2026 09:12
Add cache: true + cache-dependency-path to all setup-dotnet steps to
cache ~/.nuget/packages across job runs. Add cache: npm to all
setup-node steps to cache the npm download cache.

This eliminates redundant package downloads on every CI run. The NuGet
cache key is keyed on the hash of all .fsproj files, so it invalidates
automatically when dependencies change.

Closes #4486

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dbrattli

Copy link
Copy Markdown
Collaborator Author

Note on NuGet caching correctness

The setup-dotnet cache: true feature is designed to work with NuGet lock files (packages.lock.json), not .fsproj files. Using **/*.fsproj as the cache-dependency-path means the cache key won't capture transitive dependency version changes — if a transitive package gets a new version but no .fsproj changes, CI will restore stale cached packages.

For correct caching, the intended setup is:

  1. Add <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> to project files (or Directory.Build.props)
  2. Commit the generated packages.lock.json files
  3. Use cache-dependency-path: "**/packages.lock.json"
  4. Use dotnet restore --locked-mode so restore fails if the lock file is out of date

Reference: https://www.damirscorner.com/blog/posts/20240726-CachingNuGetPackagesInGitHubActions.html

This also has the benefit of reproducible builds beyond just caching. Worth considering whether we want to adopt lock files across the repo before merging this.

Hashing only **/*.fsproj misses version numbers defined outside project
files. FSharp.Compiler.Service.fsproj interpolates
$(SystemCollectionsImmutableVersion),
$(FSharpCoreShippedPackageVersionValue) and six more, which are defined
in .props. Bumping one of those changes the resolved package graph
without touching any .fsproj, so the cache key would not move.

Also resolve the merge with main, keeping setup-node@v7 alongside the
new `cache: npm`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli dbrattli changed the title [Repo Assist] [Eng] Add NuGet and npm package caching to CI workflow ci: cache NuGet packages and npm downloads Jul 18, 2026
@dbrattli
dbrattli marked this pull request as ready for review July 18, 2026 15:26
@dbrattli

Copy link
Copy Markdown
Collaborator Author

Marking ready. Expanding on why this should work, since the lock-file question in the earlier review is the main thing standing between this and merge.

The mechanism

The concern was that a .fsproj-hashed key means "CI will restore stale cached packages." I don't think that state is reachable. setup-dotnet's cache: true caches the global packages folder (~/.nuget/packages) and restores it as a step before dotnet restore runs. It doesn't stand in front of restore or feed it a resolution.

So the sequence on a cache hit is:

  1. ~/.nuget/packages is populated from the cache.
  2. dotnet restore runs normally and resolves the graph against the feed.
  3. For each resolved package: already in the global packages folder → reuse; not there → download.

Step 2 is unconditional. A package can only be used if restore independently resolved to that exact version — at which point it's the correct version, regardless of what the key hashed. The cache is a download accelerator sitting behind resolution, not a substitute for it.

That makes a mis-keyed cache a hit-rate problem, not a correctness one. Worst case is extra downloads, and it self-corrects on the next run. This is the difference from ecosystems where the lockfile is the resolution (npm, uv) — there a stale cache genuinely can change what you build. NuGet's global packages folder is content-addressed by id/version, so it can't.

What lock files would actually add

Reproducibility, not cache safety: a guarantee that this commit resolves to the same transitive graph in six months, with --locked-mode failing loudly if it wouldn't. Genuinely worth having. But it means 69 committed packages.lock.json files and touching them on every dependency bump, and I don't think that should gate a change whose whole benefit is "stop re-downloading 40 packages per job." Happy to open it as its own issue if you want it tracked.

The one real gap, now fixed

The original key was under-specified, just not dangerously. Hashing only **/*.fsproj missed that FSharp.Compiler.Service.fsproj interpolates its versions from .props$(SystemCollectionsImmutableVersion), $(FSharpCoreShippedPackageVersionValue) and six more. Bumping one of those changes the resolved graph without touching a single .fsproj, so the key wouldn't move and you'd sit on a cache that's missing the new package. Not wrong, but a guaranteed miss on the packages you just changed. Key now covers **/*.props and **/*.targets too.

How to confirm it's working

Cache behaviour is observable, so this doesn't have to be taken on argument. In the Actions logs:

  • This run populates the cache and will look no faster — the "Cache saved with key: ..." line at the end of a setup-dotnet step is the signal it worked.
  • The next run on this branch is the meaningful one: look for "Cache restored from key: ..." and compare restore-step duration.

If the second run doesn't show restores, the key is wrong and I'd rather find that here than after merge.

The 11 setup-dotnet blocks were byte-identical, comment included. Move
them into .github/actions/setup-dotnet so the cache key policy lives in
one place instead of eleven copies that drift apart on the next change.

Also exclude obj/ from the NuGet key and node_modules/ from the npm key.
Restore generates *.nuget.g.props/.targets under obj/, so including them
would make the key depend on whether the workspace had been built before
— a no-op on a clean CI job, but not if a job ever restores before setup.

Widen the npm key to **/package-lock.json: build.sh runs `npm install` in
src/fable-standalone and src/fable-compiler-js as well, each with its own
committed lockfile, so the root-only default key left those tarballs out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli
dbrattli requested a review from MangelMaxime July 18, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Repo Assist] [Eng] Add NuGet and npm caching to CI workflow

1 participant