From e3bc6419178ef340cfbaa49e7048a336afe714a9 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 10 Aug 2026 11:28:40 +0100 Subject: [PATCH] Cut the CI build time: analyzers once per project, cache NuGet The .NET Build step was ~4 minutes on the Windows runner. Measured with /p:ReportAnalyzer=true across all 42 compilations of a clean build: analyzers are 226s of CPU and ~40% of wall time, of which StyleCop is 155s and PublicApiAnalyzers 37s. Our own analyzer and generators are 5.2s - about 2% - so they are not the problem. Restore is another ~80s, uncached on both runners. - run analyzers on one TFM per project (the newest it builds) rather than once per TFM: every rule we run is TFM-agnostic, so a six-TFM library was paying for the same diagnostics six times. Generators are unaffected. /p:RunAnalyzers=true gets the full per-TFM sweep back. - StyleCop applies to src/ only; it was buying nothing in tests/ and toys/ that review does not. - cache ~/.nuget/packages in both CI jobs. - --no-build on the CI test steps, which were each re-entering the build for ~15s. - drop GeneratePackageOnBuild from RESPite.Benchmark, which packed a nupkg on every Release build; the pack step still publishes it. Clean full build locally: 26.7s -> ~19s, Csc CPU 110-124s -> 70-83s. --- .github/actions/run-tests/action.yml | 9 ++++++ .github/workflows/CI.yml | 9 +++++- AGENTS.md | 3 +- Directory.Build.props | 1 - Directory.Build.targets | 28 ++++++++++++++++++- src/Directory.Build.props | 4 +++ .../RESPite.Benchmark.csproj | 3 +- 7 files changed, 52 insertions(+), 5 deletions(-) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index dfb80ffa2..a6258a3ab 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -57,6 +57,14 @@ runs: 8.0.x 10.0.x + # Restore is ~80s of the build step on a cold runner, and the package set changes rarely. + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('Directory.Packages.props', 'global.json', '**/*.csproj') }} + restore-keys: ${{ runner.os }}-nuget- + - name: .NET Build shell: bash run: dotnet build Build.csproj -c Release /p:CI=true @@ -65,6 +73,7 @@ runs: shell: bash run: >- dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj + --no-build -c Release -f net10.0 --logger trx diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 80f33c9c3..6a50baebc 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -128,6 +128,13 @@ jobs: redis-cli -p 26381 INFO SERVER | grep redis_version || echo "Failed to get version for port 26381" continue-on-error: true + # Restore is ~80s of the build step on a cold runner, and the package set changes rarely. + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('Directory.Packages.props', 'global.json', '**/*.csproj') }} + restore-keys: ${{ runner.os }}-nuget- - name: .NET Build run: dotnet build Build.csproj -c Release /p:CI=true # The analyzer and the props that configures it reach consumers only through the package, and a @@ -156,7 +163,7 @@ jobs: run: | $exitCode = 0 foreach ($tfm in @("net10.0", "net481")) { - dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -c Release -f $tfm --logger trx --logger GitHubActions --results-directory ./test-results/ /p:CI=true + dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj --no-build -c Release -f $tfm --logger trx --logger GitHubActions --results-directory ./test-results/ /p:CI=true if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE } diff --git a/AGENTS.md b/AGENTS.md index b7a4d1bdb..62da6c949 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ dotnet pack src/StackExchange.Redis/StackExchange.Redis.csproj --no-build -c Rel - SDK is pinned (`global.json`, `allowPrerelease: false`); CI installs the 6/8/10 runtimes. `LangVersion` is 14. - `TreatWarningsAsErrors=true` everywhere and `Features=strict` — warnings fail the build. Analyzers (StyleCop + the custom `eng` analyzer + PublicApiAnalyzers) run as part of the build. +- Analyzers run on **one TFM per project** — the newest it builds (see `Directory.Build.targets`), because they cost ~40% of a clean build otherwise and every rule is TFM-agnostic. Source generators still run on every TFM. If you touch code inside a down-level `#if`, build it with `/p:RunAnalyzers=true` to get the full per-TFM sweep. StyleCop applies to `src/` only, not `tests/` or `toys/`. - Library multi-targets `net461;netstandard2.0;net472;net6.0;net8.0;net10.0`. Conditional compile symbols: `VECTOR_SAFE` (all but net461), `UNIX_SOCKET` (net6.0+). The test project targets `net481;net8.0;net10.0`; `BUILD_CURRENT` is defined on the newest TFM (disables some parallelism for brittle tests). ## Public API tracking (important — easy to trip over) @@ -113,6 +114,6 @@ Be consise and direct where possible, but with as much detail as is necessary; n ## Conventions -- Code style is enforced via `.editorconfig` + `Shared.ruleset` + StyleCop; 4-space indent, BOM + final newline on `.cs`, `System.*` usings first, no redundant `this.`. Build will fail on violations. +- Code style is enforced via `.editorconfig` + `Shared.ruleset` + StyleCop (StyleCop in `src/` only); 4-space indent, BOM + final newline on `.cs`, `System.*` usings first, no redundant `this.`. Build will fail on violations. Match the surrounding style in `tests/`/`toys/` too — the rules are simply not enforced there. - `InternalsVisibleTo` exposes internals to the test/benchmark/server projects, so tests reach into internal types directly. - `docs/` markdown is the user-facing documentation; update it for user-visible behavior changes. diff --git a/Directory.Build.props b/Directory.Build.props index 2289ce520..62c89b137 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -42,7 +42,6 @@ - diff --git a/Directory.Build.targets b/Directory.Build.targets index 687e19684..82752190d 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -2,4 +2,30 @@ - \ No newline at end of file + + + + + <_AnalyzedTargetFramework Condition="'$(_AnalyzedTargetFramework)' == '' and $(TargetFrameworks.Contains('net10.0'))">net10.0 + <_AnalyzedTargetFramework Condition="'$(_AnalyzedTargetFramework)' == '' and $(TargetFrameworks.Contains('net8.0'))">net8.0 + <_AnalyzedTargetFramework Condition="'$(_AnalyzedTargetFramework)' == '' and $(TargetFrameworks.Contains('net6.0'))">net6.0 + <_AnalyzedTargetFramework Condition="'$(_AnalyzedTargetFramework)' == '' and $(TargetFrameworks.Contains('netstandard2.0'))">netstandard2.0 + <_AnalyzedTargetFramework Condition="'$(_AnalyzedTargetFramework)' == '' and $(TargetFrameworks.Contains('net481'))">net481 + <_AnalyzedTargetFramework Condition="'$(_AnalyzedTargetFramework)' == '' and $(TargetFrameworks.Contains('net472'))">net472 + <_AnalyzedTargetFramework Condition="'$(_AnalyzedTargetFramework)' == '' and $(TargetFrameworks.Contains('net461'))">net461 + false + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2c51b5806..23f221374 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,6 +8,10 @@ seredis.png + + diff --git a/src/RESPite.Benchmark/RESPite.Benchmark.csproj b/src/RESPite.Benchmark/RESPite.Benchmark.csproj index 8397870af..d1b6c1e25 100644 --- a/src/RESPite.Benchmark/RESPite.Benchmark.csproj +++ b/src/RESPite.Benchmark/RESPite.Benchmark.csproj @@ -8,7 +8,8 @@ resp-benchmark true command-line "RESP" benchmark client, comparable to redis-benchmark - True + readme.md False false