diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 742bef891..f885c38cf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,3 +25,8 @@ updates: - dependency-name: "Microsoft.CodeAnalysis.CSharp.Workspaces" - dependency-name: "Microsoft.CodeAnalysis.Common" - dependency-name: "Microsoft.CodeAnalysis.Workspaces.Common" + # Analyzer-shipped BCL packages must match minimum supported SDK host. + # See: https://github.com/rjmurillo/moq.analyzers/issues/850 + - dependency-name: "System.Collections.Immutable" + - dependency-name: "System.Reflection.Metadata" + - dependency-name: "Microsoft.CodeAnalysis.AnalyzerUtilities" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index dca12913c..aa91ef519 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,11 +33,89 @@ permissions: actions: read jobs: + # Build, validate, and package. Fast gate (~5 min). + # Tests, analyzer-load-test, and perf run as separate downstream jobs. build: + runs-on: ubuntu-24.04-arm + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup, Restore, and Build Solution + uses: ./.github/actions/setup-restore-build + + - name: Validate analyzer host compatibility + shell: pwsh + run: | + # Verify shipped analyzer DLLs don't reference assembly versions + # exceeding what the minimum supported SDK host (.NET 8) provides. + # See: https://github.com/rjmurillo/moq.analyzers/issues/850 + $maxVersions = @{ + 'System.Collections.Immutable' = [Version]'8.0.0.0' + 'System.Reflection.Metadata' = [Version]'8.0.0.0' + } + $shippedDlls = @( + 'artifacts/bin/Moq.Analyzers/release/Moq.Analyzers.dll', + 'artifacts/bin/Moq.Analyzers/release/Moq.CodeFixes.dll', + 'artifacts/bin/Moq.Analyzers/release/Microsoft.CodeAnalysis.AnalyzerUtilities.dll' + ) + $failed = $false + foreach ($dll in $shippedDlls) { + $name = [System.IO.Path]::GetFileName($dll) + $bytes = [System.IO.File]::ReadAllBytes($dll) + $asm = [System.Reflection.Assembly]::Load($bytes) + $dllFailed = $false + foreach ($ref in $asm.GetReferencedAssemblies()) { + if ($maxVersions.ContainsKey($ref.Name) -and $ref.Version -gt $maxVersions[$ref.Name]) { + Write-Error "$name references $($ref.Name) $($ref.Version), max allowed is $($maxVersions[$ref.Name])" + $failed = $true + $dllFailed = $true + } + } + if (-not $dllFailed) { Write-Host "$name - OK" } + } + if ($failed) { exit 1 } + + - name: Upload binlogs + uses: actions/upload-artifact@v6 + if: success() || failure() + with: + name: binlogs + path: ./artifacts/logs + if-no-files-found: error + + - name: Upload SARIF files + uses: actions/upload-artifact@v6 + if: success() || failure() + with: + name: SARIF files + path: ./artifacts/obj/**/*.sarif + + - name: Upload packages + uses: actions/upload-artifact@v6 + with: + name: packages + path: | + ./artifacts/package + if-no-files-found: error + + - name: Upload artifacts + uses: actions/upload-artifact@v6 + if: success() || failure() + with: + name: artifacts + path: ./artifacts + if-no-files-found: error + + # Run unit tests on multiple platforms. + # Build happens independently on each runner so tests use platform-native binaries. + test: strategy: fail-fast: false matrix: - os: [windows-11-arm, ubuntu-24.04-arm] + os: [ubuntu-24.04-arm, windows-latest] runs-on: ${{ matrix.os }} @@ -45,8 +123,6 @@ jobs: IS_CODACY_COVERAGE_ALLOWED: ${{ secrets.CODACY_PROJECT_TOKEN != '' }} IS_QLTY_COVERAGE_ALLOWED: ${{ secrets.QLTY_COVERAGE_TOKEN != '' }} IS_TARGET_MAIN: ${{ github.ref == 'refs/heads/main' }} - RUN_FULL_PERF: ${{ (github.event_name == 'schedule' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.event.inputs.run_performance == 'true') }} - FORCE_PERF_BASELINE: ${{ github.event.inputs.force_baseline }} # This is also used in PerfCore.ps1 to determine if the baseline should be forced steps: - uses: actions/checkout@v6 @@ -75,29 +151,14 @@ jobs: name: CoverageHistory-${{ matrix.os }} path: ./artifacts/TestResults/coveragehistory - - name: Upload binlogs - uses: actions/upload-artifact@v6 - if: success() || failure() - with: - name: binlogs-${{ matrix.os }} - path: ./artifacts/logs - if-no-files-found: error - - name: Upload *.received.* files uses: actions/upload-artifact@v6 if: failure() with: - name: verify-test-results + name: verify-test-results-${{ matrix.os }} path: | **/*.received.* - - name: Upload SARIF files - uses: actions/upload-artifact@v6 - if: success() || failure() - with: - name: SARIF files (${{ matrix.os }}) - path: ./artifacts/obj/**/*.sarif - - name: Upload Test Report uses: actions/upload-artifact@v6 if: success() || failure() @@ -131,26 +192,205 @@ jobs: token: ${{ secrets.QLTY_COVERAGE_TOKEN }} files: ${{ github.workspace }}/artifacts/TestResults/coverage/Cobertura.xml - - name: Upload packages - uses: actions/upload-artifact@v6 + # Verify the shipped nupkg loads without CS8032 on every supported host. + # This is the end-to-end integration test for issue #850. + # + # The analyzer targets netstandard2.0 because it must load in two hosts: + # 1. dotnet CLI - csc runs on .NET (8/9/10), tested on Linux ARM + # 2. msbuild.exe - csc runs on .NET Framework (VS), tested on Windows x64 + analyzer-load-test: + needs: build + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + # dotnet CLI compiler host (modern .NET runtime) + - dotnet-version: '8.0.x' + tfm: net8.0 + runs-on: ubuntu-24.04-arm + build-engine: dotnet + - dotnet-version: '9.0.x' + tfm: net9.0 + runs-on: ubuntu-24.04-arm + build-engine: dotnet + - dotnet-version: '10.0.x' + tfm: net10.0 + runs-on: ubuntu-24.04-arm + build-engine: dotnet + # MSBuild/.NET Framework compiler host - VS 2022 (GA) + - dotnet-version: '8.0.x' + tfm: net472 + runs-on: windows-2022 + build-engine: msbuild + - dotnet-version: '8.0.x' + tfm: net48 + runs-on: windows-2022 + build-engine: msbuild + - dotnet-version: '8.0.x' + tfm: net481 + runs-on: windows-2022 + build-engine: msbuild + # MSBuild/.NET Framework compiler host - VS 2026 (beta) + - dotnet-version: '8.0.x' + tfm: net472 + runs-on: windows-2025-vs2026 + build-engine: msbuild + - dotnet-version: '8.0.x' + tfm: net48 + runs-on: windows-2025-vs2026 + build-engine: msbuild + - dotnet-version: '8.0.x' + tfm: net481 + runs-on: windows-2025-vs2026 + build-engine: msbuild + + steps: + - name: Setup .NET ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v5 with: - name: packages-${{ matrix.os }} - path: | - ./artifacts/package - if-no-files-found: error + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Setup MSBuild + if: matrix.build-engine == 'msbuild' + uses: microsoft/setup-msbuild@v2 + + - name: Download nupkg artifact + uses: actions/download-artifact@v7 + with: + name: packages + path: ./local-feed + + - name: Create test project + shell: pwsh + run: | + $pkg = Get-ChildItem ./local-feed -Recurse -Filter 'Moq.Analyzers.*.nupkg' | + Where-Object { $_.Name -notlike '*.symbols.*' } | + Select-Object -First 1 + if (-not $pkg) { + Write-Error "Could not find Moq.Analyzers.*.nupkg (excluding symbols) in ./local-feed" + exit 1 + } + $version = $pkg.Name -replace '^Moq\.Analyzers\.' -replace '\.nupkg$' + $feedDir = (Resolve-Path $pkg.DirectoryName).Path + Write-Host "Testing Moq.Analyzers $version with ${{ matrix.build-engine }} / ${{ matrix.tfm }}" + echo "ANALYZER_VERSION=$version" >> $env:GITHUB_ENV + echo "FEED_DIR=$feedDir" >> $env:GITHUB_ENV + + New-Item -ItemType Directory -Path test-project | Out-Null + + @" + + + + + + + + + "@ | Set-Content test-project/nuget.config + + @" + + + Library + ${{ matrix.tfm }} + latest + + + + + + + "@ | Set-Content test-project/TestAnalyzerLoad.csproj + + @" + namespace TestAnalyzerLoad; + public class Placeholder { } + "@ | Set-Content test-project/Placeholder.cs + + - name: Build with dotnet CLI + if: matrix.build-engine == 'dotnet' + shell: pwsh + working-directory: test-project + run: | + $output = dotnet build -v n 2>&1 | Out-String + $buildExitCode = $LASTEXITCODE + Write-Host $output + + if ($buildExitCode -ne 0) { + Write-Host "::error::Build failed with exit code $buildExitCode (dotnet / ${{ matrix.tfm }})" + exit $buildExitCode + } + + if ($output -match 'CS8032') { + Write-Host "::error::CS8032: analyzer failed to load (dotnet / ${{ matrix.tfm }}). See https://github.com/rjmurillo/moq.analyzers/issues/850" + exit 1 + } + if ($output -match '(?i)could not load file or assembly') { + Write-Host "::error::Assembly binding failure (dotnet / ${{ matrix.tfm }}). See https://github.com/rjmurillo/moq.analyzers/issues/850" + exit 1 + } + + Write-Host "" + Write-Host "Analyzer loaded successfully (dotnet / ${{ matrix.tfm }})" + + - name: Build with MSBuild + if: matrix.build-engine == 'msbuild' + shell: pwsh + working-directory: test-project + run: | + $output = msbuild TestAnalyzerLoad.csproj -restore -p:Configuration=Release -v:n 2>&1 | Out-String + $buildExitCode = $LASTEXITCODE + Write-Host $output + + if ($buildExitCode -ne 0) { + Write-Host "::error::Build failed with exit code $buildExitCode (msbuild / ${{ matrix.tfm }})" + exit $buildExitCode + } + + if ($output -match 'CS8032') { + Write-Host "::error::CS8032: analyzer failed to load (msbuild / ${{ matrix.tfm }}). See https://github.com/rjmurillo/moq.analyzers/issues/850" + exit 1 + } + if ($output -match '(?i)could not load file or assembly') { + Write-Host "::error::Assembly binding failure (msbuild / ${{ matrix.tfm }}). See https://github.com/rjmurillo/moq.analyzers/issues/850" + exit 1 + } + + Write-Host "" + Write-Host "Analyzer loaded successfully (msbuild / ${{ matrix.tfm }})" + + # Performance validation runs last, after build and tests confirm correctness. + # Builds from source on Linux ARM to get consistent benchmark results. + perf: + needs: [build, test] + runs-on: ubuntu-24.04-arm + + env: + RUN_FULL_PERF: ${{ (github.event_name == 'schedule' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.event.inputs.run_performance == 'true') }} + FORCE_PERF_BASELINE: ${{ github.event.inputs.force_baseline }} + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup, Restore, and Build Solution + uses: ./.github/actions/setup-restore-build - name: Get baseline SHA id: get-baseline-sha run: | if (-not (Test-Path build/perf/baseline.json)) { - Write-Error "baseline.json not found – aborting performance job." + Write-Error "baseline.json not found - aborting performance job." exit 1 } $baseline = Get-Content build/perf/baseline.json | ConvertFrom-Json echo "sha=$($baseline.sha)" >> $env:GITHUB_OUTPUT shell: pwsh - # The machine is not guaranteed to have the .NET SDK installed from the baseline SHA, so we need to install it + # The baseline SHA may require a different .NET SDK version - name: Checkout baseline uses: actions/checkout@v6 with: @@ -214,14 +454,6 @@ jobs: uses: actions/upload-artifact@v6 if: success() || failure() with: - name: performance-${{ matrix.os }} + name: performance path: | ./artifacts/performance/** - - - name: Upload artifacts - uses: actions/upload-artifact@v6 - if: success() || failure() - with: - name: artifacts-${{ matrix.os }} - path: ./artifacts - if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08cccfb4c..7d696286e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,8 +27,7 @@ jobs: uses: actions/download-artifact@v7 with: path: packages - pattern: packages-ubuntu-* - merge-multiple: true + name: packages - name: Publish NuGet package shell: pwsh run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1655f7107..1e2f24776 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -597,6 +597,105 @@ When making CI/CD changes: 3. **Update documentation**: Document new CI features or changes 4. **Consider performance impact**: Ensure changes don't significantly impact CI duration +### Running Workflows Locally with `gh act` + +[`gh act`](https://github.com/nektos/gh-act) runs GitHub Actions workflows locally using Docker. +This is useful for verifying CI changes before pushing. + +**Install:** + +```bash +gh extension install nektos/gh-act +``` + +**List available jobs:** + +```bash +gh act -l # list jobs triggered by push +gh act -l pull_request # list jobs triggered by pull_request +``` + +**Run a specific job:** + +```bash +gh act -j build # run the build job +gh act -j analyzer-load-test # run the analyzer load test +``` + +**Run a specific workflow file:** + +```bash +gh act -W .github/workflows/main.yml +``` + +**Filter matrix entries:** + +```bash +gh act -j analyzer-load-test --matrix tfm:net8.0 +gh act -j analyzer-load-test --matrix build-engine:msbuild +gh act -j build --matrix os:ubuntu-24.04-arm +``` + +**Enable artifact passing between jobs:** + +Jobs that depend on artifacts from earlier jobs (like `analyzer-load-test` downloading +the nupkg from `build`) require a local artifact server: + +```bash +gh act -W .github/workflows/main.yml --artifact-server-path /tmp/artifacts +``` + +**Handle secrets:** + +The workflow references optional secrets (`CODACY_PROJECT_TOKEN`, `QLTY_COVERAGE_TOKEN`). +These can be skipped locally, but if needed: + +```bash +gh act -s GITHUB_TOKEN="$(gh auth token)" # pass GitHub token +gh act --secret-file .secrets # load from file (one KEY=VALUE per line) +``` + +**Simulate `workflow_dispatch` with inputs:** + +```bash +gh act workflow_dispatch --input run_performance=true --input force_baseline=false +``` + +**Container architecture:** + +The project uses ARM runners (`ubuntu-24.04-arm`, `windows-11-arm`). Docker defaults to the +host architecture. To force a specific architecture: + +```bash +gh act --container-architecture linux/amd64 +``` + +**Create an `.actrc` file** in the repo root to set default flags (one per line): + +```text +--artifact-server-path=/tmp/artifacts +--container-architecture=linux/amd64 +``` + +**Skip steps that only make sense in CI:** + +`act` sets the `ACT` environment variable automatically. Steps can check for it: + +```yaml +if: ${{ !env.ACT }} +``` + +**Limitations:** + +- Windows-based jobs (`windows-latest`, `windows-11-arm`) do not run in `act`. Test those + through GitHub Actions directly. +- ARM runner images may not match GitHub-hosted runners exactly. +- The `microsoft/setup-msbuild` action requires Windows and will not work in `act`. +- Composite actions (like `setup-restore-build`) work, but may need the action source + checked out locally. + +For full documentation, see [nektosact.com](https://nektosact.com/usage/). + ### Performance Testing Guidelines **When Performance Testing is Required:** diff --git a/Directory.Build.targets b/Directory.Build.targets index 02c2c40a2..80401b7c1 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -5,4 +5,5 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index a9b45c0d5..38550ba98 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,14 @@ - + + @@ -34,7 +41,16 @@ - + + diff --git a/build/targets/packaging/Packaging.targets b/build/targets/packaging/Packaging.targets new file mode 100644 index 000000000..4dbcb010e --- /dev/null +++ b/build/targets/packaging/Packaging.targets @@ -0,0 +1,40 @@ + + + + + <_MaxSystemCollectionsImmutableMajor>8 + <_MaxSystemReflectionMetadataMajor>8 + + + + + + <_SciRef Include="@(ResolvedCompileFileDefinitions)" + Condition="'%(NuGetPackageId)' == 'System.Collections.Immutable'" /> + <_SrmRef Include="@(ResolvedCompileFileDefinitions)" + Condition="'%(NuGetPackageId)' == 'System.Reflection.Metadata'" /> + + + + + + + + diff --git a/renovate.json b/renovate.json index 2ef7914db..e3426367f 100644 --- a/renovate.json +++ b/renovate.json @@ -20,6 +20,16 @@ "matchUpdateTypes": ["minor", "patch"], "matchCurrentVersion": "!/^0/", "automerge": true + }, + { + "description": "Analyzer-shipped BCL packages must match minimum supported SDK host. Manual review required.", + "matchPackageNames": [ + "System.Collections.Immutable", + "System.Reflection.Metadata", + "Microsoft.CodeAnalysis.AnalyzerUtilities" + ], + "automerge": false, + "labels": ["analyzer-compat"] } ], "platformAutomerge": true diff --git a/src/tools/PerfDiff/PerfDiff.csproj b/src/tools/PerfDiff/PerfDiff.csproj index 06996fdbc..3de991806 100644 --- a/src/tools/PerfDiff/PerfDiff.csproj +++ b/src/tools/PerfDiff/PerfDiff.csproj @@ -12,6 +12,8 @@ + + diff --git a/tests/Moq.Analyzers.Benchmarks/Moq.Analyzers.Benchmarks.csproj b/tests/Moq.Analyzers.Benchmarks/Moq.Analyzers.Benchmarks.csproj index 69c241a35..025714126 100644 --- a/tests/Moq.Analyzers.Benchmarks/Moq.Analyzers.Benchmarks.csproj +++ b/tests/Moq.Analyzers.Benchmarks/Moq.Analyzers.Benchmarks.csproj @@ -14,6 +14,8 @@ + + diff --git a/tests/Moq.Analyzers.Test/AnalyzerAssemblyCompatibilityTests.cs b/tests/Moq.Analyzers.Test/AnalyzerAssemblyCompatibilityTests.cs new file mode 100644 index 000000000..23c12cdee --- /dev/null +++ b/tests/Moq.Analyzers.Test/AnalyzerAssemblyCompatibilityTests.cs @@ -0,0 +1,101 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Moq.Analyzers.Test; + +public class AnalyzerAssemblyCompatibilityTests +{ + // Primary shipped assemblies built by this project + private static readonly string PrimaryAnalyzerAssembly = "Moq.Analyzers"; + private static readonly string PrimaryCodeFixAssembly = "Moq.CodeFixes"; + + // Bundled third-party assemblies included in the package + private static readonly string BundledAnalyzerUtilities = "Microsoft.CodeAnalysis.AnalyzerUtilities"; + + // Maximum assembly versions that the minimum supported SDK host (.NET 8) provides. + // The analyzer must not reference anything higher, or it will fail to load with CS8032. + // See: https://github.com/rjmurillo/moq.analyzers/issues/850 + private static readonly Version MaxImmutableVersion = new(8, 0, 0, 0); + private static readonly Version MaxMetadataVersion = new(8, 0, 0, 0); + + public static TheoryData ShippedAssemblies => + new() + { + { PrimaryAnalyzerAssembly }, + { PrimaryCodeFixAssembly }, + { BundledAnalyzerUtilities }, + }; + + [Theory] + [MemberData(nameof(ShippedAssemblies))] + public void ShippedDlls_MustNotExceedMinimumHostAssemblyVersions(string assemblyName) + { + FileInfo testAssembly = new(Assembly.GetExecutingAssembly().Location); + FileInfo dllFile = new(Path.Combine(testAssembly.DirectoryName!, $"{assemblyName}.dll")); + + Assert.True(dllFile.Exists, $"Expected shipped DLL not found: {dllFile.FullName}"); + + AssemblyLoadContext context = new("compat-check", isCollectible: true); + try + { + Assembly assembly = context.LoadFromAssemblyPath(dllFile.FullName); + + // For bundled third-party DLLs, verify we are testing the same artifact + // that the primary analyzer assembly references, not a different version + // that might exist in the test output from the test project's own dependencies. + if (string.Equals(assemblyName, BundledAnalyzerUtilities, StringComparison.Ordinal)) + { + VerifyBundledAssemblyMatchesPrimaryReference(context, testAssembly.DirectoryName!, assembly); + } + + AssemblyName[] references = assembly.GetReferencedAssemblies(); + + AssertVersionNotExceeded(assemblyName, references, "System.Collections.Immutable", MaxImmutableVersion); + AssertVersionNotExceeded(assemblyName, references, "System.Reflection.Metadata", MaxMetadataVersion); + } + finally + { + context.Unload(); + } + } + + private static void VerifyBundledAssemblyMatchesPrimaryReference( + AssemblyLoadContext context, + string outputDirectory, + Assembly bundledAssembly) + { + // Load the primary analyzer assembly to verify the bundled assembly matches + // what the analyzer actually references. This ensures we're testing the + // artifact that will be packaged, not a different version from the test project. + string primaryPath = Path.Combine(outputDirectory, $"{PrimaryAnalyzerAssembly}.dll"); + Assembly primaryAssembly = context.LoadFromAssemblyPath(primaryPath); + + AssemblyName? expectedReference = primaryAssembly + .GetReferencedAssemblies() + .FirstOrDefault(r => string.Equals(r.Name, bundledAssembly.GetName().Name, StringComparison.Ordinal)); + + Assert.NotNull(expectedReference); + Assert.Equal( + expectedReference.Version, + bundledAssembly.GetName().Version); + } + + private static void AssertVersionNotExceeded( + string assemblyName, + AssemblyName[] references, + string referenceName, + Version maxVersion) + { + AssemblyName? reference = references.FirstOrDefault( + r => string.Equals(r.Name, referenceName, StringComparison.Ordinal)); + + if (reference?.Version is null) + { + return; + } + + Assert.True( + reference.Version <= maxVersion, + $"{assemblyName} references {referenceName} version {reference.Version}, but the minimum supported SDK host only provides {maxVersion}. See: https://github.com/rjmurillo/moq.analyzers/issues/850"); + } +}