From bf352356e16b9ffec36a58b01276ca276e176890 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sat, 22 Aug 2026 16:35:34 +0500 Subject: [PATCH 01/22] feat(extensions): deliver SP220-07 leaf packages --- .github/workflows/ci.yml | 69 +- .github/workflows/codeql.yml | 41 +- .github/workflows/dependency-review.yml | 41 +- .../workflows/reusable-release-validation.yml | 132 +++- CHANGELOG.md | 8 + Directory.Packages.props | 4 + SmartPipe.Core.slnx | 8 + .../SmartPipe.Benchmarks/SP220-07-results.md | 53 ++ .../SmartPipe.Benchmarks.csproj | 3 + .../SmartPipe.Benchmarks/Sp22007Benchmarks.cs | 213 ++++++ .../SmartPipe.Benchmarks/packages.lock.json | 19 + docs/aot-compatibility.md | 5 + docs/api-reference.md | 10 +- docs/architecture.md | 8 +- docs/channels.md | 14 + docs/data-annotations.md | 17 + docs/logging.md | 11 + docs/migration/legacy-to-typed.md | 8 + docs/package-ownership.md | 8 + docs/transforms.md | 12 + .../Consumers/ConsumerScenarioLoader.cs | 42 ++ .../Consumers/ConsumerScenarioModels.cs | 9 + .../Consumers/ConsumerScenarioRunner.cs | 169 ++++- eng/consumer-scenarios.json | 85 ++- eng/consumer-scenarios.schema.json | 21 +- eng/package-graph.json | 23 +- eng/package-ownership.json | 32 +- eng/tests/workflow_contract_tests.py | 658 +++++++++++++++++- .../ChannelMerge.cs | 252 +++++++ .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 6 + src/SmartPipe.Extensions.Channels/README.md | 3 + .../SmartPipe.Extensions.Channels.csproj | 27 + .../packages.lock.json | 40 ++ .../FilterValidationExtensions.cs | 26 + .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 9 + .../README.md | 18 + ...martPipe.Extensions.DataAnnotations.csproj | 28 + .../ValidationTransform.cs | 87 +++ .../packages.lock.json | 46 ++ .../LoggerSink.cs | 122 ++++ .../LoggerSinkOptions.cs | 32 + .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 29 + src/SmartPipe.Extensions.Logging/README.md | 21 + .../SmartPipe.Extensions.Logging.csproj | 28 + .../packages.lock.json | 40 ++ .../CompositeTransform.cs | 131 ++++ .../CompressionTransform.cs | 74 ++ .../ConditionalTransform.cs | 31 + .../FilterTransform.cs | 92 +++ .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 38 + src/SmartPipe.Extensions.Transforms/README.md | 11 + .../RuleValidationTransform.cs | 65 ++ .../SmartPipe.Extensions.Transforms.csproj | 27 + .../packages.lock.json | 40 ++ src/SmartPipe.Extensions/ChannelMerge.cs | 194 ------ .../PublicAPI.Shipped.txt | 92 +-- .../PublicAPI.Unshipped.txt | 4 + src/SmartPipe.Extensions/README.md | 5 + src/SmartPipe.Extensions/Sinks/LoggerSink.cs | 39 -- .../SmartPipe.Extensions.csproj | 4 + .../Sp22007TypeForwarders.cs | 16 + .../Transforms/CompositeTransform.cs | 56 -- .../Transforms/CompressionTransform.cs | 111 --- .../Transforms/ConditionalTransform.cs | 44 -- .../Transforms/FilterTransform.cs | 119 ---- .../Transforms/FilterValidationExtensions.cs | 25 - .../Transforms/ValidationTransform.cs | 65 -- src/SmartPipe.Extensions/packages.lock.json | 26 + .../Scenarios/channels-direct/Consumer.csproj | 4 + .../Scenarios/channels-direct/Program.cs | 19 + .../data-annotations-direct/Consumer.csproj | 5 + .../data-annotations-direct/Program.cs | 25 + .../data-annotations-runtime/Consumer.csproj | 4 + .../data-annotations-runtime/Program.cs | 25 + .../Scenarios/extensions-meta/Program.cs | 20 + .../Scenarios/legacy-binary-2.1.2/Program.cs | 29 + .../Scenarios/logging-direct/Consumer.csproj | 4 + .../Scenarios/logging-direct/Program.cs | 10 + .../transforms-direct/Consumer.csproj | 4 + .../Scenarios/transforms-direct/Program.cs | 11 + .../ChannelMergeContractTests.cs | 576 +++++++++++++++ ...SmartPipe.Extensions.Channels.Tests.csproj | 23 + .../packages.lock.json | 181 +++++ ...pe.Extensions.DataAnnotations.Tests.csproj | 23 + .../ValidationContractTests.cs | 154 ++++ .../packages.lock.json | 188 +++++ .../LoggerSinkContractTests.cs | 251 +++++++ .../SmartPipe.Extensions.Logging.Tests.csproj | 24 + .../packages.lock.json | 182 +++++ .../ChannelMergeTests.cs | 11 +- .../CompositeTransformTests.cs | 11 +- .../PackageOwnershipTests.cs | 11 +- .../Sp22007OwnershipContractTests.cs | 17 + .../packages.lock.json | 32 +- .../CompositeTransformTests.cs | 242 +++++++ ...ConditionalAndCompressionTransformTests.cs | 87 +++ .../FilterTransformTests.cs | 85 +++ .../RuleValidationTransformTests.cs | 46 ++ ...artPipe.Extensions.Transforms.Tests.csproj | 23 + .../TransformsContractTests.cs | 28 + .../packages.lock.json | 181 +++++ .../Consumers/ConsumerScenarioRunnerTests.cs | 199 ++++++ .../Consumers/ConsumerScenarioSchemaTests.cs | 100 ++- .../Consumers/LocalNuGetConfigWriterTests.cs | 2 +- .../Sp22007ActivationContractTests.cs | 103 +++ .../Packaging/PackPackagesCommandTests.cs | 10 +- .../PackageTemplateRendererTests.cs | 8 + .../ScaffoldPackageCommandTests.cs | 4 +- 112 files changed, 5987 insertions(+), 825 deletions(-) create mode 100644 benchmarks/SmartPipe.Benchmarks/SP220-07-results.md create mode 100644 benchmarks/SmartPipe.Benchmarks/Sp22007Benchmarks.cs create mode 100644 docs/channels.md create mode 100644 docs/data-annotations.md create mode 100644 docs/logging.md create mode 100644 docs/transforms.md create mode 100644 src/SmartPipe.Extensions.Channels/ChannelMerge.cs create mode 100644 src/SmartPipe.Extensions.Channels/PublicAPI.Shipped.txt create mode 100644 src/SmartPipe.Extensions.Channels/PublicAPI.Unshipped.txt create mode 100644 src/SmartPipe.Extensions.Channels/README.md create mode 100644 src/SmartPipe.Extensions.Channels/SmartPipe.Extensions.Channels.csproj create mode 100644 src/SmartPipe.Extensions.Channels/packages.lock.json create mode 100644 src/SmartPipe.Extensions.DataAnnotations/FilterValidationExtensions.cs create mode 100644 src/SmartPipe.Extensions.DataAnnotations/PublicAPI.Shipped.txt create mode 100644 src/SmartPipe.Extensions.DataAnnotations/PublicAPI.Unshipped.txt create mode 100644 src/SmartPipe.Extensions.DataAnnotations/README.md create mode 100644 src/SmartPipe.Extensions.DataAnnotations/SmartPipe.Extensions.DataAnnotations.csproj create mode 100644 src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs create mode 100644 src/SmartPipe.Extensions.DataAnnotations/packages.lock.json create mode 100644 src/SmartPipe.Extensions.Logging/LoggerSink.cs create mode 100644 src/SmartPipe.Extensions.Logging/LoggerSinkOptions.cs create mode 100644 src/SmartPipe.Extensions.Logging/PublicAPI.Shipped.txt create mode 100644 src/SmartPipe.Extensions.Logging/PublicAPI.Unshipped.txt create mode 100644 src/SmartPipe.Extensions.Logging/README.md create mode 100644 src/SmartPipe.Extensions.Logging/SmartPipe.Extensions.Logging.csproj create mode 100644 src/SmartPipe.Extensions.Logging/packages.lock.json create mode 100644 src/SmartPipe.Extensions.Transforms/CompositeTransform.cs create mode 100644 src/SmartPipe.Extensions.Transforms/CompressionTransform.cs create mode 100644 src/SmartPipe.Extensions.Transforms/ConditionalTransform.cs create mode 100644 src/SmartPipe.Extensions.Transforms/FilterTransform.cs create mode 100644 src/SmartPipe.Extensions.Transforms/PublicAPI.Shipped.txt create mode 100644 src/SmartPipe.Extensions.Transforms/PublicAPI.Unshipped.txt create mode 100644 src/SmartPipe.Extensions.Transforms/README.md create mode 100644 src/SmartPipe.Extensions.Transforms/RuleValidationTransform.cs create mode 100644 src/SmartPipe.Extensions.Transforms/SmartPipe.Extensions.Transforms.csproj create mode 100644 src/SmartPipe.Extensions.Transforms/packages.lock.json delete mode 100644 src/SmartPipe.Extensions/ChannelMerge.cs delete mode 100644 src/SmartPipe.Extensions/Sinks/LoggerSink.cs create mode 100644 src/SmartPipe.Extensions/Sp22007TypeForwarders.cs delete mode 100644 src/SmartPipe.Extensions/Transforms/CompositeTransform.cs delete mode 100644 src/SmartPipe.Extensions/Transforms/CompressionTransform.cs delete mode 100644 src/SmartPipe.Extensions/Transforms/ConditionalTransform.cs delete mode 100644 src/SmartPipe.Extensions/Transforms/FilterTransform.cs delete mode 100644 src/SmartPipe.Extensions/Transforms/FilterValidationExtensions.cs delete mode 100644 src/SmartPipe.Extensions/Transforms/ValidationTransform.cs create mode 100644 tests/Consumers/Scenarios/channels-direct/Consumer.csproj create mode 100644 tests/Consumers/Scenarios/channels-direct/Program.cs create mode 100644 tests/Consumers/Scenarios/data-annotations-direct/Consumer.csproj create mode 100644 tests/Consumers/Scenarios/data-annotations-direct/Program.cs create mode 100644 tests/Consumers/Scenarios/data-annotations-runtime/Consumer.csproj create mode 100644 tests/Consumers/Scenarios/data-annotations-runtime/Program.cs create mode 100644 tests/Consumers/Scenarios/logging-direct/Consumer.csproj create mode 100644 tests/Consumers/Scenarios/logging-direct/Program.cs create mode 100644 tests/Consumers/Scenarios/transforms-direct/Consumer.csproj create mode 100644 tests/Consumers/Scenarios/transforms-direct/Program.cs create mode 100644 tests/SmartPipe.Extensions.Channels.Tests/ChannelMergeContractTests.cs create mode 100644 tests/SmartPipe.Extensions.Channels.Tests/SmartPipe.Extensions.Channels.Tests.csproj create mode 100644 tests/SmartPipe.Extensions.Channels.Tests/packages.lock.json create mode 100644 tests/SmartPipe.Extensions.DataAnnotations.Tests/SmartPipe.Extensions.DataAnnotations.Tests.csproj create mode 100644 tests/SmartPipe.Extensions.DataAnnotations.Tests/ValidationContractTests.cs create mode 100644 tests/SmartPipe.Extensions.DataAnnotations.Tests/packages.lock.json create mode 100644 tests/SmartPipe.Extensions.Logging.Tests/LoggerSinkContractTests.cs create mode 100644 tests/SmartPipe.Extensions.Logging.Tests/SmartPipe.Extensions.Logging.Tests.csproj create mode 100644 tests/SmartPipe.Extensions.Logging.Tests/packages.lock.json create mode 100644 tests/SmartPipe.Extensions.Tests/Sp22007OwnershipContractTests.cs create mode 100644 tests/SmartPipe.Extensions.Transforms.Tests/CompositeTransformTests.cs create mode 100644 tests/SmartPipe.Extensions.Transforms.Tests/ConditionalAndCompressionTransformTests.cs create mode 100644 tests/SmartPipe.Extensions.Transforms.Tests/FilterTransformTests.cs create mode 100644 tests/SmartPipe.Extensions.Transforms.Tests/RuleValidationTransformTests.cs create mode 100644 tests/SmartPipe.Extensions.Transforms.Tests/SmartPipe.Extensions.Transforms.Tests.csproj create mode 100644 tests/SmartPipe.Extensions.Transforms.Tests/TransformsContractTests.cs create mode 100644 tests/SmartPipe.Extensions.Transforms.Tests/packages.lock.json create mode 100644 tests/SmartPipe.RepositoryChecks.Tests/PackageGraph/Sp22007ActivationContractTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeeae06..5804d45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,25 +5,28 @@ on: push: branches: [ main, upd, release/2.2.0 ] pull_request: - branches: [ main, upd, release/2.2.0, sp220/checkpoint-c ] + branches: [ main, upd, release/2.2.0, sp220/checkpoint-c, sp220/checkpoint-d ] permissions: contents: read jobs: validation: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: ./.github/workflows/reusable-release-validation.yml permissions: contents: read + with: + runner-labels: ${{ github.event_name == 'pull_request' && '["self-hosted","Windows","X64"]' || '["ubuntu-latest"]' }} hosting-integration: - name: Hosting integration (${{ matrix.os }}) - runs-on: ${{ matrix.os }} + name: Hosting integration (${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ${{ matrix.os == 'self-hosted' && fromJSON('["self-hosted","Windows","X64"]') || matrix.os }} timeout-minutes: 20 strategy: fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] + matrix: ${{ fromJSON(github.event_name == 'pull_request' && '{"os":["self-hosted"]}' || '{"os":["ubuntu-latest","windows-latest"]}') }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -45,7 +48,8 @@ jobs: run: dotnet test --project tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Hosting.Tests.Integration.GenericHostIntegrationTests --minimum-expected-tests 1 json-file-windows: - runs-on: windows-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'windows-latest' }} timeout-minutes: 20 steps: @@ -65,28 +69,43 @@ jobs: run: dotnet build tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-restore -warnaserror - name: JSON file source, path, open, and share tests + shell: pwsh run: | dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.Sources.JsonFileSourceTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.Sources.JsonFileSourceRecoveryTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.Sources.JsonFileSourceMetadataTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: JSON file sink and dispose tests + shell: pwsh run: | dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.Sinks.JsonFileSinkTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.Sinks.JsonFileSinkAppendTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Json.Tests.Sinks.JsonFileSinkLifecycleTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Dead-letter source and sink tests + shell: pwsh run: | dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.Sources.DeadLetterSourceTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.Sources.DeadLetterSourceRecoveryTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.DeadLetterSinkTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Tests.DeadLetterSinkAppendTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Json.Tests.DeadLetterSinkLifecycleTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } baseline-contract-windows: name: Baseline contract (Windows) - runs-on: windows-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'windows-latest' }} timeout-minutes: 20 steps: @@ -114,3 +133,39 @@ jobs: - name: Verify 2.1.2 baseline offline run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 --offline --mode integrity + + cleanup-self-hosted: + name: Cleanup self-hosted workspace + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + needs: [validation, hosting-integration, json-file-windows, baseline-contract-windows] + runs-on: [self-hosted, Windows, X64] + steps: + - name: Cleanup generated outputs + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is required.' } + $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + if ((Get-Item -LiteralPath $workspace -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Workspace is a reparse point.' } + $prefix = "$workspace$([IO.Path]::DirectorySeparatorChar)" + $targets = [Collections.Generic.List[string]]::new() + $targets.Add((Join-Path $workspace 'artifacts')) + $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) + $pending = [Collections.Generic.Stack[string]]::new() + $pending.Push($workspace) + while ($pending.Count) { + foreach ($directory in Get-ChildItem -LiteralPath $pending.Pop() -Force -Directory) { + if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } + if ($directory.Name -in 'bin', 'obj') { $targets.Add($directory.FullName) } + else { $pending.Push($directory.FullName) } + } + } + foreach ($target in $targets | Sort-Object Length -Descending -Unique) { + $fullPath = [IO.Path]::GetFullPath($target) + if (!$fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Outside workspace: $fullPath" } + if (Test-Path -LiteralPath $fullPath -PathType Container) { + if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point: $fullPath" } + if (Get-ChildItem -LiteralPath $fullPath -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { throw "Reparse point: $fullPath" } + Remove-Item -LiteralPath $fullPath -Recurse -Force + } + } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 58ff0b5..fddf1e3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,7 +4,7 @@ on: push: branches: [ main, upd, release/2.2.0 ] pull_request: - branches: [ main, release/2.2.0, sp220/checkpoint-c ] + branches: [ main, release/2.2.0, sp220/checkpoint-c, sp220/checkpoint-d ] schedule: - cron: '27 3 * * 1' @@ -14,7 +14,8 @@ permissions: jobs: analyze: - runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'ubuntu-latest' }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -35,3 +36,39 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + + cleanup-self-hosted: + name: Cleanup self-hosted workspace + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + needs: [analyze] + runs-on: [self-hosted, Windows, X64] + steps: + - name: Cleanup generated outputs + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is required.' } + $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + if ((Get-Item -LiteralPath $workspace -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Workspace is a reparse point.' } + $prefix = "$workspace$([IO.Path]::DirectorySeparatorChar)" + $targets = [Collections.Generic.List[string]]::new() + $targets.Add((Join-Path $workspace 'artifacts')) + $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) + $pending = [Collections.Generic.Stack[string]]::new() + $pending.Push($workspace) + while ($pending.Count) { + foreach ($directory in Get-ChildItem -LiteralPath $pending.Pop() -Force -Directory) { + if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } + if ($directory.Name -in 'bin', 'obj') { $targets.Add($directory.FullName) } + else { $pending.Push($directory.FullName) } + } + } + foreach ($target in $targets | Sort-Object Length -Descending -Unique) { + $fullPath = [IO.Path]::GetFullPath($target) + if (!$fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Outside workspace: $fullPath" } + if (Test-Path -LiteralPath $fullPath -PathType Container) { + if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point: $fullPath" } + if (Get-ChildItem -LiteralPath $fullPath -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { throw "Reparse point: $fullPath" } + Remove-Item -LiteralPath $fullPath -Recurse -Force + } + } diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 7c4eacc..f274c32 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,7 +2,7 @@ name: Dependency Review on: pull_request: - branches: [ main, release/2.2.0, sp220/checkpoint-c ] + branches: [ main, release/2.2.0, sp220/checkpoint-c, sp220/checkpoint-d ] permissions: contents: read @@ -10,7 +10,8 @@ permissions: jobs: dependency-review: - runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: [self-hosted, Windows, X64] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -18,3 +19,39 @@ jobs: - name: Dependency review uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + + cleanup-self-hosted: + name: Cleanup self-hosted workspace + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + needs: [dependency-review] + runs-on: [self-hosted, Windows, X64] + steps: + - name: Cleanup generated outputs + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is required.' } + $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + if ((Get-Item -LiteralPath $workspace -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Workspace is a reparse point.' } + $prefix = "$workspace$([IO.Path]::DirectorySeparatorChar)" + $targets = [Collections.Generic.List[string]]::new() + $targets.Add((Join-Path $workspace 'artifacts')) + $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) + $pending = [Collections.Generic.Stack[string]]::new() + $pending.Push($workspace) + while ($pending.Count) { + foreach ($directory in Get-ChildItem -LiteralPath $pending.Pop() -Force -Directory) { + if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } + if ($directory.Name -in 'bin', 'obj') { $targets.Add($directory.FullName) } + else { $pending.Push($directory.FullName) } + } + } + foreach ($target in $targets | Sort-Object Length -Descending -Unique) { + $fullPath = [IO.Path]::GetFullPath($target) + if (!$fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Outside workspace: $fullPath" } + if (Test-Path -LiteralPath $fullPath -PathType Container) { + if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point: $fullPath" } + if (Get-ChildItem -LiteralPath $fullPath -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { throw "Reparse point: $fullPath" } + Remove-Item -LiteralPath $fullPath -Recurse -Force + } + } diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index ae49519..df584dd 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -13,13 +13,19 @@ on: required: false type: string default: packages + runner-labels: + description: Runner labels as a JSON array + required: false + type: string + default: '["ubuntu-latest"]' permissions: contents: read jobs: build-test-pack: - runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ${{ fromJSON(inputs.runner-labels) }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -36,8 +42,17 @@ jobs: run: dotnet restore SmartPipe.Core.slnx --locked-mode - name: Test release version validation - shell: bash - run: bash eng/tests/validate-release-version.Tests.sh + shell: pwsh + run: | + if ($IsWindows) { + $bash = 'C:\Program Files\Git\bin\bash.exe' + if (!(Test-Path -LiteralPath $bash -PathType Leaf)) { throw "Git Bash is required at $bash." } + & $bash -lc 'eng/tests/validate-release-version.Tests.sh' + } + else { + bash eng/tests/validate-release-version.Tests.sh + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Install workflow contract parser run: python -m pip install --disable-pip-version-check ruamel.yaml==0.18.16 @@ -65,25 +80,38 @@ jobs: run: dotnet test --project tests/SmartPipe.Core.Tests/SmartPipe.Core.Tests.csproj --no-build -c Release --filter-query /[Category=ConcurrencyRegression] --minimum-expected-tests 1 - name: Extensions correctness regressions + shell: pwsh run: | dotnet test --project tests/SmartPipe.Extensions.Tests/SmartPipe.Extensions.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.HealthCheckTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Tests/SmartPipe.Extensions.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.ChannelMergeTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.DeadLetterSinkTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Tests/SmartPipe.Extensions.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.Sinks.DbSinkTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Tests/SmartPipe.Extensions.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.Selectors.DapperSelectorTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.Sinks.JsonFileSinkTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Tests/SmartPipe.Extensions.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.Sources.HttpSelectorTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: PR concurrency regression repeat if: github.event_name == 'pull_request' - shell: bash + shell: pwsh run: | - for pass in {1..10}; do - echo "Concurrency regression pass $pass" + foreach ($pass in 1..10) { + Write-Output "Concurrency regression pass $pass" dotnet test --project tests/SmartPipe.Core.Tests/SmartPipe.Core.Tests.csproj --no-build -c Release --filter-query /[Category=ConcurrencyRegression] --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Tests/SmartPipe.Extensions.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.ChannelMergeTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test --project tests/SmartPipe.Extensions.Channels.Tests/SmartPipe.Extensions.Channels.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Channels.Tests.ChannelMergeContractTests --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --no-build -c Release --filter-class SmartPipe.Extensions.Tests.Sinks.JsonFileSinkTests --minimum-expected-tests 1 - done + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } - name: Core tests with coverage timeout-minutes: 15 @@ -96,6 +124,20 @@ jobs: - name: Extensions tests run: dotnet test --project tests/SmartPipe.Extensions.Tests/SmartPipe.Extensions.Tests.csproj --no-build --verbosity normal -c Release + - name: SP220-07 leaf tests + shell: pwsh + run: | + $projects = @( + 'tests/SmartPipe.Extensions.Channels.Tests/SmartPipe.Extensions.Channels.Tests.csproj' + 'tests/SmartPipe.Extensions.Transforms.Tests/SmartPipe.Extensions.Transforms.Tests.csproj' + 'tests/SmartPipe.Extensions.Logging.Tests/SmartPipe.Extensions.Logging.Tests.csproj' + 'tests/SmartPipe.Extensions.DataAnnotations.Tests/SmartPipe.Extensions.DataAnnotations.Tests.csproj' + ) + foreach ($project in $projects) { + dotnet test --project $project --no-build --configuration Release --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - name: Dependency Injection tests run: dotnet test --project tests/SmartPipe.Extensions.DependencyInjection.Tests/SmartPipe.Extensions.DependencyInjection.Tests.csproj --configuration Release --no-build --minimum-expected-tests 1 @@ -112,29 +154,39 @@ jobs: run: dotnet test --project tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --no-build --verbosity normal -c Release - name: Test and benchmark warning gate + shell: pwsh run: | dotnet build tests/SmartPipe.Core.Tests/SmartPipe.Core.Tests.csproj --no-restore -c Release -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet build tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --no-restore -c Release -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet build tests/SmartPipe.Extensions.Tests/SmartPipe.Extensions.Tests.csproj --no-restore -c Release -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet build tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --no-restore -c Release -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet build tests/SmartPipe.Extensions.HealthChecks.Tests/SmartPipe.Extensions.HealthChecks.Tests.csproj --no-restore -c Release -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet build benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj --no-restore -c Release -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Set package version + shell: pwsh env: REQUESTED_PACKAGE_VERSION: ${{ inputs.package-version }} run: | - PACKAGE_VERSION="$REQUESTED_PACKAGE_VERSION" - if [ -z "$PACKAGE_VERSION" ]; then - PACKAGE_VERSION=$(dotnet msbuild src/SmartPipe.Core/SmartPipe.Core.csproj -getProperty:Version -nologo) - fi - echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV" + $packageVersion = $env:REQUESTED_PACKAGE_VERSION + if ([string]::IsNullOrWhiteSpace($packageVersion)) { + $packageVersion = dotnet msbuild src/SmartPipe.Core/SmartPipe.Core.csproj -getProperty:Version -nologo + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Pack packages from graph + shell: pwsh run: > dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- pack-packages - --mode current --configuration Release --package-version "$PACKAGE_VERSION" + --mode current --configuration Release --package-version "$env:PACKAGE_VERSION" --output artifacts/packages --manifest artifacts/packages/manifest.json - name: Provision 2.1.2 baseline packages @@ -153,43 +205,69 @@ jobs: run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-ownership --baseline eng/baselines/2.1.2 --packages artifacts/packages --mode current - name: Verify release versions current - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-release-version --tag "v$PACKAGE_VERSION" --package-directory artifacts/packages --mode current + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-release-version --tag "v$env:PACKAGE_VERSION" --package-directory artifacts/packages --mode current - name: Run current consumers - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --package-directory artifacts/packages --package-version "$PACKAGE_VERSION" + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - name: Run Hosting consumers - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category hosting --package-directory artifacts/packages --package-version "$PACKAGE_VERSION" + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category hosting --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - name: Run HealthChecks consumers - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category health-checks --package-directory artifacts/packages --package-version "$PACKAGE_VERSION" + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category health-checks --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - name: Run OpenTelemetry consumers - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category opentelemetry --package-directory artifacts/packages --package-version "$PACKAGE_VERSION" + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category opentelemetry --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - name: Vulnerable package scan - shell: bash + shell: pwsh run: | - mkdir -p artifacts/audit + New-Item -ItemType Directory -Path artifacts/audit -Force | Out-Null dotnet package list --project SmartPipe.Core.slnx --vulnerable --include-transitive --format json --output-version 1 --no-restore > artifacts/audit/vulnerable.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Verify direct production audit policy run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-nuget-audit --repo-root . --report artifacts/audit/vulnerable.json - name: Deprecated package scan - shell: bash + shell: pwsh run: dotnet package list --project SmartPipe.Core.slnx --deprecated --include-transitive --format json --output-version 1 --no-restore > artifacts/audit/deprecated.json - name: Outdated package report - shell: bash + shell: pwsh continue-on-error: true run: dotnet package list --project SmartPipe.Core.slnx --outdated --format json --output-version 1 --no-restore > artifacts/audit/outdated.json - name: Docs link check + if: runner.os != 'Windows' uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0 with: args: --no-progress README.md 'docs/**/*.md' + - name: Docs link check (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $lychee = Join-Path $env:RUNNER_TEMP 'lychee-v0.21.0.exe' + $exitCode = 0 + try { + Invoke-WebRequest -Uri 'https://github.com/lycheeverse/lychee/releases/download/lychee-v0.21.0/lychee-x86_64-windows.exe' -OutFile $lychee + $actualHash = (Get-FileHash -LiteralPath $lychee -Algorithm SHA256).Hash.ToLowerInvariant() + $expectedHash = 'a1784c32c63ba46dccef0698ddf6be82a83a7d0455b0fd772423d601e3c70ab4' + if ($actualHash -ne $expectedHash) { throw "Lychee SHA256 mismatch: $actualHash" } + & $lychee --no-progress README.md 'docs/**/*.md' + $exitCode = $LASTEXITCODE + } + finally { + Remove-Item -LiteralPath $lychee -Force -ErrorAction SilentlyContinue + } + if ($exitCode -ne 0) { exit $exitCode } + - name: Upload immutable packages and reports uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -202,11 +280,8 @@ jobs: if-no-files-found: error health-checks-concurrency: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ${{ fromJSON(inputs.runner-labels) }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -222,9 +297,12 @@ jobs: run: dotnet restore SmartPipe.Core.slnx --locked-mode - name: Build concurrency projects + shell: pwsh run: | dotnet build tests/SmartPipe.Extensions.DependencyInjection.Tests/SmartPipe.Extensions.DependencyInjection.Tests.csproj --configuration Release --no-restore -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet build tests/SmartPipe.Extensions.HealthChecks.Tests/SmartPipe.Extensions.HealthChecks.Tests.csproj --configuration Release --no-restore -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Run bounded observation concurrency run: dotnet test --project tests/SmartPipe.Extensions.DependencyInjection.Tests/SmartPipe.Extensions.DependencyInjection.Tests.csproj --configuration Release --no-build --filter-method SmartPipe.Extensions.DependencyInjection.Tests.RunObservationStoreTests.ConcurrentTerminalCommitsAcrossKeysRemainBoundedAndStrictlySequenced --minimum-expected-tests 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index c08c7e3..fb2385a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.2.0 + +- Extracted Channels, Transforms, Logging, and DataAnnotations implementations + into narrow packages while preserving broad-facade type identities through + forwarding. +- Added NativeAOT-safe channel, rule-transform, and safe logging paths; annotated + the reflection-based DataAnnotations invocation boundary for trimming. + ## [2.2.0] — Development ### OpenTelemetry diff --git a/Directory.Packages.props b/Directory.Packages.props index 533c3f0..6d0389c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,6 +36,10 @@ + + + + diff --git a/SmartPipe.Core.slnx b/SmartPipe.Core.slnx index c0b39a3..6835b94 100644 --- a/SmartPipe.Core.slnx +++ b/SmartPipe.Core.slnx @@ -7,10 +7,14 @@ + + + + @@ -18,6 +22,10 @@ + + + + diff --git a/benchmarks/SmartPipe.Benchmarks/SP220-07-results.md b/benchmarks/SmartPipe.Benchmarks/SP220-07-results.md new file mode 100644 index 0000000..4509de7 --- /dev/null +++ b/benchmarks/SmartPipe.Benchmarks/SP220-07-results.md @@ -0,0 +1,53 @@ +# SP220-07 benchmark snapshot + +This is an informational local snapshot for the Channels, Composite, Filter, +and Logger leaf APIs. It is not a release threshold. The worktree has no prior +comparable benchmark baseline, so these measurements establish directional +context only and do not support a regression claim. + +Run context: + +- Baseline `HEAD`: `6604355e168d9e7d404a585f30f38490d5b05730` (benchmark files were uncommitted). +- Working directory: `C:\Reposit\SmartPipe.Core\.work\wt-07\benchmarks\SmartPipe.Benchmarks`. +- OS/CPU: Windows 11 `10.0.26200.9168`, 12th Gen Intel Core i3-12100F, 4 physical/8 logical cores. +- .NET: SDK `10.0.302`, runtime `.NET 10.0.11`; BenchmarkDotNet `0.15.8`. +- The exact PowerShell command below was run from the working directory above: + +```powershell +dotnet restore .\SmartPipe.Benchmarks.csproj --locked-mode +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +dotnet build .\SmartPipe.Benchmarks.csproj -c Release --no-restore --warnaserror -v:minimal +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +dotnet run -c Release --no-build -- --filter '*Sp22007*' --iterationCount 20 --warmupCount 5 --launchCount 1 --invocationCount 1 --unrollFactor 1 --exporters json --artifacts 'BenchmarkDotNet.Artifacts\sp22007-final' +``` + +`Program.cs` supplies the single `InProcessNoEmitToolchain` job. The command +does not add `--job Dry`; therefore each of the seven filtered benchmarks ran +with one launch, five warmups, and twenty measured iterations (one invocation +and unroll factor). The MemoryDiagnoser was enabled. Generated JSON was read +from `BenchmarkDotNet.Artifacts\sp22007-final\results\` before cleanup. + +The setup is deterministic: Channels merges three completed 128-item readers +with bounded capacity 64 and also exercises the legacy pair overload; Composite +uses two initialized add-one stages and requires value `42`; Filter uses +canonical token-aware accepted and filtered predicates and throws if the +expected result state is not returned; Logger compares the legacy raw +constructor with the safe `PayloadMode.None` path using an enabled no-op logger. + +| Area / benchmark | Median | Mean | Approx. mean throughput | Allocated | +| --- | ---: | ---: | ---: | ---: | +| Channels `MergeMany_ThreeReaders_Bounded` | 239.700 us | 245.447 us | 4.07 Kops/s | 5,680 B/op | +| Channels `MergePair_Unbounded` | 165.800 us | 182.700 us | 5.47 Kops/s | 9,976 B/op | +| Composite `Transform_TwoStages` | 5.250 us | 5.185 us | 193.05 Kops/s | 520 B/op | +| Filter `Transform_TokenAware_Accepted` | 2.600 us | 2.628 us | 380.52 Kops/s | 288 B/op | +| Filter `Transform_TokenAware_Filtered` | 2.900 us | 2.995 us | 333.89 Kops/s | 0 B/op | +| Logger `Write_LegacyRaw` | 3.400 us | 3.474 us | 287.85 Kops/s | 808 B/op | +| Logger `Write_SafeDefault` | 1.700 us | 1.947 us | 513.61 Kops/s | 0 B/op | + +The measured shape is directional: the safe logger path is faster and +allocation-free relative to the legacy raw path; the filtered predicate is +slightly slower than the accepted predicate while allocating nothing; and the +bounded three-reader merge is slower than the two-reader unbounded merge for +the larger deterministic workload. Several short scenarios produced the +BenchmarkDotNet `MinIterationTime` advisory and isolated outlier removal; no +benchmark failed. There is no hard 3% shared-runner gate. diff --git a/benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj b/benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj index d133c7d..e5d661c 100644 --- a/benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj +++ b/benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj @@ -2,6 +2,9 @@ + + + diff --git a/benchmarks/SmartPipe.Benchmarks/Sp22007Benchmarks.cs b/benchmarks/SmartPipe.Benchmarks/Sp22007Benchmarks.cs new file mode 100644 index 0000000..21998b0 --- /dev/null +++ b/benchmarks/SmartPipe.Benchmarks/Sp22007Benchmarks.cs @@ -0,0 +1,213 @@ +#nullable enable + +using System.Threading.Channels; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging; +using SmartPipe.Core; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Sinks; +using SmartPipe.Extensions.Transforms; + +namespace SmartPipe.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("SP220-07", "Channels")] +public class Sp22007ChannelBenchmarks +{ + private const int ItemsPerReader = 128; + private ChannelReader[] _readers = []; + private BoundedChannelOptions _boundedOptions = null!; + + [GlobalSetup] + public void Setup() + { + _boundedOptions = new BoundedChannelOptions(64) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false, + }; + } + + [IterationSetup] + public void PrepareReaders() + { + _readers = new ChannelReader[3]; + for (var readerIndex = 0; readerIndex < _readers.Length; readerIndex++) + { + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = false, + }); + + for (var itemIndex = 0; itemIndex < ItemsPerReader; itemIndex++) + channel.Writer.TryWrite((readerIndex * ItemsPerReader) + itemIndex); + + channel.Writer.TryComplete(); + _readers[readerIndex] = channel.Reader; + } + } + + [Benchmark] + public Task MergeMany_ThreeReaders_Bounded() => + DrainAsync( + ChannelMerge.MergeMany(_readers, _boundedOptions, CancellationToken.None), + _readers.Length * ItemsPerReader); + + [Benchmark] + public Task MergePair_Unbounded() => + DrainAsync( + ChannelMerge.Merge(_readers[0], _readers[1]), + 2 * ItemsPerReader); + + private static async Task DrainAsync(ChannelReader reader, int expectedCount) + { + var count = 0; + await foreach (var _ in reader.ReadAllAsync().ConfigureAwait(false)) + count++; + + return count == expectedCount + ? count + : throw new InvalidOperationException($"Expected {expectedCount} items, received {count}."); + } +} + +[MemoryDiagnoser] +[BenchmarkCategory("SP220-07", "Composite")] +public class Sp22007CompositeBenchmarks +{ + private CompositeTransform _composite = null!; + private ProcessingEnvelope _envelope = null!; + + [GlobalSetup] + public async Task Setup() + { + _composite = new CompositeTransform( + new AddTransform(1), + new AddTransform(1)); + _envelope = ProcessingEnvelope.Create( + 40, + "sp220-07-benchmark", + "composite", + 1, + createdAtUtc: DateTimeOffset.UnixEpoch); + await _composite.InitializeAsync().ConfigureAwait(false); + } + + [GlobalCleanup] + public async Task Cleanup() => await _composite.DisposeAsync().ConfigureAwait(false); + + [Benchmark] + public async Task Transform_TwoStages() + { + var result = await _composite.TransformAsync(_envelope).ConfigureAwait(false); + if (!result.IsSuccess || result.Value != 42) + throw new InvalidOperationException("Composite benchmark did not return the expected value 42."); + + return 42; + } + + private sealed class AddTransform(int amount) : IPipelineTransformer + { + public ValueTask InitializeAsync(CancellationToken ct = default) => ValueTask.CompletedTask; + + public ValueTask> TransformAsync( + ProcessingEnvelope envelope, + CancellationToken ct = default) => + ValueTask.FromResult(StageResult.Success(envelope.Payload + amount)); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} + +[MemoryDiagnoser] +[BenchmarkCategory("SP220-07", "Filter")] +public class Sp22007FilterBenchmarks +{ + private FilterTransform _accepted = null!; + private FilterTransform _filtered = null!; + private ProcessingEnvelope _envelope = null!; + + [GlobalSetup] + public void Setup() + { + _accepted = new FilterTransform(static (value, _) => ValueTask.FromResult(value > 0)); + _filtered = new FilterTransform(static (value, _) => ValueTask.FromResult(value < 0)); + _envelope = ProcessingEnvelope.Create( + 1, + "sp220-07-benchmark", + "filter", + 1, + createdAtUtc: DateTimeOffset.UnixEpoch); + } + + [Benchmark] + public async Task Transform_TokenAware_Accepted() + { + var result = await _accepted.TransformAsync(_envelope).ConfigureAwait(false); + if (!result.IsSuccess) + throw new InvalidOperationException("Accepted filter benchmark returned a non-success result."); + + return true; + } + + [Benchmark] + public async Task Transform_TokenAware_Filtered() + { + var result = await _filtered.TransformAsync(_envelope).ConfigureAwait(false); + if (!result.IsTerminalNonFailure) + throw new InvalidOperationException("Filtered predicate benchmark returned a non-filtered result."); + + return false; + } +} + +[MemoryDiagnoser] +[BenchmarkCategory("SP220-07", "Logger")] +public class Sp22007LoggerBenchmarks +{ + private ProcessingEnvelope _envelope = null!; + private LoggerSink _legacy = null!; + private LoggerSink _safe = null!; + + [GlobalSetup] + public void Setup() + { + var logger = new EnabledNoopLogger>(); + _legacy = new LoggerSink(logger); + _safe = new LoggerSink( + logger, + new LoggerSinkOptions { PayloadMode = LoggerSinkPayloadMode.None }); + _envelope = ProcessingEnvelope.Create( + 42, + "sp220-07-benchmark", + "logger", + 1, + createdAtUtc: DateTimeOffset.UnixEpoch); + } + + [Benchmark] + public void Write_LegacyRaw() => _legacy.WriteAsync(_envelope).GetAwaiter().GetResult(); + + [Benchmark] + public void Write_SafeDefault() => _safe.WriteAsync(_envelope).GetAwaiter().GetResult(); + + private sealed class EnabledNoopLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + } + } +} diff --git a/benchmarks/SmartPipe.Benchmarks/packages.lock.json b/benchmarks/SmartPipe.Benchmarks/packages.lock.json index 2eb067a..213bd87 100644 --- a/benchmarks/SmartPipe.Benchmarks/packages.lock.json +++ b/benchmarks/SmartPipe.Benchmarks/packages.lock.json @@ -145,6 +145,25 @@ "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" } }, + "smartpipe.extensions.channels": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, + "smartpipe.extensions.logging": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "SmartPipe.Core": "[2.2.0, )" + } + }, + "smartpipe.extensions.transforms": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, "Microsoft.Extensions.DependencyInjection": { "type": "CentralTransitive", "requested": "[10.0.8, )", diff --git a/docs/aot-compatibility.md b/docs/aot-compatibility.md index b6cc576..bc20c23 100644 --- a/docs/aot-compatibility.md +++ b/docs/aot-compatibility.md @@ -72,3 +72,8 @@ Database helpers have source-safe paths: The runtime does not add hidden persistence, dynamic plugin loading, or source materialization for replay. + +Channels, reflection-free Transforms rules, and the safe Logging options path are +trim and NativeAOT consumer-tested. `ValidationTransform.TransformAsync` and +`ToFilter` are explicitly `RequiresUnreferencedCode`; use +`RuleValidationTransform` instead when publishing trimmed or NativeAOT code. diff --git a/docs/api-reference.md b/docs/api-reference.md index c9e928b..9b58fc9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -69,10 +69,12 @@ and `ToFactory` throw on instance pipelines; use `.Transform(instance)` and `JsonFileSink`, `DeadLetterSink`, `DeadLetterWriteFailureMode`, `DeadLetterWriteException`, and `JsonTransform`. -`SmartPipe.Extensions` provides the remaining typed selectors, transforms, -sinks, DI registration, hosted service integration, and health-check support. -Version 2.1.2 forwards the JSON types to the dedicated package for 2.x -compatibility; namespaces and public signatures are unchanged. +`SmartPipe.Extensions.Channels`, `.Transforms`, `.Logging`, and +`.DataAnnotations` own the SP220-07 implementations. `SmartPipe.Extensions` +references those leaves and forwards the shipped type identities; namespaces and +public signatures remain unchanged. Install a leaf directly for narrow dependency +closure. See [Channels](channels.md), [Transforms](transforms.md), +[Logging](logging.md), and [DataAnnotations](data-annotations.md). - Factory-created `PipelineRun` instances preserve runtime controls, structured drain, and metrics while adding DI scope lifetime management. diff --git a/docs/architecture.md b/docs/architecture.md index c94151f..86875e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,8 +33,11 @@ Factory-based DI registration creates a new scope and new runtime per run. SmartPipe.Core ↑ ├── SmartPipe.Extensions.Json + ├── SmartPipe.Extensions.Channels + ├── SmartPipe.Extensions.Transforms ← SmartPipe.Extensions.DataAnnotations + ├── SmartPipe.Extensions.Logging └── SmartPipe.Extensions - └── SmartPipe.Extensions.Json (2.x compatibility bridge) + └── leaf packages (2.x compatibility forwarding) ``` `SmartPipe.Extensions.Json` owns JSON file, transform, and JSON dead-letter @@ -42,6 +45,9 @@ implementations. It must never reference `SmartPipe.Extensions`. `SmartPipe.Extensions` 2.1.2 references the JSON package only to preserve the 2.x type-forwarding contract. +SP220-07 applies the same single-implementation boundary to Channels, Transforms, +Logging, and DataAnnotations. Leaves never reference the facade. + Core permanently owns `DeadLetterEnvelope`, `IDeadLetterSerializer`, and the standard `JsonLinesDeadLetterSerializer` codec reused by the JSON source and sink. This is the final ownership boundary, not a deferred package move. diff --git a/docs/channels.md b/docs/channels.md new file mode 100644 index 0000000..f540f89 --- /dev/null +++ b/docs/channels.md @@ -0,0 +1,14 @@ +# Channels + +Install `SmartPipe.Extensions.Channels` for `ChannelMerge`; the broad +`SmartPipe.Extensions` package forwards the same public type for 2.x compatibility. + +`Merge` accepts either the shipped pair of readers or an unconfigured +`IReadOnlyList` of readers. Use `MergeMany` when configuring bounded output options +or cancellation. Each input preserves its own order; arrival order between inputs +is intentionally not defined. Bounded options apply backpressure to every pump. +Caller cancellation stops pending reads and writes, completes the output with +cancellation, and the lowest-index observed input failure wins when inputs fail +concurrently. If cancellation callbacks also fail, the primary input failure is +retained first and callback failures follow it in an `AggregateException`. Abandoning +a bounded output reader without cancelling remains caller responsibility. diff --git a/docs/data-annotations.md b/docs/data-annotations.md new file mode 100644 index 0000000..29db0bd --- /dev/null +++ b/docs/data-annotations.md @@ -0,0 +1,17 @@ +# DataAnnotations validation + +Install `SmartPipe.Extensions.DataAnnotations` for the compatibility +`ValidationTransform` and `ToFilter` APIs. Validation follows BCL +`Validator.TryValidateObject` behavior and does not recursively walk nested object +graphs. Custom rules freeze on initialization or first execution. + +The reflection path is marked `RequiresUnreferencedCode`; direct invocation emits +the trimming warning and is not a NativeAOT-safe contract. A package-reference-only +trimmed application remains clean when it does not invoke that path. Use +`RuleValidationTransform` from `SmartPipe.Extensions.Transforms` for a +reflection-free trimmed or NativeAOT validation path. + +Release validation keeps the two boundaries separate: an untrimmed runtime +consumer invokes `ValidationTransform` and observes an invalid-model failure, +while the trimmed consumer remains clean and separately proves the exact IL2026 +diagnostic when reflection validation is enabled. diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..47ca02c --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,11 @@ +# Logging + +Install `SmartPipe.Extensions.Logging` for `LoggerSink`. The shipped one-argument +constructor remains non-obsolete and preserves raw structured payload logging for +source and binary compatibility. + +New code should pass `LoggerSinkOptions`. `PayloadMode.None` logs no payload; +`Formatted` requires an explicit formatter and enforces the configured length cap; +`UnsafeRaw` is an explicit opt-in to the legacy exposure. Trace identifiers can be +disabled independently. Disabled log levels do not invoke formatters. Never place +credentials, tokens, service providers, or exception graphs in payload formatters. diff --git a/docs/migration/legacy-to-typed.md b/docs/migration/legacy-to-typed.md index 5d41da2..5c9d17f 100644 --- a/docs/migration/legacy-to-typed.md +++ b/docs/migration/legacy-to-typed.md @@ -85,3 +85,11 @@ services.AddSmartPipe( Factories create a fresh runtime per run and preserve scoped dependency ownership. + +## Narrow extension packages + +Existing `SmartPipe.Extensions` source and binaries keep the same namespaces and +type identities through forwarding. New applications should install +`SmartPipe.Extensions.Channels`, `.Transforms`, `.Logging`, or `.DataAnnotations` +directly. The legacy `LoggerSink(ILogger>)` constructor is not +obsolete in 2.2.0; choose the options constructor to disable raw payload logging. diff --git a/docs/package-ownership.md b/docs/package-ownership.md index 361c504..9d20294 100644 --- a/docs/package-ownership.md +++ b/docs/package-ownership.md @@ -7,5 +7,13 @@ The machine-readable authority is `eng/package-ownership.json`. | Canonical observation contracts | `SmartPipe.Extensions.DependencyInjection` | none | new 2.2 API | | Key-based liveness/readiness API | `SmartPipe.Extensions.HealthChecks` | none | new 2.2 API | | Legacy snapshot, monitor, options, and registration | `SmartPipe.Extensions` | `SmartPipe.Extensions` | quarantined compatibility implementation | +| `ChannelMerge` | `SmartPipe.Extensions.Channels` | `SmartPipe.Extensions` | type forwarding | +| Composite, conditional, compression, and filter transforms | `SmartPipe.Extensions.Transforms` | `SmartPipe.Extensions` | type forwarding | +| `LoggerSink` | `SmartPipe.Extensions.Logging` | `SmartPipe.Extensions` | type forwarding | +| `ValidationTransform` and `ToFilter` | `SmartPipe.Extensions.DataAnnotations` | `SmartPipe.Extensions` | type forwarding | The HealthChecks leaf depends only on Core, DependencyInjection, DI abstractions, Diagnostics.HealthChecks, and Options. It does not depend on Hosting, ASP.NET Core, or the broad facade. + +The four SP220-07 leaves do not reference the broad facade. DataAnnotations has +the single narrow leaf edge to Transforms required by `ToFilter`; other leaves +depend only on Core and Logging additionally uses Logging.Abstractions. diff --git a/docs/transforms.md b/docs/transforms.md new file mode 100644 index 0000000..83561cd --- /dev/null +++ b/docs/transforms.md @@ -0,0 +1,12 @@ +# Transforms + +`SmartPipe.Extensions.Transforms` contains `CompositeTransform`, +`ConditionalTransform`, `CompressionTransform`, `FilterTransform`, and the +reflection-free `RuleValidationTransform`. + +`CompositeTransform` must initialize successfully before transformation. It +initializes once, rolls partial initialization back in reverse order, short-circuits +terminal results, and disposes acquired children once in reverse order. Filter +predicates receive the caller token; `&`, `|`, and `!` short-circuit. Rules freeze +on initialization or first execution. Compression supports Brotli and GZip and +observes cancellation before work. diff --git a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioLoader.cs b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioLoader.cs index a59798b..2f47772 100644 --- a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioLoader.cs +++ b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioLoader.cs @@ -55,6 +55,7 @@ private static void Validate(string root, ConsumerScenarioDocument document, Pac if (scenario.PackageIds.Count == 0 || scenario.PackageIds.Any(id => !graphIds.Contains(id))) throw new ConsumerScenarioException("SPCONS006", $"Scenario '{scenario.Id}' references an unknown package ID."); if (scenario.Timeout <= TimeSpan.Zero || scenario.Timeout > MaximumTimeout) throw new ConsumerScenarioException("SPCONS007", $"Scenario '{scenario.Id}' timeout is outside policy."); if ((scenario.Mode == ConsumerMode.BinaryCompatibility) != (scenario.BaselineVersion is not null)) throw new ConsumerScenarioException("SPCONS008", $"Scenario '{scenario.Id}' baseline version contract is invalid."); + ValidateExpectedPublishDiagnostic(scenario, template); if (scenario.ExpectedSmartPipeDependencies.Any(id => !graphIds.Contains(id))) throw new ConsumerScenarioException("SPCONS006", $"Scenario '{scenario.Id}' expects an unknown package ID."); } if (requiredAtRelease.Any(id => ids.Contains(id))) @@ -96,6 +97,47 @@ private static void Validate(string root, ConsumerScenarioDocument document, Pac throw new ConsumerScenarioException("SPCONS009", "requiredAtRelease must match the planned package consumer scenarios."); } + private static void ValidateExpectedPublishDiagnostic(ConsumerScenario scenario, string template) + { + var expectation = scenario.ExpectedPublishDiagnostic; + if (expectation is null) return; + if (scenario.Mode is not (ConsumerMode.PublishTrimmed or ConsumerMode.PublishNativeAot) + || expectation.Code.Length != 6 + || !expectation.Code.StartsWith("IL", StringComparison.Ordinal) + || expectation.Code.AsSpan(2).IndexOfAnyExceptInRange('0', '9') >= 0 + || expectation.Line <= 0 + || expectation.MsBuildProperties.Count == 0 + || expectation.MsBuildProperties.Distinct(StringComparer.Ordinal).Count() != expectation.MsBuildProperties.Count + || expectation.MsBuildProperties.Any(static property => !IsNormalizedBooleanProperty(property))) + throw new ConsumerScenarioException("SPCONS023", $"Scenario '{scenario.Id}' has an invalid expected publish diagnostic contract."); + + string source; + try + { + source = ResolveContained(Path.GetDirectoryName(template)!, expectation.SourcePath, "expectedPublishDiagnostic.sourcePath"); + } + catch (ConsumerScenarioException exception) when (exception.Code == "SPCONS005") + { + throw new ConsumerScenarioException("SPCONS023", $"Scenario '{scenario.Id}' has an invalid expected publish diagnostic source.", exception); + } + if (!source.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) + || !File.Exists(source) + || (File.GetAttributes(source) & FileAttributes.ReparsePoint) != 0 + || File.ReadLines(source).Take(expectation.Line).Count() != expectation.Line) + throw new ConsumerScenarioException("SPCONS023", $"Scenario '{scenario.Id}' has an invalid expected publish diagnostic source."); + } + + private static bool IsNormalizedBooleanProperty(string property) + { + var separator = property.IndexOf('='); + if (separator <= 0 || separator != property.LastIndexOf('=')) return false; + var name = property.AsSpan(0, separator); + var value = property.AsSpan(separator + 1); + return char.IsAsciiLetter(name[0]) + && name[1..].IndexOfAnyExcept("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.") < 0 + && (value.SequenceEqual("true") || value.SequenceEqual("false")); + } + internal static string ResolveContained(string root, string value, string name) { if (string.IsNullOrWhiteSpace(value) || Path.IsPathRooted(value) || value.Contains('\\') || value.Split('/').Any(x => x is "" or "." or "..")) diff --git a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioModels.cs b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioModels.cs index 21f03f7..a346930 100644 --- a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioModels.cs +++ b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioModels.cs @@ -22,10 +22,19 @@ internal sealed record ConsumerScenario public required IReadOnlyList ExpectedSmartPipeDependencies { get; init; } public required IReadOnlyList ForbiddenDependencies { get; init; } public string? BaselineVersion { get; init; } + public ExpectedPublishDiagnostic? ExpectedPublishDiagnostic { get; init; } public required TimeSpan Timeout { get; init; } public required bool RunSecondLockedRestore { get; init; } } +internal sealed record ExpectedPublishDiagnostic +{ + public required string Code { get; init; } + public required string SourcePath { get; init; } + public required int Line { get; init; } + public required IReadOnlyList MsBuildProperties { get; init; } +} + internal sealed record ConsumerScenarioDocument { public required int SchemaVersion { get; init; } diff --git a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs index 9f16049..5513d57 100644 --- a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs +++ b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs @@ -2,6 +2,7 @@ using System.IO.Compression; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Xml.Linq; using SmartPipe.RepositoryChecks.Baselines; using SmartPipe.RepositoryChecks.Infrastructure; @@ -47,13 +48,14 @@ public async Task> RunAsync(RunConsumersOp .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase); var externalPackageIds = await ReadExternalPackageIdsAsync(options.RepositoryRoot, ct).ConfigureAwait(false); var results = new List(); - foreach (var scenario in scenarios) results.Add(await RunScenarioAsync(options, scenario, externalPackageVersions, externalPackageIds, ct).ConfigureAwait(false)); + foreach (var scenario in scenarios) results.Add(await RunScenarioAsync(options, scenario, graph, externalPackageVersions, externalPackageIds, ct).ConfigureAwait(false)); return results; } private async Task RunScenarioAsync( RunConsumersOptions options, ConsumerScenario scenario, + PackageGraphDocument graph, IReadOnlyDictionary externalPackageVersions, IReadOnlyList externalPackageIds, CancellationToken ct) @@ -93,6 +95,8 @@ private async Task RunScenarioAsync( var restore = new List { "restore", project, "--configfile", config, "--packages", packages, "--use-lock-file" }; if (scenario.Mode is ConsumerMode.PublishTrimmed or ConsumerMode.PublishNativeAot) { restore.Add("-r"); restore.Add(rid); } if (scenario.Mode == ConsumerMode.PublishNativeAot) restore.Add("-p:PublishAot=true"); + if (scenario.ExpectedPublishDiagnostic is { } restoreExpectation) + restore.AddRange(restoreExpectation.MsBuildProperties.Select(static property => "-p:" + property)); await RunRequiredAsync("dotnet", restore, source, logs, options.RepositoryRoot, scenario.Timeout, events, ct).ConfigureAwait(false); if (scenario.RunSecondLockedRestore) { @@ -101,11 +105,13 @@ private async Task RunScenarioAsync( } string outputDirectory; + IReadOnlyList? publishArguments = null; if (scenario.Mode is ConsumerMode.PublishTrimmed or ConsumerMode.PublishNativeAot) { outputDirectory = Path.Combine(workspace, "publish"); var publish = new List { "publish", project, "-c", "Release", "-r", rid, "--self-contained", "true", "--no-restore", "-o", outputDirectory, "-p:PublishTrimmed=true", "-p:TrimMode=link" }; if (scenario.Mode == ConsumerMode.PublishNativeAot) publish.Add("-p:PublishAot=true"); + publishArguments = publish; await RunRequiredAsync("dotnet", publish, source, logs, options.RepositoryRoot, scenario.Timeout, events, ct).ConfigureAwait(false); } else @@ -117,11 +123,34 @@ private async Task RunScenarioAsync( var assets = Path.Combine(source, "obj", "project.assets.json"); if (!File.Exists(assets)) throw new ConsumerScenarioException("SPCONS011", $"Scenario '{scenario.Id}' did not produce project.assets.json."); var observed = await InspectDependenciesAsync(assets, scenario, ct).ConfigureAwait(false); + IReadOnlyList replacementPackageIds = scenario.PackageIds; if (scenario.Mode == ConsumerMode.BinaryCompatibility) - foreach (var replacement in await ReplaceRuntimeAssembliesWithoutBuildAsync(outputDirectory, options.PackageDirectory, options.PackageVersion, scenario.PackageIds, ct).ConfigureAwait(false)) events.Add(replacement); + { + replacementPackageIds = CurrentSmartPipeClosure(graph, scenario.PackageIds); + foreach (var replacement in await ReplaceRuntimeAssembliesWithoutBuildAsync(outputDirectory, options.PackageDirectory, options.PackageVersion, replacementPackageIds, ct).ConfigureAwait(false)) events.Add(replacement); + } await InspectRuntimeArtifactsAsync(outputDirectory, scenario.Mode, ct).ConfigureAwait(false); await ExecuteAsync(outputDirectory, project, scenario, logs, options.RepositoryRoot, events, ct).ConfigureAwait(false); - if (scenario.Mode == ConsumerMode.BinaryCompatibility) ValidateBinaryCompatibilityPhases(events, scenario.PackageIds.Count); + if (scenario.ExpectedPublishDiagnostic is { } expectation) + { + var diagnosticRestore = restore.ToList(); + diagnosticRestore.Remove("--use-lock-file"); + diagnosticRestore.Add("--locked-mode"); + var diagnosticPublish = publishArguments!.ToList(); + diagnosticPublish[diagnosticPublish.IndexOf("-o") + 1] = Path.Combine(workspace, "expected-diagnostic-publish"); + await RunExpectedPublishDiagnosticAsync( + diagnosticRestore, + diagnosticPublish, + expectation, + source, + logs, + options.RepositoryRoot, + Path.GetFullPath(expectation.SourcePath, Path.GetDirectoryName(project)!), + scenario.Timeout, + events, + ct).ConfigureAwait(false); + } + if (scenario.Mode == ConsumerMode.BinaryCompatibility) ValidateBinaryCompatibilityPhases(events, replacementPackageIds.Count); var result = new ConsumerScenarioResult(1, scenario.Id, "passed", options.PackageVersion, scenario.RunSecondLockedRestore, started.ElapsedMilliseconds, observed, events); @@ -166,6 +195,112 @@ private async Task RunRequiredAsync(string fileName, IReadO return result; } + internal static IReadOnlyList BuildExpectedDiagnosticPublishArguments( + IReadOnlyList publishArguments, + ExpectedPublishDiagnostic expectation) + { + var arguments = publishArguments.ToList(); + arguments.Add("-warnaserror"); + arguments.AddRange(expectation.MsBuildProperties.Select(static property => "-p:" + property)); + return arguments; + } + + internal async Task RunExpectedPublishDiagnosticAsync( + IReadOnlyList restoreArguments, + IReadOnlyList publishArguments, + ExpectedPublishDiagnostic expectation, + string cwd, + string logs, + string repositoryRoot, + string expectedSource, + TimeSpan timeout, + List events, + CancellationToken ct) + { + var restore = restoreArguments.ToList(); + foreach (var property in expectation.MsBuildProperties.Select(static property => "-p:" + property)) + if (!restore.Contains(property, StringComparer.Ordinal)) restore.Add(property); + await RunRequiredAsync("dotnet", restore, cwd, logs, repositoryRoot, timeout, events, ct).ConfigureAwait(false); + var arguments = BuildExpectedDiagnosticPublishArguments(publishArguments, expectation); + var result = await _processRunner.RunAsync(new("dotnet", arguments, cwd, logs, timeout), ct).ConfigureAwait(false); + events.Add(new( + "expected-publish-diagnostic", + result.Command, + result.ExitCode, + result.StartedUtc, + result.DurationMs, + Normalize(result.StandardOutputLog, cwd), + Normalize(result.StandardErrorLog, cwd))); + await ValidateExpectedPublishDiagnosticAsync(result, expectation, expectedSource, repositoryRoot, ct).ConfigureAwait(false); + } + + internal static async Task ValidateExpectedPublishDiagnosticAsync( + DotNetProcessResult result, + ExpectedPublishDiagnostic expectation, + string expectedSource, + string repositoryRoot, + CancellationToken ct) + { + if (result.ExitCode == 0) + throw new ConsumerScenarioException("SPCONS024", $"Expected publish diagnostic {expectation.Code} was not emitted."); + + var root = Path.GetFullPath(repositoryRoot); + var stdoutLog = Path.GetFullPath(result.StandardOutputLog); + var stderrLog = Path.GetFullPath(result.StandardErrorLog); + EnsureContained(root, stdoutLog); + EnsureContained(root, stderrLog); + if (!File.Exists(stdoutLog) || !File.Exists(stderrLog) + || (File.GetAttributes(stdoutLog) & FileAttributes.ReparsePoint) != 0 + || (File.GetAttributes(stderrLog) & FileAttributes.ReparsePoint) != 0) + throw new ConsumerScenarioException("SPCONS009", "Expected diagnostic evidence logs are invalid."); + + var output = await File.ReadAllTextAsync(stdoutLog, ct).ConfigureAwait(false) + + "\n" + + await File.ReadAllTextAsync(stderrLog, ct).ConfigureAwait(false); + var diagnostics = Regex.Matches( + output, + @"^(?[^\r\n]+?\.cs)\((?[1-9][0-9]*),(?[1-9][0-9]*)\):[^\r\n]*?\b(?[A-Z]{2}[0-9]{4}):", + RegexOptions.Multiline | RegexOptions.CultureInvariant); + var errors = Regex.Matches( + output, + @"\berror\s+(?[A-Z][A-Z0-9]*[0-9]{4}):", + RegexOptions.CultureInvariant); + var expectedFullPath = Path.GetFullPath(expectedSource); + var expectedRedactedPath = DiagnosticRedactor.Redact(expectedFullPath).Replace('\\', '/'); + var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var matchingCodeAndLine = diagnostics.Cast().Count(match => + match.Groups["code"].Value == expectation.Code + && int.Parse(match.Groups["line"].Value, System.Globalization.CultureInfo.InvariantCulture) == expectation.Line); + var matching = diagnostics.Cast().Count(match => + { + var reported = match.Groups["path"].Value.Trim(); + var sourceMatches = reported.StartsWith("/", StringComparison.Ordinal) + || reported.StartsWith("\\", StringComparison.Ordinal) + ? string.Equals(reported.Replace('\\', '/'), expectedRedactedPath, pathComparison) + : string.Equals( + Path.IsPathFullyQualified(reported) + ? Path.GetFullPath(reported) + : Path.GetFullPath(reported, Path.GetDirectoryName(expectedFullPath)!), + expectedFullPath, + pathComparison); + return sourceMatches + && match.Groups["code"].Value == expectation.Code + && int.Parse(match.Groups["line"].Value, System.Globalization.CultureInfo.InvariantCulture) == expectation.Line; + }); + if (matching == 1 + && diagnostics.Count == 1 + && errors.Count == 1 + && errors[0].Groups["code"].Value == expectation.Code) return; + if (matching > 1) + throw new ConsumerScenarioException("SPCONS024", $"Expected publish diagnostic {expectation.Code} was emitted more than once."); + if (matchingCodeAndLine == 1 + && diagnostics.Count == 1 + && errors.Count == 1 + && errors[0].Groups["code"].Value == expectation.Code) + throw new ConsumerScenarioException("SPCONS024", $"Expected publish diagnostic {expectation.Code} was emitted from an unexpected source."); + throw BuildProcessFailure(result, repositoryRoot); + } + internal static ConsumerScenarioException BuildProcessFailure(DotNetProcessResult result, string repositoryRoot) { ArgumentNullException.ThrowIfNull(result); @@ -281,6 +416,34 @@ private static async Task> ReplaceRuntimeAss return events; } + internal static IReadOnlyList CurrentSmartPipeClosure( + PackageGraphDocument graph, + IReadOnlyList packageIds) + { + var nodes = graph.Packages.ToDictionary(package => package.Id, StringComparer.OrdinalIgnoreCase); + var closure = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Visit(string packageId) + { + if (!closure.Add(packageId)) + return; + + if (nodes.TryGetValue(packageId, out var package)) + foreach (var dependency in package.CurrentDependencies.RequiredSmartPipePackages) + Visit(dependency); + } + + foreach (var packageId in packageIds) + Visit(packageId); + + return TopologicalPackageSorter.Sort(closure.ToDictionary( + packageId => packageId, + packageId => (IReadOnlyList)(nodes.TryGetValue(packageId, out var package) + ? package.CurrentDependencies.RequiredSmartPipePackages.Where(closure.Contains).ToArray() + : []), + StringComparer.OrdinalIgnoreCase)); + } + internal static async Task ExtractValidatedEntryAsync( string archivePath, string entryPath, diff --git a/eng/consumer-scenarios.json b/eng/consumer-scenarios.json index f7552b9..35161e3 100644 --- a/eng/consumer-scenarios.json +++ b/eng/consumer-scenarios.json @@ -1,9 +1,6 @@ { "schemaVersion": 1, "requiredAtRelease": [ - "channels-direct", - "transforms-direct", - "logging-direct", "csv-direct", "dapper-direct", "entity-framework-core-direct", @@ -11,8 +8,7 @@ "polly-direct", "http-direct", "testing-direct", - "http-json-direct", - "data-annotations-direct" + "http-json-direct" ], "scenarios": [ { @@ -45,7 +41,7 @@ "mode": "build-and-run", "templatePath": "tests/Consumers/Scenarios/extensions-meta/Consumer.csproj", "packageIds": ["SmartPipe.Extensions"], - "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.Channels", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.Logging", "SmartPipe.Extensions.DataAnnotations", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json"], "forbiddenDependencies": [], "baselineVersion": null, "timeout": "00:05:00", @@ -141,7 +137,7 @@ "mode": "build-and-run", "templatePath": "tests/Consumers/Scenarios/dependency-injection-facade-source/Consumer.csproj", "packageIds": ["SmartPipe.Core", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions"], - "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions", "SmartPipe.Extensions.Channels", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.Logging", "SmartPipe.Extensions.DataAnnotations", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json"], "forbiddenDependencies": [], "baselineVersion": null, "timeout": "00:05:00", @@ -203,7 +199,7 @@ "mode": "build-and-run", "templatePath": "tests/Consumers/Scenarios/hosting-facade-source/Consumer.csproj", "packageIds": ["SmartPipe.Core", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions"], - "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.Channels", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.Logging", "SmartPipe.Extensions.DataAnnotations", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json"], "forbiddenDependencies": [], "baselineVersion": null, "timeout": "00:05:00", @@ -333,7 +329,7 @@ "mode": "build-and-run", "templatePath": "tests/Consumers/Scenarios/opentelemetry-facade/Consumer.csproj", "packageIds": ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.OpenTelemetry"], - "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json", "SmartPipe.Extensions.OpenTelemetry"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.Channels", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.Logging", "SmartPipe.Extensions.DataAnnotations", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json", "SmartPipe.Extensions.OpenTelemetry"], "forbiddenDependencies": ["SmartPipe.Extensions.HealthChecks"], "baselineVersion": null, "timeout": "00:05:00", @@ -364,6 +360,77 @@ "baselineVersion": null, "timeout": "00:15:00", "runSecondLockedRestore": true + }, + { + "id": "channels-direct", + "set": "current", + "category": "sp220-07", + "mode": "publish-native-aot", + "templatePath": "tests/Consumers/Scenarios/channels-direct/Consumer.csproj", + "packageIds": ["SmartPipe.Extensions.Channels"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.Channels"], + "forbiddenDependencies": ["SmartPipe.Extensions"], + "baselineVersion": null, + "timeout": "00:15:00", + "runSecondLockedRestore": true + }, + { + "id": "transforms-direct", + "set": "current", + "category": "sp220-07", + "mode": "publish-native-aot", + "templatePath": "tests/Consumers/Scenarios/transforms-direct/Consumer.csproj", + "packageIds": ["SmartPipe.Extensions.Transforms"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.Transforms"], + "forbiddenDependencies": ["SmartPipe.Extensions"], + "baselineVersion": null, + "timeout": "00:15:00", + "runSecondLockedRestore": true + }, + { + "id": "logging-direct", + "set": "current", + "category": "sp220-07", + "mode": "publish-native-aot", + "templatePath": "tests/Consumers/Scenarios/logging-direct/Consumer.csproj", + "packageIds": ["SmartPipe.Extensions.Logging"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.Logging"], + "forbiddenDependencies": ["SmartPipe.Extensions"], + "baselineVersion": null, + "timeout": "00:15:00", + "runSecondLockedRestore": true + }, + { + "id": "data-annotations-direct", + "set": "current", + "category": "sp220-07", + "mode": "publish-trimmed", + "templatePath": "tests/Consumers/Scenarios/data-annotations-direct/Consumer.csproj", + "packageIds": ["SmartPipe.Extensions.DataAnnotations"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.DataAnnotations"], + "forbiddenDependencies": ["SmartPipe.Extensions"], + "baselineVersion": null, + "expectedPublishDiagnostic": { + "code": "IL2026", + "sourcePath": "Program.cs", + "line": 9, + "msBuildProperties": ["EnableTrimAnalyzer=true", "InvokeReflectionValidation=true"] + }, + "timeout": "00:10:00", + "runSecondLockedRestore": true + }, + { + "id": "data-annotations-runtime", + "set": "current", + "category": "sp220-07", + "mode": "build-and-run", + "templatePath": "tests/Consumers/Scenarios/data-annotations-runtime/Consumer.csproj", + "packageIds": ["SmartPipe.Extensions.DataAnnotations"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.DataAnnotations"], + "forbiddenDependencies": ["SmartPipe.Extensions"], + "baselineVersion": null, + "timeout": "00:05:00", + "runSecondLockedRestore": true } ] } diff --git a/eng/consumer-scenarios.schema.json b/eng/consumer-scenarios.schema.json index 21af711..88b75bb 100644 --- a/eng/consumer-scenarios.schema.json +++ b/eng/consumer-scenarios.schema.json @@ -13,8 +13,8 @@ }, "scenarios": { "type": "array", - "minItems": 19, - "maxItems": 19, + "minItems": 33, + "maxItems": 33, "items": { "$ref": "#/$defs/scenario" } } }, @@ -33,9 +33,26 @@ "expectedSmartPipeDependencies": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, "forbiddenDependencies": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, "baselineVersion": { "type": ["string", "null"], "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "expectedPublishDiagnostic": { "$ref": "#/$defs/expectedPublishDiagnostic" }, "timeout": { "type": "string", "pattern": "^[0-9]{2}:[0-5][0-9]:[0-5][0-9]$" }, "runSecondLockedRestore": { "type": "boolean" } } + }, + "expectedPublishDiagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["code", "sourcePath", "line", "msBuildProperties"], + "properties": { + "code": { "type": "string", "pattern": "^IL[0-9]{4}$" }, + "sourcePath": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*\\.cs$" }, + "line": { "type": "integer", "minimum": 1 }, + "msBuildProperties": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.]*=(?:true|false)$" } + } + } } } } diff --git a/eng/package-graph.json b/eng/package-graph.json index b0a7abc..56f577b 100644 --- a/eng/package-graph.json +++ b/eng/package-graph.json @@ -65,9 +65,9 @@ { "id": "SmartPipe.Extensions.Channels", "projectPath": "src/SmartPipe.Extensions.Channels/SmartPipe.Extensions.Channels.csproj", - "lifecycle": "planned", + "lifecycle": "active", "activationEpic": "SP220-07", - "scaffoldKind": "core-leaf", + "scaffoldKind": null, "publishOrder": 2, "baselineVersion": null, "aotContract": "full", @@ -99,9 +99,9 @@ { "id": "SmartPipe.Extensions.Transforms", "projectPath": "src/SmartPipe.Extensions.Transforms/SmartPipe.Extensions.Transforms.csproj", - "lifecycle": "planned", + "lifecycle": "active", "activationEpic": "SP220-07", - "scaffoldKind": "core-leaf", + "scaffoldKind": null, "publishOrder": 3, "baselineVersion": null, "aotContract": "full", @@ -133,9 +133,9 @@ { "id": "SmartPipe.Extensions.Logging", "projectPath": "src/SmartPipe.Extensions.Logging/SmartPipe.Extensions.Logging.csproj", - "lifecycle": "planned", + "lifecycle": "active", "activationEpic": "SP220-07", - "scaffoldKind": "framework-integration", + "scaffoldKind": null, "publishOrder": 4, "baselineVersion": null, "aotContract": "full", @@ -706,9 +706,9 @@ { "id": "SmartPipe.Extensions.DataAnnotations", "projectPath": "src/SmartPipe.Extensions.DataAnnotations/SmartPipe.Extensions.DataAnnotations.csproj", - "lifecycle": "planned", + "lifecycle": "active", "activationEpic": "SP220-07", - "scaffoldKind": "composed-integration", + "scaffoldKind": null, "publishOrder": 18, "baselineVersion": null, "aotContract": "annotated-reflection", @@ -736,7 +736,8 @@ }, "temporaryAllowances": [], "consumerScenarios": [ - "data-annotations-direct" + "data-annotations-direct", + "data-annotations-runtime" ] }, { @@ -751,6 +752,10 @@ "currentDependencies": { "requiredSmartPipePackages": [ "SmartPipe.Core", + "SmartPipe.Extensions.Channels", + "SmartPipe.Extensions.Transforms", + "SmartPipe.Extensions.Logging", + "SmartPipe.Extensions.DataAnnotations", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json" diff --git a/eng/package-ownership.json b/eng/package-ownership.json index 7c5dc2b..e00a18b 100644 --- a/eng/package-ownership.json +++ b/eng/package-ownership.json @@ -15,13 +15,13 @@ { "typePattern": "SmartPipe.Extensions.ChannelMerge*", "baselineAssembly": "SmartPipe.Extensions", - "currentImplementationAssembly": "SmartPipe.Extensions", + "currentImplementationAssembly": "SmartPipe.Extensions.Channels", "targetImplementationAssembly": "SmartPipe.Extensions.Channels", "compatibilityAssembly": "SmartPipe.Extensions", "strategy": "type-forward", "migrationEpic": "SP220-07", "namespacePreserved": true, - "evidence": "SP220 extraction plan" + "evidence": "facade ExportedType forwarder and Channels implementation" }, { "typePattern": "SmartPipe.Extensions.DeadLetter*", @@ -213,13 +213,13 @@ { "typePattern": "SmartPipe.Extensions.Sinks.LoggerSink*", "baselineAssembly": "SmartPipe.Extensions", - "currentImplementationAssembly": "SmartPipe.Extensions", + "currentImplementationAssembly": "SmartPipe.Extensions.Logging", "targetImplementationAssembly": "SmartPipe.Extensions.Logging", "compatibilityAssembly": "SmartPipe.Extensions", "strategy": "type-forward", "migrationEpic": "SP220-07", "namespacePreserved": true, - "evidence": "SP220 logging extraction" + "evidence": "facade ExportedType forwarder and Logging implementation" }, { "typePattern": "SmartPipe.Extensions.SmartPipeDefinition*", @@ -290,35 +290,35 @@ { "typePattern": "SmartPipe.Extensions.Transforms.CompositeTransform*", "baselineAssembly": "SmartPipe.Extensions", - "currentImplementationAssembly": "SmartPipe.Extensions", + "currentImplementationAssembly": "SmartPipe.Extensions.Transforms", "targetImplementationAssembly": "SmartPipe.Extensions.Transforms", "compatibilityAssembly": "SmartPipe.Extensions", "strategy": "type-forward", "migrationEpic": "SP220-07", "namespacePreserved": true, - "evidence": "SP220 transforms extraction" + "evidence": "facade ExportedType forwarder and Transforms implementation" }, { "typePattern": "SmartPipe.Extensions.Transforms.Compression*", "baselineAssembly": "SmartPipe.Extensions", - "currentImplementationAssembly": "SmartPipe.Extensions", + "currentImplementationAssembly": "SmartPipe.Extensions.Transforms", "targetImplementationAssembly": "SmartPipe.Extensions.Transforms", "compatibilityAssembly": "SmartPipe.Extensions", "strategy": "type-forward", "migrationEpic": "SP220-07", "namespacePreserved": true, - "evidence": "SP220 transforms extraction" + "evidence": "facade ExportedType forwarder and Transforms implementation" }, { "typePattern": "SmartPipe.Extensions.Transforms.ConditionalTransform*", "baselineAssembly": "SmartPipe.Extensions", - "currentImplementationAssembly": "SmartPipe.Extensions", + "currentImplementationAssembly": "SmartPipe.Extensions.Transforms", "targetImplementationAssembly": "SmartPipe.Extensions.Transforms", "compatibilityAssembly": "SmartPipe.Extensions", "strategy": "type-forward", "migrationEpic": "SP220-07", "namespacePreserved": true, - "evidence": "SP220 transforms extraction" + "evidence": "facade ExportedType forwarder and Transforms implementation" }, { "typePattern": "SmartPipe.Extensions.Transforms.CsvTransform*", @@ -334,24 +334,24 @@ { "typePattern": "SmartPipe.Extensions.Transforms.FilterTransform*", "baselineAssembly": "SmartPipe.Extensions", - "currentImplementationAssembly": "SmartPipe.Extensions", + "currentImplementationAssembly": "SmartPipe.Extensions.Transforms", "targetImplementationAssembly": "SmartPipe.Extensions.Transforms", "compatibilityAssembly": "SmartPipe.Extensions", "strategy": "type-forward", "migrationEpic": "SP220-07", "namespacePreserved": true, - "evidence": "SP220 transforms extraction" + "evidence": "facade ExportedType forwarder and Transforms implementation" }, { "typePattern": "SmartPipe.Extensions.Transforms.FilterValidationExtensions*", "baselineAssembly": "SmartPipe.Extensions", - "currentImplementationAssembly": "SmartPipe.Extensions", + "currentImplementationAssembly": "SmartPipe.Extensions.DataAnnotations", "targetImplementationAssembly": "SmartPipe.Extensions.DataAnnotations", "compatibilityAssembly": "SmartPipe.Extensions", "strategy": "type-forward", "migrationEpic": "SP220-07", "namespacePreserved": true, - "evidence": "SP220 validation extraction" + "evidence": "facade ExportedType forwarder and DataAnnotations implementation" }, { "typePattern": "SmartPipe.Extensions.Transforms.JsonTransform*", @@ -389,13 +389,13 @@ { "typePattern": "SmartPipe.Extensions.Transforms.ValidationTransform*", "baselineAssembly": "SmartPipe.Extensions", - "currentImplementationAssembly": "SmartPipe.Extensions", + "currentImplementationAssembly": "SmartPipe.Extensions.DataAnnotations", "targetImplementationAssembly": "SmartPipe.Extensions.DataAnnotations", "compatibilityAssembly": "SmartPipe.Extensions", "strategy": "type-forward", "migrationEpic": "SP220-07", "namespacePreserved": true, - "evidence": "SP220 validation extraction" + "evidence": "facade ExportedType forwarder and DataAnnotations implementation" } ] } diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 7561dd3..d9dd20a 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -31,6 +31,52 @@ ) } SHA_REF = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") +SELF_HOSTED_WINDOWS = ["self-hosted", "Windows", "X64"] +SELF_HOSTED_WINDOWS_JSON = '["self-hosted","Windows","X64"]' +SAME_REPOSITORY_PR_GUARD = ( + "github.event_name != 'pull_request' || " + "github.event.pull_request.head.repo.full_name == github.repository" +) +PULL_REQUEST_SAME_REPOSITORY_GUARD = "github.event.pull_request.head.repo.full_name == github.repository" +CLEANUP_SAME_REPOSITORY_GUARD = ( + "always() && (github.event_name != 'pull_request' || " + "github.event.pull_request.head.repo.full_name == github.repository)" +) +CLEANUP_PULL_REQUEST_GUARD = ( + "always() && github.event_name == 'pull_request' && " + "github.event.pull_request.head.repo.full_name == github.repository" +) +CI_VALIDATION_RUNNER_INPUT = ( + "${{ github.event_name == 'pull_request' && " + "'[\"self-hosted\",\"Windows\",\"X64\"]' || " + "'[\"ubuntu-latest\"]' }}" +) +CI_WINDOWS_RUNNER = ( + "${{ github.event_name == 'pull_request' && " + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || " + "'windows-latest' }}" +) +CODEQL_RUNNER = ( + "${{ github.event_name == 'pull_request' && " + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || " + "'ubuntu-latest' }}" +) +HOSTING_NAME = "${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}" +HOSTING_RUNNER = ( + "${{ matrix.os == 'self-hosted' && " + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || matrix.os }}" +) +HOSTING_MATRIX = ( + "${{ fromJSON(github.event_name == 'pull_request' && " + "'{\"os\":[\"self-hosted\"]}' || " + "'{\"os\":[\"ubuntu-latest\",\"windows-latest\"]}') }}" +) +LYCHEE_URL = ( + "https://github.com/lycheeverse/lychee/releases/download/" + "lychee-v0.21.0/lychee-x86_64-windows.exe" +) +LYCHEE_SHA256 = "a1784c32c63ba46dccef0698ddf6be82a83a7d0455b0fd772423d601e3c70ab4" +NATIVE_FAIL_FAST_GUARD = "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }" REPOSITORY_CHECKS_PROFILE_COMMAND = ( "dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj " "--configuration Release --no-build -- verify --profile sp220-05 " @@ -43,6 +89,194 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) +def require_self_hosted_windows(job: dict, label: str) -> None: + require(job.get("runs-on") == SELF_HOSTED_WINDOWS, + f"{label} must target the self-hosted Windows X64 runner labels.") + + +def require_parameterized_runner(job: dict, label: str) -> None: + require(job.get("runs-on") == "${{ fromJSON(inputs.runner-labels) }}", + f"{label} must use the runner-labels workflow input.") + + +def require_runner_expression(job: dict, expected: str, label: str) -> None: + require(job.get("runs-on") == expected, + f"{label} must use the event-aware runner expression.") + + +def require_same_repository_pr_guard(job: dict, label: str, allow_non_pr: bool = True) -> None: + expected = SAME_REPOSITORY_PR_GUARD if allow_non_pr else PULL_REQUEST_SAME_REPOSITORY_GUARD + require(job.get("if") == expected, + f"{label} must use the same-repository pull_request guard.") + + +def assert_cleanup_job( + workflow: dict, + workflow_name: str, + expected_needs: list[str], + expected_guard: str, +) -> None: + job = workflow["jobs"].get("cleanup-self-hosted") + require(isinstance(job, dict), + f"{workflow_name} must define cleanup-self-hosted.") + require(job.get("name") == "Cleanup self-hosted workspace", + f"{workflow_name} cleanup must preserve its check name.") + require(job.get("needs") == expected_needs, + f"{workflow_name} cleanup must wait for every workflow job.") + require_self_hosted_windows(job, f"{workflow_name} cleanup") + require(job.get("if") == expected_guard, + f"{workflow_name} cleanup must always run only for trusted repository work.") + cleanup_steps = steps(job, f"{workflow_name} cleanup") + require(len(cleanup_steps) == 1, + f"{workflow_name} cleanup must contain exactly one cleanup step.") + cleanup = named_step(cleanup_steps, "Cleanup generated outputs") + require(cleanup.get("shell") == "pwsh", + f"{workflow_name} cleanup must use PowerShell on Windows.") + script = str(cleanup.get("run", "")) + for token in ( + "$env:GITHUB_WORKSPACE", "[IO.Path]::GetFullPath", "StartsWith", + "[StringComparison]::OrdinalIgnoreCase", "[IO.FileAttributes]::ReparsePoint", + "Join-Path $workspace 'artifacts'", + "Join-Path $workspace 'BenchmarkDotNet.Artifacts'", + "$directory.Name -in 'bin', 'obj'", + "Remove-Item -LiteralPath $fullPath -Recurse -Force", + ): + require(token in script, + f"{workflow_name} cleanup must enforce safe workspace-bound deletion ({token}).") + require("git clean" not in script.lower(), + f"{workflow_name} cleanup must not use git clean.") + require(re.search(r"Remove-Item\s+-LiteralPath\s+\$workspace(?:\s|$)", script) is None, + f"{workflow_name} cleanup must not delete the workspace root.") + direct_reparse_guard = ( + "if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band " + "[IO.FileAttributes]::ReparsePoint)" + ) + require(direct_reparse_guard in script, + f"{workflow_name} cleanup must reject direct target reparse points before recursive deletion.") + require(script.index(direct_reparse_guard) < script.index( + "Get-ChildItem -LiteralPath $fullPath -Force -Recurse"), + f"{workflow_name} cleanup must check direct target reparse points before recursion.") + + +def assert_reusable_windows_shell_contract(reusable_steps: list[dict]) -> None: + release_version = named_step(reusable_steps, "Test release version validation") + release_run = str(release_version.get("run", "")) + require(release_version.get("shell") == "pwsh" + and r"C:\Program Files\Git\bin\bash.exe" in release_run + and "Test-Path" in release_run + and "$IsWindows" in release_run + and "-lc 'eng/tests/validate-release-version.Tests.sh'" in release_run + and "bash eng/tests/validate-release-version.Tests.sh" in release_run, + "Release version validation must use verified Git Bash on Windows and bash on hosted Linux.") + repeat = named_step(reusable_steps, "PR concurrency regression repeat") + repeat_run = str(repeat.get("run", "")) + require(repeat.get("shell") == "pwsh" and "foreach ($pass in 1..10)" in repeat_run, + "PR concurrency repeat must use the Windows PowerShell loop.") + require("tests/SmartPipe.Extensions.Channels.Tests/SmartPipe.Extensions.Channels.Tests.csproj" in repeat_run, + "PR concurrency repeat must exercise the extracted Channels package.") + + leaf_tests = named_step(reusable_steps, "SP220-07 leaf tests") + leaf_run = str(leaf_tests.get("run", "")) + for project in ("Channels", "Transforms", "Logging", "DataAnnotations"): + project_path = f"tests/SmartPipe.Extensions.{project}.Tests/SmartPipe.Extensions.{project}.Tests.csproj" + require(project_path in leaf_run, + f"SP220-07 leaf test step must execute {project_path}.") + require(leaf_tests.get("shell") == "pwsh" + and "$projects = @(" in leaf_run + and "foreach ($project in $projects)" in leaf_run + and "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }" in leaf_run, + "SP220-07 leaf tests must fail immediately after every failed project test.") + package_version = named_step(reusable_steps, "Set package version") + package_run = str(package_version.get("run", "")) + require(package_version.get("shell") == "pwsh" + and "$env:REQUESTED_PACKAGE_VERSION" in package_run + and "$env:GITHUB_ENV" in package_run, + "Package version setup must use PowerShell environment handling.") + for name in ("Vulnerable package scan", "Deprecated package scan", "Outdated package report", "Docs link check (Windows)"): + require(named_step(reusable_steps, name).get("shell") == "pwsh", + f"{name} must use PowerShell on Windows.") + require(not any(step.get("shell") == "bash" for step in reusable_steps), + "Reusable Windows validation must not depend on an implicit Bash shell.") + package_command_steps = [step for step in reusable_steps + if "--package-version" in str(step.get("run", "")) + or "--tag \"v" in str(step.get("run", ""))] + package_commands = [str(step.get("run", "")) for step in package_command_steps] + require(package_commands and all("$env:PACKAGE_VERSION" in command for command in package_commands), + "Reusable package commands must read PACKAGE_VERSION from the PowerShell environment.") + require(all(step.get("shell") == "pwsh" for step in package_command_steps), + "Reusable package commands must use PowerShell on hosted Linux and Windows.") + + +def assert_multiline_native_fail_fast_contract(reusable_steps: list[dict]) -> None: + for name in ( + "Extensions correctness regressions", + "PR concurrency regression repeat", + "SP220-07 leaf tests", + "Test and benchmark warning gate", + ): + step = named_step(reusable_steps, name) + require(step.get("shell") == "pwsh", + f"{name} must use PowerShell for native command failure handling.") + lines = [line.strip() for line in str(step.get("run", "")).splitlines() + if line.strip()] + dotnet_indexes = [index for index, line in enumerate(lines) + if line.startswith("dotnet ")] + require(dotnet_indexes, + f"{name} must contain native dotnet commands.") + for index in dotnet_indexes: + require(index + 1 < len(lines) and lines[index + 1] == NATIVE_FAIL_FAST_GUARD, + f"Every dotnet command in {name} must immediately fail on nonzero LASTEXITCODE.") + + +def assert_all_multiline_native_fail_fast_contract(documents: dict[str, dict]) -> None: + for workflow_name, workflow in documents.items(): + if workflow_name not in { + "ci.yml", + "codeql.yml", + "dependency-review.yml", + "reusable-release-validation.yml", + }: + continue + for job_name, job in workflow.get("jobs", {}).items(): + job_steps = job.get("steps") + if not isinstance(job_steps, list): + continue + for step in job_steps: + lines = [line.strip() for line in str(step.get("run", "")).splitlines() + if line.strip()] + if len(lines) < 2: + continue + dotnet_indexes = [index for index, line in enumerate(lines) + if line.startswith("dotnet ")] + if not dotnet_indexes: + continue + label = f"{workflow_name}:{job_name}:{step.get('name', '')}" + require(step.get("shell") == "pwsh", + f"Multiline native block {label} must use explicit PowerShell.") + for index in dotnet_indexes: + require(index + 1 < len(lines) and lines[index + 1] == NATIVE_FAIL_FAST_GUARD, + f"Every dotnet command in multiline {label} must immediately fail on nonzero LASTEXITCODE.") + + +def assert_lychee_contract(reusable_steps: list[dict]) -> None: + linux = named_step(reusable_steps, "Docs link check") + require(linux.get("if") == "runner.os != 'Windows'" + and linux.get("uses") == "lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0", + "Linux Docs link check must retain the pinned Lychee action.") + windows = named_step(reusable_steps, "Docs link check (Windows)") + run = str(windows.get("run", "")) + require(windows.get("if") == "runner.os == 'Windows'" + and windows.get("shell") == "pwsh" + and LYCHEE_URL in run and LYCHEE_SHA256 in run + and "Get-FileHash" in run and "SHA256" in run, + "Windows Docs link check must download the pinned Lychee binary and verify SHA256.") + lychee_text = json.dumps({"linux": linux, "windows": windows}) + require("GITHUB_TOKEN" not in lychee_text and "github-token" not in lychee_text.lower(), + "Docs link check must not expose or require GITHUB_TOKEN.") + require("lycheeverse/lychee-action" not in run, + "Windows Docs link check must not use the hosted Lychee action.") + + def load_workflows() -> dict[str, dict]: yaml = YAML(typ="safe", pure=True) yaml.version = (1, 2) @@ -259,9 +493,11 @@ def assert_consumer_contract() -> None: "health-checks-nativeaot", "opentelemetry-direct", "opentelemetry-otlp", "opentelemetry-facade", "opentelemetry-trim", "opentelemetry-nativeaot", + "channels-direct", "transforms-direct", "logging-direct", "data-annotations-direct", + "data-annotations-runtime", } - require(len(current) == 28 and {scenario["id"] for scenario in current} == expected, - "Current consumer set must contain the exact twenty-eight scenarios.") + require(len(current) == 33 and {scenario["id"] for scenario in current} == expected, + "Current consumer set must contain the exact thirty-three scenarios.") hosting = [scenario for scenario in current if scenario.get("category") == "hosting"] require({scenario["id"] for scenario in hosting} == { "hosting-direct", "hosting-facade-source", "hosting-facade-binary-2.1.2", @@ -304,6 +540,8 @@ def validate(documents: dict[str, dict]) -> None: branches = workflow.get("on", {}).get("pull_request", {}).get("branches", []) require("sp220/checkpoint-c" in branches, f"{workflow_name} pull_request must include sp220/checkpoint-c.") + require("sp220/checkpoint-d" in branches, + f"{workflow_name} pull_request must include sp220/checkpoint-d.") for event in ("push", "pull_request"): branches = ci.get("on", {}).get(event, {}).get("branches", []) @@ -315,16 +553,16 @@ def validate(documents: dict[str, dict]) -> None: "workflow_dispatch": None, "push": {"branches": ["main", "upd", "release/2.2.0"]}, "pull_request": { - "branches": ["main", "upd", "release/2.2.0", "sp220/checkpoint-c"] + "branches": ["main", "upd", "release/2.2.0", "sp220/checkpoint-c", "sp220/checkpoint-d"] }, }, "codeql.yml": { "push": {"branches": ["main", "upd", "release/2.2.0"]}, - "pull_request": {"branches": ["main", "release/2.2.0", "sp220/checkpoint-c"]}, + "pull_request": {"branches": ["main", "release/2.2.0", "sp220/checkpoint-c", "sp220/checkpoint-d"]}, "schedule": [{"cron": "27 3 * * 1"}], }, "dependency-review.yml": { - "pull_request": {"branches": ["main", "release/2.2.0", "sp220/checkpoint-c"]}, + "pull_request": {"branches": ["main", "release/2.2.0", "sp220/checkpoint-c", "sp220/checkpoint-d"]}, }, } for workflow_name, expected in expected_triggers.items(): @@ -333,13 +571,26 @@ def validate(documents: dict[str, dict]) -> None: workflow_call = reusable.get("on", {}).get("workflow_call") require(isinstance(workflow_call, dict), "Reusable validation must declare on.workflow_call.") + runner_input = workflow_call.get("inputs", {}).get("runner-labels") + require(runner_input == { + "description": "Runner labels as a JSON array", + "required": False, + "type": "string", + "default": '["ubuntu-latest"]', + }, "Reusable validation must default runner-labels to hosted Linux.") reusable_job = reusable["jobs"].get("build-test-pack") require(isinstance(reusable_job, dict), "Reusable validation must define build-test-pack.") + require_parameterized_runner(reusable_job, "Reusable build-test-pack") + require_same_repository_pr_guard(reusable_job, "Reusable build-test-pack") reusable_steps = steps(reusable_job, "reusable build-test-pack") reusable_runs = runs(reusable_steps) require(named_step(reusable_steps, "Test workflow contracts").get("run") == "./eng/tests/workflow-contract.Tests.ps1", "Reusable validation must execute the workflow contract test.") + assert_reusable_windows_shell_contract(reusable_steps) + assert_multiline_native_fail_fast_contract(reusable_steps) + assert_all_multiline_native_fail_fast_contract(documents) + assert_lychee_contract(reusable_steps) require(any("ruamel.yaml==0.18.16" in command for command in reusable_runs), "Reusable validation must install the pinned YAML 1.2 parser.") restores = [command for command in reusable_runs if "dotnet restore SmartPipe.Core.slnx" in command] @@ -376,13 +627,13 @@ def validate(documents: dict[str, dict]) -> None: "Reusable DI tests must run after Build.") assert_repository_checks_profile(reusable_steps, build_step, repository_test_step) - assert_baseline_lane(reusable_job, "Reusable Linux baseline lane") + assert_baseline_lane(reusable_job, "Reusable Windows baseline lane") required_steps = ( "Verify RepositoryChecks profile", "Format verify", "Build", "Repository baseline contract tests", "Core tests with coverage", "Core stress tests", - "Extensions tests", "Dependency Injection tests", "HealthChecks tests", "Hosting lifecycle regressions", "Hosting tests", + "Extensions tests", "SP220-07 leaf tests", "Dependency Injection tests", "HealthChecks tests", "Hosting lifecycle regressions", "Hosting tests", "JSON Extensions tests", "Core correctness regressions", "Core concurrency regressions", "Extensions correctness regressions", "PR concurrency regression repeat", "Test and benchmark warning gate", @@ -392,7 +643,7 @@ def validate(documents: dict[str, dict]) -> None: "Run current consumers", "Run Hosting consumers", "Run HealthChecks consumers", "Run OpenTelemetry consumers", "Vulnerable package scan", "Verify direct production audit policy", "Deprecated package scan", - "Outdated package report", "Docs link check", + "Outdated package report", "Docs link check", "Docs link check (Windows)", "Upload immutable packages and reports", ) for name in required_steps: @@ -430,9 +681,10 @@ def validate(documents: dict[str, dict]) -> None: concurrency_job = reusable["jobs"].get("health-checks-concurrency") require(isinstance(concurrency_job, dict), "Reusable validation must define the HealthChecks concurrency OS matrix.") - require(concurrency_job.get("strategy", {}).get("matrix", {}).get("os") == - ["ubuntu-latest", "windows-latest"], - "HealthChecks concurrency matrix must run on Linux and Windows.") + require_parameterized_runner(concurrency_job, "HealthChecks concurrency") + require_same_repository_pr_guard(concurrency_job, "HealthChecks concurrency") + require("strategy" not in concurrency_job, + "HealthChecks concurrency must use one self-hosted Windows lane while Linux hosted minutes are unavailable.") concurrency_steps = steps(concurrency_job, "reusable health-checks-concurrency") for step_name in ("Run bounded observation concurrency", "Run concurrent health evaluation"): command = str(named_step(concurrency_steps, step_name).get("run", "")) @@ -481,18 +733,23 @@ def validate(documents: dict[str, dict]) -> None: require(validation == { "uses": "./.github/workflows/reusable-release-validation.yml", "permissions": {"contents": "read"}, + "if": SAME_REPOSITORY_PR_GUARD, + "with": {"runner-labels": CI_VALIDATION_RUNNER_INPUT}, }, "CI validation must be the exact reusable workflow caller with read-only contents permission.") pull_request = ci.get("on", {}).get("pull_request", {}) require("paths-ignore" not in pull_request, "CI pull requests must not exclude Hosting package, tests, or docs paths.") hosting_integration = ci["jobs"].get("hosting-integration") require(isinstance(hosting_integration, dict) - and hosting_integration.get("name") == "Hosting integration (${{ matrix.os }})" - and hosting_integration.get("runs-on") == "${{ matrix.os }}", - "CI must define the Hosting integration OS matrix.") - hosting_matrix = hosting_integration.get("strategy", {}).get("matrix", {}).get("os") - require(hosting_matrix == ["ubuntu-latest", "windows-latest"], - "Hosting integration must run on Linux and Windows.") + and hosting_integration.get("name") == f"Hosting integration ({HOSTING_NAME})", + "CI must preserve the Hosting integration check name across event routes.") + require_same_repository_pr_guard(hosting_integration, "Hosting integration") + require_runner_expression(hosting_integration, HOSTING_RUNNER, "Hosting integration") + hosting_strategy = hosting_integration.get("strategy") + require(isinstance(hosting_strategy, dict) + and hosting_strategy.get("fail-fast") is False + and hosting_strategy.get("matrix") == HOSTING_MATRIX, + "Hosting integration must use one Windows PR leg and the original hosted non-PR matrix.") hosting_steps = steps(hosting_integration, "hosting-integration") hosting_runs = runs(hosting_steps) integration_run = str(named_step( @@ -501,8 +758,9 @@ def validate(documents: dict[str, dict]) -> None: in integration_run, "Hosting OS matrix must run the real Generic Host integration tests.") windows = ci["jobs"].get("json-file-windows") - require(isinstance(windows, dict) and windows.get("runs-on") == "windows-latest", - "CI must define the Windows JSON lane on windows-latest.") + require(isinstance(windows, dict), "CI must define the Windows JSON lane.") + require_runner_expression(windows, CI_WINDOWS_RUNNER, "Windows JSON lane") + require_same_repository_pr_guard(windows, "Windows JSON lane") windows_steps = steps(windows, "json-file-windows") windows_runs = runs(windows_steps) windows_restores = [command for command in windows_runs if "dotnet restore SmartPipe.Core.slnx" in command] @@ -520,9 +778,10 @@ def validate(documents: dict[str, dict]) -> None: baseline_windows = ci["jobs"].get("baseline-contract-windows") require(isinstance(baseline_windows, dict) - and baseline_windows.get("name") == "Baseline contract (Windows)" - and baseline_windows.get("runs-on") == "windows-latest", + and baseline_windows.get("name") == "Baseline contract (Windows)", "CI must define the uniquely named Windows baseline contract job.") + require_runner_expression(baseline_windows, CI_WINDOWS_RUNNER, "Windows baseline contract lane") + require_same_repository_pr_guard(baseline_windows, "Windows baseline contract lane") baseline_windows_steps = steps(baseline_windows, "Windows baseline contract lane") checkout = baseline_windows_steps[0] require(str(checkout.get("uses", "")).startswith("actions/checkout") @@ -543,6 +802,7 @@ def validate(documents: dict[str, dict]) -> None: explicit_names.append((str(job["name"]), file_name, job_id)) duplicates = {name for name, _, _ in explicit_names if sum(item[0] == name for item in explicit_names) > 1} + duplicates.discard("Cleanup self-hosted workspace") require(not duplicates, f"Required job/check names must be unique: {sorted(duplicates)}") windows_text = "\n".join(windows_runs) @@ -553,6 +813,30 @@ def validate(documents: dict[str, dict]) -> None: "Windows lifecycle filter must not use the obsolete " "SmartPipe.Extensions.Tests.Sinks namespace.") + assert_cleanup_job( + ci, + "ci.yml", + ["validation", "hosting-integration", "json-file-windows", "baseline-contract-windows"], + CLEANUP_PULL_REQUEST_GUARD, + ) + assert_cleanup_job(codeql, "codeql.yml", ["analyze"], CLEANUP_PULL_REQUEST_GUARD) + assert_cleanup_job( + dependency_review, + "dependency-review.yml", + ["dependency-review"], + CLEANUP_PULL_REQUEST_GUARD, + ) + + codeql_job = codeql["jobs"].get("analyze") + require(isinstance(codeql_job, dict), "CodeQL must define the analyze job.") + require_runner_expression(codeql_job, CODEQL_RUNNER, "CodeQL analyze") + require_same_repository_pr_guard(codeql_job, "CodeQL analyze") + dependency_review_job = dependency_review["jobs"].get("dependency-review") + require(isinstance(dependency_review_job, dict), + "Dependency Review must define the dependency-review job.") + require_self_hosted_windows(dependency_review_job, "Dependency Review") + require_same_repository_pr_guard(dependency_review_job, "Dependency Review", allow_non_pr=False) + all_runs = windows_runs + hosting_runs + reusable_runs filtered = [command for command in all_runs if "--filter-class" in command or "--filter-query" in command] @@ -575,6 +859,8 @@ def validate(documents: dict[str, dict]) -> None: require(validation.get("needs") == "version", "Publish validation must depend exactly on version.") require(validation.get("uses") == "./.github/workflows/reusable-release-validation.yml", "Publish validation must call the local reusable workflow.") + require("runner-labels" not in validation.get("with", {}), + "Publish validation must use reusable hosted Linux runner default.") require(validation.get("with") == { "package-version": "${{ needs.version.outputs.package-version }}", "artifact-name": "${{ needs.version.outputs.artifact-name }}", @@ -653,16 +939,31 @@ def _remove_ci_checkpoint_branch(documents: dict[str, dict]) -> None: branches.remove("sp220/checkpoint-c") +def _remove_ci_checkpoint_d_branch(documents: dict[str, dict]) -> None: + branches = documents["ci.yml"]["on"]["pull_request"]["branches"] + branches.remove("sp220/checkpoint-d") + + def _remove_codeql_checkpoint_branch(documents: dict[str, dict]) -> None: branches = documents["codeql.yml"]["on"]["pull_request"]["branches"] branches.remove("sp220/checkpoint-c") +def _remove_codeql_checkpoint_d_branch(documents: dict[str, dict]) -> None: + branches = documents["codeql.yml"]["on"]["pull_request"]["branches"] + branches.remove("sp220/checkpoint-d") + + def _remove_dependency_review_checkpoint_branch(documents: dict[str, dict]) -> None: branches = documents["dependency-review.yml"]["on"]["pull_request"]["branches"] branches.remove("sp220/checkpoint-c") +def _remove_dependency_review_checkpoint_d_branch(documents: dict[str, dict]) -> None: + branches = documents["dependency-review.yml"]["on"]["pull_request"]["branches"] + branches.remove("sp220/checkpoint-d") + + def _remove_linux_offline_verification(documents: dict[str, dict]) -> None: job = documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"] job["steps"] = [step for step in job["steps"] @@ -671,7 +972,9 @@ def _remove_linux_offline_verification(documents: dict[str, dict]) -> None: def _make_windows_offline_network_capable(documents: dict[str, dict]) -> None: job = documents["ci.yml"]["jobs"]["baseline-contract-windows"] - named_step(job["steps"], "Verify 2.1.2 baseline offline")["run"] += "\nInvoke-WebRequest https://example.test" + step = named_step(job["steps"], "Verify 2.1.2 baseline offline") + step["shell"] = "pwsh" + step["run"] += f"\n{NATIVE_FAIL_FAST_GUARD}\nInvoke-WebRequest https://example.test" def _remove_repository_test_minimum(documents: dict[str, dict]) -> None: @@ -718,8 +1021,168 @@ def _add_consumer_logs_to_upload(documents: dict[str, dict]) -> None: upload["with"]["path"] += "\nartifacts/consumers/**/logs/**" -def _remove_hosting_matrix(documents: dict[str, dict]) -> None: - del documents["ci.yml"]["jobs"]["hosting-integration"] +def _use_hosted_runner_for_required_lanes(documents: dict[str, dict]) -> None: + lanes = ( + ("ci.yml", "hosting-integration"), + ("ci.yml", "json-file-windows"), + ("ci.yml", "baseline-contract-windows"), + ("reusable-release-validation.yml", "build-test-pack"), + ("reusable-release-validation.yml", "health-checks-concurrency"), + ("codeql.yml", "analyze"), + ("dependency-review.yml", "dependency-review"), + ) + for workflow_name, job_name in lanes: + documents[workflow_name]["jobs"][job_name]["runs-on"] = "windows-latest" + + +def _make_ci_validation_always_self_hosted(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["validation"]["with"]["runner-labels"] = SELF_HOSTED_WINDOWS_JSON + + +def _make_hosting_always_self_hosted(documents: dict[str, dict]) -> None: + job = documents["ci.yml"]["jobs"]["hosting-integration"] + job["strategy"]["matrix"] = ( + "${{ fromJSON('{\"os\":[\"self-hosted\"]}') }}" + ) + + +def _make_hosting_static_runner(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["hosting-integration"]["runs-on"] = SELF_HOSTED_WINDOWS + + +def _make_ci_json_always_self_hosted(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["json-file-windows"]["runs-on"] = SELF_HOSTED_WINDOWS + + +def _make_ci_baseline_always_self_hosted(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["baseline-contract-windows"]["runs-on"] = SELF_HOSTED_WINDOWS + + +def _make_codeql_always_self_hosted(documents: dict[str, dict]) -> None: + documents["codeql.yml"]["jobs"]["analyze"]["runs-on"] = SELF_HOSTED_WINDOWS + + +def _make_cleanup_non_pr_capable(documents: dict[str, dict], workflow_name: str) -> None: + documents[workflow_name]["jobs"]["cleanup-self-hosted"]["if"] = CLEANUP_SAME_REPOSITORY_GUARD + + +def _remove_ci_runner_override(documents: dict[str, dict]) -> None: + del documents["ci.yml"]["jobs"]["validation"]["with"]["runner-labels"] + + +def _change_runner_default(documents: dict[str, dict]) -> None: + documents["reusable-release-validation.yml"]["on"]["workflow_call"]["inputs"]["runner-labels"][ + "default" + ] = '["windows-latest"]' + + +def _override_publish_runner(documents: dict[str, dict]) -> None: + documents["publish-nuget.yml"]["jobs"]["validation"].setdefault("with", {})[ + "runner-labels" + ] = SELF_HOSTED_WINDOWS_JSON + + +def _remove_leaf_exit_guard(documents: dict[str, dict]) -> None: + leaf = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "SP220-07 leaf tests", + ) + leaf["run"] = "\n".join( + line for line in str(leaf.get("run", "")).splitlines() + if NATIVE_FAIL_FAST_GUARD not in line + ) + + +def _remove_native_fail_fast_guard(documents: dict[str, dict], step_name: str) -> None: + job = documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"] + step = named_step(job["steps"], step_name) + step["run"] = "\n".join( + line for line in str(step.get("run", "")).splitlines() + if NATIVE_FAIL_FAST_GUARD not in line + ) + + +def _remove_multiline_native_fail_fast_guard( + documents: dict[str, dict], + workflow_name: str, + job_name: str, + step_name: str, +) -> None: + job = documents[workflow_name]["jobs"][job_name] + step = named_step(job["steps"], step_name) + step["run"] = "\n".join( + line for line in str(step.get("run", "")).splitlines() + if NATIVE_FAIL_FAST_GUARD not in line + ) + + +def _remove_linux_release_fallback(documents: dict[str, dict]) -> None: + release = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Test release version validation", + ) + release["run"] = str(release.get("run", "")).replace( + "bash eng/tests/validate-release-version.Tests.sh", + "Write-Output 'Linux fallback removed'", + ) + + +def _remove_package_command_shell(documents: dict[str, dict]) -> None: + job = documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"] + step = named_step(job["steps"], "Pack packages from graph") + step.pop("shell", None) + + +def _remove_windows_lychee_step(documents: dict[str, dict]) -> None: + job = documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"] + job["steps"] = [step for step in job["steps"] if step.get("name") != "Docs link check (Windows)"] + + +def _add_lychee_token(documents: dict[str, dict]) -> None: + linux = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Docs link check", + ) + linux["env"] = {"GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}"} + + +def _remove_reusable_pr_guard(documents: dict[str, dict]) -> None: + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"].pop("if", None) + + +def _remove_ci_cleanup_job(documents: dict[str, dict]) -> None: + del documents["ci.yml"]["jobs"]["cleanup-self-hosted"] + + +def _make_ci_cleanup_delete_workspace_root(documents: dict[str, dict]) -> None: + cleanup = named_step( + documents["ci.yml"]["jobs"]["cleanup-self-hosted"]["steps"], + "Cleanup generated outputs", + ) + cleanup["run"] = str(cleanup["run"]) + "\nRemove-Item -LiteralPath $workspace -Recurse -Force" + + +def _remove_cleanup_direct_target_guard(documents: dict[str, dict], workflow_name: str) -> None: + cleanup = named_step( + documents[workflow_name]["jobs"]["cleanup-self-hosted"]["steps"], + "Cleanup generated outputs", + ) + direct_reparse_guard = ( + "if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band " + "[IO.FileAttributes]::ReparsePoint)" + ) + cleanup["run"] = "\n".join( + line for line in str(cleanup.get("run", "")).splitlines() + if direct_reparse_guard not in line + ) + + +def _restore_lychee_action(documents: dict[str, dict]) -> None: + cleanup = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Docs link check", + ) + cleanup["uses"] = "lycheeverse/lychee-action@v2" def _move_graph_before_integrity(documents: dict[str, dict]) -> None: @@ -848,16 +1311,31 @@ def main() -> int: _remove_ci_checkpoint_branch, "ci.yml pull_request must include sp220/checkpoint-c", ) + assert_mutation_rejected( + documents, + _remove_ci_checkpoint_d_branch, + "ci.yml pull_request must include sp220/checkpoint-d", + ) assert_mutation_rejected( documents, _remove_codeql_checkpoint_branch, "codeql.yml pull_request must include sp220/checkpoint-c", ) + assert_mutation_rejected( + documents, + _remove_codeql_checkpoint_d_branch, + "codeql.yml pull_request must include sp220/checkpoint-d", + ) assert_mutation_rejected( documents, _remove_dependency_review_checkpoint_branch, "dependency-review.yml pull_request must include sp220/checkpoint-c", ) + assert_mutation_rejected( + documents, + _remove_dependency_review_checkpoint_d_branch, + "dependency-review.yml pull_request must include sp220/checkpoint-d", + ) assert_mutation_rejected(documents, _remove_linux_offline_verification, "Verify 2.1.2 baseline offline") assert_mutation_rejected(documents, _make_windows_offline_network_capable, "must not be network-capable") assert_mutation_rejected(documents, _remove_repository_test_minimum, "--minimum-expected-tests 1") @@ -865,7 +1343,7 @@ def main() -> int: assert_mutation_rejected( documents, _make_linux_baseline_checkout_shallow, - "Reusable Linux baseline lane baseline verification checkout must fetch full Git history.", + "Reusable Windows baseline lane baseline verification checkout must fetch full Git history.", ) assert_mutation_rejected( documents, @@ -889,8 +1367,134 @@ def main() -> int: ) assert_mutation_rejected( documents, - _remove_hosting_matrix, - "Hosting integration OS matrix", + _use_hosted_runner_for_required_lanes, + "runner-labels workflow input", + ) + assert_mutation_rejected( + documents, + _make_ci_validation_always_self_hosted, + "exact reusable workflow caller", + ) + assert_mutation_rejected( + documents, + _make_hosting_always_self_hosted, + "original hosted non-PR matrix", + ) + assert_mutation_rejected( + documents, + _make_hosting_static_runner, + "event-aware runner expression", + ) + assert_mutation_rejected( + documents, + _make_ci_json_always_self_hosted, + "event-aware runner expression", + ) + assert_mutation_rejected( + documents, + _make_ci_baseline_always_self_hosted, + "event-aware runner expression", + ) + assert_mutation_rejected( + documents, + _make_codeql_always_self_hosted, + "event-aware runner expression", + ) + assert_mutation_rejected( + documents, + _remove_ci_runner_override, + "exact reusable workflow caller", + ) + assert_mutation_rejected( + documents, + _change_runner_default, + "default runner-labels to hosted Linux", + ) + assert_mutation_rejected( + documents, + _override_publish_runner, + "Publish validation must use reusable hosted Linux runner default", + ) + assert_mutation_rejected( + documents, + _remove_leaf_exit_guard, + "fail immediately after every failed project test", + ) + for step_name in ( + "Extensions correctness regressions", + "PR concurrency regression repeat", + "Test and benchmark warning gate", + ): + assert_mutation_rejected( + documents, + lambda docs, name=step_name: _remove_native_fail_fast_guard(docs, name), + f"Every dotnet command in {step_name} must immediately fail", + ) + for workflow_name, job_name, step_name in ( + ("ci.yml", "json-file-windows", "JSON file source, path, open, and share tests"), + ("ci.yml", "json-file-windows", "JSON file sink and dispose tests"), + ("ci.yml", "json-file-windows", "Dead-letter source and sink tests"), + ("reusable-release-validation.yml", "health-checks-concurrency", "Build concurrency projects"), + ("reusable-release-validation.yml", "build-test-pack", "Vulnerable package scan"), + ): + label = f"{workflow_name}:{job_name}:{step_name}" + assert_mutation_rejected( + documents, + lambda docs, wf=workflow_name, job=job_name, step=step_name: + _remove_multiline_native_fail_fast_guard(docs, wf, job, step), + f"Every dotnet command in multiline {label} must immediately fail", + ) + assert_mutation_rejected( + documents, + _remove_linux_release_fallback, + "verified Git Bash on Windows and bash on hosted Linux", + ) + assert_mutation_rejected( + documents, + _remove_package_command_shell, + "package commands must use PowerShell on hosted Linux and Windows", + ) + assert_mutation_rejected( + documents, + _remove_reusable_pr_guard, + "same-repository pull_request guard", + ) + assert_mutation_rejected( + documents, + _remove_ci_cleanup_job, + "must define cleanup-self-hosted", + ) + for workflow_name in ("ci.yml", "codeql.yml", "dependency-review.yml"): + assert_mutation_rejected( + documents, + lambda docs, name=workflow_name: _make_cleanup_non_pr_capable(docs, name), + f"{workflow_name} cleanup must always run only for trusted repository work", + ) + assert_mutation_rejected( + documents, + _make_ci_cleanup_delete_workspace_root, + "must not delete the workspace root", + ) + for workflow_name in ("ci.yml", "codeql.yml", "dependency-review.yml"): + assert_mutation_rejected( + documents, + lambda docs, name=workflow_name: _remove_cleanup_direct_target_guard(docs, name), + f"{workflow_name} cleanup must reject direct target reparse points", + ) + assert_mutation_rejected( + documents, + _restore_lychee_action, + "Linux Docs link check must retain the pinned Lychee action", + ) + assert_mutation_rejected( + documents, + _remove_windows_lychee_step, + "Docs link check (Windows)", + ) + assert_mutation_rejected( + documents, + _add_lychee_token, + "must not expose or require GITHUB_TOKEN", ) assert_mutation_rejected( documents, diff --git a/src/SmartPipe.Extensions.Channels/ChannelMerge.cs b/src/SmartPipe.Extensions.Channels/ChannelMerge.cs new file mode 100644 index 0000000..5e85890 --- /dev/null +++ b/src/SmartPipe.Extensions.Channels/ChannelMerge.cs @@ -0,0 +1,252 @@ +using System.Threading.Channels; + +namespace SmartPipe.Extensions; + +/// +/// Provides methods for merging multiple streams into a single reader. +/// Items from all readers are interleaved as they arrive. +/// +public static class ChannelMerge +{ + /// + /// Merges two instances into a single channel reader. + /// Both readers are pumped concurrently, and items are written to the output as they arrive. + /// Uses unbounded channel by default; pass for bounded capacity. + /// + /// The type of items in the channels. + /// The first channel reader. + /// The second channel reader. + /// Optional bounded channel options. If null, an unbounded channel is created. + /// A that receives items from both input readers. +#pragma warning disable RS0027 // Existing optional overload preserved for source compatibility. + public static ChannelReader Merge( + ChannelReader first, + ChannelReader second, + BoundedChannelOptions? options = null) + { + return Merge(first, second, options, CancellationToken.None); + } +#pragma warning restore RS0027 + + /// + /// Merges two instances into a single channel reader. + /// Both readers are pumped concurrently until they complete or cancellation is requested. + /// Uses unbounded channel by default; pass for bounded capacity. + /// + /// The type of items in the channels. + /// The first channel reader. + /// The second channel reader. + /// Optional bounded channel options. If null, an unbounded channel is created. + /// A token that cancels both input pumps. + /// A that receives items from both input readers. + public static ChannelReader Merge( + ChannelReader first, + ChannelReader second, + BoundedChannelOptions? options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + + return MergeMany( + new[] { first, second }, + options, + cancellationToken); + } + + /// + /// Merges all readers into a single channel reader. + /// Each reader is pumped concurrently and retains its own item order. + /// + /// The type of items in the channels. + /// The readers to merge. + /// A reader receiving items from every input reader. + public static ChannelReader Merge( + IReadOnlyList> readers) + { + return MergeMany(readers, null, CancellationToken.None); + } + + /// + /// Merges all readers into a single channel reader with output configuration and cancellation support. + /// + /// The type of items in the channels. + /// The readers to merge. + /// Optional bounded output channel options. + /// A token that cancels all input pumps. + /// A reader receiving items from every input reader. + public static ChannelReader MergeMany( + IReadOnlyList> readers, + BoundedChannelOptions? options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(readers); + + var readerSnapshot = new ChannelReader[readers.Count]; + for (var index = 0; index < readers.Count; index++) + { + readerSnapshot[index] = readers[index] + ?? throw new ArgumentNullException(nameof(readers)); + } + + var output = CreateOutput(options); + if (readerSnapshot.Length == 0) + { + output.Writer.TryComplete(); + return output.Reader; + } + + _ = CompleteMergeAsync( + readerSnapshot, + output.Writer, + cancellationToken); + + return output.Reader; + } + + private static Channel CreateOutput(BoundedChannelOptions? options) + { + if (options is null) + return Channel.CreateUnbounded(); + + var snapshot = new BoundedChannelOptions(options.Capacity) + { + FullMode = options.FullMode, + SingleReader = options.SingleReader, + SingleWriter = false, + AllowSynchronousContinuations = options.AllowSynchronousContinuations, + }; + + return Channel.CreateBounded(snapshot); + } + + private static async Task CompleteMergeAsync( + IReadOnlyList> readers, + ChannelWriter writer, + CancellationToken externalCancellationToken) + { + var coordinator = new MergeFailureCoordinator(externalCancellationToken); + using var pumpCancellation = CancellationTokenSource.CreateLinkedTokenSource( + externalCancellationToken); + var pumps = new Task[readers.Count]; + + for (var index = 0; index < readers.Count; index++) + { + pumps[index] = PumpAndCancelOnFailureAsync( + readers[index], + writer, + pumpCancellation, + coordinator, + index); + } + + try + { + await Task.WhenAll(pumps).ConfigureAwait(false); + } + catch + { + // Completion is coordinated explicitly so sibling cancellation cannot + // replace an observed input failure. + } + finally + { + writer.TryComplete(coordinator.GetCompletionError()); + } + } + + private static async Task PumpAndCancelOnFailureAsync( + ChannelReader reader, + ChannelWriter writer, + CancellationTokenSource cancellationSource, + MergeFailureCoordinator coordinator, + int readerIndex) + { + try + { + await PumpAsync(reader, writer, cancellationSource.Token).ConfigureAwait(false); + } + catch (Exception exception) + { + if (exception is not OperationCanceledException || !cancellationSource.IsCancellationRequested) + { + coordinator.RecordInputFailure(readerIndex, exception); + try + { + await cancellationSource.CancelAsync().ConfigureAwait(false); + } + catch (Exception cancellationFailure) + { + coordinator.RecordCancellationFailure(cancellationFailure); + } + } + + throw; + } + } + + private static async Task PumpAsync( + ChannelReader reader, + ChannelWriter writer, + CancellationToken cancellationToken) + { + await foreach ( + var item in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + while (await writer.WaitToWriteAsync(cancellationToken).ConfigureAwait(false)) + { + if (writer.TryWrite(item)) + break; + } + } + } + + private sealed class MergeFailureCoordinator(CancellationToken externalCancellationToken) + { + private readonly object _gate = new(); + private readonly CancellationToken _externalCancellationToken = externalCancellationToken; + private readonly List _inputFailures = []; + private Exception? _cancellationFailure; + + public Exception? GetCompletionError() + { + lock (_gate) + { + if (_inputFailures.Count > 0) + { + var primary = _inputFailures[0]; + for (var index = 1; index < _inputFailures.Count; index++) + { + if (_inputFailures[index].ReaderIndex < primary.ReaderIndex) + primary = _inputFailures[index]; + } + + return _cancellationFailure is null + ? primary.Exception + : new AggregateException(primary.Exception, _cancellationFailure); + } + + if (_cancellationFailure is not null) + return _cancellationFailure; + } + + return _externalCancellationToken.IsCancellationRequested + ? new OperationCanceledException(_externalCancellationToken) + : null; + } + + public void RecordCancellationFailure(Exception exception) + { + lock (_gate) + _cancellationFailure ??= exception; + } + + public void RecordInputFailure(int readerIndex, Exception exception) + { + lock (_gate) + _inputFailures.Add(new InputFailure(readerIndex, exception)); + } + + private sealed record InputFailure(int ReaderIndex, Exception Exception); + } +} diff --git a/src/SmartPipe.Extensions.Channels/PublicAPI.Shipped.txt b/src/SmartPipe.Extensions.Channels/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/SmartPipe.Extensions.Channels/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/SmartPipe.Extensions.Channels/PublicAPI.Unshipped.txt b/src/SmartPipe.Extensions.Channels/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..949de00 --- /dev/null +++ b/src/SmartPipe.Extensions.Channels/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +#nullable enable +SmartPipe.Extensions.ChannelMerge +static SmartPipe.Extensions.ChannelMerge.Merge(System.Threading.Channels.ChannelReader! first, System.Threading.Channels.ChannelReader! second, System.Threading.Channels.BoundedChannelOptions? options = null) -> System.Threading.Channels.ChannelReader! +static SmartPipe.Extensions.ChannelMerge.Merge(System.Threading.Channels.ChannelReader! first, System.Threading.Channels.ChannelReader! second, System.Threading.Channels.BoundedChannelOptions? options, System.Threading.CancellationToken cancellationToken) -> System.Threading.Channels.ChannelReader! +static SmartPipe.Extensions.ChannelMerge.Merge(System.Collections.Generic.IReadOnlyList!>! readers) -> System.Threading.Channels.ChannelReader! +static SmartPipe.Extensions.ChannelMerge.MergeMany(System.Collections.Generic.IReadOnlyList!>! readers, System.Threading.Channels.BoundedChannelOptions? options, System.Threading.CancellationToken cancellationToken) -> System.Threading.Channels.ChannelReader! diff --git a/src/SmartPipe.Extensions.Channels/README.md b/src/SmartPipe.Extensions.Channels/README.md new file mode 100644 index 0000000..00ae0fb --- /dev/null +++ b/src/SmartPipe.Extensions.Channels/README.md @@ -0,0 +1,3 @@ +# SmartPipe.Extensions.Channels + +Channel merge primitives for SmartPipe.Core. diff --git a/src/SmartPipe.Extensions.Channels/SmartPipe.Extensions.Channels.csproj b/src/SmartPipe.Extensions.Channels/SmartPipe.Extensions.Channels.csproj new file mode 100644 index 0000000..6794b50 --- /dev/null +++ b/src/SmartPipe.Extensions.Channels/SmartPipe.Extensions.Channels.csproj @@ -0,0 +1,27 @@ + + + + true + + + + SmartPipe.Extensions.Channels + Channel merge primitives for SmartPipe.Core. + SmartPipe;pipeline;channels + $(MSBuildProjectDirectory)/README.md + true + true + true + true + + + + + + + + + + + + diff --git a/src/SmartPipe.Extensions.Channels/packages.lock.json b/src/SmartPipe.Extensions.Channels/packages.lock.json new file mode 100644 index 0000000..6ce8da1 --- /dev/null +++ b/src/SmartPipe.Extensions.Channels/packages.lock.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + }, + "smartpipe.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + } + } + } +} \ No newline at end of file diff --git a/src/SmartPipe.Extensions.DataAnnotations/FilterValidationExtensions.cs b/src/SmartPipe.Extensions.DataAnnotations/FilterValidationExtensions.cs new file mode 100644 index 0000000..3946d22 --- /dev/null +++ b/src/SmartPipe.Extensions.DataAnnotations/FilterValidationExtensions.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms; + +/// Extension methods for converting validation transforms to filters. +public static class FilterValidationExtensions +{ + private const string ReflectionContract = + "Reflection-based DataAnnotations validation is not trimming-safe."; + + /// + /// Converts a validation transform into a filter. Invalid items are filtered out. + /// + /// The data type. + /// The validation transform to convert. + /// A token-aware filter backed by the validation transform. + [RequiresUnreferencedCode(ReflectionContract)] + public static FilterTransform ToFilter(this ValidationTransform validator) => + new FilterTransform(async (item, ct) => + { + var result = await validator.TransformAsync( + ProcessingEnvelope.Create(item), ct).ConfigureAwait(false); + return result.IsSuccess; + }); +} diff --git a/src/SmartPipe.Extensions.DataAnnotations/PublicAPI.Shipped.txt b/src/SmartPipe.Extensions.DataAnnotations/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/SmartPipe.Extensions.DataAnnotations/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/SmartPipe.Extensions.DataAnnotations/PublicAPI.Unshipped.txt b/src/SmartPipe.Extensions.DataAnnotations/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..68225c7 --- /dev/null +++ b/src/SmartPipe.Extensions.DataAnnotations/PublicAPI.Unshipped.txt @@ -0,0 +1,9 @@ +#nullable enable +SmartPipe.Extensions.Transforms.FilterValidationExtensions +SmartPipe.Extensions.Transforms.ValidationTransform +SmartPipe.Extensions.Transforms.ValidationTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.ValidationTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.ValidationTransform.Require(System.Func! condition, string! message) -> SmartPipe.Extensions.Transforms.ValidationTransform! +SmartPipe.Extensions.Transforms.ValidationTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +SmartPipe.Extensions.Transforms.ValidationTransform.ValidationTransform() -> void +static SmartPipe.Extensions.Transforms.FilterValidationExtensions.ToFilter(this SmartPipe.Extensions.Transforms.ValidationTransform! validator) -> SmartPipe.Extensions.Transforms.FilterTransform! diff --git a/src/SmartPipe.Extensions.DataAnnotations/README.md b/src/SmartPipe.Extensions.DataAnnotations/README.md new file mode 100644 index 0000000..106f380 --- /dev/null +++ b/src/SmartPipe.Extensions.DataAnnotations/README.md @@ -0,0 +1,18 @@ +# SmartPipe.Extensions.DataAnnotations + +DataAnnotations validation transforms for SmartPipe.Core. + +`ValidationTransform` keeps the existing public namespace +`SmartPipe.Extensions.Transforms` and combines object/property DataAnnotations +validation with fluent `Require` rules. Validation follows +`Validator.TryValidateObject` and is deliberately non-recursive: nested object +properties are not walked. + +Rules are mutable during configuration and freeze on initialization or the +first execution. Adding a rule after that point throws +`InvalidOperationException`. `ToFilter()` forwards the pipeline cancellation +token to validation and filters invalid items. + +The reflection-based `TransformAsync` and `ToFilter` APIs carry +`RequiresUnreferencedCode`; use them only when the required DataAnnotations +metadata is preserved by the application. diff --git a/src/SmartPipe.Extensions.DataAnnotations/SmartPipe.Extensions.DataAnnotations.csproj b/src/SmartPipe.Extensions.DataAnnotations/SmartPipe.Extensions.DataAnnotations.csproj new file mode 100644 index 0000000..f6b20ff --- /dev/null +++ b/src/SmartPipe.Extensions.DataAnnotations/SmartPipe.Extensions.DataAnnotations.csproj @@ -0,0 +1,28 @@ + + + + true + + + + SmartPipe.Extensions.DataAnnotations + DataAnnotations validation transforms for SmartPipe.Core. + SmartPipe;pipeline;validation;dataannotations + $(MSBuildProjectDirectory)/README.md + true + true + true + true + + + + + + + + + + + + + diff --git a/src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs b/src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs new file mode 100644 index 0000000..fe3aeff --- /dev/null +++ b/src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs @@ -0,0 +1,87 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms; + +/// +/// Validates items with DataAnnotations attributes and custom validation rules. +/// +/// The data type to validate. +public class ValidationTransform : IPipelineTransformer +{ + private const string ReflectionContract = + "Reflection-based DataAnnotations validation is not trimming-safe."; + + private readonly object _sync = new(); + private readonly List> _rules = []; + private Func[]? _frozenRules; + + /// Adds a custom validation rule to the transform. + /// The condition that must be true for validation to pass. + /// The error message if the condition fails. + /// This transform instance for fluent chaining. + public ValidationTransform Require(Func condition, string message) + { + lock (_sync) + { + if (_frozenRules is not null) + throw new InvalidOperationException("Validation rules are frozen."); + + _rules.Add(x => condition(x) ? null : message); + } + + return this; + } + + /// + public ValueTask InitializeAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + Freeze(); + return ValueTask.CompletedTask; + } + + /// +#pragma warning disable IL2046 // IPipelineTransformer predates the RUC contract; direct calls remain annotated. + [RequiresUnreferencedCode(ReflectionContract)] + public ValueTask> TransformAsync( + ProcessingEnvelope envelope, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + Func[] rules = Freeze(); + var errors = new List(); + T payload = envelope.Payload!; + + var validationResults = new List(); + var validationContext = new ValidationContext(payload!); + if (!Validator.TryValidateObject(payload!, validationContext, validationResults, true)) + errors.AddRange(validationResults.Select(r => r.ErrorMessage ?? "Validation failed")); + + ct.ThrowIfCancellationRequested(); + foreach (Func rule in rules) + { + var error = rule(payload); + if (error is not null) + errors.Add(error); + ct.ThrowIfCancellationRequested(); + } + + return errors.Count == 0 + ? ValueTask.FromResult(StageResult.Success(payload)) + : ValueTask.FromResult( + StageResult.Failure( + new SmartPipeError(string.Join("; ", errors), ErrorType.Permanent, "Validation"))); + } +#pragma warning restore IL2046 + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private Func[] Freeze() + { + lock (_sync) + return _frozenRules ??= [.. _rules]; + } +} diff --git a/src/SmartPipe.Extensions.DataAnnotations/packages.lock.json b/src/SmartPipe.Extensions.DataAnnotations/packages.lock.json new file mode 100644 index 0000000..c23e00b --- /dev/null +++ b/src/SmartPipe.Extensions.DataAnnotations/packages.lock.json @@ -0,0 +1,46 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + }, + "smartpipe.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + } + }, + "smartpipe.extensions.transforms": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + } + } + } +} \ No newline at end of file diff --git a/src/SmartPipe.Extensions.Logging/LoggerSink.cs b/src/SmartPipe.Extensions.Logging/LoggerSink.cs new file mode 100644 index 0000000..f3048af --- /dev/null +++ b/src/SmartPipe.Extensions.Logging/LoggerSink.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging; +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Sinks; + +/// Sink that logs processing results using . +/// Data type. +public partial class LoggerSink : IPipelineSink +{ + private const int MaximumAllowedFormattedPayloadLength = 64 * 1024; + + private readonly ILogger> _logger; + private readonly LoggerSinkOptions? _options; + + /// Creates the legacy raw-payload logger sink. + /// Logger instance. + /// This constructor preserves the shipped raw-payload compatibility behavior. + public LoggerSink(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// Creates a logger sink with an explicit payload exposure policy. + /// Logger instance. + /// Safe payload exposure options. + public LoggerSink(ILogger> logger, LoggerSinkOptions options) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = ValidateOptions(options); + } + + /// + public ValueTask InitializeAsync(CancellationToken ct = default) => ValueTask.CompletedTask; + + /// + public ValueTask WriteAsync(ProcessingEnvelope envelope, CancellationToken ct = default) + { + if (_options is null) + { + _logger.LogInformation( + "Processed item [TraceId: {TraceId}] successfully. Value: {@Value}", + envelope.TraceId, + envelope.Payload); + + return ValueTask.CompletedTask; + } + + if (_options.PayloadMode is LoggerSinkPayloadMode.UnsafeRaw) + { + _logger.LogInformation( + "Processed item [TraceId: {TraceId}] successfully. Value: {@Value}", + envelope.TraceId, + envelope.Payload); + + return ValueTask.CompletedTask; + } + + if (!_logger.IsEnabled(LogLevel.Information)) + return ValueTask.CompletedTask; + + if (_options.PayloadMode is LoggerSinkPayloadMode.Formatted) + { + var formattedPayload = _options.Formatter!(envelope.Payload); + formattedPayload = formattedPayload is null || formattedPayload.Length <= _options.MaximumFormattedPayloadLength + ? formattedPayload + : formattedPayload[.._options.MaximumFormattedPayloadLength]; + + if (_options.IncludeTraceId) + LogFormatted(_logger, envelope.TraceId, formattedPayload); + else + LogFormattedWithoutTrace(_logger, formattedPayload); + } + else if (_options.IncludeTraceId) + { + LogProcessed(_logger, envelope.TraceId); + } + else + { + LogProcessedWithoutTrace(_logger); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private static LoggerSinkOptions ValidateOptions(LoggerSinkOptions? options) + { + ArgumentNullException.ThrowIfNull(options); + + if (!Enum.IsDefined(options.PayloadMode)) + throw new ArgumentOutOfRangeException(nameof(options), "PayloadMode is not defined."); + + if (options.MaximumFormattedPayloadLength is <= 0 or > MaximumAllowedFormattedPayloadLength) + { + throw new ArgumentOutOfRangeException( + nameof(options), + $"MaximumFormattedPayloadLength must be between 1 and {MaximumAllowedFormattedPayloadLength}."); + } + + if (options.PayloadMode is LoggerSinkPayloadMode.Formatted && options.Formatter is null) + throw new ArgumentException("A formatter is required for formatted payload mode.", nameof(options)); + + if (options.PayloadMode is not LoggerSinkPayloadMode.Formatted && options.Formatter is not null) + throw new ArgumentException("Formatter is only valid for formatted payload mode.", nameof(options)); + + return options; + } + + [LoggerMessage(1000, LogLevel.Information, "Processed item [TraceId: {TraceId}] successfully.", EventName = "SmartPipeItem")] + private static partial void LogProcessed(ILogger logger, ulong traceId); + + [LoggerMessage(1000, LogLevel.Information, "Processed item successfully.", EventName = "SmartPipeItemWithoutTrace")] + private static partial void LogProcessedWithoutTrace(ILogger logger); + + [LoggerMessage(1000, LogLevel.Information, "Processed item [TraceId: {TraceId}] successfully. FormattedPayload: {FormattedPayload}", EventName = "SmartPipeItemFormatted")] + private static partial void LogFormatted(ILogger logger, ulong traceId, string? formattedPayload); + + [LoggerMessage(1000, LogLevel.Information, "Processed item successfully. FormattedPayload: {FormattedPayload}", EventName = "SmartPipeItemFormattedWithoutTrace")] + private static partial void LogFormattedWithoutTrace(ILogger logger, string? formattedPayload); +} diff --git a/src/SmartPipe.Extensions.Logging/LoggerSinkOptions.cs b/src/SmartPipe.Extensions.Logging/LoggerSinkOptions.cs new file mode 100644 index 0000000..bf2afeb --- /dev/null +++ b/src/SmartPipe.Extensions.Logging/LoggerSinkOptions.cs @@ -0,0 +1,32 @@ +using System; + +namespace SmartPipe.Extensions.Sinks; + +/// Controls the payload exposure of the safe constructor. +public sealed record LoggerSinkOptions +{ + /// Gets the payload logging mode. + public LoggerSinkPayloadMode PayloadMode { get; init; } = LoggerSinkPayloadMode.None; + + /// Gets whether the safe event includes the envelope trace identifier. + public bool IncludeTraceId { get; init; } = true; + + /// Gets the formatter used when is formatted. + public Func? Formatter { get; init; } + + /// Gets the maximum number of characters emitted by . + public int MaximumFormattedPayloadLength { get; init; } = 1024; +} + +/// Payload exposure modes for the safe logger sink constructor. +public enum LoggerSinkPayloadMode +{ + /// Do not log the payload. + None = 0, + + /// Log only the bounded string returned by the configured formatter. + Formatted = 1, + + /// Explicitly opt in to the legacy raw-payload event. + UnsafeRaw = 2, +} diff --git a/src/SmartPipe.Extensions.Logging/PublicAPI.Shipped.txt b/src/SmartPipe.Extensions.Logging/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/SmartPipe.Extensions.Logging/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/SmartPipe.Extensions.Logging/PublicAPI.Unshipped.txt b/src/SmartPipe.Extensions.Logging/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..442b6a5 --- /dev/null +++ b/src/SmartPipe.Extensions.Logging/PublicAPI.Unshipped.txt @@ -0,0 +1,29 @@ +#nullable enable + +SmartPipe.Extensions.Sinks.LoggerSink +SmartPipe.Extensions.Sinks.LoggerSink.DisposeAsync() -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Sinks.LoggerSink.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Sinks.LoggerSink.LoggerSink(Microsoft.Extensions.Logging.ILogger!>! logger) -> void +SmartPipe.Extensions.Sinks.LoggerSink.LoggerSink(Microsoft.Extensions.Logging.ILogger!>! logger, SmartPipe.Extensions.Sinks.LoggerSinkOptions! options) -> void +SmartPipe.Extensions.Sinks.LoggerSink.WriteAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Sinks.LoggerSinkOptions +SmartPipe.Extensions.Sinks.LoggerSinkOptions.$() -> SmartPipe.Extensions.Sinks.LoggerSinkOptions! +override SmartPipe.Extensions.Sinks.LoggerSinkOptions.Equals(object? obj) -> bool +SmartPipe.Extensions.Sinks.LoggerSinkOptions.Equals(SmartPipe.Extensions.Sinks.LoggerSinkOptions? other) -> bool +SmartPipe.Extensions.Sinks.LoggerSinkOptions.Formatter.get -> System.Func? +SmartPipe.Extensions.Sinks.LoggerSinkOptions.Formatter.init -> void +override SmartPipe.Extensions.Sinks.LoggerSinkOptions.GetHashCode() -> int +SmartPipe.Extensions.Sinks.LoggerSinkOptions.IncludeTraceId.get -> bool +SmartPipe.Extensions.Sinks.LoggerSinkOptions.IncludeTraceId.init -> void +SmartPipe.Extensions.Sinks.LoggerSinkOptions.LoggerSinkOptions() -> void +SmartPipe.Extensions.Sinks.LoggerSinkOptions.MaximumFormattedPayloadLength.get -> int +SmartPipe.Extensions.Sinks.LoggerSinkOptions.MaximumFormattedPayloadLength.init -> void +SmartPipe.Extensions.Sinks.LoggerSinkOptions.PayloadMode.get -> SmartPipe.Extensions.Sinks.LoggerSinkPayloadMode +SmartPipe.Extensions.Sinks.LoggerSinkOptions.PayloadMode.init -> void +override SmartPipe.Extensions.Sinks.LoggerSinkOptions.ToString() -> string! +static SmartPipe.Extensions.Sinks.LoggerSinkOptions.operator !=(SmartPipe.Extensions.Sinks.LoggerSinkOptions? left, SmartPipe.Extensions.Sinks.LoggerSinkOptions? right) -> bool +static SmartPipe.Extensions.Sinks.LoggerSinkOptions.operator ==(SmartPipe.Extensions.Sinks.LoggerSinkOptions? left, SmartPipe.Extensions.Sinks.LoggerSinkOptions? right) -> bool +SmartPipe.Extensions.Sinks.LoggerSinkPayloadMode +SmartPipe.Extensions.Sinks.LoggerSinkPayloadMode.Formatted = 1 -> SmartPipe.Extensions.Sinks.LoggerSinkPayloadMode +SmartPipe.Extensions.Sinks.LoggerSinkPayloadMode.None = 0 -> SmartPipe.Extensions.Sinks.LoggerSinkPayloadMode +SmartPipe.Extensions.Sinks.LoggerSinkPayloadMode.UnsafeRaw = 2 -> SmartPipe.Extensions.Sinks.LoggerSinkPayloadMode diff --git a/src/SmartPipe.Extensions.Logging/README.md b/src/SmartPipe.Extensions.Logging/README.md new file mode 100644 index 0000000..ddd3fc0 --- /dev/null +++ b/src/SmartPipe.Extensions.Logging/README.md @@ -0,0 +1,21 @@ +# SmartPipe.Extensions.Logging + +Logging sinks for SmartPipe.Core. + +`LoggerSink(ILogger>)` remains the legacy raw-payload +compatibility path. It keeps the existing Information-level message and +structured `TraceId`/`Value` fields. + +Use the additive options constructor for a safe default: + +```csharp +var sink = new LoggerSink( + logger, + new LoggerSinkOptions()); +``` + +The default `LoggerSinkPayloadMode.None` records the trace identifier without +the payload. `Formatted` accepts a caller-owned formatter and truncates its +result to `MaximumFormattedPayloadLength`; the formatter is skipped when +Information logging is disabled. `UnsafeRaw` is an explicit opt-in to the +legacy raw-payload event. diff --git a/src/SmartPipe.Extensions.Logging/SmartPipe.Extensions.Logging.csproj b/src/SmartPipe.Extensions.Logging/SmartPipe.Extensions.Logging.csproj new file mode 100644 index 0000000..8e92fb4 --- /dev/null +++ b/src/SmartPipe.Extensions.Logging/SmartPipe.Extensions.Logging.csproj @@ -0,0 +1,28 @@ + + + + true + + + + SmartPipe.Extensions.Logging + Logging sinks for SmartPipe.Core. + SmartPipe;pipeline;logging + $(MSBuildProjectDirectory)/README.md + true + true + true + true + + + + + + + + + + + + + diff --git a/src/SmartPipe.Extensions.Logging/packages.lock.json b/src/SmartPipe.Extensions.Logging/packages.lock.json new file mode 100644 index 0000000..4f2d8ef --- /dev/null +++ b/src/SmartPipe.Extensions.Logging/packages.lock.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + }, + "smartpipe.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + } + } + } +} \ No newline at end of file diff --git a/src/SmartPipe.Extensions.Transforms/CompositeTransform.cs b/src/SmartPipe.Extensions.Transforms/CompositeTransform.cs new file mode 100644 index 0000000..356d7fd --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/CompositeTransform.cs @@ -0,0 +1,131 @@ +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms; + +/// Combines transforms sequentially and owns their asynchronous lifecycle. +public class CompositeTransform : IPipelineTransformer +{ + private readonly object _sync = new(); + private readonly IPipelineTransformer[] _transforms; + private readonly List> _acquired = []; + private Task? _initializeTask; + private Task? _disposeTask; + private bool _initialized; + + /// Initializes a composite from transforms in acquisition order. + public CompositeTransform(params IPipelineTransformer[] transforms) + { + ArgumentNullException.ThrowIfNull(transforms); + if (Array.Exists(transforms, static transform => transform is null)) + throw new ArgumentException("Transforms cannot contain null elements.", nameof(transforms)); + + _transforms = [.. transforms]; + } + + /// + public ValueTask InitializeAsync(CancellationToken ct = default) + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposeTask is not null, this); + _initializeTask ??= InitializeCoreAsync(ct); + return new ValueTask(_initializeTask); + } + } + + /// + public async ValueTask> TransformAsync( + ProcessingEnvelope envelope, + CancellationToken ct = default) + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposeTask is not null, this); + if (!_initialized) + throw new InvalidOperationException("The composite must be initialized before transforming items."); + } + + var current = envelope; + foreach (IPipelineTransformer transform in _transforms) + { + StageResult result = await transform.TransformAsync(current, ct).ConfigureAwait(false); + if (!result.IsSuccess) + return result; + + current = current with { Payload = result.Value! }; + } + + return StageResult.Success(current.Payload); + } + + /// + public ValueTask DisposeAsync() + { + lock (_sync) + { + _disposeTask ??= DisposeCoreAsync(_initializeTask); + return new ValueTask(_disposeTask); + } + } + + private async Task InitializeCoreAsync(CancellationToken ct) + { + try + { + foreach (IPipelineTransformer transform in _transforms) + { + _acquired.Add(transform); + await transform.InitializeAsync(ct).ConfigureAwait(false); + } + + lock (_sync) + _initialized = true; + } + catch (Exception primary) + { + List errors = [primary]; + await CleanupAsync(errors).ConfigureAwait(false); + if (errors.Count == 1) + throw; + + throw new AggregateException(errors); + } + } + + private async Task DisposeCoreAsync(Task? initializeTask) + { + if (initializeTask is not null) + { + try + { + await initializeTask.ConfigureAwait(false); + } + catch + { + // Initialization reports its own primary failure and performs rollback. + } + } + + var errors = new List(); + await CleanupAsync(errors).ConfigureAwait(false); + if (errors.Count > 0) + throw new AggregateException(errors); + } + + private async Task CleanupAsync(List errors) + { + for (int i = _acquired.Count - 1; i >= 0; i--) + { + try + { + await _acquired[i].DisposeAsync().ConfigureAwait(false); + } + catch (Exception error) + { + errors.Add(error); + } + } + + _acquired.Clear(); + } +} diff --git a/src/SmartPipe.Extensions.Transforms/CompressionTransform.cs b/src/SmartPipe.Extensions.Transforms/CompressionTransform.cs new file mode 100644 index 0000000..a65d8d1 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/CompressionTransform.cs @@ -0,0 +1,74 @@ +using System.IO.Compression; +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms; + +/// Supported byte-array compression algorithms. +public enum CompressionAlgorithm +{ + /// Brotli compression. + Brotli, + + /// GZip compression. + GZip, +} + +/// Compresses byte arrays using Brotli or GZip. +public class CompressionTransform : IPipelineTransformer +{ + private readonly CompressionAlgorithm _algorithm; + private readonly CompressionLevel _level; + + /// Initializes a byte-array compression transform. + public CompressionTransform( + CompressionAlgorithm algorithm = CompressionAlgorithm.Brotli, + CompressionLevel level = CompressionLevel.Optimal) + { + if (!Enum.IsDefined(algorithm)) + throw new ArgumentOutOfRangeException(nameof(algorithm)); + if (!Enum.IsDefined(level)) + throw new ArgumentOutOfRangeException(nameof(level)); + + _algorithm = algorithm; + _level = level; + } + + /// + public ValueTask InitializeAsync(CancellationToken ct = default) => ValueTask.CompletedTask; + + /// + public ValueTask> TransformAsync( + ProcessingEnvelope envelope, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + try + { + using var output = new MemoryStream(); + using (Stream compressor = _algorithm switch + { + CompressionAlgorithm.Brotli => new BrotliStream(output, _level), + CompressionAlgorithm.GZip => new GZipStream(output, _level), + _ => throw new ArgumentOutOfRangeException(nameof(_algorithm)), + }) + { + compressor.Write(envelope.Payload); + } + + return ValueTask.FromResult(StageResult.Success(output.ToArray())); + } + catch (IOException error) + { + return ValueTask.FromResult(StageResult.Failure( + new SmartPipeError($"Compression IO error: {error.Message}", ErrorType.Transient, "Compression", error))); + } + catch (NotSupportedException error) + { + return ValueTask.FromResult(StageResult.Failure( + new SmartPipeError($"Compression not supported: {error.Message}", ErrorType.Permanent, "Compression", error))); + } + } + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/SmartPipe.Extensions.Transforms/ConditionalTransform.cs b/src/SmartPipe.Extensions.Transforms/ConditionalTransform.cs new file mode 100644 index 0000000..1bb0ab0 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/ConditionalTransform.cs @@ -0,0 +1,31 @@ +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms; + +/// Applies an owned child transform when a synchronous predicate matches. +public class ConditionalTransform : IPipelineTransformer +{ + private readonly Func _condition; + private readonly IPipelineTransformer _transform; + + /// Initializes a conditional transform. + public ConditionalTransform(Func condition, IPipelineTransformer transform) + { + _condition = condition ?? throw new ArgumentNullException(nameof(condition)); + _transform = transform ?? throw new ArgumentNullException(nameof(transform)); + } + + /// + public ValueTask InitializeAsync(CancellationToken ct = default) => _transform.InitializeAsync(ct); + + /// + public ValueTask> TransformAsync( + ProcessingEnvelope envelope, + CancellationToken ct = default) => + _condition(envelope.Payload) + ? _transform.TransformAsync(envelope, ct) + : ValueTask.FromResult(StageResult.Success(envelope.Payload)); + + /// + public ValueTask DisposeAsync() => _transform.DisposeAsync(); +} diff --git a/src/SmartPipe.Extensions.Transforms/FilterTransform.cs b/src/SmartPipe.Extensions.Transforms/FilterTransform.cs new file mode 100644 index 0000000..28a2cc0 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/FilterTransform.cs @@ -0,0 +1,92 @@ +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms; + +/// Filters items with synchronous or asynchronous predicates. +public class FilterTransform : IPipelineTransformer +{ + private readonly Func> _predicate; + + /// Initializes a filter from a synchronous predicate. + public FilterTransform(Func predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + _predicate = (item, ct) => + { + ct.ThrowIfCancellationRequested(); + bool result = predicate(item); + ct.ThrowIfCancellationRequested(); + return ValueTask.FromResult(result); + }; + } + + /// Initializes a filter from a legacy asynchronous predicate without token support. + public FilterTransform(Func> asyncPredicate) + { + ArgumentNullException.ThrowIfNull(asyncPredicate); + _predicate = async (item, ct) => + { + ct.ThrowIfCancellationRequested(); + bool result = await asyncPredicate(item).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + return result; + }; + } + + /// Initializes a filter from the canonical token-aware predicate. + public FilterTransform(Func> predicate) => + _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + + /// Combines two filters with logical AND. + public static FilterTransform operator &(FilterTransform a, FilterTransform b) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + return new FilterTransform(async (item, ct) => + await a.EvaluateAsync(item, ct).ConfigureAwait(false) + && await b.EvaluateAsync(item, ct).ConfigureAwait(false)); + } + + /// Combines two filters with logical OR. + public static FilterTransform operator |(FilterTransform a, FilterTransform b) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + return new FilterTransform(async (item, ct) => + await a.EvaluateAsync(item, ct).ConfigureAwait(false) + || await b.EvaluateAsync(item, ct).ConfigureAwait(false)); + } + + /// Negates a filter. + public static FilterTransform operator !(FilterTransform a) + { + ArgumentNullException.ThrowIfNull(a); + return new FilterTransform(async (item, ct) => + !await a.EvaluateAsync(item, ct).ConfigureAwait(false)); + } + + /// Combines this filter with another using logical AND. + public FilterTransform And(FilterTransform other) => this & other; + + /// Combines this filter with another using logical OR. + public FilterTransform Or(FilterTransform other) => this | other; + + /// Negates this filter. + public FilterTransform Not() => !this; + + /// + public ValueTask InitializeAsync(CancellationToken ct = default) => ValueTask.CompletedTask; + + /// + public async ValueTask> TransformAsync( + ProcessingEnvelope envelope, + CancellationToken ct = default) => + await EvaluateAsync(envelope.Payload, ct).ConfigureAwait(false) + ? StageResult.Success(envelope.Payload) + : StageResult.Filtered(); + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private ValueTask EvaluateAsync(T item, CancellationToken ct) => _predicate(item, ct); +} diff --git a/src/SmartPipe.Extensions.Transforms/PublicAPI.Shipped.txt b/src/SmartPipe.Extensions.Transforms/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/SmartPipe.Extensions.Transforms/PublicAPI.Unshipped.txt b/src/SmartPipe.Extensions.Transforms/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..85ec544 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/PublicAPI.Unshipped.txt @@ -0,0 +1,38 @@ +#nullable enable +SmartPipe.Extensions.Transforms.CompositeTransform +SmartPipe.Extensions.Transforms.CompositeTransform.CompositeTransform(params SmartPipe.Core.IPipelineTransformer![]! transforms) -> void +SmartPipe.Extensions.Transforms.CompositeTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.CompositeTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.CompositeTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +SmartPipe.Extensions.Transforms.CompressionAlgorithm +SmartPipe.Extensions.Transforms.CompressionAlgorithm.Brotli = 0 -> SmartPipe.Extensions.Transforms.CompressionAlgorithm +SmartPipe.Extensions.Transforms.CompressionAlgorithm.GZip = 1 -> SmartPipe.Extensions.Transforms.CompressionAlgorithm +SmartPipe.Extensions.Transforms.CompressionTransform +SmartPipe.Extensions.Transforms.CompressionTransform.CompressionTransform(SmartPipe.Extensions.Transforms.CompressionAlgorithm algorithm = SmartPipe.Extensions.Transforms.CompressionAlgorithm.Brotli, System.IO.Compression.CompressionLevel level = System.IO.Compression.CompressionLevel.Optimal) -> void +SmartPipe.Extensions.Transforms.CompressionTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.CompressionTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.CompressionTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +SmartPipe.Extensions.Transforms.ConditionalTransform +SmartPipe.Extensions.Transforms.ConditionalTransform.ConditionalTransform(System.Func! condition, SmartPipe.Core.IPipelineTransformer! transform) -> void +SmartPipe.Extensions.Transforms.ConditionalTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.ConditionalTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.ConditionalTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +SmartPipe.Extensions.Transforms.FilterTransform +SmartPipe.Extensions.Transforms.FilterTransform.And(SmartPipe.Extensions.Transforms.FilterTransform! other) -> SmartPipe.Extensions.Transforms.FilterTransform! +SmartPipe.Extensions.Transforms.FilterTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.FilterTransform.FilterTransform(System.Func>! predicate) -> void +SmartPipe.Extensions.Transforms.FilterTransform.FilterTransform(System.Func!>! asyncPredicate) -> void +SmartPipe.Extensions.Transforms.FilterTransform.FilterTransform(System.Func! predicate) -> void +SmartPipe.Extensions.Transforms.FilterTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.FilterTransform.Not() -> SmartPipe.Extensions.Transforms.FilterTransform! +SmartPipe.Extensions.Transforms.FilterTransform.Or(SmartPipe.Extensions.Transforms.FilterTransform! other) -> SmartPipe.Extensions.Transforms.FilterTransform! +SmartPipe.Extensions.Transforms.FilterTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +SmartPipe.Extensions.Transforms.RuleValidationTransform +SmartPipe.Extensions.Transforms.RuleValidationTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.RuleValidationTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Transforms.RuleValidationTransform.Require(System.Func! condition, string! message) -> SmartPipe.Extensions.Transforms.RuleValidationTransform! +SmartPipe.Extensions.Transforms.RuleValidationTransform.RuleValidationTransform() -> void +SmartPipe.Extensions.Transforms.RuleValidationTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +static SmartPipe.Extensions.Transforms.FilterTransform.operator !(SmartPipe.Extensions.Transforms.FilterTransform! a) -> SmartPipe.Extensions.Transforms.FilterTransform! +static SmartPipe.Extensions.Transforms.FilterTransform.operator &(SmartPipe.Extensions.Transforms.FilterTransform! a, SmartPipe.Extensions.Transforms.FilterTransform! b) -> SmartPipe.Extensions.Transforms.FilterTransform! +static SmartPipe.Extensions.Transforms.FilterTransform.operator |(SmartPipe.Extensions.Transforms.FilterTransform! a, SmartPipe.Extensions.Transforms.FilterTransform! b) -> SmartPipe.Extensions.Transforms.FilterTransform! diff --git a/src/SmartPipe.Extensions.Transforms/README.md b/src/SmartPipe.Extensions.Transforms/README.md new file mode 100644 index 0000000..e2fb552 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/README.md @@ -0,0 +1,11 @@ +# SmartPipe.Extensions.Transforms + +Composable transforms for SmartPipe.Core without the broad extensions facade. + +- `CompositeTransform` owns and sequences child transforms with deterministic rollback and disposal. +- `ConditionalTransform` applies an owned child only when its predicate matches. +- `CompressionTransform` compresses byte arrays with Brotli or GZip. +- `FilterTransform` supports synchronous, legacy task-based, and token-aware predicates. +- `RuleValidationTransform` freezes reflection-free application rules before execution. + +Legacy task predicates cannot cancel predicate work because their delegate has no cancellation token. Use the token-aware `Func>` constructor when cancellation must reach the predicate. diff --git a/src/SmartPipe.Extensions.Transforms/RuleValidationTransform.cs b/src/SmartPipe.Extensions.Transforms/RuleValidationTransform.cs new file mode 100644 index 0000000..73d7376 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/RuleValidationTransform.cs @@ -0,0 +1,65 @@ +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms; + +/// Validates items with reflection-free application rules. +public class RuleValidationTransform : IPipelineTransformer +{ + private readonly object _sync = new(); + private readonly List<(Func Condition, string Message)> _rules = []; + private (Func Condition, string Message)[]? _frozenRules; + + /// Adds a rule that must return true for validation to succeed. + public RuleValidationTransform Require(Func condition, string message) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + lock (_sync) + { + if (_frozenRules is not null) + throw new InvalidOperationException("Validation rules are frozen."); + + _rules.Add((condition, message)); + } + + return this; + } + + /// + public ValueTask InitializeAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + Freeze(); + return ValueTask.CompletedTask; + } + + /// + public ValueTask> TransformAsync( + ProcessingEnvelope envelope, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + (Func Condition, string Message)[] rules = Freeze(); + List? failures = null; + foreach ((Func condition, string message) in rules) + { + if (!condition(envelope.Payload)) + (failures ??= []).Add(message); + ct.ThrowIfCancellationRequested(); + } + + return ValueTask.FromResult(failures is null + ? StageResult.Success(envelope.Payload) + : StageResult.Failure(new SmartPipeError( + string.Join("; ", failures), ErrorType.Permanent, "Validation"))); + } + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private (Func Condition, string Message)[] Freeze() + { + lock (_sync) + return _frozenRules ??= [.. _rules]; + } +} diff --git a/src/SmartPipe.Extensions.Transforms/SmartPipe.Extensions.Transforms.csproj b/src/SmartPipe.Extensions.Transforms/SmartPipe.Extensions.Transforms.csproj new file mode 100644 index 0000000..e493b27 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/SmartPipe.Extensions.Transforms.csproj @@ -0,0 +1,27 @@ + + + + true + + + + SmartPipe.Extensions.Transforms + Composable transforms for SmartPipe.Core. + SmartPipe;pipeline;transforms + $(MSBuildProjectDirectory)/README.md + true + true + true + true + + + + + + + + + + + + diff --git a/src/SmartPipe.Extensions.Transforms/packages.lock.json b/src/SmartPipe.Extensions.Transforms/packages.lock.json new file mode 100644 index 0000000..6ce8da1 --- /dev/null +++ b/src/SmartPipe.Extensions.Transforms/packages.lock.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + }, + "smartpipe.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + } + } + } +} \ No newline at end of file diff --git a/src/SmartPipe.Extensions/ChannelMerge.cs b/src/SmartPipe.Extensions/ChannelMerge.cs deleted file mode 100644 index ffd9c3c..0000000 --- a/src/SmartPipe.Extensions/ChannelMerge.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System.Threading.Channels; - -namespace SmartPipe.Extensions; - -/// -/// Provides methods for merging multiple streams into a single reader. -/// Items from all readers are interleaved as they arrive. -/// -public static class ChannelMerge -{ - /// - /// Merges two instances into a single channel reader. - /// Both readers are pumped concurrently, and items are written to the output as they arrive. - /// Uses unbounded channel by default; pass for bounded capacity. - /// - /// The type of items in the channels. - /// The first channel reader. - /// The second channel reader. - /// Optional bounded channel options. If null, an unbounded channel is created. - /// A that receives items from both input readers. -#pragma warning disable RS0027 // Existing optional overload preserved for source compatibility. - public static ChannelReader Merge( - ChannelReader first, - ChannelReader second, - BoundedChannelOptions? options = null - ) - { - return Merge(first, second, options, CancellationToken.None); - } -#pragma warning restore RS0027 - - /// - /// Merges two instances into a single channel reader. - /// Both readers are pumped concurrently until they complete or cancellation is requested. - /// Uses unbounded channel by default; pass for bounded capacity. - /// - /// The type of items in the channels. - /// The first channel reader. - /// The second channel reader. - /// Optional bounded channel options. If null, an unbounded channel is created. - /// A token that cancels both input pumps. - /// A that receives items from both input readers. - public static ChannelReader Merge( - ChannelReader first, - ChannelReader second, - BoundedChannelOptions? options, - CancellationToken cancellationToken - ) - { - ArgumentNullException.ThrowIfNull(first); - ArgumentNullException.ThrowIfNull(second); - - var output = - options != null ? Channel.CreateBounded(options) : Channel.CreateUnbounded(); - - _ = CompleteMergeAsync(first, second, output.Writer, cancellationToken); - - return output.Reader; - } - - private static async Task CompleteMergeAsync( - ChannelReader first, - ChannelReader second, - ChannelWriter writer, - CancellationToken cancellationToken - ) - { - var coordinator = new MergeFailureCoordinator(cancellationToken); - - using var pumpCancellation = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken - ); - var firstPump = PumpAndCancelOnFailureAsync( - first, - writer, - pumpCancellation, - coordinator); - var secondPump = PumpAndCancelOnFailureAsync( - second, - writer, - pumpCancellation, - coordinator); - - try - { - await Task.WhenAll(firstPump, secondPump).ConfigureAwait(false); - } - catch - { - // Completion is coordinated explicitly so sibling cancellation or - // cancellation callback failures cannot replace the primary input failure. - } - finally - { - writer.TryComplete(coordinator.GetCompletionError()); - } - } - - private static async Task PumpAndCancelOnFailureAsync( - ChannelReader reader, - ChannelWriter writer, - CancellationTokenSource cancellationSource, - MergeFailureCoordinator coordinator - ) - { - try - { - await PumpAsync(reader, writer, cancellationSource.Token).ConfigureAwait(false); - } - catch (Exception ex) - { - if (ex is not OperationCanceledException || !cancellationSource.IsCancellationRequested) - { - coordinator.TryRecordFailure(ex); - try - { - await cancellationSource.CancelAsync().ConfigureAwait(false); - } - catch (Exception cancellationFailure) - { - coordinator.TryRecordFailure(cancellationFailure); - } - } - - throw; - } - } - - /// - /// Pumps items from a source to a target . - /// - /// The type of items. - /// The source channel reader. - /// The target channel writer. - /// A token that cancels pending reads and writes. - private static async Task PumpAsync( - ChannelReader reader, - ChannelWriter writer, - CancellationToken cancellationToken - ) - { - await foreach ( - var item in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false) - ) - { - var written = false; - - while ( - await writer.WaitToWriteAsync(cancellationToken).ConfigureAwait(false) - ) - { - if (writer.TryWrite(item)) - { - written = true; - break; - } - } - - if (!written) - return; - } - } - - private sealed class MergeFailureCoordinator - { - private readonly object _gate = new(); - private readonly CancellationToken _externalCancellationToken; - private Exception? _primaryFailure; - - public MergeFailureCoordinator(CancellationToken externalCancellationToken) - { - _externalCancellationToken = externalCancellationToken; - } - - public void TryRecordFailure(Exception exception) - { - lock (_gate) - _primaryFailure ??= exception; - } - - public Exception? GetCompletionError() - { - lock (_gate) - { - if (_primaryFailure is not null) - return _primaryFailure; - } - - return _externalCancellationToken.IsCancellationRequested - ? new OperationCanceledException(_externalCancellationToken) - : null; - } - } -} diff --git a/src/SmartPipe.Extensions/PublicAPI.Shipped.txt b/src/SmartPipe.Extensions/PublicAPI.Shipped.txt index e574258..917fab7 100644 --- a/src/SmartPipe.Extensions/PublicAPI.Shipped.txt +++ b/src/SmartPipe.Extensions/PublicAPI.Shipped.txt @@ -4,7 +4,7 @@ override SmartPipe.Extensions.SmartPipeHealthSnapshot.GetHashCode() -> int override SmartPipe.Extensions.SmartPipeHealthSnapshot.ToString() -> string! override SmartPipe.Extensions.SmartPipeHostedService.ExecuteAsync(System.Threading.CancellationToken ct) -> System.Threading.Tasks.Task! override SmartPipe.Extensions.SmartPipeHostedService.StopAsync(System.Threading.CancellationToken ct) -> System.Threading.Tasks.Task! -SmartPipe.Extensions.ChannelMerge +SmartPipe.Extensions.ChannelMerge (forwarded, contained in SmartPipe.Extensions.Channels) SmartPipe.Extensions.ISmartPipeDefinition SmartPipe.Extensions.ISmartPipeDefinition.PipelineId.get -> string! SmartPipe.Extensions.ISmartPipeFactory @@ -81,11 +81,11 @@ SmartPipe.Extensions.Sinks.HttpSink.HttpSink(System.Net.Http.HttpClient! http SmartPipe.Extensions.Sinks.HttpSink.HttpSink(System.Net.Http.HttpClient! http, string! url) -> void SmartPipe.Extensions.Sinks.HttpSink.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask SmartPipe.Extensions.Sinks.HttpSink.WriteAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Sinks.LoggerSink -SmartPipe.Extensions.Sinks.LoggerSink.DisposeAsync() -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Sinks.LoggerSink.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Sinks.LoggerSink.LoggerSink(Microsoft.Extensions.Logging.ILogger!>! logger) -> void -SmartPipe.Extensions.Sinks.LoggerSink.WriteAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +SmartPipe.Extensions.Sinks.LoggerSink (forwarded, contained in SmartPipe.Extensions.Logging) +SmartPipe.Extensions.Sinks.LoggerSink.DisposeAsync() -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Logging) +SmartPipe.Extensions.Sinks.LoggerSink.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Logging) +SmartPipe.Extensions.Sinks.LoggerSink.LoggerSink(Microsoft.Extensions.Logging.ILogger!>! logger) -> void (forwarded, contained in SmartPipe.Extensions.Logging) +SmartPipe.Extensions.Sinks.LoggerSink.WriteAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Logging) SmartPipe.Extensions.SmartPipeDefinition SmartPipe.Extensions.SmartPipeDefinition.PipelineId.get -> string! SmartPipe.Extensions.SmartPipeDefinitionBuilder @@ -146,39 +146,39 @@ SmartPipe.Extensions.SmartPipeRunHealthMonitor.SmartPipeRunHeal SmartPipe.Extensions.SmartPipeRunHealthMonitor.Track(SmartPipe.Core.PipelineRun! run) -> void SmartPipe.Extensions.SmartPipeRunHealthMonitor.Track(System.Func! stateProvider, System.Func! metricsProvider) -> void SmartPipe.Extensions.SmartPipeServiceCollectionExtensions -SmartPipe.Extensions.Transforms.CompositeTransform -SmartPipe.Extensions.Transforms.CompositeTransform.CompositeTransform(params SmartPipe.Core.IPipelineTransformer![]! transforms) -> void -SmartPipe.Extensions.Transforms.CompositeTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.CompositeTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.CompositeTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> -SmartPipe.Extensions.Transforms.CompressionAlgorithm -SmartPipe.Extensions.Transforms.CompressionAlgorithm.Brotli = 0 -> SmartPipe.Extensions.Transforms.CompressionAlgorithm -SmartPipe.Extensions.Transforms.CompressionAlgorithm.GZip = 1 -> SmartPipe.Extensions.Transforms.CompressionAlgorithm -SmartPipe.Extensions.Transforms.CompressionTransform -SmartPipe.Extensions.Transforms.CompressionTransform.CompressionTransform(SmartPipe.Extensions.Transforms.CompressionAlgorithm algorithm = SmartPipe.Extensions.Transforms.CompressionAlgorithm.Brotli, System.IO.Compression.CompressionLevel level = System.IO.Compression.CompressionLevel.Optimal) -> void -SmartPipe.Extensions.Transforms.CompressionTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.CompressionTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.CompressionTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> -SmartPipe.Extensions.Transforms.ConditionalTransform -SmartPipe.Extensions.Transforms.ConditionalTransform.ConditionalTransform(System.Func! condition, SmartPipe.Core.IPipelineTransformer! transform) -> void -SmartPipe.Extensions.Transforms.ConditionalTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.ConditionalTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.ConditionalTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +SmartPipe.Extensions.Transforms.CompositeTransform (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompositeTransform.CompositeTransform(params SmartPipe.Core.IPipelineTransformer![]! transforms) -> void (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompositeTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompositeTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompositeTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompressionAlgorithm (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompressionAlgorithm.Brotli = 0 -> SmartPipe.Extensions.Transforms.CompressionAlgorithm (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompressionAlgorithm.GZip = 1 -> SmartPipe.Extensions.Transforms.CompressionAlgorithm (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompressionTransform (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompressionTransform.CompressionTransform(SmartPipe.Extensions.Transforms.CompressionAlgorithm algorithm = SmartPipe.Extensions.Transforms.CompressionAlgorithm.Brotli, System.IO.Compression.CompressionLevel level = System.IO.Compression.CompressionLevel.Optimal) -> void (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompressionTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompressionTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.CompressionTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.ConditionalTransform (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.ConditionalTransform.ConditionalTransform(System.Func! condition, SmartPipe.Core.IPipelineTransformer! transform) -> void (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.ConditionalTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.ConditionalTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.ConditionalTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> (forwarded, contained in SmartPipe.Extensions.Transforms) SmartPipe.Extensions.Transforms.CsvTransform SmartPipe.Extensions.Transforms.CsvTransform.CsvTransform(string! delimiter = ",", System.Globalization.CultureInfo? culture = null, System.Action? configure = null) -> void SmartPipe.Extensions.Transforms.CsvTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask SmartPipe.Extensions.Transforms.CsvTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask SmartPipe.Extensions.Transforms.CsvTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> -SmartPipe.Extensions.Transforms.FilterTransform -SmartPipe.Extensions.Transforms.FilterTransform.And(SmartPipe.Extensions.Transforms.FilterTransform! other) -> SmartPipe.Extensions.Transforms.FilterTransform! -SmartPipe.Extensions.Transforms.FilterTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.FilterTransform.FilterTransform(System.Func! predicate) -> void -SmartPipe.Extensions.Transforms.FilterTransform.FilterTransform(System.Func!>! asyncPredicate) -> void -SmartPipe.Extensions.Transforms.FilterTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.FilterTransform.Not() -> SmartPipe.Extensions.Transforms.FilterTransform! -SmartPipe.Extensions.Transforms.FilterTransform.Or(SmartPipe.Extensions.Transforms.FilterTransform! other) -> SmartPipe.Extensions.Transforms.FilterTransform! -SmartPipe.Extensions.Transforms.FilterTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> -SmartPipe.Extensions.Transforms.FilterValidationExtensions +SmartPipe.Extensions.Transforms.FilterTransform (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterTransform.And(SmartPipe.Extensions.Transforms.FilterTransform! other) -> SmartPipe.Extensions.Transforms.FilterTransform! (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterTransform.FilterTransform(System.Func! predicate) -> void (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterTransform.FilterTransform(System.Func!>! asyncPredicate) -> void (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterTransform.Not() -> SmartPipe.Extensions.Transforms.FilterTransform! (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterTransform.Or(SmartPipe.Extensions.Transforms.FilterTransform! other) -> SmartPipe.Extensions.Transforms.FilterTransform! (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Transforms.FilterValidationExtensions (forwarded, contained in SmartPipe.Extensions.DataAnnotations) SmartPipe.Extensions.Transforms.MapsterTransform SmartPipe.Extensions.Transforms.MapsterTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask SmartPipe.Extensions.Transforms.MapsterTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -189,23 +189,23 @@ SmartPipe.Extensions.Transforms.PollyResilienceTransform.DisposeAsync() -> Sy SmartPipe.Extensions.Transforms.PollyResilienceTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask SmartPipe.Extensions.Transforms.PollyResilienceTransform.PollyResilienceTransform(Polly.ResiliencePipeline! pipeline, Microsoft.Extensions.Logging.ILogger!>? logger = null) -> void SmartPipe.Extensions.Transforms.PollyResilienceTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> -SmartPipe.Extensions.Transforms.ValidationTransform -SmartPipe.Extensions.Transforms.ValidationTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.ValidationTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -SmartPipe.Extensions.Transforms.ValidationTransform.Require(System.Func! condition, string! message) -> SmartPipe.Extensions.Transforms.ValidationTransform! -SmartPipe.Extensions.Transforms.ValidationTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> -SmartPipe.Extensions.Transforms.ValidationTransform.ValidationTransform() -> void -static SmartPipe.Extensions.ChannelMerge.Merge(System.Threading.Channels.ChannelReader! first, System.Threading.Channels.ChannelReader! second, System.Threading.Channels.BoundedChannelOptions? options = null) -> System.Threading.Channels.ChannelReader! -static SmartPipe.Extensions.ChannelMerge.Merge(System.Threading.Channels.ChannelReader! first, System.Threading.Channels.ChannelReader! second, System.Threading.Channels.BoundedChannelOptions? options, System.Threading.CancellationToken cancellationToken) -> System.Threading.Channels.ChannelReader! +SmartPipe.Extensions.Transforms.ValidationTransform (forwarded, contained in SmartPipe.Extensions.DataAnnotations) +SmartPipe.Extensions.Transforms.ValidationTransform.DisposeAsync() -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.DataAnnotations) +SmartPipe.Extensions.Transforms.ValidationTransform.InitializeAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask (forwarded, contained in SmartPipe.Extensions.DataAnnotations) +SmartPipe.Extensions.Transforms.ValidationTransform.Require(System.Func! condition, string! message) -> SmartPipe.Extensions.Transforms.ValidationTransform! (forwarded, contained in SmartPipe.Extensions.DataAnnotations) +SmartPipe.Extensions.Transforms.ValidationTransform.TransformAsync(SmartPipe.Core.ProcessingEnvelope! envelope, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> (forwarded, contained in SmartPipe.Extensions.DataAnnotations) +SmartPipe.Extensions.Transforms.ValidationTransform.ValidationTransform() -> void (forwarded, contained in SmartPipe.Extensions.DataAnnotations) +static SmartPipe.Extensions.ChannelMerge.Merge(System.Threading.Channels.ChannelReader! first, System.Threading.Channels.ChannelReader! second, System.Threading.Channels.BoundedChannelOptions? options = null) -> System.Threading.Channels.ChannelReader! (forwarded, contained in SmartPipe.Extensions.Channels) +static SmartPipe.Extensions.ChannelMerge.Merge(System.Threading.Channels.ChannelReader! first, System.Threading.Channels.ChannelReader! second, System.Threading.Channels.BoundedChannelOptions? options, System.Threading.CancellationToken cancellationToken) -> System.Threading.Channels.ChannelReader! (forwarded, contained in SmartPipe.Extensions.Channels) static SmartPipe.Extensions.SmartPipeHealthSnapshot.operator !=(SmartPipe.Extensions.SmartPipeHealthSnapshot? left, SmartPipe.Extensions.SmartPipeHealthSnapshot? right) -> bool static SmartPipe.Extensions.SmartPipeHealthSnapshot.operator ==(SmartPipe.Extensions.SmartPipeHealthSnapshot? left, SmartPipe.Extensions.SmartPipeHealthSnapshot? right) -> bool static SmartPipe.Extensions.SmartPipeServiceCollectionExtensions.AddSmartPipe(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! pipelineId, System.Action!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static SmartPipe.Extensions.SmartPipeServiceCollectionExtensions.AddSmartPipeHealthCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder! builder, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = null, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = null) -> Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder! static SmartPipe.Extensions.SmartPipeServiceCollectionExtensions.AddSmartPipeHostedService(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! pipelineId, System.Action!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static SmartPipe.Extensions.Transforms.FilterTransform.operator !(SmartPipe.Extensions.Transforms.FilterTransform! a) -> SmartPipe.Extensions.Transforms.FilterTransform! -static SmartPipe.Extensions.Transforms.FilterTransform.operator &(SmartPipe.Extensions.Transforms.FilterTransform! a, SmartPipe.Extensions.Transforms.FilterTransform! b) -> SmartPipe.Extensions.Transforms.FilterTransform! -static SmartPipe.Extensions.Transforms.FilterTransform.operator |(SmartPipe.Extensions.Transforms.FilterTransform! a, SmartPipe.Extensions.Transforms.FilterTransform! b) -> SmartPipe.Extensions.Transforms.FilterTransform! -static SmartPipe.Extensions.Transforms.FilterValidationExtensions.ToFilter(this SmartPipe.Extensions.Transforms.ValidationTransform! validator) -> SmartPipe.Extensions.Transforms.FilterTransform! +static SmartPipe.Extensions.Transforms.FilterTransform.operator !(SmartPipe.Extensions.Transforms.FilterTransform! a) -> SmartPipe.Extensions.Transforms.FilterTransform! (forwarded, contained in SmartPipe.Extensions.Transforms) +static SmartPipe.Extensions.Transforms.FilterTransform.operator &(SmartPipe.Extensions.Transforms.FilterTransform! a, SmartPipe.Extensions.Transforms.FilterTransform! b) -> SmartPipe.Extensions.Transforms.FilterTransform! (forwarded, contained in SmartPipe.Extensions.Transforms) +static SmartPipe.Extensions.Transforms.FilterTransform.operator |(SmartPipe.Extensions.Transforms.FilterTransform! a, SmartPipe.Extensions.Transforms.FilterTransform! b) -> SmartPipe.Extensions.Transforms.FilterTransform! (forwarded, contained in SmartPipe.Extensions.Transforms) +static SmartPipe.Extensions.Transforms.FilterValidationExtensions.ToFilter(this SmartPipe.Extensions.Transforms.ValidationTransform! validator) -> SmartPipe.Extensions.Transforms.FilterTransform! (forwarded, contained in SmartPipe.Extensions.DataAnnotations) SmartPipe.Extensions.Selectors.DeadLetterSource (forwarded, contained in SmartPipe.Extensions.Json) SmartPipe.Extensions.Selectors.DeadLetterSource.DeadLetterSource(string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! valueTypeInfo) -> void (forwarded, contained in SmartPipe.Extensions.Json) SmartPipe.Extensions.Selectors.DeadLetterSource.DeadLetterSource(string! path) -> void (forwarded, contained in SmartPipe.Extensions.Json) diff --git a/src/SmartPipe.Extensions/PublicAPI.Unshipped.txt b/src/SmartPipe.Extensions/PublicAPI.Unshipped.txt index 952c990..43ce8b1 100644 --- a/src/SmartPipe.Extensions/PublicAPI.Unshipped.txt +++ b/src/SmartPipe.Extensions/PublicAPI.Unshipped.txt @@ -1,2 +1,6 @@ #nullable enable SmartPipe.Extensions.Selectors.DeadLetterSource.DeadLetterSource(string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! valueTypeInfo, SmartPipe.Extensions.DeadLetterSourceOptions! options) -> void (forwarded, contained in SmartPipe.Extensions.Json) +static SmartPipe.Extensions.ChannelMerge.Merge(System.Collections.Generic.IReadOnlyList!>! readers) -> System.Threading.Channels.ChannelReader! (forwarded, contained in SmartPipe.Extensions.Channels) +static SmartPipe.Extensions.ChannelMerge.MergeMany(System.Collections.Generic.IReadOnlyList!>! readers, System.Threading.Channels.BoundedChannelOptions? options, System.Threading.CancellationToken cancellationToken) -> System.Threading.Channels.ChannelReader! (forwarded, contained in SmartPipe.Extensions.Channels) +SmartPipe.Extensions.Transforms.FilterTransform.FilterTransform(System.Func>! predicate) -> void (forwarded, contained in SmartPipe.Extensions.Transforms) +SmartPipe.Extensions.Sinks.LoggerSink.LoggerSink(Microsoft.Extensions.Logging.ILogger!>! logger, SmartPipe.Extensions.Sinks.LoggerSinkOptions! options) -> void (forwarded, contained in SmartPipe.Extensions.Logging) diff --git a/src/SmartPipe.Extensions/README.md b/src/SmartPipe.Extensions/README.md index f1da240..9e27547 100644 --- a/src/SmartPipe.Extensions/README.md +++ b/src/SmartPipe.Extensions/README.md @@ -107,6 +107,11 @@ token remains available. dotnet add package SmartPipe.Extensions --version 2.1.2 ``` +For narrow SP220-07 integrations, install `SmartPipe.Extensions.Channels`, +`SmartPipe.Extensions.Transforms`, `SmartPipe.Extensions.Logging`, or +`SmartPipe.Extensions.DataAnnotations` directly. The broad package forwards the +existing public types and pulls these leaves only as a compatibility facade. + For JSON-only integrations, prefer: ```bash diff --git a/src/SmartPipe.Extensions/Sinks/LoggerSink.cs b/src/SmartPipe.Extensions/Sinks/LoggerSink.cs deleted file mode 100644 index 4b117d9..0000000 --- a/src/SmartPipe.Extensions/Sinks/LoggerSink.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.Extensions.Logging; -using SmartPipe.Core; - -namespace SmartPipe.Extensions.Sinks; - -/// -/// Sink that logs processing results using ILogger. -/// Works with any logging provider (Serilog, NLog, Azure Monitor). -/// -/// Data type. -public class LoggerSink : IPipelineSink -{ - private readonly ILogger> _logger; - - /// Create logger sink with given ILogger. - /// Logger instance. - public LoggerSink(ILogger> logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - public ValueTask InitializeAsync(CancellationToken ct = default) => ValueTask.CompletedTask; - - /// - public ValueTask WriteAsync(ProcessingEnvelope envelope, CancellationToken ct = default) - { - _logger.LogInformation( - "Processed item [TraceId: {TraceId}] successfully. Value: {@Value}", - envelope.TraceId, - envelope.Payload - ); - - return ValueTask.CompletedTask; - } - - /// - public ValueTask DisposeAsync() => ValueTask.CompletedTask; -} diff --git a/src/SmartPipe.Extensions/SmartPipe.Extensions.csproj b/src/SmartPipe.Extensions/SmartPipe.Extensions.csproj index 27e222d..fd8e259 100644 --- a/src/SmartPipe.Extensions/SmartPipe.Extensions.csproj +++ b/src/SmartPipe.Extensions/SmartPipe.Extensions.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/src/SmartPipe.Extensions/Sp22007TypeForwarders.cs b/src/SmartPipe.Extensions/Sp22007TypeForwarders.cs new file mode 100644 index 0000000..8532b13 --- /dev/null +++ b/src/SmartPipe.Extensions/Sp22007TypeForwarders.cs @@ -0,0 +1,16 @@ +using System.Runtime.CompilerServices; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Sinks; +using SmartPipe.Extensions.Transforms; + +#pragma warning disable RS0027 // Forwarded optional overloads preserve the shipped facade contract. +[assembly: TypeForwardedTo(typeof(ChannelMerge))] +[assembly: TypeForwardedTo(typeof(CompositeTransform<>))] +[assembly: TypeForwardedTo(typeof(CompressionAlgorithm))] +[assembly: TypeForwardedTo(typeof(CompressionTransform))] +[assembly: TypeForwardedTo(typeof(ConditionalTransform<>))] +[assembly: TypeForwardedTo(typeof(FilterTransform<>))] +[assembly: TypeForwardedTo(typeof(FilterValidationExtensions))] +[assembly: TypeForwardedTo(typeof(ValidationTransform<>))] +[assembly: TypeForwardedTo(typeof(LoggerSink<>))] +#pragma warning restore RS0027 diff --git a/src/SmartPipe.Extensions/Transforms/CompositeTransform.cs b/src/SmartPipe.Extensions/Transforms/CompositeTransform.cs deleted file mode 100644 index 3f0453a..0000000 --- a/src/SmartPipe.Extensions/Transforms/CompositeTransform.cs +++ /dev/null @@ -1,56 +0,0 @@ -using SmartPipe.Core; - -namespace SmartPipe.Extensions.Transforms; - -/// -/// Combines multiple instances into a single transform. -/// Transforms are applied sequentially; if any transform fails, the failure is returned immediately. -/// Implements for pipeline integration. -/// -/// The data type. -public class CompositeTransform : IPipelineTransformer -{ - private readonly IPipelineTransformer[] _transforms; - - /// - /// Initializes a new instance of with the specified transforms. - /// - /// The transforms to apply sequentially. - /// Thrown when is null. - public CompositeTransform(params IPipelineTransformer[] transforms) => - _transforms = transforms ?? throw new ArgumentNullException(nameof(transforms)); - - /// - public async ValueTask InitializeAsync(CancellationToken ct = default) - { - foreach (var t in _transforms) - await t.InitializeAsync(ct); - } - - /// - public async ValueTask> TransformAsync( - ProcessingEnvelope envelope, - CancellationToken ct = default - ) - { - var current = envelope; - foreach (var t in _transforms) - { - var result = await t.TransformAsync(current, ct); - if (!result.IsSuccess) - return result; - current = current with - { - Payload = result.Value!, - }; - } - return StageResult.Success(current.Payload); - } - - /// - public async ValueTask DisposeAsync() - { - foreach (var t in _transforms) - await t.DisposeAsync(); - } -} diff --git a/src/SmartPipe.Extensions/Transforms/CompressionTransform.cs b/src/SmartPipe.Extensions/Transforms/CompressionTransform.cs deleted file mode 100644 index 252b02e..0000000 --- a/src/SmartPipe.Extensions/Transforms/CompressionTransform.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.IO.Compression; -using System.Text; -using SmartPipe.Core; - -namespace SmartPipe.Extensions.Transforms; - -/// -/// Compression transformer using Brotli or GZip algorithms. -/// Compresses arrays for efficient storage or transmission. -/// Implements for pipeline integration (T = byte[]). -/// -public enum CompressionAlgorithm -{ - /// - /// Brotli compression algorithm - offers good compression ratio with moderate speed. - /// - Brotli, - - /// - /// GZip compression algorithm - widely supported with good compression. - /// - GZip, -} - -/// -/// Transformer that compresses byte array payloads using Brotli or GZip algorithms. -/// Implements for pipeline integration with [] input and output. -/// -public class CompressionTransform : IPipelineTransformer -{ - private readonly CompressionAlgorithm _algorithm; - private readonly CompressionLevel _level; - - /// - /// Initializes a new instance of . - /// - /// The compression algorithm to use. Defaults to . - /// The compression level. Defaults to . - public CompressionTransform( - CompressionAlgorithm algorithm = CompressionAlgorithm.Brotli, - CompressionLevel level = CompressionLevel.Optimal - ) - { - _algorithm = algorithm; - _level = level; - } - - /// - public ValueTask InitializeAsync(CancellationToken ct = default) => ValueTask.CompletedTask; - - /// - public ValueTask> TransformAsync( - ProcessingEnvelope envelope, - CancellationToken ct = default - ) - { - try - { - using var output = new MemoryStream(); - using (var compressor = CreateCompressor(output)) - compressor.Write(envelope.Payload, 0, envelope.Payload.Length); - - return ValueTask.FromResult( - StageResult.Success(output.ToArray()) - ); - } - catch (IOException ex) - { - return ValueTask.FromResult( - StageResult.Failure( - new SmartPipeError( - $"Compression IO error: {ex.Message}", - ErrorType.Transient, - "Compression", - ex - ) - ) - ); - } - catch (NotSupportedException ex) - { - return ValueTask.FromResult( - StageResult.Failure( - new SmartPipeError( - $"Compression not supported: {ex.Message}", - ErrorType.Permanent, - "Compression", - ex - ) - ) - ); - } - } - - /// - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - /// - /// Creates a compression stream for the specified algorithm. - /// - /// The output stream to write compressed data to. - /// A compression stream wrapper. - /// Thrown when is unknown. - private Stream CreateCompressor(Stream output) => - _algorithm switch - { - CompressionAlgorithm.Brotli => new BrotliStream(output, _level), - CompressionAlgorithm.GZip => new GZipStream(output, _level), - _ => throw new ArgumentOutOfRangeException(nameof(_algorithm)), - }; -} diff --git a/src/SmartPipe.Extensions/Transforms/ConditionalTransform.cs b/src/SmartPipe.Extensions/Transforms/ConditionalTransform.cs deleted file mode 100644 index d9c1184..0000000 --- a/src/SmartPipe.Extensions/Transforms/ConditionalTransform.cs +++ /dev/null @@ -1,44 +0,0 @@ -using SmartPipe.Core; - -namespace SmartPipe.Extensions.Transforms; - -/// -/// Conditionally applies a transform based on a predicate. -/// If the condition is met, the transform is applied; otherwise the item passes through unchanged. -/// Implements for pipeline integration. -/// -/// The data type. -public class ConditionalTransform : IPipelineTransformer -{ - private readonly Func _condition; - private readonly IPipelineTransformer _transform; - - /// - /// Initializes a new instance of . - /// - /// The predicate to determine if the transform should be applied. - /// The transform to apply when the condition is true. - /// Thrown when or is null. - public ConditionalTransform(Func condition, IPipelineTransformer transform) - { - _condition = condition ?? throw new ArgumentNullException(nameof(condition)); - _transform = transform ?? throw new ArgumentNullException(nameof(transform)); - } - - /// - public ValueTask InitializeAsync(CancellationToken ct = default) => _transform.InitializeAsync(ct); - - /// - public async ValueTask> TransformAsync( - ProcessingEnvelope envelope, - CancellationToken ct = default - ) - { - if (_condition(envelope.Payload)) - return await _transform.TransformAsync(envelope, ct); - return StageResult.Success(envelope.Payload); - } - - /// - public ValueTask DisposeAsync() => _transform.DisposeAsync(); -} diff --git a/src/SmartPipe.Extensions/Transforms/FilterTransform.cs b/src/SmartPipe.Extensions/Transforms/FilterTransform.cs deleted file mode 100644 index 3cc10ac..0000000 --- a/src/SmartPipe.Extensions/Transforms/FilterTransform.cs +++ /dev/null @@ -1,119 +0,0 @@ -using SmartPipe.Core; - -namespace SmartPipe.Extensions.Transforms; - -/// -/// Filters items by predicate. Returns for non-matching items. -/// Implements for pipeline integration. -/// -/// The data type. -public class FilterTransform : IPipelineTransformer -{ - private readonly Func? _predicate; - private readonly Func>? _asyncPredicate; - - /// - /// Initializes a new instance of with a synchronous predicate. - /// - /// The synchronous predicate to filter items. - /// Thrown when is null. - public FilterTransform(Func predicate) => - _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); - - /// - /// Initializes a new instance of with an asynchronous predicate. - /// - /// The asynchronous predicate to filter items. - /// Thrown when is null. - public FilterTransform(Func> asyncPredicate) => - _asyncPredicate = asyncPredicate ?? throw new ArgumentNullException(nameof(asyncPredicate)); - - /// - /// Combines two filters with logical AND operator. - /// - public static FilterTransform operator &(FilterTransform a, FilterTransform b) - { - ArgumentNullException.ThrowIfNull(a); - ArgumentNullException.ThrowIfNull(b); - - return new(async x => - { - if (!await a.EvaluateAsync(x).ConfigureAwait(false)) - return false; - - return await b.EvaluateAsync(x).ConfigureAwait(false); - }); - } - - /// - /// Combines two filters with logical OR operator. - /// - public static FilterTransform operator |(FilterTransform a, FilterTransform b) - { - ArgumentNullException.ThrowIfNull(a); - ArgumentNullException.ThrowIfNull(b); - - return new(async x => - { - if (await a.EvaluateAsync(x).ConfigureAwait(false)) - return true; - - return await b.EvaluateAsync(x).ConfigureAwait(false); - }); - } - - /// - /// Negates the filter condition. - /// - public static FilterTransform operator !(FilterTransform a) - { - ArgumentNullException.ThrowIfNull(a); - - return new(async x => !await a.EvaluateAsync(x).ConfigureAwait(false)); - } - - /// - /// Combines this filter with another using logical AND. - /// - /// The other filter to combine with. - /// A new filter that requires both conditions to be true. - public FilterTransform And(FilterTransform other) => this & other; - - /// - /// Combines this filter with another using logical OR. - /// - /// The other filter to combine with. - /// A new filter where either condition can be true. - public FilterTransform Or(FilterTransform other) => this | other; - - /// - /// Negates this filter condition. - /// - /// A new filter with inverted condition. - public FilterTransform Not() => !this; - - /// - public ValueTask InitializeAsync(CancellationToken ct = default) => ValueTask.CompletedTask; - - /// - public async ValueTask> TransformAsync( - ProcessingEnvelope envelope, - CancellationToken ct = default - ) - { - bool isMatch = await EvaluateAsync(envelope.Payload).ConfigureAwait(false); - - if (isMatch) - return StageResult.Success(envelope.Payload); - - return StageResult.Filtered(); - } - - /// - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - private async Task EvaluateAsync(T item) => - _asyncPredicate != null - ? await _asyncPredicate(item).ConfigureAwait(false) - : _predicate!(item); -} diff --git a/src/SmartPipe.Extensions/Transforms/FilterValidationExtensions.cs b/src/SmartPipe.Extensions/Transforms/FilterValidationExtensions.cs deleted file mode 100644 index 4219ae4..0000000 --- a/src/SmartPipe.Extensions/Transforms/FilterValidationExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using SmartPipe.Core; - -namespace SmartPipe.Extensions.Transforms; - -/// -/// Extension methods for converting to . -/// Enables using validation logic as a filtering mechanism in pipelines. -/// -public static class FilterValidationExtensions -{ - /// - /// Converts a into a . - /// Items that pass validation will pass the filter; invalid items will be filtered out. - /// - /// The data type. - /// The validation transform to convert. - /// A filter transform that uses validation results to filter items. - public static FilterTransform ToFilter(this ValidationTransform validator) => - new FilterTransform(async item => - { - var envelope = ProcessingEnvelope.Create(item); - var result = await validator.TransformAsync(envelope).ConfigureAwait(false); - return result.IsSuccess; - }); -} diff --git a/src/SmartPipe.Extensions/Transforms/ValidationTransform.cs b/src/SmartPipe.Extensions/Transforms/ValidationTransform.cs deleted file mode 100644 index 7bf57ad..0000000 --- a/src/SmartPipe.Extensions/Transforms/ValidationTransform.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using SmartPipe.Core; - -namespace SmartPipe.Extensions.Transforms; - -/// -/// Validates items using DataAnnotations attributes and custom validation rules. -/// Returns with validation errors for invalid items. -/// Implements for pipeline integration. -/// -/// The data type to validate. -public class ValidationTransform : IPipelineTransformer -{ - private readonly List> _rules = []; - - /// - /// Adds a custom validation rule to the transform. - /// - /// The condition that must be true for validation to pass. - /// The error message if the condition fails. - /// This transform instance for fluent chaining. - public ValidationTransform Require(Func condition, string message) - { - _rules.Add(x => condition(x) ? null : message); - return this; - } - - /// - public ValueTask InitializeAsync(CancellationToken ct = default) => ValueTask.CompletedTask; - - /// - public ValueTask> TransformAsync( - ProcessingEnvelope envelope, - CancellationToken ct = default - ) - { - var errors = new List(); - - // DataAnnotations - var validationResults = new List(); - var validationContext = new ValidationContext(envelope.Payload!); - if (!Validator.TryValidateObject(envelope.Payload!, validationContext, validationResults, true)) - errors.AddRange(validationResults.Select(r => r.ErrorMessage ?? "Validation failed")); - - // Custom rules - foreach (var rule in _rules) - { - var error = rule(envelope.Payload); - if (error != null) - errors.Add(error); - } - - if (errors.Count == 0) - return ValueTask.FromResult(StageResult.Success(envelope.Payload)); - - return ValueTask.FromResult( - StageResult.Failure( - new SmartPipeError(string.Join("; ", errors), ErrorType.Permanent, "Validation") - ) - ); - } - - /// - public ValueTask DisposeAsync() => ValueTask.CompletedTask; -} diff --git a/src/SmartPipe.Extensions/packages.lock.json b/src/SmartPipe.Extensions/packages.lock.json index 3f9a431..653373e 100644 --- a/src/SmartPipe.Extensions/packages.lock.json +++ b/src/SmartPipe.Extensions/packages.lock.json @@ -301,6 +301,19 @@ "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" } }, + "smartpipe.extensions.channels": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, + "smartpipe.extensions.dataannotations": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )", + "SmartPipe.Extensions.Transforms": "[2.2.0, )" + } + }, "smartpipe.extensions.dependencyinjection": { "type": "Project", "dependencies": { @@ -325,6 +338,19 @@ "SmartPipe.Core": "[2.2.0, )" } }, + "smartpipe.extensions.logging": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "SmartPipe.Core": "[2.2.0, )" + } + }, + "smartpipe.extensions.transforms": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, "Microsoft.Extensions.DependencyInjection": { "type": "CentralTransitive", "requested": "[10.0.8, )", diff --git a/tests/Consumers/Scenarios/channels-direct/Consumer.csproj b/tests/Consumers/Scenarios/channels-direct/Consumer.csproj new file mode 100644 index 0000000..1064709 --- /dev/null +++ b/tests/Consumers/Scenarios/channels-direct/Consumer.csproj @@ -0,0 +1,4 @@ + + Exenet10.0enableenabletrue + + diff --git a/tests/Consumers/Scenarios/channels-direct/Program.cs b/tests/Consumers/Scenarios/channels-direct/Program.cs new file mode 100644 index 0000000..123a140 --- /dev/null +++ b/tests/Consumers/Scenarios/channels-direct/Program.cs @@ -0,0 +1,19 @@ +using System.Threading.Channels; +using SmartPipe.Extensions; + +var first = Channel.CreateUnbounded(); +var second = Channel.CreateUnbounded(); +await first.Writer.WriteAsync(20); +await second.Writer.WriteAsync(22); +first.Writer.Complete(); +second.Writer.Complete(); + +var values = new List(); +await foreach (var value in ChannelMerge.Merge(first.Reader, second.Reader).ReadAllAsync()) + values.Add(value); + +if (values.Sum() != 42) + return 1; + +Console.WriteLine("CONSUMER_OK channels-direct"); +return 0; diff --git a/tests/Consumers/Scenarios/data-annotations-direct/Consumer.csproj b/tests/Consumers/Scenarios/data-annotations-direct/Consumer.csproj new file mode 100644 index 0000000..aff485a --- /dev/null +++ b/tests/Consumers/Scenarios/data-annotations-direct/Consumer.csproj @@ -0,0 +1,5 @@ + + Exenet10.0enableenabletrue + $(DefineConstants);INVOKE_RUC + + diff --git a/tests/Consumers/Scenarios/data-annotations-direct/Program.cs b/tests/Consumers/Scenarios/data-annotations-direct/Program.cs new file mode 100644 index 0000000..582c918 --- /dev/null +++ b/tests/Consumers/Scenarios/data-annotations-direct/Program.cs @@ -0,0 +1,25 @@ +using SmartPipe.Extensions.Transforms; + +#if INVOKE_RUC +using System.ComponentModel.DataAnnotations; +using SmartPipe.Core; + +var transform = new ValidationTransform(); +await transform.InitializeAsync(); +var result = await transform.TransformAsync( + ProcessingEnvelope.Create(new AnnotatedModel())); +if (result.IsSuccess) + return 1; +#else +_ = typeof(ValidationTransform<>); +#endif +Console.WriteLine("CONSUMER_OK data-annotations-direct"); +return 0; + +#if INVOKE_RUC +internal sealed class AnnotatedModel +{ + [Required] + public string? Name { get; init; } +} +#endif diff --git a/tests/Consumers/Scenarios/data-annotations-runtime/Consumer.csproj b/tests/Consumers/Scenarios/data-annotations-runtime/Consumer.csproj new file mode 100644 index 0000000..04d75d4 --- /dev/null +++ b/tests/Consumers/Scenarios/data-annotations-runtime/Consumer.csproj @@ -0,0 +1,4 @@ + + Exenet10.0enableenabletrue + + diff --git a/tests/Consumers/Scenarios/data-annotations-runtime/Program.cs b/tests/Consumers/Scenarios/data-annotations-runtime/Program.cs new file mode 100644 index 0000000..e503f8d --- /dev/null +++ b/tests/Consumers/Scenarios/data-annotations-runtime/Program.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using SmartPipe.Core; +using SmartPipe.Extensions.Transforms; + +await using var transform = new ValidationTransform(); +await transform.InitializeAsync(); +var result = await transform.TransformAsync( + ProcessingEnvelope.Create(new InvalidModel())); +if (result.IsSuccess + || result.Error is not { } error + || error.Message != "name required" + || error.Type != ErrorType.Permanent + || error.Category != "Validation") +{ + return 1; +} + +Console.WriteLine("CONSUMER_OK data-annotations-runtime"); +return 0; + +internal sealed class InvalidModel +{ + [Required(ErrorMessage = "name required")] + public string? Name { get; init; } +} diff --git a/tests/Consumers/Scenarios/extensions-meta/Program.cs b/tests/Consumers/Scenarios/extensions-meta/Program.cs index 6f7b5e7..6ae78c9 100644 --- a/tests/Consumers/Scenarios/extensions-meta/Program.cs +++ b/tests/Consumers/Scenarios/extensions-meta/Program.cs @@ -1,3 +1,7 @@ +using Microsoft.Extensions.Logging.Abstractions; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Selectors; +using SmartPipe.Extensions.Sinks; using Mapster; using SmartPipe.Core; using SmartPipe.Extensions.Transforms; @@ -5,6 +9,22 @@ _ = typeof(PipelineBuilder); _ = typeof(JsonTransform); +var composite = new CompositeTransform(new FilterTransform(static value => value > 0)); +await composite.InitializeAsync(); +_ = new FilterTransform(static value => value > 0) + & !new FilterTransform(static value => value < 100); +_ = new ValidationTransform().Require(static value => value > 0, "positive required"); +_ = new LoggerSink(NullLogger>.Instance); + +var forwarded = typeof(DapperSelector<>).Assembly.GetForwardedTypes(); +Type[] expectedForwarded = +[ + typeof(ChannelMerge), typeof(CompositeTransform<>), typeof(FilterTransform<>), + typeof(LoggerSink<>), typeof(ValidationTransform<>), +]; +if (expectedForwarded.Except(forwarded).Any()) + throw new InvalidOperationException("SP220-07 facade reflection identity failed."); + var defaultTransform = new MapsterTransform(); var defaultResult = await defaultTransform.TransformAsync( ProcessingEnvelope.Create(new DefaultSource { Name = "Alice", Age = 25 })); diff --git a/tests/Consumers/Scenarios/legacy-binary-2.1.2/Program.cs b/tests/Consumers/Scenarios/legacy-binary-2.1.2/Program.cs index b66e3bf..5966715 100644 --- a/tests/Consumers/Scenarios/legacy-binary-2.1.2/Program.cs +++ b/tests/Consumers/Scenarios/legacy-binary-2.1.2/Program.cs @@ -1,6 +1,35 @@ +using Microsoft.Extensions.Logging.Abstractions; +using System.Threading.Channels; using SmartPipe.Core; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Sinks; using SmartPipe.Extensions.Transforms; _ = new CircuitBreaker(); _ = typeof(JsonTransform); + +var first = Channel.CreateUnbounded(); +var second = Channel.CreateUnbounded(); +await first.Writer.WriteAsync(20); +await second.Writer.WriteAsync(22); +first.Writer.Complete(); +second.Writer.Complete(); +var merged = new List(); +await foreach (var value in ChannelMerge.Merge(first.Reader, second.Reader).ReadAllAsync()) + merged.Add(value); + +var composite = new CompositeTransform(new FilterTransform(static value => value > 0)); +await composite.InitializeAsync(); +var transformed = await composite.TransformAsync(ProcessingEnvelope.Create(42)); +var validator = new ValidationTransform().Require(static value => value == 42, "expected 42"); +await validator.InitializeAsync(); +var validation = await validator.TransformAsync(ProcessingEnvelope.Create(42)); +var filtered = await validator.ToFilter().TransformAsync(ProcessingEnvelope.Create(42)); +var logger = new LoggerSink(NullLogger>.Instance); +await logger.WriteAsync(ProcessingEnvelope.Create(42)); + +if (merged.Sum() != 42 || !transformed.IsSuccess || !validation.IsSuccess || !filtered.IsSuccess) + return 1; + Console.WriteLine("CONSUMER_OK legacy-binary-2.1.2"); +return 0; diff --git a/tests/Consumers/Scenarios/logging-direct/Consumer.csproj b/tests/Consumers/Scenarios/logging-direct/Consumer.csproj new file mode 100644 index 0000000..85b78c2 --- /dev/null +++ b/tests/Consumers/Scenarios/logging-direct/Consumer.csproj @@ -0,0 +1,4 @@ + + Exenet10.0enableenabletrue + + diff --git a/tests/Consumers/Scenarios/logging-direct/Program.cs b/tests/Consumers/Scenarios/logging-direct/Program.cs new file mode 100644 index 0000000..bbd61e6 --- /dev/null +++ b/tests/Consumers/Scenarios/logging-direct/Program.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Logging.Abstractions; +using SmartPipe.Core; +using SmartPipe.Extensions.Sinks; + +var sink = new LoggerSink( + NullLogger>.Instance, + new LoggerSinkOptions { PayloadMode = LoggerSinkPayloadMode.None }); +await sink.WriteAsync(ProcessingEnvelope.Create(42)); + +Console.WriteLine("CONSUMER_OK logging-direct"); diff --git a/tests/Consumers/Scenarios/transforms-direct/Consumer.csproj b/tests/Consumers/Scenarios/transforms-direct/Consumer.csproj new file mode 100644 index 0000000..89cbbc7 --- /dev/null +++ b/tests/Consumers/Scenarios/transforms-direct/Consumer.csproj @@ -0,0 +1,4 @@ + + Exenet10.0enableenabletrue + + diff --git a/tests/Consumers/Scenarios/transforms-direct/Program.cs b/tests/Consumers/Scenarios/transforms-direct/Program.cs new file mode 100644 index 0000000..7d50c2e --- /dev/null +++ b/tests/Consumers/Scenarios/transforms-direct/Program.cs @@ -0,0 +1,11 @@ +using SmartPipe.Core; +using SmartPipe.Extensions.Transforms; + +var transform = new RuleValidationTransform().Require(static value => value == 42, "unexpected value"); +await transform.InitializeAsync(); +var result = await transform.TransformAsync(ProcessingEnvelope.Create(42)); +if (!result.IsSuccess || result.Value != 42) + return 1; + +Console.WriteLine("CONSUMER_OK transforms-direct"); +return 0; diff --git a/tests/SmartPipe.Extensions.Channels.Tests/ChannelMergeContractTests.cs b/tests/SmartPipe.Extensions.Channels.Tests/ChannelMergeContractTests.cs new file mode 100644 index 0000000..85a9959 --- /dev/null +++ b/tests/SmartPipe.Extensions.Channels.Tests/ChannelMergeContractTests.cs @@ -0,0 +1,576 @@ +using System.Threading.Channels; +using SmartPipe.Extensions; + +namespace SmartPipe.Extensions.Channels.Tests; + +public sealed class ChannelMergeContractTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan LivenessTimeout = TimeSpan.FromMilliseconds(250); + + [Fact] + public void Merge_NullReaderCollection_ThrowsArgumentNullException() + { + IReadOnlyList>? readers = null; + var act = () => + { + _ = ChannelMerge.Merge(readers!); + }; + + Assert.Throws(act); + } + + [Fact] + public void Merge_NullReaderElement_ThrowsArgumentException() + { + ChannelReader[] readers = [Channel.CreateUnbounded().Reader, null!]; + + var act = () => + { + _ = ChannelMerge.Merge(readers); + }; + + var exception = Assert.Throws(act); + + Assert.Equal("readers", exception.ParamName); + } + + [Fact] + public async Task Merge_ZeroReaders_CompletesAsEmpty() + { + var merged = ChannelMerge.Merge(Array.Empty>()); + + var results = await ReadAllWithTimeoutAsync(merged); + + Assert.Empty(results); + } + + [Fact] + public async Task Merge_OneReader_PreservesReaderOrder() + { + var source = CreateCompletedReader([1, 2, 3]); + + var merged = ChannelMerge.Merge(new[] { source }); + var results = await ReadAllWithTimeoutAsync(merged); + + Assert.Equal([1, 2, 3], results); + } + + [Fact] + public async Task Merge_NReaders_PreservesPerReaderOrderAndBackpressure() + { + var readers = new[] + { + CreateCompletedReader([0, 1, 2, 3]), + CreateCompletedReader([100, 101, 102, 103]), + CreateCompletedReader([200, 201, 202, 203]), + }; + var options = new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = true, + }; + + var merged = ChannelMerge.MergeMany(readers, options, CancellationToken.None); + var results = await ReadAllWithTimeoutAsync(merged); + + Assert.Equal(12, results.Count); + Assert.Equal([0, 1, 2, 3], results.Where(value => value < 100)); + Assert.Equal([100, 101, 102, 103], results.Where(value => value is >= 100 and < 200)); + Assert.Equal([200, 201, 202, 203], results.Where(value => value >= 200)); + } + + [Fact] + public async Task Merge_BoundedCapacityBlocksSecondWriteUntilFirstItemIsConsumed() + { + var source = new ReadTrackingReader([1, 2, 3]); + var options = new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.Wait, + }; + var merged = ChannelMerge.MergeMany(new[] { source }, options, CancellationToken.None); + + await WaitForOutputReadyAsync(merged); + await WaitWithTimeout(source.SecondRead); + + await Assert.ThrowsAsync( + () => source.ThirdRead.WaitAsync(LivenessTimeout, TestContext.Current.CancellationToken)); + + Assert.True(merged.TryRead(out var first)); + Assert.Equal(1, first); + await WaitWithTimeout(source.ThirdRead); + + var remaining = await ReadAllWithTimeoutAsync(merged); + + Assert.Equal([2, 3], remaining); + } + + [Fact] + public async Task Merge_LegacyPairOverload_RemainsUsable() + { + var first = CreateCompletedReader([1, 2, 3]); + var second = CreateCompletedReader([10, 11, 12]); + + var merged = ChannelMerge.Merge(first, second); + var results = await ReadAllWithTimeoutAsync(merged); + + Assert.Equal([1, 2, 3], results.Where(value => value < 10)); + Assert.Equal([10, 11, 12], results.Where(value => value >= 10)); + } + + [Fact] + public void Merge_LegacyPairOverload_NullFirst_ThrowsArgumentNullException() + { + var second = Channel.CreateUnbounded(); + var act = () => + { + _ = ChannelMerge.Merge(null!, second.Reader); + }; + + var exception = Assert.Throws(act); + + Assert.Equal("first", exception.ParamName); + } + + [Fact] + public void Merge_LegacyPairOverload_NullSecond_ThrowsArgumentNullException() + { + var first = Channel.CreateUnbounded(); + var act = () => + { + _ = ChannelMerge.Merge(first.Reader, null!); + }; + + var exception = Assert.Throws(act); + + Assert.Equal("second", exception.ParamName); + } + + [Fact] +#pragma warning disable xUnit1051 // Default tokens are intentional source-compatibility probes. + public void Merge_LegacyPairOverload_AllNullAndDefaultCallsRemainSourceCompatible() + { + var twoNull = Assert.Throws( + () => _ = ChannelMerge.Merge(null!, null!)); + var twoDefault = Assert.Throws( + () => _ = ChannelMerge.Merge(default!, default!)); + var threeNull = Assert.Throws( + () => _ = ChannelMerge.Merge(null!, null!, null)); + var threeDefault = Assert.Throws( + () => _ = ChannelMerge.Merge(default!, default!, default)); + var fourNull = Assert.Throws( + () => _ = ChannelMerge.Merge(null!, null!, null, default)); + var fourDefault = Assert.Throws( + () => _ = ChannelMerge.Merge(default!, default!, default!, default)); + + Assert.All( + new[] { twoNull, twoDefault, threeNull, threeDefault, fourNull, fourDefault }, + exception => Assert.Equal("first", exception.ParamName)); + } +#pragma warning restore xUnit1051 + + [Fact] + public async Task Merge_InputFailureWithCancellationCallbackFailure_PreservesPrimaryThenCallbackAggregate() + { + var first = Channel.CreateUnbounded(); + var callbackFailure = new InvalidOperationException("cancellation callback failed"); + var second = new CancellationCallbackFailureReader(callbackFailure); + var primary = new InvalidOperationException("primary input failed"); + var merged = ChannelMerge.Merge(first.Reader, second); + + await WaitWithTimeout(second.Started); + first.Writer.TryComplete(primary); + + var exception = await Assert.ThrowsAsync( + () => ReadAllWithTimeoutAsync(merged)); + + Assert.Same(primary, exception.InnerExceptions[0]); + var callbackAggregate = Assert.IsType(exception.InnerExceptions[1]); + Assert.Same(callbackFailure, callbackAggregate.InnerExceptions[0]); + } + + [Fact] + public async Task Merge_OptionsAreSnapshottedBeforeCallerMutation() + { + var readers = new[] + { + CreateCompletedReader([1, 2, 3]), + CreateCompletedReader([4, 5, 6]), + }; + var options = new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = false, + }; + + var merged = ChannelMerge.MergeMany(readers, options, CancellationToken.None); + Assert.Equal(1, options.Capacity); + Assert.Equal(BoundedChannelFullMode.Wait, options.FullMode); + Assert.True(options.SingleReader); + Assert.True(options.SingleWriter); + Assert.False(options.AllowSynchronousContinuations); + + options.FullMode = BoundedChannelFullMode.DropWrite; + options.Capacity = 2; + options.SingleReader = false; + options.SingleWriter = false; + options.AllowSynchronousContinuations = true; + + var results = await ReadAllWithTimeoutAsync(merged); + + Assert.Equal(6, results.Count); + Assert.Equal([1, 2, 3, 4, 5, 6], results.OrderBy(value => value)); + } + + [Fact] + public async Task Merge_PreCancelledToken_ShutsDownOutputAsCancellation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var readers = new[] + { + Channel.CreateUnbounded().Reader, + Channel.CreateUnbounded().Reader, + }; + + var merged = ChannelMerge.MergeMany(readers, null, cancellation.Token); + + await Assert.ThrowsAnyAsync( + () => ReadAllWithTimeoutAsync(merged)); + } + + [Fact] + public async Task Merge_CancellationWithReadyData_PreservesQueuedDataAndFaultsOutput() + { + var ready = CreateCompletedReader([42]); + var pending = new CancellationGateReader(); + using var cancellation = new CancellationTokenSource(); + var merged = ChannelMerge.MergeMany( + new[] { ready, pending }, + options: null, + cancellation.Token); + + await WaitWithTimeout(pending.Started); + await WaitForOutputReadyAsync(merged); + cancellation.Cancel(); + await WaitWithTimeout(pending.Cancelled); + + var observation = await ReadAllCapturingCancellationWithTimeoutAsync(merged); + + Assert.True(observation.Canceled); + Assert.Equal([42], observation.Items); + } + + [Fact] + public async Task Merge_CancellationWhileWriteIsPending_UnblocksPumpAndFaultsOutput() + { + var source = new PendingAfterItemsReader([1, 2]); + var options = new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.Wait, + }; + using var cancellation = new CancellationTokenSource(); + var merged = ChannelMerge.MergeMany(new[] { source }, options, cancellation.Token); + + await WaitForOutputReadyAsync(merged); + await WaitWithTimeout(source.SecondRead); + cancellation.Cancel(); + + var observation = await Assert.ThrowsAnyAsync( + () => ReadAllWithTimeoutAsync(merged)); + + Assert.NotNull(observation); + Assert.False(source.AfterItemsWaitEntered.IsCompleted); + } + + [Fact] + public async Task Merge_MultipleInputFailures_UsesLowestReaderIndexAsPrimary() + { + var first = new GateFaultReader(); + var second = new GateFaultReader(); + var expectedPrimary = new InvalidOperationException("reader zero failed"); + var secondary = new InvalidOperationException("reader one failed"); + var merged = ChannelMerge.Merge(new ChannelReader[] { first, second }); + + await WaitWithTimeout(Task.WhenAll(first.Started, second.Started)); + second.Fail(secondary); + await WaitWithTimeout(second.FailureThrown); + first.Fail(expectedPrimary); + + var exception = await Assert.ThrowsAsync( + () => ReadAllWithTimeoutAsync(merged)); + + Assert.Same(expectedPrimary, exception); + } + + private static ChannelReader CreateCompletedReader(IEnumerable items) + { + var channel = Channel.CreateUnbounded(); + foreach (var item in items) + channel.Writer.TryWrite(item); + channel.Writer.TryComplete(); + return channel.Reader; + } + + private static Task> ReadAllWithTimeoutAsync(ChannelReader reader) + { + return ReadAllAsync(reader, TestContext.Current.CancellationToken) + .WaitAsync(Timeout, TestContext.Current.CancellationToken); + } + + private static Task<(List Items, bool Canceled)> ReadAllCapturingCancellationWithTimeoutAsync( + ChannelReader reader) + { + return ReadAllCapturingCancellationAsync(reader, TestContext.Current.CancellationToken) + .WaitAsync(Timeout, TestContext.Current.CancellationToken); + } + + private static async Task WaitForOutputReadyAsync(ChannelReader reader) + { + var ready = await reader.WaitToReadAsync(TestContext.Current.CancellationToken) + .AsTask() + .WaitAsync(Timeout, TestContext.Current.CancellationToken); + + Assert.True(ready); + } + + private static Task WaitWithTimeout(Task task) + { + return task.WaitAsync(Timeout, TestContext.Current.CancellationToken); + } + + private static async Task> ReadAllAsync( + ChannelReader reader, + CancellationToken cancellationToken) + { + var results = new List(); + await foreach (var item in reader.ReadAllAsync(cancellationToken)) + results.Add(item); + return results; + } + + private static async Task<(List Items, bool Canceled)> ReadAllCapturingCancellationAsync( + ChannelReader reader, + CancellationToken cancellationToken) + { + var results = new List(); + try + { + await foreach (var item in reader.ReadAllAsync(cancellationToken)) + results.Add(item); + } + catch (OperationCanceledException) + { + return (results, true); + } + + return (results, false); + } + + private sealed class ReadTrackingReader : ChannelReader + { + private readonly Channel _source = Channel.CreateUnbounded(); + private readonly TaskCompletionSource _secondRead = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _thirdRead = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _readCount; + + public ReadTrackingReader(IEnumerable items) + { + foreach (var item in items) + _source.Writer.TryWrite(item); + _source.Writer.TryComplete(); + } + + public Task SecondRead => _secondRead.Task; + + public Task ThirdRead => _thirdRead.Task; + + public override bool TryRead(out T item) + { + if (!_source.Reader.TryRead(out item!)) + return false; + + switch (Interlocked.Increment(ref _readCount)) + { + case 2: + _secondRead.TrySetResult(); + break; + case 3: + _thirdRead.TrySetResult(); + break; + } + + return true; + } + + public override ValueTask WaitToReadAsync( + CancellationToken cancellationToken = default) + { + return _source.Reader.WaitToReadAsync(cancellationToken); + } + } + + private sealed class PendingAfterItemsReader : ChannelReader + { + private readonly Queue _items; + private readonly TaskCompletionSource _afterItemsWait = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _afterItemsWaitEntered = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _secondRead = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _readCount; + + public PendingAfterItemsReader(IEnumerable items) + { + _items = new Queue(items); + } + + public Task AfterItemsWaitEntered => _afterItemsWaitEntered.Task; + + public Task SecondRead => _secondRead.Task; + + public override bool TryRead(out T item) + { + if (_items.Count == 0) + { + item = default!; + return false; + } + + item = _items.Dequeue(); + if (Interlocked.Increment(ref _readCount) == 2) + _secondRead.TrySetResult(); + return true; + } + + public override ValueTask WaitToReadAsync( + CancellationToken cancellationToken = default) + { + if (_items.Count > 0) + return ValueTask.FromResult(true); + + _afterItemsWaitEntered.TrySetResult(); + return new ValueTask(_afterItemsWait.Task); + } + } + + private sealed class CancellationGateReader : ChannelReader + { + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _cancelled = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _started = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Cancelled => _cancelled.Task; + + public Task Started => _started.Task; + + public override bool TryRead(out T item) + { + item = default!; + return false; + } + + public override async ValueTask WaitToReadAsync( + CancellationToken cancellationToken = default) + { + _started.TrySetResult(); + try + { + return await _completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _cancelled.TrySetResult(); + throw; + } + } + } + + private sealed class CancellationCallbackFailureReader : ChannelReader + { + private readonly Exception _callbackFailure; + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _started = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public CancellationCallbackFailureReader(Exception callbackFailure) + { + _callbackFailure = callbackFailure; + } + + public Task Started => _started.Task; + + public override bool TryRead(out T item) + { + item = default!; + return false; + } + + public override async ValueTask WaitToReadAsync( + CancellationToken cancellationToken = default) + { + _started.TrySetResult(); + await using var completeRegistration = cancellationToken.UnsafeRegister( + static state => + { + var reader = (CancellationCallbackFailureReader)state!; + reader._completion.TrySetCanceled(); + }, + this); + await using var throwingRegistration = cancellationToken.UnsafeRegister( + static state => + { + var reader = (CancellationCallbackFailureReader)state!; + throw reader._callbackFailure; + }, + this); + + return await _completion.Task.ConfigureAwait(false); + } + } + + private sealed class GateFaultReader : ChannelReader + { + private readonly TaskCompletionSource _failure = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _failureThrown = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _started = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task FailureThrown => _failureThrown.Task; + + public Task Started => _started.Task; + + public override bool TryRead(out T item) + { + item = default!; + return false; + } + + public override async ValueTask WaitToReadAsync( + CancellationToken cancellationToken = default) + { + _started.TrySetResult(); + var failure = await _failure.Task.ConfigureAwait(false); + _failureThrown.TrySetResult(); + throw failure; + } + + public void Fail(Exception exception) + { + _failure.TrySetResult(exception); + } + } +} diff --git a/tests/SmartPipe.Extensions.Channels.Tests/SmartPipe.Extensions.Channels.Tests.csproj b/tests/SmartPipe.Extensions.Channels.Tests/SmartPipe.Extensions.Channels.Tests.csproj new file mode 100644 index 0000000..f596e2c --- /dev/null +++ b/tests/SmartPipe.Extensions.Channels.Tests/SmartPipe.Extensions.Channels.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + false + true + false + true + true + Exe + + + + + + + + + + + + + diff --git a/tests/SmartPipe.Extensions.Channels.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Channels.Tests/packages.lock.json new file mode 100644 index 0000000..d755454 --- /dev/null +++ b/tests/SmartPipe.Extensions.Channels.Tests/packages.lock.json @@ -0,0 +1,181 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", + "dependencies": { + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "S0LJpeMIMrmbVLXDCvPVX47OLk28qBYfGU+5SNCbarOEdw8oKLfiVqaACwuYRvLiOqDEB/+VJ8gTSB1ZwheoOQ==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v2": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "H580BvHyuADoWzlH9zRk5fqVyGucm6mhph+k40CQc9O4ie+Buxa4Pk9Q92BEClqIICqi25J7fuMII9qFYYgKtw==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "MrHYdPZ1CiyYp5bfjzNSghfVwl/I9osMazcZMAbwZY0BhR32i70YLf4zSXECvU2qt2PvDdrjYpGRgBscFbjDpw==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "43NCOTEENtdc9fmlzX9KHQR14AZEYek5r4jOJlWPhTyV1+aYAQYl4x773nYXU5TKxV6+rMuniJ7wcj9C9qrP1A==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "2zKkQKaUoaKgb/3AekboWOdLMh4upCo1nLWQnjGzp8r9YjiNOZRrzTsJQ3A4U03AcbH0evlIvFDKYSUqmTVuug==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.6.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "zW82tdCm+T1uUD1JKE+SmhgMq8nCAvcFPRLIVEiRgaxBSjcyJEKopLU3bHGOa416q+N3Dz7m1zLoPR5VJ5OQ+Q==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.0.2", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.0.2", + "Microsoft.Testing.Platform": "2.0.2", + "Microsoft.Testing.Platform.MSBuild": "2.0.2", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "smartpipe.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + } + }, + "smartpipe.extensions.channels": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + } + } + } +} \ No newline at end of file diff --git a/tests/SmartPipe.Extensions.DataAnnotations.Tests/SmartPipe.Extensions.DataAnnotations.Tests.csproj b/tests/SmartPipe.Extensions.DataAnnotations.Tests/SmartPipe.Extensions.DataAnnotations.Tests.csproj new file mode 100644 index 0000000..c4ca867 --- /dev/null +++ b/tests/SmartPipe.Extensions.DataAnnotations.Tests/SmartPipe.Extensions.DataAnnotations.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + false + true + false + true + true + Exe + + + + + + + + + + + + + diff --git a/tests/SmartPipe.Extensions.DataAnnotations.Tests/ValidationContractTests.cs b/tests/SmartPipe.Extensions.DataAnnotations.Tests/ValidationContractTests.cs new file mode 100644 index 0000000..c105f74 --- /dev/null +++ b/tests/SmartPipe.Extensions.DataAnnotations.Tests/ValidationContractTests.cs @@ -0,0 +1,154 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using SmartPipe.Core; +using SmartPipe.Extensions.Transforms; + +namespace SmartPipe.Extensions.DataAnnotations.Tests; + +public sealed class ValidationContractTests +{ + private const string ReflectionContract = + "Reflection-based DataAnnotations validation is not trimming-safe."; + + [Fact] + public async Task ValidationIsNonRecursive() + { + var validation = new ValidationTransform() + .Require(static value => value.Inner is not null, "inner required"); + + var result = await validation.TransformAsync( + ProcessingEnvelope.Create(new Outer + { + Name = "outer", + Inner = new Inner(), + }), TestContext.Current.CancellationToken); + + // Catches replacing Validator.TryValidateObject with a recursive graph walker. + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task ValidationAggregatesAttributeAndRuleErrorsInLegacyOrder() + { + var validation = new ValidationTransform() + .Require(static _ => false, "custom rule"); + + var result = await validation.TransformAsync( + ProcessingEnvelope.Create(new Outer + { + Inner = new Inner(), + }), TestContext.Current.CancellationToken); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + + // Catches dropping an attribute error, changing aggregation order, or losing custom rules. + Assert.Equal("outer name; custom rule", result.Error!.Value.Message); + Assert.Equal(ErrorType.Permanent, result.Error.Value.Type); + Assert.Equal("Validation", result.Error.Value.Category); + } + + [Fact] + public async Task ValidationRulesFreezeAfterInitialization() + { + var validation = new ValidationTransform(); + await validation.InitializeAsync(TestContext.Current.CancellationToken); + + // Catches allowing configuration mutation after the snapshot is published. + Assert.Throws(() => + { + validation.Require(static _ => true, "late rule"); + }); + } + + [Fact] + public async Task ValidationRulesFreezeBeforeFirstExecution() + { + var validation = new ValidationTransform(); + await validation.TransformAsync( + ProcessingEnvelope.Create(new Outer { Name = "outer" }), + TestContext.Current.CancellationToken); + + // Catches freezing only from InitializeAsync and leaving the first execution mutable. + Assert.Throws(() => + { + validation.Require(static _ => true, "late rule"); + }); + } + + [Fact] + public async Task ValidationTransformPropagatesCancellationToken() + { + var validation = new ValidationTransform(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + // Catches ignoring the transform cancellation token before reflection starts. + await Assert.ThrowsAsync(() => + validation.TransformAsync( + ProcessingEnvelope.Create(new Outer()), cancellation.Token).AsTask()); + } + + [Fact] + public async Task ToFilterPropagatesCancellationToken() + { + var filter = new ValidationTransform().ToFilter(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + // Catches a bridge that invokes ValidationTransform without forwarding the token. + await Assert.ThrowsAsync(() => + filter.TransformAsync( + ProcessingEnvelope.Create(new Outer()), cancellation.Token).AsTask()); + } + + [Fact] + public async Task ToFilterConvertsValidationFailureToFilteredResult() + { + var filter = new ValidationTransform() + .Require(static _ => false, "custom rule") + .ToFilter(); + + var result = await filter.TransformAsync( + ProcessingEnvelope.Create(new Outer { Name = "outer" }), + TestContext.Current.CancellationToken); + + // Catches returning a failed validation result directly instead of the filter terminal state. + Assert.Equal(StageResultKind.Filtered, result.Kind); + } + + [Fact] + public void ReflectionValidationPathsCarryExactTrimmingContract() + { + var transformMethod = typeof(ValidationTransform) + .GetMethod( + nameof(ValidationTransform.TransformAsync), + [typeof(ProcessingEnvelope), typeof(CancellationToken)]); + var bridgeMethod = typeof(FilterValidationExtensions) + .GetMethod(nameof(FilterValidationExtensions.ToFilter))!; + + var transformContract = transformMethod?.GetCustomAttribute(); + var bridgeContract = bridgeMethod.GetCustomAttribute(); + + // Catches hiding IL2026 with suppression or annotating a non-invoking member. + Assert.NotNull(transformContract); + Assert.NotNull(bridgeContract); + Assert.Equal(ReflectionContract, transformContract!.Message); + Assert.Equal(ReflectionContract, bridgeContract!.Message); + } + + private sealed class Outer + { + [Required(ErrorMessage = "outer name")] + public string? Name { get; init; } + + public Inner? Inner { get; init; } + } + + private sealed class Inner + { + [Required(ErrorMessage = "inner name")] + public string? Name { get; init; } + } +} diff --git a/tests/SmartPipe.Extensions.DataAnnotations.Tests/packages.lock.json b/tests/SmartPipe.Extensions.DataAnnotations.Tests/packages.lock.json new file mode 100644 index 0000000..3889cd1 --- /dev/null +++ b/tests/SmartPipe.Extensions.DataAnnotations.Tests/packages.lock.json @@ -0,0 +1,188 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", + "dependencies": { + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "S0LJpeMIMrmbVLXDCvPVX47OLk28qBYfGU+5SNCbarOEdw8oKLfiVqaACwuYRvLiOqDEB/+VJ8gTSB1ZwheoOQ==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v2": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "H580BvHyuADoWzlH9zRk5fqVyGucm6mhph+k40CQc9O4ie+Buxa4Pk9Q92BEClqIICqi25J7fuMII9qFYYgKtw==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "MrHYdPZ1CiyYp5bfjzNSghfVwl/I9osMazcZMAbwZY0BhR32i70YLf4zSXECvU2qt2PvDdrjYpGRgBscFbjDpw==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "43NCOTEENtdc9fmlzX9KHQR14AZEYek5r4jOJlWPhTyV1+aYAQYl4x773nYXU5TKxV6+rMuniJ7wcj9C9qrP1A==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "2zKkQKaUoaKgb/3AekboWOdLMh4upCo1nLWQnjGzp8r9YjiNOZRrzTsJQ3A4U03AcbH0evlIvFDKYSUqmTVuug==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.6.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "zW82tdCm+T1uUD1JKE+SmhgMq8nCAvcFPRLIVEiRgaxBSjcyJEKopLU3bHGOa416q+N3Dz7m1zLoPR5VJ5OQ+Q==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.0.2", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.0.2", + "Microsoft.Testing.Platform": "2.0.2", + "Microsoft.Testing.Platform.MSBuild": "2.0.2", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "smartpipe.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + } + }, + "smartpipe.extensions.dataannotations": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )", + "SmartPipe.Extensions.Transforms": "[2.2.0, )" + } + }, + "smartpipe.extensions.transforms": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + } + } + } +} \ No newline at end of file diff --git a/tests/SmartPipe.Extensions.Logging.Tests/LoggerSinkContractTests.cs b/tests/SmartPipe.Extensions.Logging.Tests/LoggerSinkContractTests.cs new file mode 100644 index 0000000..6568de4 --- /dev/null +++ b/tests/SmartPipe.Extensions.Logging.Tests/LoggerSinkContractTests.cs @@ -0,0 +1,251 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; +using SmartPipe.Core; +using SmartPipe.Extensions.Sinks; + +namespace SmartPipe.Extensions.Logging.Tests; + +public sealed class LoggerSinkContractTests +{ + [Fact] + public async Task LegacyConstructorPreservesRawPayloadStructuredContractAndIsNotObsolete() + { + var constructor = typeof(LoggerSink).GetConstructor( + [typeof(ILogger>)]); + Assert.NotNull(constructor); + Assert.Null(constructor!.GetCustomAttribute()); + + var logger = new CapturingLogger>(); + var sink = new LoggerSink(logger); + await sink.WriteAsync(ProcessingEnvelope.Create("raw payload", "pipeline", "run", 42), TestContext.Current.CancellationToken); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal(0, entry.EventId.Id); + Assert.Null(entry.EventId.Name); + Assert.Equal("raw payload", entry.Properties["@Value"]); + Assert.Equal((ulong)42, entry.Properties["TraceId"]); + Assert.Equal( + "Processed item [TraceId: {TraceId}] successfully. Value: {@Value}", + entry.Properties["{OriginalFormat}"]); + Assert.Contains("raw payload", entry.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SafeDefaultDoesNotCaptureRawPayloadAndPreservesTraceIdEventContract() + { + var logger = new CapturingLogger>(); + var sink = new LoggerSink(logger, new LoggerSinkOptions()); + await sink.WriteAsync(ProcessingEnvelope.Create("secret payload", "pipeline", "run", 42), TestContext.Current.CancellationToken); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal(1000, entry.EventId.Id); + Assert.Equal("SmartPipeItem", entry.EventId.Name); + Assert.Equal((ulong)42, entry.Properties["TraceId"]); + Assert.DoesNotContain("Value", entry.Properties.Keys); + Assert.DoesNotContain("@Value", entry.Properties.Keys); + Assert.DoesNotContain("secret payload", entry.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task FormattedPayloadIsBoundedBeforeItIsLogged() + { + var formatterCalls = 0; + var logger = new CapturingLogger>(); + var sink = new LoggerSink( + logger, + new LoggerSinkOptions + { + PayloadMode = LoggerSinkPayloadMode.Formatted, + Formatter = payload => + { + formatterCalls++; + return payload; + }, + MaximumFormattedPayloadLength = 5, + }); + + await sink.WriteAsync(ProcessingEnvelope.Create("secret payload", "pipeline", "run", 42), TestContext.Current.CancellationToken); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(1, formatterCalls); + Assert.Equal("secre", entry.Properties["FormattedPayload"]); + Assert.DoesNotContain("Value", entry.Properties.Keys); + Assert.DoesNotContain("@Value", entry.Properties.Keys); + Assert.DoesNotContain("secret payload", entry.Message, StringComparison.Ordinal); + Assert.Equal((ulong)42, entry.Properties["TraceId"]); + } + + [Fact] + public async Task FormattedSafeModeDoesNotExposeRawPayloadThroughStateOrMessage() + { + const string rawPayload = "secret payload"; + var logger = new CapturingLogger>(); + var sink = new LoggerSink( + logger, + new LoggerSinkOptions + { + PayloadMode = LoggerSinkPayloadMode.Formatted, + Formatter = _ => "redacted", + }); + + await sink.WriteAsync( + ProcessingEnvelope.Create(rawPayload, "pipeline", "run", 42), + TestContext.Current.CancellationToken); + + var entry = Assert.Single(logger.Entries); + Assert.Equal("redacted", entry.Properties["FormattedPayload"]); + Assert.DoesNotContain("Value", entry.Properties.Keys); + Assert.DoesNotContain("@Value", entry.Properties.Keys); + Assert.DoesNotContain(rawPayload, entry.Message, StringComparison.Ordinal); + Assert.DoesNotContain( + rawPayload, + string.Join('|', entry.Properties.Values.Select(static value => value?.ToString() ?? string.Empty)), + StringComparison.Ordinal); + } + + [Fact] + public async Task FormattedSafeModeWithoutTraceIdOmitsTraceMetadataAndPreservesEventMetadata() + { + var logger = new CapturingLogger>(); + var sink = new LoggerSink( + logger, + new LoggerSinkOptions + { + PayloadMode = LoggerSinkPayloadMode.Formatted, + IncludeTraceId = false, + Formatter = _ => "redacted", + }); + + await sink.WriteAsync( + ProcessingEnvelope.Create("secret payload", "pipeline", "run", 42), + TestContext.Current.CancellationToken); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal(1000, entry.EventId.Id); + Assert.Equal("SmartPipeItemFormattedWithoutTrace", entry.EventId.Name); + Assert.DoesNotContain("TraceId", entry.Properties.Keys); + Assert.Equal("redacted", entry.Properties["FormattedPayload"]); + Assert.DoesNotContain("Value", entry.Properties.Keys); + Assert.DoesNotContain("@Value", entry.Properties.Keys); + } + + [Fact] + public async Task FormatterIsNotInvokedWhenInformationIsDisabled() + { + var formatterCalls = 0; + var logger = new CapturingLogger>(LogLevel.Warning); + var sink = new LoggerSink( + logger, + new LoggerSinkOptions + { + PayloadMode = LoggerSinkPayloadMode.Formatted, + Formatter = _ => + { + formatterCalls++; + return "formatted"; + }, + }); + + await sink.WriteAsync(ProcessingEnvelope.Create("secret payload"), TestContext.Current.CancellationToken); + + Assert.Equal(0, formatterCalls); + Assert.Empty(logger.Entries); + } + + [Fact] + public async Task RawPayloadRequiresExplicitUnsafeMode() + { + var logger = new CapturingLogger>(); + var sink = new LoggerSink( + logger, + new LoggerSinkOptions { PayloadMode = LoggerSinkPayloadMode.UnsafeRaw }); + + await sink.WriteAsync(ProcessingEnvelope.Create("explicit raw payload"), TestContext.Current.CancellationToken); + + var entry = Assert.Single(logger.Entries); + Assert.True( + entry.Properties.TryGetValue("@Value", out var value), + $"Captured keys: {string.Join(", ", entry.Properties.Keys)}"); + Assert.Equal("explicit raw payload", value); + } + + [Fact] + public void ConstructorsRejectNullAndInvalidOptionsAtTheBoundary() + { + var logger = new CapturingLogger>(); + + Assert.Throws(() => new LoggerSink(null!)); + Assert.Throws(() => new LoggerSink(logger, null!)); + Assert.Throws(() => new LoggerSink( + logger, + new LoggerSinkOptions { PayloadMode = LoggerSinkPayloadMode.Formatted })); + Assert.Throws(() => new LoggerSink( + logger, + new LoggerSinkOptions { MaximumFormattedPayloadLength = 0 })); + Assert.Throws(() => new LoggerSink( + logger, + new LoggerSinkOptions { PayloadMode = (LoggerSinkPayloadMode)99 })); + } + + [Fact] + public void FormattedPayloadLengthAcceptsRevisedUpperBoundAndRejectsValuesAboveIt() + { + const int revisedMaximum = 64 * 1024; + var logger = new CapturingLogger>(); + + var sink = new LoggerSink( + logger, + new LoggerSinkOptions + { + PayloadMode = LoggerSinkPayloadMode.Formatted, + Formatter = _ => "redacted", + MaximumFormattedPayloadLength = revisedMaximum, + }); + + Assert.NotNull(sink); + Assert.Throws(() => new LoggerSink( + logger, + new LoggerSinkOptions + { + PayloadMode = LoggerSinkPayloadMode.Formatted, + Formatter = _ => "redacted", + MaximumFormattedPayloadLength = revisedMaximum + 1, + })); + } + + private sealed class CapturingLogger(LogLevel minimumLevel = LogLevel.Trace) : ILogger + { + internal List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= minimumLevel; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var properties = state is IEnumerable> values + ? values.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) + : new Dictionary(); + + Entries.Add(new Entry( + logLevel, + eventId, + formatter(state, exception), + properties)); + } + + internal sealed record Entry( + LogLevel Level, + EventId EventId, + string Message, + IReadOnlyDictionary Properties); + } +} diff --git a/tests/SmartPipe.Extensions.Logging.Tests/SmartPipe.Extensions.Logging.Tests.csproj b/tests/SmartPipe.Extensions.Logging.Tests/SmartPipe.Extensions.Logging.Tests.csproj new file mode 100644 index 0000000..6bf87eb --- /dev/null +++ b/tests/SmartPipe.Extensions.Logging.Tests/SmartPipe.Extensions.Logging.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + false + true + false + true + true + Exe + + + + + + + + + + + + + + diff --git a/tests/SmartPipe.Extensions.Logging.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Logging.Tests/packages.lock.json new file mode 100644 index 0000000..0e312c3 --- /dev/null +++ b/tests/SmartPipe.Extensions.Logging.Tests/packages.lock.json @@ -0,0 +1,182 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", + "dependencies": { + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "S0LJpeMIMrmbVLXDCvPVX47OLk28qBYfGU+5SNCbarOEdw8oKLfiVqaACwuYRvLiOqDEB/+VJ8gTSB1ZwheoOQ==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v2": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "H580BvHyuADoWzlH9zRk5fqVyGucm6mhph+k40CQc9O4ie+Buxa4Pk9Q92BEClqIICqi25J7fuMII9qFYYgKtw==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "MrHYdPZ1CiyYp5bfjzNSghfVwl/I9osMazcZMAbwZY0BhR32i70YLf4zSXECvU2qt2PvDdrjYpGRgBscFbjDpw==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "43NCOTEENtdc9fmlzX9KHQR14AZEYek5r4jOJlWPhTyV1+aYAQYl4x773nYXU5TKxV6+rMuniJ7wcj9C9qrP1A==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "2zKkQKaUoaKgb/3AekboWOdLMh4upCo1nLWQnjGzp8r9YjiNOZRrzTsJQ3A4U03AcbH0evlIvFDKYSUqmTVuug==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.6.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "zW82tdCm+T1uUD1JKE+SmhgMq8nCAvcFPRLIVEiRgaxBSjcyJEKopLU3bHGOa416q+N3Dz7m1zLoPR5VJ5OQ+Q==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.0.2", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.0.2", + "Microsoft.Testing.Platform": "2.0.2", + "Microsoft.Testing.Platform.MSBuild": "2.0.2", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "smartpipe.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + } + }, + "smartpipe.extensions.logging": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "SmartPipe.Core": "[2.2.0, )" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + } + } + } +} \ No newline at end of file diff --git a/tests/SmartPipe.Extensions.Tests/ChannelMergeTests.cs b/tests/SmartPipe.Extensions.Tests/ChannelMergeTests.cs index 9307ffe..3e078b4 100644 --- a/tests/SmartPipe.Extensions.Tests/ChannelMergeTests.cs +++ b/tests/SmartPipe.Extensions.Tests/ChannelMergeTests.cs @@ -232,11 +232,12 @@ await Assert.ThrowsAnyAsync( } [Fact] - public async Task CancellationCallbackFailure_ShouldNotReplaceInputFailure() + public async Task CancellationCallbackFailure_IsReportedAfterPrimaryInputFailure() { var first = Channel.CreateUnbounded(); + var callbackFailure = new InvalidOperationException("cancellation callback failed"); var second = new CancellationCallbackFailureReader( - new InvalidOperationException("cancellation callback failed")); + callbackFailure); var expected = new InvalidOperationException("primary input failed"); var merged = ChannelMerge.Merge(first.Reader, second); @@ -244,10 +245,12 @@ public async Task CancellationCallbackFailure_ShouldNotReplaceInputFailure() first.Writer.TryComplete(expected); var readTask = ReadAllAsync(merged); - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => readTask.WaitAsync(Timeout)); - exception.Should().BeSameAs(expected); + exception.InnerExceptions[0].Should().BeSameAs(expected); + var callbackAggregate = exception.InnerExceptions[1].Should().BeOfType().Subject; + callbackAggregate.InnerExceptions[0].Should().BeSameAs(callbackFailure); } private static async Task> ReadAllAsync(ChannelReader reader) diff --git a/tests/SmartPipe.Extensions.Tests/CompositeTransformTests.cs b/tests/SmartPipe.Extensions.Tests/CompositeTransformTests.cs index 4da60da..419db3b 100644 --- a/tests/SmartPipe.Extensions.Tests/CompositeTransformTests.cs +++ b/tests/SmartPipe.Extensions.Tests/CompositeTransformTests.cs @@ -12,8 +12,10 @@ public async Task Composite_ShouldApplyAllTransforms() var t1 = new TestTransform(x => x * 2); var t2 = new TestTransform(x => x + 1); var composite = new CompositeTransform(t1, t2); + await composite.InitializeAsync(TestContext.Current.CancellationToken); - var result = await composite.TransformAsync(ProcessingEnvelope.Create(5)); + var result = await composite.TransformAsync( + ProcessingEnvelope.Create(5), TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(11); // (5*2)+1 @@ -26,8 +28,10 @@ public async Task Composite_ShouldStopOnFirstFailure() var t2 = new FailTransform(); var t3 = new TestTransform(x => x + 1); var composite = new CompositeTransform(t1, t2, t3); + await composite.InitializeAsync(TestContext.Current.CancellationToken); - var result = await composite.TransformAsync(ProcessingEnvelope.Create(5)); + var result = await composite.TransformAsync( + ProcessingEnvelope.Create(5), TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); // t2 fails } @@ -40,8 +44,9 @@ public async Task Composite_ShouldPreserveTraceIdAcrossTransforms() var t2 = new ObservingTransform(x => x + 1, observedTraceIds); var composite = new CompositeTransform(t1, t2); var envelope = ProcessingEnvelope.Create(5); + await composite.InitializeAsync(TestContext.Current.CancellationToken); - var result = await composite.TransformAsync(envelope); + var result = await composite.TransformAsync(envelope, TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); observedTraceIds.Should().Equal(envelope.TraceId, envelope.TraceId); diff --git a/tests/SmartPipe.Extensions.Tests/PackageOwnershipTests.cs b/tests/SmartPipe.Extensions.Tests/PackageOwnershipTests.cs index 7a9ad85..4f5e8d0 100644 --- a/tests/SmartPipe.Extensions.Tests/PackageOwnershipTests.cs +++ b/tests/SmartPipe.Extensions.Tests/PackageOwnershipTests.cs @@ -24,7 +24,7 @@ public void JsonIntegrationTypes_AreOwnedByDedicatedAssembly() } [Fact] - public void Extensions_ForwardsEveryJsonTypeThatExistedIn211_AndNoNewOptions() + public void Extensions_ForwardsEveryExtractedCompatibilityType_AndNoNewJsonOptions() { var expectedForwardedTypes = new HashSet { @@ -35,6 +35,15 @@ public void Extensions_ForwardsEveryJsonTypeThatExistedIn211_AndNoNewOptions() typeof(DeadLetterWriteFailureMode), typeof(DeadLetterWriteException), typeof(JsonTransform<,>), + typeof(ChannelMerge), + typeof(CompositeTransform<>), + typeof(CompressionAlgorithm), + typeof(CompressionTransform), + typeof(ConditionalTransform<>), + typeof(FilterTransform<>), + typeof(FilterValidationExtensions), + typeof(ValidationTransform<>), + typeof(LoggerSink<>), }; var extensionsAssembly = typeof(DapperSelector<>).Assembly; diff --git a/tests/SmartPipe.Extensions.Tests/Sp22007OwnershipContractTests.cs b/tests/SmartPipe.Extensions.Tests/Sp22007OwnershipContractTests.cs new file mode 100644 index 0000000..02e0878 --- /dev/null +++ b/tests/SmartPipe.Extensions.Tests/Sp22007OwnershipContractTests.cs @@ -0,0 +1,17 @@ +using SmartPipe.Extensions.Sinks; +using SmartPipe.Extensions.Transforms; + +namespace SmartPipe.Extensions.Tests; + +public sealed class Sp22007OwnershipContractTests +{ + [Fact] + public void MovedTypesArePhysicallyOwnedByTheirLeafPackages() + { + Assert.Equal("SmartPipe.Extensions.Channels", typeof(ChannelMerge).Assembly.GetName().Name); + Assert.Equal("SmartPipe.Extensions.Transforms", typeof(CompositeTransform<>).Assembly.GetName().Name); + Assert.Equal("SmartPipe.Extensions.Transforms", typeof(FilterTransform<>).Assembly.GetName().Name); + Assert.Equal("SmartPipe.Extensions.Logging", typeof(LoggerSink<>).Assembly.GetName().Name); + Assert.Equal("SmartPipe.Extensions.DataAnnotations", typeof(ValidationTransform<>).Assembly.GetName().Name); + } +} diff --git a/tests/SmartPipe.Extensions.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Tests/packages.lock.json index 9d2a61f..9de86df 100644 --- a/tests/SmartPipe.Extensions.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.Tests/packages.lock.json @@ -488,9 +488,26 @@ "Microsoft.Extensions.Options": "[10.0.8, )", "Microsoft.Extensions.Resilience": "[10.6.0, )", "SmartPipe.Core": "[2.2.0, )", + "SmartPipe.Extensions.Channels": "[2.2.0, )", + "SmartPipe.Extensions.DataAnnotations": "[2.2.0, )", "SmartPipe.Extensions.DependencyInjection": "[2.2.0, )", "SmartPipe.Extensions.Hosting": "[2.2.0, )", - "SmartPipe.Extensions.Json": "[2.2.0, )" + "SmartPipe.Extensions.Json": "[2.2.0, )", + "SmartPipe.Extensions.Logging": "[2.2.0, )", + "SmartPipe.Extensions.Transforms": "[2.2.0, )" + } + }, + "smartpipe.extensions.channels": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, + "smartpipe.extensions.dataannotations": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )", + "SmartPipe.Extensions.Transforms": "[2.2.0, )" } }, "smartpipe.extensions.dependencyinjection": { @@ -517,6 +534,19 @@ "SmartPipe.Core": "[2.2.0, )" } }, + "smartpipe.extensions.logging": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "SmartPipe.Core": "[2.2.0, )" + } + }, + "smartpipe.extensions.transforms": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, "CsvHelper": { "type": "CentralTransitive", "requested": "[33.1.0, )", diff --git a/tests/SmartPipe.Extensions.Transforms.Tests/CompositeTransformTests.cs b/tests/SmartPipe.Extensions.Transforms.Tests/CompositeTransformTests.cs new file mode 100644 index 0000000..42eee3f --- /dev/null +++ b/tests/SmartPipe.Extensions.Transforms.Tests/CompositeTransformTests.cs @@ -0,0 +1,242 @@ +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms.Tests; + +public sealed class CompositeTransformTests +{ + [Fact] + public async Task InitializeAsync_IsSingleShotForConcurrentCallers() + { + var entered = NewGate(); + var release = NewGate(); + var child = new StubTransform(initialize: async _ => + { + entered.SetResult(); + await release.Task; + }); + var composite = new CompositeTransform(child); + + Task first = composite.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Task second = composite.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + + Assert.Same(first, second); + Assert.Equal(1, child.InitializeCount); + release.SetResult(); + await Task.WhenAll(first, second); + } + + [Fact] + public async Task InitializeAsync_RollsBackInReverseOrderAndKeepsPrimaryFailureFirst() + { + var events = new List(); + var first = new StubTransform( + initialize: _ => Record(events, "init:first"), + dispose: () => Fail(events, "dispose:first", "cleanup first")); + var second = new StubTransform( + initialize: _ => Fail(events, "init:second", "initialize second"), + dispose: () => Fail(events, "dispose:second", "cleanup second")); + var composite = new CompositeTransform(first, second); + + var error = await Assert.ThrowsAsync(() => + composite.InitializeAsync(TestContext.Current.CancellationToken).AsTask()); + + Assert.Equal( + ["initialize second", "cleanup second", "cleanup first"], + error.InnerExceptions.Select(static exception => exception.Message)); + Assert.Equal(["init:first", "init:second", "dispose:second", "dispose:first"], events); + } + + [Fact] + public async Task DisposeAsync_IsSingleShotBestEffortAndReverseOrder() + { + var events = new List(); + var first = new StubTransform(dispose: () => Fail(events, "first", "first failed")); + var second = new StubTransform(dispose: () => Fail(events, "second", "second failed")); + var composite = new CompositeTransform(first, second); + await composite.InitializeAsync(TestContext.Current.CancellationToken); + + Task firstDispose = composite.DisposeAsync().AsTask(); + Task secondDispose = composite.DisposeAsync().AsTask(); + Assert.Same(firstDispose, secondDispose); + var error = await Assert.ThrowsAsync(() => firstDispose); + + Assert.Equal(["second", "first"], events); + Assert.Equal(["second failed", "first failed"], error.InnerExceptions.Select(static exception => exception.Message)); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, second.DisposeCount); + } + + [Fact] + public async Task DisposeAsync_RacingInitializationWaitsAndCleansExactlyOnce() + { + var entered = NewGate(); + var release = NewGate(); + var child = new StubTransform(initialize: async _ => + { + entered.SetResult(); + await release.Task; + }); + var composite = new CompositeTransform(child); + Task initialize = composite.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Task dispose = composite.DisposeAsync().AsTask(); + Assert.False(dispose.IsCompleted); + release.SetResult(); + await Task.WhenAll(initialize, dispose); + + Assert.Equal(1, child.DisposeCount); + await Assert.ThrowsAsync(() => + composite.TransformAsync( + ProcessingEnvelope.Create(1), TestContext.Current.CancellationToken).AsTask()); + } + + [Fact] + public async Task TransformAsync_PreservesEnvelopeAndShortCircuitsTerminalResult() + { + ulong observedTraceId = 0; + var first = new StubTransform(transform: (envelope, token) => + { + observedTraceId = envelope.TraceId; + return ValueTask.FromResult(StageResult.Filtered()); + }); + var second = new StubTransform(); + var composite = new CompositeTransform(first, second); + await composite.InitializeAsync(TestContext.Current.CancellationToken); + var envelope = ProcessingEnvelope.Create(5); + + StageResult result = await composite.TransformAsync(envelope, TestContext.Current.CancellationToken); + + Assert.Equal(StageResultKind.Filtered, result.Kind); + Assert.Equal(envelope.TraceId, observedTraceId); + Assert.Equal(0, second.TransformCount); + } + + [Fact] + public async Task TransformAsync_ReturnsExactFailureAndDoesNotInvokeDownstreamChild() + { + var marker = new InvalidOperationException("failure identity"); + var error = new SmartPipeError("terminal failure", ErrorType.Permanent, "CompositeTest", marker); + StageResult expected = StageResult.Failure(error); + var failing = new StubTransform(transform: (_, _) => ValueTask.FromResult(expected)); + var downstream = new StubTransform(); + var composite = new CompositeTransform(failing, downstream); + await composite.InitializeAsync(TestContext.Current.CancellationToken); + + StageResult actual = await composite.TransformAsync( + ProcessingEnvelope.Create(5), TestContext.Current.CancellationToken); + + Assert.Equal(expected, actual); + Assert.Equal(error, actual.Error); + Assert.Same(marker, actual.Error!.Value.InnerException); + Assert.Equal(1, failing.TransformCount); + Assert.Equal(1, downstream.InitializeCount); + Assert.Equal(0, downstream.TransformCount); + } + + [Fact] + public async Task TransformAsync_PassesExactCallerTokenToEveryChild() + { + var observedTokens = new List(); + var first = new StubTransform(transform: (envelope, token) => + { + observedTokens.Add(token); + return ValueTask.FromResult(StageResult.Success(envelope.Payload + 1)); + }); + var second = new StubTransform(transform: (envelope, token) => + { + observedTokens.Add(token); + return ValueTask.FromResult(StageResult.Success(envelope.Payload + 1)); + }); + var composite = new CompositeTransform(first, second); + await composite.InitializeAsync(TestContext.Current.CancellationToken); + using var cancellation = new CancellationTokenSource(); + + StageResult result = await composite.TransformAsync( + ProcessingEnvelope.Create(1), cancellation.Token); + + Assert.Equal(3, result.Value); + Assert.Equal([cancellation.Token, cancellation.Token], observedTokens); + Assert.All(observedTokens, token => Assert.Equal(cancellation.Token, token)); + } + + [Fact] + public async Task TransformAsync_RequiresSuccessfulInitialization() + { + var composite = new CompositeTransform(new StubTransform()); + + await Assert.ThrowsAsync(() => + composite.TransformAsync( + ProcessingEnvelope.Create(1), TestContext.Current.CancellationToken).AsTask()); + } + + [Fact] + public async Task ConstructorDefensivelyCopiesAndRejectsNullChildren() + { + var original = new StubTransform(); + IPipelineTransformer[] children = [original]; + var composite = new CompositeTransform(children); + children[0] = new StubTransform(initialize: _ => throw new InvalidOperationException("mutated")); + + await composite.InitializeAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, original.InitializeCount); + Assert.Throws(() => new CompositeTransform(null!)); + Assert.Throws(() => new CompositeTransform(original, null!)); + } + + private static TaskCompletionSource NewGate() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static ValueTask Record(List events, string value) + { + events.Add(value); + return ValueTask.CompletedTask; + } + + private static ValueTask Fail(List events, string value, string message) + { + events.Add(value); + return ValueTask.FromException(new InvalidOperationException(message)); + } + + private sealed class StubTransform : IPipelineTransformer + { + private readonly Func _initialize; + private readonly Func, CancellationToken, ValueTask>> _transform; + private readonly Func _dispose; + + internal StubTransform( + Func? initialize = null, + Func, CancellationToken, ValueTask>>? transform = null, + Func? dispose = null) + { + _initialize = initialize ?? (_ => ValueTask.CompletedTask); + _transform = transform ?? ((envelope, _) => ValueTask.FromResult(StageResult.Success(envelope.Payload))); + _dispose = dispose ?? (() => ValueTask.CompletedTask); + } + + internal int InitializeCount { get; private set; } + internal int TransformCount { get; private set; } + internal int DisposeCount { get; private set; } + + public ValueTask InitializeAsync(CancellationToken ct = default) + { + InitializeCount++; + return _initialize(ct); + } + + public ValueTask> TransformAsync(ProcessingEnvelope envelope, CancellationToken ct = default) + { + TransformCount++; + return _transform(envelope, ct); + } + + public ValueTask DisposeAsync() + { + DisposeCount++; + return _dispose(); + } + } +} diff --git a/tests/SmartPipe.Extensions.Transforms.Tests/ConditionalAndCompressionTransformTests.cs b/tests/SmartPipe.Extensions.Transforms.Tests/ConditionalAndCompressionTransformTests.cs new file mode 100644 index 0000000..cb530e3 --- /dev/null +++ b/tests/SmartPipe.Extensions.Transforms.Tests/ConditionalAndCompressionTransformTests.cs @@ -0,0 +1,87 @@ +using System.IO.Compression; +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms.Tests; + +public sealed class ConditionalAndCompressionTransformTests +{ + [Fact] + public async Task ConditionalTransform_AppliesOnlyMatchingBranchAndPassesExactToken() + { + CancellationToken observed = default; + var child = new DelegateTransform((envelope, token) => + { + observed = token; + return ValueTask.FromResult(StageResult.Success(envelope.Payload * 2)); + }); + var transform = new ConditionalTransform(static value => value > 0, child); + using var cancellation = new CancellationTokenSource(); + + StageResult skipped = await transform.TransformAsync(ProcessingEnvelope.Create(0), cancellation.Token); + StageResult applied = await transform.TransformAsync(ProcessingEnvelope.Create(2), cancellation.Token); + + Assert.Equal(0, skipped.Value); + Assert.Equal(4, applied.Value); + Assert.Equal(1, child.TransformCount); + Assert.Equal(cancellation.Token, observed); + await transform.InitializeAsync(cancellation.Token); + await transform.DisposeAsync(); + Assert.Equal(cancellation.Token, child.InitializeToken); + Assert.Equal(1, child.DisposeCount); + } + + [Theory] + [InlineData(CompressionAlgorithm.Brotli)] + [InlineData(CompressionAlgorithm.GZip)] + public async Task CompressionTransform_RoundTripsKnownPayload(CompressionAlgorithm algorithm) + { + byte[] payload = "SmartPipe deterministic compression payload"u8.ToArray(); + var transform = new CompressionTransform(algorithm); + + StageResult result = await transform.TransformAsync( + ProcessingEnvelope.Create(payload), TestContext.Current.CancellationToken); + + Assert.True(result.IsSuccess); + using var input = new MemoryStream(result.Value!); + using Stream decompressor = algorithm == CompressionAlgorithm.Brotli + ? new BrotliStream(input, CompressionMode.Decompress) + : new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + await decompressor.CopyToAsync(output, TestContext.Current.CancellationToken); + Assert.Equal(payload, output.ToArray()); + } + + [Fact] + public void CompressionTransform_RejectsUnknownConfiguration() + { + Assert.Throws(() => new CompressionTransform((CompressionAlgorithm)42)); + Assert.Throws(() => new CompressionTransform(level: (CompressionLevel)42)); + } + + private sealed class DelegateTransform( + Func, CancellationToken, ValueTask>> transform) + : IPipelineTransformer + { + internal int TransformCount { get; private set; } + internal int DisposeCount { get; private set; } + internal CancellationToken InitializeToken { get; private set; } + + public ValueTask InitializeAsync(CancellationToken ct = default) + { + InitializeToken = ct; + return ValueTask.CompletedTask; + } + + public ValueTask> TransformAsync(ProcessingEnvelope envelope, CancellationToken ct = default) + { + TransformCount++; + return transform(envelope, ct); + } + + public ValueTask DisposeAsync() + { + DisposeCount++; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/SmartPipe.Extensions.Transforms.Tests/FilterTransformTests.cs b/tests/SmartPipe.Extensions.Transforms.Tests/FilterTransformTests.cs new file mode 100644 index 0000000..a691bdf --- /dev/null +++ b/tests/SmartPipe.Extensions.Transforms.Tests/FilterTransformTests.cs @@ -0,0 +1,85 @@ +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms.Tests; + +public sealed class FilterTransformTests +{ + [Fact] + public async Task TokenAwarePredicateReceivesExactTokenAndCancellation() + { + CancellationToken observed = default; + var filter = new FilterTransform((_, token) => + { + observed = token; + token.ThrowIfCancellationRequested(); + return ValueTask.FromResult(true); + }); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + filter.TransformAsync(ProcessingEnvelope.Create(1), cancellation.Token).AsTask()); + Assert.Equal(cancellation.Token, observed); + } + + [Fact] + public async Task AndOrAndNotShortCircuitAndPreserveExactToken() + { + using var cancellation = new CancellationTokenSource(); + int rightCalls = 0; + var falseFilter = new FilterTransform((_, token) => + { + Assert.Equal(cancellation.Token, token); + return ValueTask.FromResult(false); + }); + var trueFilter = new FilterTransform((_, token) => + { + Assert.Equal(cancellation.Token, token); + rightCalls++; + return ValueTask.FromResult(true); + }); + ProcessingEnvelope envelope = ProcessingEnvelope.Create(1); + + Assert.Equal(StageResultKind.Filtered, (await (falseFilter & trueFilter).TransformAsync(envelope, cancellation.Token)).Kind); + Assert.Equal(0, rightCalls); + Assert.True((await (trueFilter | falseFilter).TransformAsync(envelope, cancellation.Token)).IsSuccess); + Assert.Equal(1, rightCalls); + Assert.True((await (!falseFilter).TransformAsync(envelope, cancellation.Token)).IsSuccess); + } + + [Fact] + public async Task ExistingSyncAndTaskConstructorsKeepTheirBehavior() + { + var synchronous = new FilterTransform(static value => value > 0); + var taskBased = new FilterTransform(static value => Task.FromResult(value > 0)); + + CancellationToken token = TestContext.Current.CancellationToken; + Assert.True((await synchronous.TransformAsync(ProcessingEnvelope.Create(1), token)).IsSuccess); + Assert.True((await taskBased.TransformAsync(ProcessingEnvelope.Create(1), token)).IsSuccess); + Assert.Equal(StageResultKind.Filtered, (await synchronous.TransformAsync(ProcessingEnvelope.Create(0), token)).Kind); + Assert.Equal(StageResultKind.Filtered, (await taskBased.TransformAsync(ProcessingEnvelope.Create(0), token)).Kind); + } + + [Fact] + public async Task LegacyTaskPredicateIsNotCanceledBehindTheDelegate() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var filter = new FilterTransform(async _ => + { + entered.SetResult(); + await release.Task; + return true; + }); + using var cancellation = new CancellationTokenSource(); + + Task operation = filter.TransformAsync( + ProcessingEnvelope.Create(1), cancellation.Token).AsTask(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + cancellation.Cancel(); + + Assert.False(operation.IsCompleted); + release.SetResult(); + await Assert.ThrowsAnyAsync(() => operation); + } +} diff --git a/tests/SmartPipe.Extensions.Transforms.Tests/RuleValidationTransformTests.cs b/tests/SmartPipe.Extensions.Transforms.Tests/RuleValidationTransformTests.cs new file mode 100644 index 0000000..58684b6 --- /dev/null +++ b/tests/SmartPipe.Extensions.Transforms.Tests/RuleValidationTransformTests.cs @@ -0,0 +1,46 @@ +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Transforms.Tests; + +public sealed class RuleValidationTransformTests +{ + [Fact] + public async Task TransformAsync_ReturnsExplicitOrderedRuleFailures() + { + var transform = new RuleValidationTransform() + .Require(static value => value > 0, "positive") + .Require(static value => value % 2 == 0, "even"); + await transform.InitializeAsync(TestContext.Current.CancellationToken); + + StageResult result = await transform.TransformAsync( + ProcessingEnvelope.Create(-1), TestContext.Current.CancellationToken); + + Assert.False(result.IsSuccess); + SmartPipeError error = result.Error!.Value; + Assert.Equal(ErrorType.Permanent, error.Type); + Assert.Equal("Validation", error.Category); + Assert.Equal("positive; even", error.Message); + } + + [Fact] + public async Task InitializeAsync_FreezesRulesAndIsIdempotent() + { + var transform = new RuleValidationTransform() + .Require(static value => value > 0, "positive"); + + CancellationToken token = TestContext.Current.CancellationToken; + await Task.WhenAll(transform.InitializeAsync(token).AsTask(), transform.InitializeAsync(token).AsTask()); + + Assert.Throws(() => transform.Require(static value => value < 10, "small")); + Assert.True((await transform.TransformAsync(ProcessingEnvelope.Create(1), token)).IsSuccess); + } + + [Fact] + public void Require_RejectsInvalidRuleDefinition() + { + var transform = new RuleValidationTransform(); + + Assert.Throws(() => transform.Require(null!, "message")); + Assert.Throws(() => transform.Require(static _ => true, "")); + } +} diff --git a/tests/SmartPipe.Extensions.Transforms.Tests/SmartPipe.Extensions.Transforms.Tests.csproj b/tests/SmartPipe.Extensions.Transforms.Tests/SmartPipe.Extensions.Transforms.Tests.csproj new file mode 100644 index 0000000..9a9d6fb --- /dev/null +++ b/tests/SmartPipe.Extensions.Transforms.Tests/SmartPipe.Extensions.Transforms.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + false + true + false + true + true + Exe + + + + + + + + + + + + + diff --git a/tests/SmartPipe.Extensions.Transforms.Tests/TransformsContractTests.cs b/tests/SmartPipe.Extensions.Transforms.Tests/TransformsContractTests.cs new file mode 100644 index 0000000..5f84ace --- /dev/null +++ b/tests/SmartPipe.Extensions.Transforms.Tests/TransformsContractTests.cs @@ -0,0 +1,28 @@ +using SmartPipe.Core; +using SmartPipe.Extensions.Transforms; + +namespace SmartPipe.Extensions.Transforms.Tests; + +public sealed class TransformsContractTests +{ + [Fact] + public async Task FilterAndRulesUseTheRevisedContracts() + { + var expected = new CancellationTokenSource().Token; + var observed = CancellationToken.None; + var filter = new FilterTransform((_, token) => + { + observed = token; + return ValueTask.FromResult(true); + }); + var rules = new RuleValidationTransform() + .Require(static value => value > 0, "positive"); + + await filter.TransformAsync(ProcessingEnvelope.Create(1), expected); + await rules.InitializeAsync(TestContext.Current.CancellationToken); + + Assert.Equal(expected, observed); + Assert.Throws((Action)(() => + rules.Require(static value => value < 10, "less than ten"))); + } +} diff --git a/tests/SmartPipe.Extensions.Transforms.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Transforms.Tests/packages.lock.json new file mode 100644 index 0000000..0876ec2 --- /dev/null +++ b/tests/SmartPipe.Extensions.Transforms.Tests/packages.lock.json @@ -0,0 +1,181 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", + "dependencies": { + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "S0LJpeMIMrmbVLXDCvPVX47OLk28qBYfGU+5SNCbarOEdw8oKLfiVqaACwuYRvLiOqDEB/+VJ8gTSB1ZwheoOQ==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v2": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "H580BvHyuADoWzlH9zRk5fqVyGucm6mhph+k40CQc9O4ie+Buxa4Pk9Q92BEClqIICqi25J7fuMII9qFYYgKtw==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "MrHYdPZ1CiyYp5bfjzNSghfVwl/I9osMazcZMAbwZY0BhR32i70YLf4zSXECvU2qt2PvDdrjYpGRgBscFbjDpw==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "43NCOTEENtdc9fmlzX9KHQR14AZEYek5r4jOJlWPhTyV1+aYAQYl4x773nYXU5TKxV6+rMuniJ7wcj9C9qrP1A==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "2zKkQKaUoaKgb/3AekboWOdLMh4upCo1nLWQnjGzp8r9YjiNOZRrzTsJQ3A4U03AcbH0evlIvFDKYSUqmTVuug==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.6.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "zW82tdCm+T1uUD1JKE+SmhgMq8nCAvcFPRLIVEiRgaxBSjcyJEKopLU3bHGOa416q+N3Dz7m1zLoPR5VJ5OQ+Q==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.0.2", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.0.2", + "Microsoft.Testing.Platform": "2.0.2", + "Microsoft.Testing.Platform.MSBuild": "2.0.2", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "smartpipe.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + } + }, + "smartpipe.extensions.transforms": { + "type": "Project", + "dependencies": { + "SmartPipe.Core": "[2.2.0, )" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + } + } + } +} \ No newline at end of file diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs index 2de113c..7a7e319 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs @@ -97,6 +97,172 @@ public void ProcessFailure_RejectsEvidenceOutsideRepositoryRoot() Assert.DoesNotContain('\n', error.Message); } + [Fact] + public void ExpectedPublishDiagnostic_AppendsDeclaredPropertiesAndWarningsAsErrors() + { + var expectation = new ExpectedPublishDiagnostic + { + Code = "IL2026", + SourcePath = "Program.cs", + Line = 9, + MsBuildProperties = ["EnableTrimAnalyzer=true", "InvokeReflectionValidation=true"], + }; + + var arguments = ConsumerScenarioRunner.BuildExpectedDiagnosticPublishArguments( + ["publish", "Consumer.csproj", "--no-restore"], + expectation); + + Assert.Equal( + ["publish", "Consumer.csproj", "--no-restore", "-warnaserror", "-p:EnableTrimAnalyzer=true", "-p:InvokeReflectionValidation=true"], + arguments); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ExpectedPublishDiagnostic_AcceptsExactConsumerCallSiteFromEitherLog(bool useStandardOutput) + { + using var fixture = new RepositoryTestDirectory(); + var source = fixture.Write("source/Program.cs", new string('\n', 8) + "CallRuc();\n"); + var diagnostic = $"{source}(9,1): Trim analysis error IL2026: Using member requires unreferenced code.\n"; + var stdout = useStandardOutput ? diagnostic : string.Empty; + var stderr = useStandardOutput ? string.Empty : diagnostic; + var result = DiagnosticResult(fixture, 1, stdout, stderr); + + await ConsumerScenarioRunner.ValidateExpectedPublishDiagnosticAsync( + result, + DiagnosticExpectation(), + source, + fixture.Path, + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ExpectedPublishDiagnostic_AcceptsExactConsumerCallSiteWithRedactedHomePath() + { + using var fixture = new RepositoryTestDirectory(); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + Assert.False(string.IsNullOrWhiteSpace(home)); + var source = Path.Combine(home, "SmartPipe.RepositoryChecks.Tests", Guid.NewGuid().ToString("N"), "Program.cs"); + var reportedSource = DiagnosticRedactor.Redact(source); + Assert.StartsWith("", reportedSource, StringComparison.Ordinal); + var diagnostic = $"{reportedSource}(9,1): Trim analysis error IL2026: Using member requires unreferenced code.\n"; + + await ConsumerScenarioRunner.ValidateExpectedPublishDiagnosticAsync( + DiagnosticResult(fixture, 1, diagnostic, string.Empty), + DiagnosticExpectation(), + source, + fixture.Path, + TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData("wrong-source")] + [InlineData("missing-boundary")] + [InlineData("embedded-token")] + public async Task ExpectedPublishDiagnostic_RejectsNonExactRedactedHomePath(string mutation) + { + using var fixture = new RepositoryTestDirectory(); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + Assert.False(string.IsNullOrWhiteSpace(home)); + var source = Path.Combine(home, "SmartPipe.RepositoryChecks.Tests", Guid.NewGuid().ToString("N"), "Program.cs"); + var reportedSource = DiagnosticRedactor.Redact(source); + Assert.StartsWith("", reportedSource, StringComparison.Ordinal); + var mutatedSource = mutation switch + { + "wrong-source" => reportedSource.Replace("Program.cs", "Other.cs", StringComparison.Ordinal), + "missing-boundary" => reportedSource.Replace("", "suffix", StringComparison.Ordinal), + "embedded-token" => "prefix" + reportedSource, + _ => throw new ArgumentOutOfRangeException(nameof(mutation)), + }; + var diagnostic = $"{mutatedSource}(9,1): Trim analysis error IL2026: Using member requires unreferenced code.\n"; + + var error = await Assert.ThrowsAsync(() => + ConsumerScenarioRunner.ValidateExpectedPublishDiagnosticAsync( + DiagnosticResult(fixture, 1, diagnostic, string.Empty), + DiagnosticExpectation(), + source, + fixture.Path, + TestContext.Current.CancellationToken)); + + Assert.Equal("SPCONS024", error.Code); + } + + [Fact] + public async Task ExpectedPublishDiagnosticPhase_RunsDeclaredFailureAndRecordsItsEvent() + { + using var fixture = new RepositoryTestDirectory(); + var source = fixture.Write("source/Program.cs", new string('\n', 8) + "CallRuc();\n"); + var diagnostic = $"{source}(9,1): Trim analysis error IL2026: Using member requires unreferenced code.\n"; + var stdoutLog = fixture.Write("logs/stdout.log", diagnostic); + var stderrLog = fixture.Write("logs/stderr.log", string.Empty); + var process = new FakeProcessRunner( + new ProcessResult(0, string.Empty, string.Empty, stdoutLog, stderrLog), + new ProcessResult(1, diagnostic, string.Empty, stdoutLog, stderrLog)); + var runner = new ConsumerScenarioRunner(new DotNetProcessRunner(process)); + var events = new List(); + + await runner.RunExpectedPublishDiagnosticAsync( + ["restore", "Consumer.csproj", "--locked-mode"], + ["publish", "Consumer.csproj", "--no-restore"], + DiagnosticExpectation(), + fixture.Path, + Path.Combine(fixture.Path, "logs"), + fixture.Path, + source, + TimeSpan.FromMinutes(1), + events, + TestContext.Current.CancellationToken); + + Assert.Equal( + ["restore", "Consumer.csproj", "--locked-mode", "-p:EnableTrimAnalyzer=true", "-p:InvokeReflectionValidation=true"], + process.Requests[0].Arguments); + Assert.Equal( + ["publish", "Consumer.csproj", "--no-restore", "-warnaserror", "-p:EnableTrimAnalyzer=true", "-p:InvokeReflectionValidation=true"], + process.Requests[1].Arguments); + Assert.Equal("process", events[0].Phase); + var command = events[1]; + Assert.Equal("expected-publish-diagnostic", command.Phase); + Assert.Equal(1, command.ExitCode); + } + + [Theory] + [InlineData("success", "SPCONS024")] + [InlineData("wrong-code", "SPCONS014")] + [InlineData("wrong-line", "SPCONS014")] + [InlineData("wrong-source", "SPCONS024")] + [InlineData("duplicate", "SPCONS024")] + [InlineData("infrastructure", "SPCONS014")] + [InlineData("expected-plus-infrastructure", "SPCONS014")] + public async Task ExpectedPublishDiagnostic_RejectsSuccessAndNonExactFailures(string mutation, string code) + { + using var fixture = new RepositoryTestDirectory(); + var source = fixture.Write("source/Program.cs", new string('\n', 8) + "CallRuc();\n"); + var otherSource = fixture.Write("source/Other.cs", new string('\n', 8) + "CallRuc();\n"); + var exact = $"{source}(9,1): Trim analysis error IL2026: Using member requires unreferenced code.\n"; + var (exitCode, output) = mutation switch + { + "success" => (0, exact), + "wrong-code" => (1, exact.Replace("IL2026", "IL2055", StringComparison.Ordinal)), + "wrong-line" => (1, exact.Replace("(9,1)", "(8,1)", StringComparison.Ordinal)), + "wrong-source" => (1, exact.Replace(source, otherSource, StringComparison.Ordinal)), + "duplicate" => (1, exact + exact), + "infrastructure" => (1, "error NETSDK1047: Assets file has no target.\n"), + "expected-plus-infrastructure" => (1, exact + "error NETSDK1047: Assets file has no target.\n"), + _ => throw new ArgumentOutOfRangeException(nameof(mutation)), + }; + + var error = await Assert.ThrowsAsync(() => + ConsumerScenarioRunner.ValidateExpectedPublishDiagnosticAsync( + DiagnosticResult(fixture, exitCode, output, string.Empty), + DiagnosticExpectation(), + source, + fixture.Path, + TestContext.Current.CancellationToken)); + + Assert.Equal(code, error.Code); + } + [Fact] public void SuccessfulConsumerResult_SerializesWithSchemaVersionOneShape() { @@ -197,6 +363,24 @@ public void BinaryPhaseEvidence_ProvesSingleBuildThenHashReplacementAndRunWithou Assert.Equal("SPCONS020", Assert.Throws(() => ConsumerScenarioRunner.ValidateBinaryCompatibilityPhases(invalid, 1)).Code); } + [Fact] + public async Task BinaryReplacementClosure_IncludesCurrentFacadeForwardingDependencies() + { + var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); + var graph = await new SmartPipe.RepositoryChecks.PackageGraph.PackageGraphLoader().LoadAsync( + root, "eng/package-graph.json", TestContext.Current.CancellationToken); + + var closure = ConsumerScenarioRunner.CurrentSmartPipeClosure( + graph, ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.Json"]); + + Assert.Equal( + ["SmartPipe.Core", "SmartPipe.Extensions.Channels", "SmartPipe.Extensions.Transforms", + "SmartPipe.Extensions.DataAnnotations", "SmartPipe.Extensions.DependencyInjection", + "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json", + "SmartPipe.Extensions.Logging", "SmartPipe.Extensions"], + closure); + } + [Fact] public void Redact_RemovesUserInfoQueryAndCredentials() { @@ -253,4 +437,19 @@ private static string FixtureExecutable() return Path.Combine(root, "tests", "SmartPipe.RepositoryChecks.ProcessFixture", "bin", configuration, "net10.0", "SmartPipe.RepositoryChecks.ProcessFixture" + (OperatingSystem.IsWindows() ? ".exe" : string.Empty)); } + + private static ExpectedPublishDiagnostic DiagnosticExpectation() => new() + { + Code = "IL2026", + SourcePath = "Program.cs", + Line = 9, + MsBuildProperties = ["EnableTrimAnalyzer=true", "InvokeReflectionValidation=true"], + }; + + private static DotNetProcessResult DiagnosticResult(RepositoryTestDirectory fixture, int exitCode, string stdout, string stderr) + { + var stdoutLog = fixture.Write("logs/stdout.log", stdout); + var stderrLog = fixture.Write("logs/stderr.log", stderr); + return new(exitCode, stdout, stderr, stdoutLog, stderrLog, "dotnet publish", DateTimeOffset.UnixEpoch, 1); + } } diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioSchemaTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioSchemaTests.cs index 791c237..6be6542 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioSchemaTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioSchemaTests.cs @@ -9,12 +9,12 @@ namespace SmartPipe.RepositoryChecks.Tests.Consumers; public sealed class ConsumerScenarioSchemaTests { [Fact] - public async Task CurrentManifest_HasExactlyTwentyEightStrictScenarios() + public async Task CurrentManifest_HasExactlyThirtyThreeStrictScenarios() { var root = RepositoryRoot(); var graph = await new PackageGraphLoader().LoadAsync(root, "eng/package-graph.json", TestContext.Current.CancellationToken); var document = await new ConsumerScenarioLoader().LoadAsync(root, "eng/consumer-scenarios.json", graph, TestContext.Current.CancellationToken); - Assert.Equal(28, document.Scenarios.Count); + Assert.Equal(33, document.Scenarios.Count); Assert.Equal( [ "core-direct", @@ -45,6 +45,11 @@ public async Task CurrentManifest_HasExactlyTwentyEightStrictScenarios() "opentelemetry-facade", "opentelemetry-trim", "opentelemetry-nativeaot", + "channels-direct", + "transforms-direct", + "logging-direct", + "data-annotations-direct", + "data-annotations-runtime", ], document.Scenarios.Select(x => x.Id)); Assert.All( @@ -56,6 +61,13 @@ public async Task CurrentManifest_HasExactlyTwentyEightStrictScenarios() Assert.All( document.Scenarios.Where(scenario => scenario.Id.StartsWith("opentelemetry-", StringComparison.Ordinal)), scenario => Assert.Equal("opentelemetry", scenario.Category)); + Assert.All( + document.Scenarios.Where(scenario => scenario.Id is "channels-direct" or "transforms-direct" or "logging-direct" or "data-annotations-direct" or "data-annotations-runtime"), + scenario => Assert.Equal("sp220-07", scenario.Category)); + var dataAnnotations = Assert.Single(document.Scenarios, scenario => scenario.Id == "data-annotations-direct"); + Assert.Equal( + ["EnableTrimAnalyzer=true", "InvokeReflectionValidation=true"], + Assert.IsType(dataAnnotations.ExpectedPublishDiagnostic).MsBuildProperties); } [Theory] @@ -109,6 +121,90 @@ public async Task Loader_AcceptsThirtyMinutePolicyBoundary() Assert.Equal(TimeSpan.FromMinutes(30), result.Scenarios[0].Timeout); } + [Fact] + public async Task Loader_AcceptsManifestDrivenExpectedPublishDiagnostic() + { + using var fixture = new RepositoryTestDirectory(); + fixture.Write("tests/Consumers/Scenarios/fixture/Consumer.csproj", ""); + fixture.Write("tests/Consumers/Scenarios/fixture/Program.cs", "return 0;"); + var json = ValidJson() + .Replace("\"mode\": \"build-and-run\"", "\"mode\": \"publish-trimmed\"", StringComparison.Ordinal) + .Replace("\"baselineVersion\": null,", """ + "baselineVersion": null, + "expectedPublishDiagnostic": { + "code": "IL2026", + "sourcePath": "Program.cs", + "line": 1, + "msBuildProperties": ["InvokeReflectionValidation=true"] + }, + """, StringComparison.Ordinal); + fixture.Write("eng/consumer-scenarios.json", json); + var root = RepositoryRoot(); + var graph = await new PackageGraphLoader().LoadAsync(root, "eng/package-graph.json", TestContext.Current.CancellationToken); + + var result = await new ConsumerScenarioLoader().LoadAsync( + fixture.Path, + "eng/consumer-scenarios.json", + FixtureGraph(graph), + TestContext.Current.CancellationToken); + + var expectation = result.Scenarios[0].ExpectedPublishDiagnostic; + Assert.NotNull(expectation); + Assert.Equal("IL2026", expectation.Code); + Assert.Equal("Program.cs", expectation.SourcePath); + Assert.Equal(1, expectation.Line); + Assert.Equal(["InvokeReflectionValidation=true"], expectation.MsBuildProperties); + } + + [Theory] + [InlineData("mode")] + [InlineData("code")] + [InlineData("source")] + [InlineData("line")] + [InlineData("property")] + [InlineData("duplicate-property")] + public async Task Loader_RejectsInvalidExpectedPublishDiagnostic(string mutation) + { + using var fixture = new RepositoryTestDirectory(); + fixture.Write("tests/Consumers/Scenarios/fixture/Consumer.csproj", ""); + fixture.Write("tests/Consumers/Scenarios/fixture/Program.cs", "return 0;"); + var json = ValidJson() + .Replace("\"mode\": \"build-and-run\"", "\"mode\": \"publish-trimmed\"", StringComparison.Ordinal) + .Replace("\"baselineVersion\": null,", """ + "baselineVersion": null, + "expectedPublishDiagnostic": { + "code": "IL2026", + "sourcePath": "Program.cs", + "line": 1, + "msBuildProperties": ["InvokeReflectionValidation=true"] + }, + """, StringComparison.Ordinal); + json = mutation switch + { + "mode" => json.Replace("publish-trimmed", "build-and-run", StringComparison.Ordinal), + "code" => json.Replace("IL2026", "CS2026", StringComparison.Ordinal), + "source" => json.Replace("Program.cs", "../Program.cs", StringComparison.Ordinal), + "line" => json.Replace("\"line\": 1", "\"line\": 0", StringComparison.Ordinal), + "property" => json.Replace("InvokeReflectionValidation=true", "Bad Property=true", StringComparison.Ordinal), + "duplicate-property" => json.Replace( + "[\"InvokeReflectionValidation=true\"]", + "[\"InvokeReflectionValidation=true\", \"InvokeReflectionValidation=true\"]", + StringComparison.Ordinal), + _ => throw new ArgumentOutOfRangeException(nameof(mutation)), + }; + fixture.Write("eng/consumer-scenarios.json", json); + var root = RepositoryRoot(); + var graph = await new PackageGraphLoader().LoadAsync(root, "eng/package-graph.json", TestContext.Current.CancellationToken); + + var error = await Assert.ThrowsAsync(() => new ConsumerScenarioLoader().LoadAsync( + fixture.Path, + "eng/consumer-scenarios.json", + FixtureGraph(graph), + TestContext.Current.CancellationToken)); + + Assert.Equal("SPCONS023", error.Code); + } + [Fact] public async Task Loader_RejectsCurrentScenarioAbsentFromPackageGraph() { diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs index 4a6993b..6d75437 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs @@ -33,7 +33,7 @@ public void TrackedScenarioProjects_AreVersionlessCpmConsumers() { var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); var projects = Directory.EnumerateFiles(Path.Combine(root, "tests", "Consumers", "Scenarios"), "*.csproj", SearchOption.AllDirectories).ToArray(); - Assert.Equal(21, projects.Length); + Assert.Equal(26, projects.Length); Assert.All(projects, project => Assert.DoesNotContain(" Version=", File.ReadAllText(project), StringComparison.Ordinal)); } diff --git a/tests/SmartPipe.RepositoryChecks.Tests/PackageGraph/Sp22007ActivationContractTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/PackageGraph/Sp22007ActivationContractTests.cs new file mode 100644 index 0000000..24a5a97 --- /dev/null +++ b/tests/SmartPipe.RepositoryChecks.Tests/PackageGraph/Sp22007ActivationContractTests.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using SmartPipe.RepositoryChecks.PackageGraph; + +namespace SmartPipe.RepositoryChecks.Tests.PackageGraph; + +[Trait("Category", "PackageInfrastructure")] +public sealed class Sp22007ActivationContractTests +{ + private static readonly string[] PackageIds = + [ + "SmartPipe.Extensions.Channels", + "SmartPipe.Extensions.Transforms", + "SmartPipe.Extensions.Logging", + "SmartPipe.Extensions.DataAnnotations", + ]; + + [Fact] + public async Task LeafPackagesAreActiveWithExactDependencyEdges() + { + var root = RepositoryRoot(); + var graph = await new PackageGraphLoader().LoadAsync( + root, + "eng/package-graph.json", + TestContext.Current.CancellationToken); + + foreach (var id in PackageIds) + { + var package = Assert.Single(graph.Packages, item => item.Id == id); + Assert.Equal(PackageLifecycle.Active, package.Lifecycle); + Assert.Null(package.ScaffoldKind); + Assert.True(File.Exists(Path.Combine(root, package.ProjectPath))); + } + + Assert.Equal(["SmartPipe.Core"], Assert.Single(graph.Packages, item => item.Id == PackageIds[0]).CurrentDependencies.RequiredSmartPipePackages); + Assert.Equal(["SmartPipe.Core"], Assert.Single(graph.Packages, item => item.Id == PackageIds[1]).CurrentDependencies.RequiredSmartPipePackages); + Assert.Equal(["SmartPipe.Core"], Assert.Single(graph.Packages, item => item.Id == PackageIds[2]).CurrentDependencies.RequiredSmartPipePackages); + Assert.Equal(["SmartPipe.Core", "SmartPipe.Extensions.Transforms"], Assert.Single(graph.Packages, item => item.Id == PackageIds[3]).CurrentDependencies.RequiredSmartPipePackages); + Assert.Equal(["Microsoft.Extensions.Logging.Abstractions"], Assert.Single(graph.Packages, item => item.Id == PackageIds[2]).CurrentDependencies.AllowedExternalPackages); + } + + [Fact] + public void OwnershipManifestUsesTypeForwardingForEveryMovedCluster() + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepositoryRoot(), "eng/package-ownership.json"))); + var assignments = document.RootElement.GetProperty("assignments").EnumerateArray() + .ToDictionary(item => item.GetProperty("typePattern").GetString()!, StringComparer.Ordinal); + var expected = new Dictionary(StringComparer.Ordinal) + { + ["SmartPipe.Extensions.ChannelMerge*"] = "SmartPipe.Extensions.Channels", + ["SmartPipe.Extensions.Transforms.CompositeTransform*"] = "SmartPipe.Extensions.Transforms", + ["SmartPipe.Extensions.Transforms.ConditionalTransform*"] = "SmartPipe.Extensions.Transforms", + ["SmartPipe.Extensions.Transforms.FilterTransform*"] = "SmartPipe.Extensions.Transforms", + ["SmartPipe.Extensions.Transforms.ValidationTransform*"] = "SmartPipe.Extensions.DataAnnotations", + ["SmartPipe.Extensions.Transforms.FilterValidationExtensions*"] = "SmartPipe.Extensions.DataAnnotations", + ["SmartPipe.Extensions.Sinks.LoggerSink*"] = "SmartPipe.Extensions.Logging", + }; + + foreach (var (pattern, target) in expected) + { + var assignment = Assert.Contains(pattern, assignments); + Assert.Equal(target, assignment.GetProperty("targetImplementationAssembly").GetString()); + Assert.Equal("type-forward", assignment.GetProperty("strategy").GetString()); + Assert.Equal("SP220-07", assignment.GetProperty("migrationEpic").GetString()); + } + } + + [Fact] + public void ConsumerManifestActivatesTheExactDirectLeafScenarios() + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepositoryRoot(), "eng/consumer-scenarios.json"))); + var scenarios = document.RootElement.GetProperty("scenarios").EnumerateArray() + .ToDictionary(item => item.GetProperty("id").GetString()!, StringComparer.Ordinal); + var expectedClosures = new Dictionary(StringComparer.Ordinal) + { + ["channels-direct"] = ["SmartPipe.Core", "SmartPipe.Extensions.Channels"], + ["transforms-direct"] = ["SmartPipe.Core", "SmartPipe.Extensions.Transforms"], + ["logging-direct"] = ["SmartPipe.Core", "SmartPipe.Extensions.Logging"], + ["data-annotations-direct"] = ["SmartPipe.Core", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.DataAnnotations"], + ["data-annotations-runtime"] = ["SmartPipe.Core", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.DataAnnotations"], + }; + var expectedDirectPackages = new Dictionary(StringComparer.Ordinal) + { + ["channels-direct"] = "SmartPipe.Extensions.Channels", + ["transforms-direct"] = "SmartPipe.Extensions.Transforms", + ["logging-direct"] = "SmartPipe.Extensions.Logging", + ["data-annotations-direct"] = "SmartPipe.Extensions.DataAnnotations", + ["data-annotations-runtime"] = "SmartPipe.Extensions.DataAnnotations", + }; + + foreach (var (id, dependencies) in expectedClosures) + { + var scenario = Assert.Contains(id, scenarios); + Assert.Equal("current", scenario.GetProperty("set").GetString()); + Assert.Equal([expectedDirectPackages[id]], scenario.GetProperty("packageIds").EnumerateArray().Select(item => item.GetString())); + Assert.Equal(dependencies, scenario.GetProperty("expectedSmartPipeDependencies").EnumerateArray().Select(item => item.GetString())); + Assert.Contains("SmartPipe.Extensions", scenario.GetProperty("forbiddenDependencies").EnumerateArray().Select(item => item.GetString())); + Assert.True(scenario.GetProperty("runSecondLockedRestore").GetBoolean()); + Assert.True(File.Exists(Path.Combine(RepositoryRoot(), scenario.GetProperty("templatePath").GetString()!))); + } + } + + private static string RepositoryRoot() => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); +} diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Packaging/PackPackagesCommandTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Packaging/PackPackagesCommandTests.cs index 1264bf5..49a6973 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Packaging/PackPackagesCommandTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Packaging/PackPackagesCommandTests.cs @@ -16,10 +16,10 @@ public async Task Current_PacksGraphNodesTopologicallyWithArgumentListAndImmutab var manifest = await new PackPackagesCommand(runner).ExecuteAsync(new( fixture.Path, PackageGraphMode.Current, "Release", "2.2.0", Path.Combine(fixture.Path, "artifacts/packages"), Path.Combine(fixture.Path, "artifacts/packages/manifest.json")), TestContext.Current.CancellationToken); - Assert.Equal(["SmartPipe.Core", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json", "SmartPipe.Extensions", "SmartPipe.Extensions.HealthChecks", "SmartPipe.Extensions.OpenTelemetry"], manifest.Packages.Select(x => x.Id)); - Assert.Equal([1, 14, 16, 5, 19, 17, 15], manifest.Packages.Select(x => x.PublishOrder)); + Assert.Equal(["SmartPipe.Core", "SmartPipe.Extensions.Channels", "SmartPipe.Extensions.Transforms", "SmartPipe.Extensions.DataAnnotations", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.Json", "SmartPipe.Extensions.Logging", "SmartPipe.Extensions", "SmartPipe.Extensions.HealthChecks", "SmartPipe.Extensions.OpenTelemetry"], manifest.Packages.Select(x => x.Id)); + Assert.Equal([1, 2, 3, 18, 14, 16, 5, 4, 19, 17, 15], manifest.Packages.Select(x => x.PublishOrder)); Assert.All(manifest.Packages, item => { Assert.Equal(64, item.NupkgSha256.Length); Assert.Equal(64, item.SnupkgSha256.Length); Assert.DoesNotContain('\\', item.NupkgPath); }); - Assert.Equal(7, runner.Requests.Count); + Assert.Equal(11, runner.Requests.Count); Assert.All(runner.Requests, request => { Assert.Equal("dotnet", request.FileName); Assert.Equal(fixture.Path, request.WorkingDirectory); @@ -38,6 +38,10 @@ private static RepositoryTestDirectory CreateRepository() var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); fixture.Write("eng/package-graph.json", File.ReadAllText(Path.Combine(root, "eng/package-graph.json"))); fixture.Write("src/SmartPipe.Core/SmartPipe.Core.csproj", ""); + fixture.Write("src/SmartPipe.Extensions.Channels/SmartPipe.Extensions.Channels.csproj", ""); + fixture.Write("src/SmartPipe.Extensions.Transforms/SmartPipe.Extensions.Transforms.csproj", ""); + fixture.Write("src/SmartPipe.Extensions.Logging/SmartPipe.Extensions.Logging.csproj", ""); + fixture.Write("src/SmartPipe.Extensions.DataAnnotations/SmartPipe.Extensions.DataAnnotations.csproj", ""); fixture.Write("src/SmartPipe.Extensions.Json/SmartPipe.Extensions.Json.csproj", ""); fixture.Write("src/SmartPipe.Extensions.DependencyInjection/SmartPipe.Extensions.DependencyInjection.csproj", ""); fixture.Write("src/SmartPipe.Extensions.Hosting/SmartPipe.Extensions.Hosting.csproj", ""); diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Scaffolding/PackageTemplateRendererTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Scaffolding/PackageTemplateRendererTests.cs index 1ecc759..8bb968b 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Scaffolding/PackageTemplateRendererTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Scaffolding/PackageTemplateRendererTests.cs @@ -20,6 +20,14 @@ public async Task Render_AllKindsAreDeterministicLfOnlySnapshots(string id, stri var root = RepositoryRoot(); var graph = await new PackageGraphLoader().LoadAsync(root, "eng/package-graph.json", TestContext.Current.CancellationToken); var node = graph.Packages.Single(x => x.Id == id); + if (id == "SmartPipe.Extensions.Channels") + { + node = node with { Lifecycle = PackageLifecycle.Planned, ScaffoldKind = PackageScaffoldKind.CoreLeaf }; + graph = graph with + { + Packages = graph.Packages.Select(item => item.Id == id ? node : item).ToArray(), + }; + } var first = new PackageTemplateRenderer(root).Render(graph, node); var second = new PackageTemplateRenderer(root).Render(graph, node); Assert.Equal(kind, first.Kind.ToString()); diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Scaffolding/ScaffoldPackageCommandTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Scaffolding/ScaffoldPackageCommandTests.cs index 7347975..f30225d 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Scaffolding/ScaffoldPackageCommandTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Scaffolding/ScaffoldPackageCommandTests.cs @@ -8,7 +8,7 @@ namespace SmartPipe.RepositoryChecks.Tests.Scaffolding; public sealed class ScaffoldPackageCommandTests { [Fact] - public async Task DryRun_AllTwelvePlannedIds_WritesNothing() + public async Task DryRun_AllEightPlannedIds_WritesNothing() { var root = RepositoryRoot(); var graph = await new PackageGraphLoader().LoadAsync(root, "eng/package-graph.json", TestContext.Current.CancellationToken); var command = new ScaffoldPackageCommand(); @@ -17,7 +17,7 @@ public async Task DryRun_AllTwelvePlannedIds_WritesNothing() var report = await command.ExecuteAsync(new(root, node.Id, true, null), TestContext.Current.CancellationToken); Assert.True(report.Success); Assert.Equal(node.Id, report.PackageId); Assert.All(report.Files, path => Assert.False(File.Exists(Path.Combine(root, path)))); } - Assert.Equal(12, graph.Packages.Count(x => x.Lifecycle == PackageLifecycle.Planned)); + Assert.Equal(8, graph.Packages.Count(x => x.Lifecycle == PackageLifecycle.Planned)); } [Fact] From 89db94d254ccd22eeeadc2bcf2eda9da5d21984f Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sat, 22 Aug 2026 18:17:50 +0500 Subject: [PATCH 02/22] fix(runtime): close SP220-07 acceptance gates --- .github/workflows/codeql.yml | 3 + docs/channels.md | 2 + docs/data-annotations.md | 4 +- docs/recipes/graceful-shutdown.md | 4 + docs/runtime-contracts.md | 3 + .../Consumers/ConsumerScenarioRunner.cs | 118 +++++++++++++++++- eng/tests/workflow_contract_tests.py | 61 +++++++++ src/SmartPipe.Core/TypedPipelineRuntime.cs | 9 +- .../ChannelMerge.cs | 13 +- .../ValidationTransform.cs | 5 +- .../data-annotations-runtime/Program.cs | 10 +- .../ChannelMergeContractTests.cs | 18 +++ .../ValidationContractTests.cs | 13 ++ .../Extensions/SmartPipeTypedDiTests.cs | 17 +++ .../Consumers/ConsumerScenarioRunnerTests.cs | 64 +++++++++- 15 files changed, 324 insertions(+), 20 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fddf1e3..4f9cc6d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,6 +36,9 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + ram: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '16384' || '' }} + threads: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '2' || '' }} cleanup-self-hosted: name: Cleanup self-hosted workspace diff --git a/docs/channels.md b/docs/channels.md index f540f89..12c2b29 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -7,6 +7,8 @@ Install `SmartPipe.Extensions.Channels` for `ChannelMerge`; the broad `IReadOnlyList` of readers. Use `MergeMany` when configuring bounded output options or cancellation. Each input preserves its own order; arrival order between inputs is intentionally not defined. Bounded options apply backpressure to every pump. +Zero readers return an already-completing reader; for nonempty input, reader/null +validation precedes bounded output-option validation and snapshotting. Caller cancellation stops pending reads and writes, completes the output with cancellation, and the lowest-index observed input failure wins when inputs fail concurrently. If cancellation callbacks also fail, the primary input failure is diff --git a/docs/data-annotations.md b/docs/data-annotations.md index 29db0bd..dd11197 100644 --- a/docs/data-annotations.md +++ b/docs/data-annotations.md @@ -3,7 +3,9 @@ Install `SmartPipe.Extensions.DataAnnotations` for the compatibility `ValidationTransform` and `ToFilter` APIs. Validation follows BCL `Validator.TryValidateObject` behavior and does not recursively walk nested object -graphs. Custom rules freeze on initialization or first execution. +graphs. Custom rules freeze on initialization or first execution. Value-type +payloads are validated using one boxed instance, so DataAnnotations and custom +`Require` rules work consistently for both reference and value types. The reflection path is marked `RequiresUnreferencedCode`; direct invocation emits the trimming warning and is not a NativeAOT-safe contract. A package-reference-only diff --git a/docs/recipes/graceful-shutdown.md b/docs/recipes/graceful-shutdown.md index 91ee13b..b882c9e 100644 --- a/docs/recipes/graceful-shutdown.md +++ b/docs/recipes/graceful-shutdown.md @@ -31,6 +31,10 @@ Drain cancels the source-read token. Cooperative sources blocked inside return on their own. Use `CancelAsync` or `AbortAsync` when shutdown must interrupt stages, sinks, processing, or output readers too. +Drain is safe to request after `Completion`, including for factory-created runs +whose scope has already been disposed. `TryDrainAsync` reports +`AlreadyCompleted`; `DrainAsync` is an idempotent no-op. + ## Cooperative cancellation `CancelAsync` requests cooperative cancellation. The run state becomes diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md index ad39ba6..d55c7d1 100644 --- a/docs/runtime-contracts.md +++ b/docs/runtime-contracts.md @@ -156,6 +156,9 @@ token for work that was already accepted. A drain timeout throws `TryDrainAsync` is the structured non-throwing drain API. It returns `PipelineDrainResult` with `Completed`, `TimedOutStillRunning`, `CancelledByCaller`, `Faulted`, or `AlreadyCompleted`. +After `Completion` finishes, a drain request remains idempotent even when a +factory-owned scope has already disposed the completed runtime: `TryDrainAsync` +returns `AlreadyCompleted`, and `DrainAsync` completes without throwing. `CancelAsync` cancels source and in-flight processing. It records cancellation intent and requests cooperative cancellation. The terminal state is not final diff --git a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs index 5513d57..f6e5b71 100644 --- a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs +++ b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs @@ -23,6 +23,7 @@ internal sealed record RunConsumersOptions( internal sealed class ConsumerScenarioRunner(DotNetProcessRunner? processRunner = null) { + private static readonly TimeSpan ExpectedDiagnosticRegexTimeout = TimeSpan.FromSeconds(1); private readonly DotNetProcessRunner _processRunner = processRunner ?? new(); public async Task> RunAsync(RunConsumersOptions options, CancellationToken ct) @@ -127,6 +128,19 @@ private async Task RunScenarioAsync( if (scenario.Mode == ConsumerMode.BinaryCompatibility) { replacementPackageIds = CurrentSmartPipeClosure(graph, scenario.PackageIds); + await RefreshBinaryCompatibilityDeploymentMetadataAsync( + workspace, + project, + outputDirectory, + replacementPackageIds, + options.PackageDirectory, + options.PackageVersion, + externalPackageVersions, + externalPackageIds, + options.RepositoryRoot, + scenario.Timeout, + events, + ct).ConfigureAwait(false); foreach (var replacement in await ReplaceRuntimeAssembliesWithoutBuildAsync(outputDirectory, options.PackageDirectory, options.PackageVersion, replacementPackageIds, ct).ConfigureAwait(false)) events.Add(replacement); } await InspectRuntimeArtifactsAsync(outputDirectory, scenario.Mode, ct).ConfigureAwait(false); @@ -260,11 +274,13 @@ internal static async Task ValidateExpectedPublishDiagnosticAsync( var diagnostics = Regex.Matches( output, @"^(?[^\r\n]+?\.cs)\((?[1-9][0-9]*),(?[1-9][0-9]*)\):[^\r\n]*?\b(?[A-Z]{2}[0-9]{4}):", - RegexOptions.Multiline | RegexOptions.CultureInvariant); + RegexOptions.Multiline | RegexOptions.CultureInvariant, + ExpectedDiagnosticRegexTimeout); var errors = Regex.Matches( output, @"\berror\s+(?[A-Z][A-Z0-9]*[0-9]{4}):", - RegexOptions.CultureInvariant); + RegexOptions.CultureInvariant, + ExpectedDiagnosticRegexTimeout); var expectedFullPath = Path.GetFullPath(expectedSource); var expectedRedactedPath = DiagnosticRedactor.Redact(expectedFullPath).Replace('\\', '/'); var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; @@ -402,6 +418,71 @@ private async Task ExecuteAsync(string output, string project, ConsumerScenario else await RunRequiredAsync("dotnet", [Path.Combine(output, name + ".dll")], output, logs, repositoryRoot, scenario.Timeout, events, ct).ConfigureAwait(false); } + internal async Task RefreshBinaryCompatibilityDeploymentMetadataAsync( + string workspace, + string project, + string outputDirectory, + IReadOnlyList currentPackageIds, + string currentPackageDirectory, + string currentPackageVersion, + IReadOnlyDictionary externalPackageVersions, + IReadOnlyList externalPackageIds, + string repositoryRoot, + TimeSpan timeout, + List events, + CancellationToken ct) + { + var consumerAssembly = Path.Combine(outputDirectory, Path.GetFileNameWithoutExtension(project) + ".dll"); + if (!File.Exists(consumerAssembly)) + throw new ConsumerScenarioException("SPCONS011", "Binary compatibility consumer assembly is missing."); + var beforeHash = await Hashing.Sha256FileAsync(consumerAssembly, ct).ConfigureAwait(false); + + _ = await new ConsumerCentralPackagesWriter().WriteAsync( + workspace, + currentPackageIds, + currentPackageVersion, + externalPackageVersions, + ct).ConfigureAwait(false); + var config = await new LocalNuGetConfigWriter().WriteAsync( + workspace, + currentPackageDirectory, + ct, + workspace, + externalPackageIds).ConfigureAwait(false); + var source = Path.GetDirectoryName(project)!; + var logs = Path.Combine(workspace, "logs"); + await RunRequiredAsync( + "dotnet", + ["restore", project, "--configfile", config, "--packages", Path.Combine(workspace, "packages"), "--use-lock-file", "--force-evaluate"], + source, + logs, + repositoryRoot, + timeout, + events, + ct).ConfigureAwait(false); + await RunRequiredAsync( + "dotnet", + ["msbuild", project, "-t:GenerateBuildDependencyFile", "-p:Configuration=Release"], + source, + logs, + repositoryRoot, + timeout, + events, + ct).ConfigureAwait(false); + + var afterHash = await Hashing.Sha256FileAsync(consumerAssembly, ct).ConfigureAwait(false); + if (!string.Equals(beforeHash, afterHash, StringComparison.OrdinalIgnoreCase)) + throw new ConsumerScenarioException("SPCONS020", "Binary compatibility deployment metadata changed the consumer assembly."); + events.Add(new( + "binary-deployment-metadata", + $"refresh-deps consumer-before-sha256={beforeHash} consumer-after-sha256={afterHash}", + 0, + DateTimeOffset.UtcNow, + 0, + "", + "")); + } + private static async Task> ReplaceRuntimeAssembliesWithoutBuildAsync(string output, string feed, string version, IReadOnlyList packageIds, CancellationToken ct) { var events = new List(); @@ -497,8 +578,39 @@ internal static void ValidateBinaryCompatibilityPhases(IReadOnlyList (item, index)).Where(x => x.item.Phase == "process" && x.item.Command.Contains(" build ", StringComparison.Ordinal)).ToArray(); if (builds.Length != 1) throw new ConsumerScenarioException("SPCONS020", "Binary compatibility must contain exactly one build phase."); var buildIndex = builds[0].index; + var metadata = events.Select((item, index) => (item, index)).Where(x => x.item.Phase == "binary-deployment-metadata").ToArray(); + if (metadata.Length != 1) + throw new ConsumerScenarioException("SPCONS020", "Binary compatibility deployment metadata evidence is incomplete."); + var metadataIndex = metadata[0].index; + var metadataFields = metadata[0].item.Command.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var beforeHashes = metadataFields.Where(field => field.StartsWith("consumer-before-sha256=", StringComparison.Ordinal)).ToArray(); + var afterHashes = metadataFields.Where(field => field.StartsWith("consumer-after-sha256=", StringComparison.Ordinal)).ToArray(); + var beforeHash = beforeHashes.Length == 1 ? beforeHashes[0]["consumer-before-sha256=".Length..] : ""; + var afterHash = afterHashes.Length == 1 ? afterHashes[0]["consumer-after-sha256=".Length..] : ""; + if (beforeHash.Length != 64 + || afterHash.Length != 64 + || !beforeHash.All(Uri.IsHexDigit) + || !afterHash.All(Uri.IsHexDigit) + || !string.Equals(beforeHash, afterHash, StringComparison.OrdinalIgnoreCase)) + throw new ConsumerScenarioException("SPCONS020", "Binary compatibility consumer hash evidence is invalid."); + var currentRestores = events.Select((item, index) => (item, index)).Where(x => + x.index > buildIndex + && x.index < metadataIndex + && x.item.Phase == "process" + && x.item.Command.Contains(" restore ", StringComparison.Ordinal)).ToArray(); + var dependencyFiles = events.Select((item, index) => (item, index)).Where(x => + x.index > buildIndex + && x.index < metadataIndex + && x.item.Phase == "process" + && x.item.Command.Contains(" msbuild ", StringComparison.Ordinal) + && x.item.Command.Contains("-t:GenerateBuildDependencyFile", StringComparison.Ordinal) + && !x.item.Command.Contains("Compile", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (currentRestores.Length != 1 + || dependencyFiles.Length != 1 + || currentRestores[0].index >= dependencyFiles[0].index) + throw new ConsumerScenarioException("SPCONS020", "Binary compatibility deployment metadata commands are incomplete or unordered."); var replacements = events.Select((item, index) => (item, index)).Where(x => x.item.Phase == "binary-runtime-replacement").ToArray(); - if (replacements.Length != expectedReplacements || replacements.Any(x => x.index <= buildIndex || !x.item.Command.Contains("sha256=", StringComparison.Ordinal))) + if (replacements.Length != expectedReplacements || replacements.Any(x => x.index <= metadataIndex || !x.item.Command.Contains("sha256=", StringComparison.Ordinal))) throw new ConsumerScenarioException("SPCONS020", "Binary compatibility runtime replacement evidence is incomplete or unordered."); var firstReplacement = replacements[0].index; if (events.Skip(firstReplacement).Any(x => x.Phase == "process" && (x.Command.Contains(" build ", StringComparison.Ordinal) || x.Command.Contains(" restore ", StringComparison.Ordinal)))) diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index d9dd20a..6de5139 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -61,6 +61,16 @@ "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || " "'ubuntu-latest' }}" ) +CODEQL_PR_RAM = ( + "${{ github.event_name == 'pull_request' && " + "github.event.pull_request.head.repo.full_name == github.repository && " + "'16384' || '' }}" +) +CODEQL_PR_THREADS = ( + "${{ github.event_name == 'pull_request' && " + "github.event.pull_request.head.repo.full_name == github.repository && " + "'2' || '' }}" +) HOSTING_NAME = "${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}" HOSTING_RUNNER = ( "${{ matrix.os == 'self-hosted' && " @@ -104,6 +114,15 @@ def require_runner_expression(job: dict, expected: str, label: str) -> None: f"{label} must use the event-aware runner expression.") +def assert_codeql_resource_contract(job: dict) -> None: + analysis = named_step(steps(job, "CodeQL analyze"), "Perform CodeQL Analysis") + inputs = analysis.get("with") + require(isinstance(inputs, dict) + and inputs.get("ram") == CODEQL_PR_RAM + and inputs.get("threads") == CODEQL_PR_THREADS, + "CodeQL analyze resource cap must be limited to same-repository Windows pull requests.") + + def require_same_repository_pr_guard(job: dict, label: str, allow_non_pr: bool = True) -> None: expected = SAME_REPOSITORY_PR_GUARD if allow_non_pr else PULL_REQUEST_SAME_REPOSITORY_GUARD require(job.get("if") == expected, @@ -831,6 +850,7 @@ def validate(documents: dict[str, dict]) -> None: require(isinstance(codeql_job, dict), "CodeQL must define the analyze job.") require_runner_expression(codeql_job, CODEQL_RUNNER, "CodeQL analyze") require_same_repository_pr_guard(codeql_job, "CodeQL analyze") + assert_codeql_resource_contract(codeql_job) dependency_review_job = dependency_review["jobs"].get("dependency-review") require(isinstance(dependency_review_job, dict), "Dependency Review must define the dependency-review job.") @@ -1062,6 +1082,32 @@ def _make_codeql_always_self_hosted(documents: dict[str, dict]) -> None: documents["codeql.yml"]["jobs"]["analyze"]["runs-on"] = SELF_HOSTED_WINDOWS +def _remove_codeql_resource_cap(documents: dict[str, dict]) -> None: + analysis = named_step( + documents["codeql.yml"]["jobs"]["analyze"]["steps"], + "Perform CodeQL Analysis", + ) + analysis["with"].pop("ram", None) + + +def _make_codeql_resource_cap_unconditional(documents: dict[str, dict]) -> None: + analysis = named_step( + documents["codeql.yml"]["jobs"]["analyze"]["steps"], + "Perform CodeQL Analysis", + ) + analysis["with"]["ram"] = "16384" + analysis["with"]["threads"] = "2" + + +def _make_codeql_resource_cap_linux_wide(documents: dict[str, dict]) -> None: + analysis = named_step( + documents["codeql.yml"]["jobs"]["analyze"]["steps"], + "Perform CodeQL Analysis", + ) + analysis["with"]["ram"] = str(analysis["with"]["ram"]).replace("|| ''", "|| '16384'") + analysis["with"]["threads"] = str(analysis["with"]["threads"]).replace("|| ''", "|| '2'") + + def _make_cleanup_non_pr_capable(documents: dict[str, dict], workflow_name: str) -> None: documents[workflow_name]["jobs"]["cleanup-self-hosted"]["if"] = CLEANUP_SAME_REPOSITORY_GUARD @@ -1400,6 +1446,21 @@ def main() -> int: _make_codeql_always_self_hosted, "event-aware runner expression", ) + assert_mutation_rejected( + documents, + _remove_codeql_resource_cap, + "CodeQL analyze resource cap", + ) + assert_mutation_rejected( + documents, + _make_codeql_resource_cap_unconditional, + "CodeQL analyze resource cap", + ) + assert_mutation_rejected( + documents, + _make_codeql_resource_cap_linux_wide, + "CodeQL analyze resource cap", + ) assert_mutation_rejected( documents, _remove_ci_runner_override, diff --git a/src/SmartPipe.Core/TypedPipelineRuntime.cs b/src/SmartPipe.Core/TypedPipelineRuntime.cs index 3a29469..f89be62 100644 --- a/src/SmartPipe.Core/TypedPipelineRuntime.cs +++ b/src/SmartPipe.Core/TypedPipelineRuntime.cs @@ -1328,7 +1328,14 @@ internal void RequestDrain() { Volatile.Write(ref _drainRequested, 1); RecordSourceStopReason(SourceStopReason.Drain); - _sourceCts.Cancel(); + try + { + _sourceCts.Cancel(); + } + catch (ObjectDisposedException) when (Volatile.Read(ref _disposed) != 0) + { + // Completion-owned disposal won the race; the run is already terminal. + } } private void RequestStopAccepting() => Volatile.Write(ref _stopAcceptingRequested, 1); diff --git a/src/SmartPipe.Extensions.Channels/ChannelMerge.cs b/src/SmartPipe.Extensions.Channels/ChannelMerge.cs index 5e85890..e664257 100644 --- a/src/SmartPipe.Extensions.Channels/ChannelMerge.cs +++ b/src/SmartPipe.Extensions.Channels/ChannelMerge.cs @@ -82,6 +82,13 @@ public static ChannelReader MergeMany( { ArgumentNullException.ThrowIfNull(readers); + if (readers.Count == 0) + { + var emptyOutput = CreateOutput(options); + emptyOutput.Writer.TryComplete(); + return emptyOutput.Reader; + } + var readerSnapshot = new ChannelReader[readers.Count]; for (var index = 0; index < readers.Count; index++) { @@ -90,12 +97,6 @@ public static ChannelReader MergeMany( } var output = CreateOutput(options); - if (readerSnapshot.Length == 0) - { - output.Writer.TryComplete(); - return output.Reader; - } - _ = CompleteMergeAsync( readerSnapshot, output.Writer, diff --git a/src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs b/src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs index fe3aeff..6ebae6d 100644 --- a/src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs +++ b/src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs @@ -53,10 +53,11 @@ public ValueTask> TransformAsync( Func[] rules = Freeze(); var errors = new List(); T payload = envelope.Payload!; + object payloadInstance = payload!; var validationResults = new List(); - var validationContext = new ValidationContext(payload!); - if (!Validator.TryValidateObject(payload!, validationContext, validationResults, true)) + var validationContext = new ValidationContext(payloadInstance); + if (!Validator.TryValidateObject(payloadInstance, validationContext, validationResults, true)) errors.AddRange(validationResults.Select(r => r.ErrorMessage ?? "Validation failed")); ct.ThrowIfCancellationRequested(); diff --git a/tests/Consumers/Scenarios/data-annotations-runtime/Program.cs b/tests/Consumers/Scenarios/data-annotations-runtime/Program.cs index e503f8d..2a7ab24 100644 --- a/tests/Consumers/Scenarios/data-annotations-runtime/Program.cs +++ b/tests/Consumers/Scenarios/data-annotations-runtime/Program.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using SmartPipe.Core; using SmartPipe.Extensions.Transforms; +using InvalidModel = SmartPipe.ConsumerScenarios.DataAnnotationsRuntime.InvalidModel; await using var transform = new ValidationTransform(); await transform.InitializeAsync(); @@ -18,8 +19,11 @@ Console.WriteLine("CONSUMER_OK data-annotations-runtime"); return 0; -internal sealed class InvalidModel +namespace SmartPipe.ConsumerScenarios.DataAnnotationsRuntime { - [Required(ErrorMessage = "name required")] - public string? Name { get; init; } + internal sealed class InvalidModel + { + [Required(ErrorMessage = "name required")] + public string? Name { get; init; } + } } diff --git a/tests/SmartPipe.Extensions.Channels.Tests/ChannelMergeContractTests.cs b/tests/SmartPipe.Extensions.Channels.Tests/ChannelMergeContractTests.cs index 85a9959..33c0703 100644 --- a/tests/SmartPipe.Extensions.Channels.Tests/ChannelMergeContractTests.cs +++ b/tests/SmartPipe.Extensions.Channels.Tests/ChannelMergeContractTests.cs @@ -35,6 +35,24 @@ public void Merge_NullReaderElement_ThrowsArgumentException() Assert.Equal("readers", exception.ParamName); } + [Fact] + public void MergeMany_NullReaderElementIsValidatedBeforeInvalidOptions() + { + ChannelReader[] readers = [null!]; + var invalidOptions = new BoundedChannelOptions(1); + // The public setter rejects invalid modes, so seed the invalid state only to test validation order. + var modeField = typeof(BoundedChannelOptions).GetField( + "_mode", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(modeField); + modeField.SetValue(invalidOptions, (BoundedChannelFullMode)int.MaxValue); + + var exception = Assert.Throws( + () => _ = ChannelMerge.MergeMany(readers, invalidOptions, CancellationToken.None)); + + Assert.Equal("readers", exception.ParamName); + } + [Fact] public async Task Merge_ZeroReaders_CompletesAsEmpty() { diff --git a/tests/SmartPipe.Extensions.DataAnnotations.Tests/ValidationContractTests.cs b/tests/SmartPipe.Extensions.DataAnnotations.Tests/ValidationContractTests.cs index c105f74..ad774e9 100644 --- a/tests/SmartPipe.Extensions.DataAnnotations.Tests/ValidationContractTests.cs +++ b/tests/SmartPipe.Extensions.DataAnnotations.Tests/ValidationContractTests.cs @@ -49,6 +49,19 @@ public async Task ValidationAggregatesAttributeAndRuleErrorsInLegacyOrder() Assert.Equal("Validation", result.Error.Value.Category); } + [Fact] + public async Task ValidationSupportsValueTypesWithCustomRules() + { + var validation = new ValidationTransform() + .Require(static value => value > 0, "positive"); + + var result = await validation.TransformAsync( + ProcessingEnvelope.Create(0), TestContext.Current.CancellationToken); + + Assert.False(result.IsSuccess); + Assert.Equal("positive", result.Error!.Value.Message); + } + [Fact] public async Task ValidationRulesFreezeAfterInitialization() { diff --git a/tests/SmartPipe.Extensions.Tests/Extensions/SmartPipeTypedDiTests.cs b/tests/SmartPipe.Extensions.Tests/Extensions/SmartPipeTypedDiTests.cs index acd3c50..009b056 100644 --- a/tests/SmartPipe.Extensions.Tests/Extensions/SmartPipeTypedDiTests.cs +++ b/tests/SmartPipe.Extensions.Tests/Extensions/SmartPipeTypedDiTests.cs @@ -146,6 +146,23 @@ public async Task DI_Factory_Run_PreservesTryDrainAsync() } } + [Fact] + public async Task DI_Factory_Run_DrainAfterScopedCompletion_ReturnsAlreadyCompleted() + { + var services = CreateTypedPipelineServices(); + using var provider = services.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + var factory = provider.GetRequiredService>(); + + var run = await factory.StartAsync(); + await run.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + + var result = await run.TryDrainAsync(TimeSpan.FromSeconds(1)); + result.Status.Should().Be(PipelineDrainStatus.AlreadyCompleted); + var drain = async () => await run.DrainAsync(TimeSpan.FromSeconds(1)); + await drain.Should().NotThrowAsync(); + } + [Fact] public async Task DI_Factory_Run_PreservesMetricsSnapshot() { diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs index 7a7e319..88080d7 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs @@ -348,21 +348,77 @@ public void TemplateCopy_RejectsDirectoryReparsePointBeforeDescent() } [Fact] - public void BinaryPhaseEvidence_ProvesSingleBuildThenHashReplacementAndRunWithoutRebuild() + public void BinaryPhaseEvidence_ProvesSingleBuildThenDeploymentMetadataAndHashReplacement() { var now = DateTimeOffset.UtcNow; + var hash = new string('a', 64); var events = new ConsumerCommandEvent[] { new("process", "dotnet restore Consumer.csproj", 0, now, 1, "logs/a", "logs/b"), new("process", "dotnet build Consumer.csproj --no-restore", 0, now.AddSeconds(1), 1, "logs/c", "logs/d"), - new("binary-runtime-replacement", "replace-runtime package=SmartPipe.Core sha256=" + new string('a', 64), 0, now.AddSeconds(2), 0, "", ""), - new("process", "dotnet Consumer.dll", 0, now.AddSeconds(3), 1, "logs/e", "logs/f"), + new("process", "dotnet restore Consumer.csproj --use-lock-file --force-evaluate", 0, now.AddSeconds(2), 1, "logs/e", "logs/f"), + new("process", "dotnet msbuild Consumer.csproj -t:GenerateBuildDependencyFile -p:Configuration=Release", 0, now.AddSeconds(3), 1, "logs/g", "logs/h"), + new("binary-deployment-metadata", $"refresh-deps consumer-before-sha256={hash} consumer-after-sha256={hash}", 0, now.AddSeconds(4), 0, "", ""), + new("binary-runtime-replacement", "replace-runtime package=SmartPipe.Core sha256=" + hash, 0, now.AddSeconds(5), 0, "", ""), + new("process", "dotnet Consumer.dll", 0, now.AddSeconds(6), 1, "logs/i", "logs/j"), }; ConsumerScenarioRunner.ValidateBinaryCompatibilityPhases(events, 1); - var invalid = events.Append(new("process", "dotnet build Consumer.csproj", 0, now.AddSeconds(4), 1, "logs/g", "logs/h")).ToArray(); + var missingMetadata = events.Where(item => item.Phase != "binary-deployment-metadata").ToArray(); + Assert.Equal("SPCONS020", Assert.Throws(() => ConsumerScenarioRunner.ValidateBinaryCompatibilityPhases(missingMetadata, 1)).Code); + var changedBinary = events.Select(item => item.Phase == "binary-deployment-metadata" + ? item with { Command = item.Command.Replace("consumer-after-sha256=" + hash, "consumer-after-sha256=" + new string('b', 64), StringComparison.Ordinal) } + : item).ToArray(); + Assert.Equal("SPCONS020", Assert.Throws(() => ConsumerScenarioRunner.ValidateBinaryCompatibilityPhases(changedBinary, 1)).Code); + var invalid = events.Append(new("process", "dotnet build Consumer.csproj", 0, now.AddSeconds(7), 1, "logs/k", "logs/l")).ToArray(); Assert.Equal("SPCONS020", Assert.Throws(() => ConsumerScenarioRunner.ValidateBinaryCompatibilityPhases(invalid, 1)).Code); } + [Fact] + public async Task BinaryDeploymentMetadata_UsesCurrentRestoreAndDepsTargetWithoutChangingConsumerBinary() + { + using var fixture = new RepositoryTestDirectory(); + var project = fixture.Write("source/Consumer.csproj", ""); + var output = Path.Combine(fixture.Path, "source", "bin", "Release", "net10.0"); + Directory.CreateDirectory(output); + var consumerAssembly = Path.Combine(output, "Consumer.dll"); + var binary = new byte[] { 1, 3, 3, 7 }; + await File.WriteAllBytesAsync(consumerAssembly, binary, TestContext.Current.CancellationToken); + var stdoutLog = fixture.Write("logs/stdout.log", string.Empty); + var stderrLog = fixture.Write("logs/stderr.log", string.Empty); + var process = new FakeProcessRunner( + new ProcessResult(0, string.Empty, string.Empty, stdoutLog, stderrLog), + new ProcessResult(0, string.Empty, string.Empty, stdoutLog, stderrLog)); + var runner = new ConsumerScenarioRunner(new DotNetProcessRunner(process)); + var events = new List(); + + await runner.RefreshBinaryCompatibilityDeploymentMetadataAsync( + fixture.Path, + project, + output, + ["SmartPipe.Core", "SmartPipe.Extensions", "SmartPipe.Extensions.Channels"], + Path.Combine(fixture.Path, "current-feed"), + "2.2.0", + new Dictionary { ["Microsoft.Extensions.Logging.Abstractions"] = "10.0.8" }, + ["Microsoft.Extensions.*"], + fixture.Path, + TimeSpan.FromMinutes(1), + events, + TestContext.Current.CancellationToken); + + Assert.Equal( + ["restore", project, "--configfile", Path.Combine(fixture.Path, "NuGet.Config"), "--packages", Path.Combine(fixture.Path, "packages"), "--use-lock-file", "--force-evaluate"], + process.Requests[0].Arguments); + Assert.Equal( + ["msbuild", project, "-t:GenerateBuildDependencyFile", "-p:Configuration=Release"], + process.Requests[1].Arguments); + Assert.Equal(binary, await File.ReadAllBytesAsync(consumerAssembly, TestContext.Current.CancellationToken)); + Assert.Equal("binary-deployment-metadata", events[^1].Phase); + var expectedHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(binary)); + Assert.Contains($"consumer-before-sha256={expectedHash}", events[^1].Command, StringComparison.OrdinalIgnoreCase); + Assert.Contains($"consumer-after-sha256={expectedHash}", events[^1].Command, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SmartPipe.Extensions.Channels\" Version=\"2.2.0", await File.ReadAllTextAsync(Path.Combine(fixture.Path, "Directory.Packages.props"), TestContext.Current.CancellationToken), StringComparison.Ordinal); + } + [Fact] public async Task BinaryReplacementClosure_IncludesCurrentFacadeForwardingDependencies() { From 4b604d7003abf8c5f016abc13ac6e64e348af76d Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sat, 22 Aug 2026 19:06:01 +0500 Subject: [PATCH 03/22] fix(tooling): normalize process-host pipe failures --- .../Infrastructure/ProcessHostControlProtocol.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/eng/SmartPipe.RepositoryChecks/Infrastructure/ProcessHostControlProtocol.cs b/eng/SmartPipe.RepositoryChecks/Infrastructure/ProcessHostControlProtocol.cs index 57b6a52..a412ae7 100644 --- a/eng/SmartPipe.RepositoryChecks/Infrastructure/ProcessHostControlProtocol.cs +++ b/eng/SmartPipe.RepositoryChecks/Infrastructure/ProcessHostControlProtocol.cs @@ -127,7 +127,18 @@ private static async Task ReadExactlyAsync( var totalRead = 0; while (totalRead < buffer.Length) { - var read = await stream.ReadAsync(buffer[totalRead..], cancellationToken).ConfigureAwait(false); + int read; + try + { + read = await stream.ReadAsync(buffer[totalRead..], cancellationToken).ConfigureAwait(false); + } + catch (IOException exception) + { + throw new ProcessHostProtocolException( + "The process-host control channel failed before a complete frame was received.", + exception); + } + if (read == 0) { throw new ProcessHostProtocolException( From 45d44b57f396f3c312980d431a018b3cd24b7024 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sat, 22 Aug 2026 19:48:21 +0500 Subject: [PATCH 04/22] fix(ci): isolate self-hosted NuGet packages --- .github/workflows/ci.yml | 4 ++ .github/workflows/codeql.yml | 4 ++ .../workflows/reusable-release-validation.yml | 3 ++ eng/tests/workflow_contract_tests.py | 54 ++++++++++++++++++- 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5804d45..1f7e9f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ on: permissions: contents: read +env: + NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} + jobs: validation: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -151,6 +154,7 @@ jobs: $targets = [Collections.Generic.List[string]]::new() $targets.Add((Join-Path $workspace 'artifacts')) $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) + $targets.Add((Join-Path $workspace '.nuget')) $pending = [Collections.Generic.Stack[string]]::new() $pending.Push($workspace) while ($pending.Count) { diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4f9cc6d..2e44a0e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,6 +12,9 @@ permissions: contents: read security-events: write +env: + NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} + jobs: analyze: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -57,6 +60,7 @@ jobs: $targets = [Collections.Generic.List[string]]::new() $targets.Add((Join-Path $workspace 'artifacts')) $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) + $targets.Add((Join-Path $workspace '.nuget')) $pending = [Collections.Generic.Stack[string]]::new() $pending.Push($workspace) while ($pending.Count) { diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index df584dd..fc358d6 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -22,6 +22,9 @@ on: permissions: contents: read +env: + NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} + jobs: build-test-pack: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 6de5139..66f45b9 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -71,6 +71,10 @@ "github.event.pull_request.head.repo.full_name == github.repository && " "'2' || '' }}" ) +NUGET_PACKAGES_PR = ( + "${{ github.event_name == 'pull_request' && " + "format('{0}/.nuget/packages', github.workspace) || '' }}" +) HOSTING_NAME = "${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}" HOSTING_RUNNER = ( "${{ matrix.os == 'self-hosted' && " @@ -123,6 +127,13 @@ def assert_codeql_resource_contract(job: dict) -> None: "CodeQL analyze resource cap must be limited to same-repository Windows pull requests.") +def assert_nuget_isolation_contract(workflow: dict, workflow_name: str) -> None: + environment = workflow.get("env") + require(isinstance(environment, dict) + and environment.get("NUGET_PACKAGES") == NUGET_PACKAGES_PR, + f"{workflow_name} must isolate pull-request NuGet packages inside GITHUB_WORKSPACE.") + + def require_same_repository_pr_guard(job: dict, label: str, allow_non_pr: bool = True) -> None: expected = SAME_REPOSITORY_PR_GUARD if allow_non_pr else PULL_REQUEST_SAME_REPOSITORY_GUARD require(job.get("if") == expected, @@ -134,6 +145,7 @@ def assert_cleanup_job( workflow_name: str, expected_needs: list[str], expected_guard: str, + cleanup_nuget: bool = False, ) -> None: job = workflow["jobs"].get("cleanup-self-hosted") require(isinstance(job, dict), @@ -162,6 +174,9 @@ def assert_cleanup_job( ): require(token in script, f"{workflow_name} cleanup must enforce safe workspace-bound deletion ({token}).") + if cleanup_nuget: + require("Join-Path $workspace '.nuget'" in script, + f"{workflow_name} cleanup must remove its workspace-local NuGet packages.") require("git clean" not in script.lower(), f"{workflow_name} cleanup must not use git clean.") require(re.search(r"Remove-Item\s+-LiteralPath\s+\$workspace(?:\s|$)", script) is None, @@ -601,6 +616,7 @@ def validate(documents: dict[str, dict]) -> None: require(isinstance(reusable_job, dict), "Reusable validation must define build-test-pack.") require_parameterized_runner(reusable_job, "Reusable build-test-pack") require_same_repository_pr_guard(reusable_job, "Reusable build-test-pack") + assert_nuget_isolation_contract(reusable, "reusable-release-validation.yml") reusable_steps = steps(reusable_job, "reusable build-test-pack") reusable_runs = runs(reusable_steps) require(named_step(reusable_steps, "Test workflow contracts").get("run") == @@ -837,14 +853,23 @@ def validate(documents: dict[str, dict]) -> None: "ci.yml", ["validation", "hosting-integration", "json-file-windows", "baseline-contract-windows"], CLEANUP_PULL_REQUEST_GUARD, + cleanup_nuget=True, + ) + assert_cleanup_job( + codeql, + "codeql.yml", + ["analyze"], + CLEANUP_PULL_REQUEST_GUARD, + cleanup_nuget=True, ) - assert_cleanup_job(codeql, "codeql.yml", ["analyze"], CLEANUP_PULL_REQUEST_GUARD) assert_cleanup_job( dependency_review, "dependency-review.yml", ["dependency-review"], CLEANUP_PULL_REQUEST_GUARD, ) + assert_nuget_isolation_contract(ci, "ci.yml") + assert_nuget_isolation_contract(codeql, "codeql.yml") codeql_job = codeql["jobs"].get("analyze") require(isinstance(codeql_job, dict), "CodeQL must define the analyze job.") @@ -1108,6 +1133,10 @@ def _make_codeql_resource_cap_linux_wide(documents: dict[str, dict]) -> None: analysis["with"]["threads"] = str(analysis["with"]["threads"]).replace("|| ''", "|| '2'") +def _remove_nuget_isolation(documents: dict[str, dict], workflow_name: str) -> None: + documents[workflow_name]["env"].pop("NUGET_PACKAGES", None) + + def _make_cleanup_non_pr_capable(documents: dict[str, dict], workflow_name: str) -> None: documents[workflow_name]["jobs"]["cleanup-self-hosted"]["if"] = CLEANUP_SAME_REPOSITORY_GUARD @@ -1223,6 +1252,17 @@ def _remove_cleanup_direct_target_guard(documents: dict[str, dict], workflow_nam ) +def _remove_cleanup_nuget_target(documents: dict[str, dict], workflow_name: str) -> None: + cleanup = named_step( + documents[workflow_name]["jobs"]["cleanup-self-hosted"]["steps"], + "Cleanup generated outputs", + ) + cleanup["run"] = "\n".join( + line for line in str(cleanup.get("run", "")).splitlines() + if "Join-Path $workspace '.nuget'" not in line + ) + + def _restore_lychee_action(documents: dict[str, dict]) -> None: cleanup = named_step( documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], @@ -1461,6 +1501,12 @@ def main() -> int: _make_codeql_resource_cap_linux_wide, "CodeQL analyze resource cap", ) + for workflow_name in ("ci.yml", "codeql.yml", "reusable-release-validation.yml"): + assert_mutation_rejected( + documents, + lambda docs, name=workflow_name: _remove_nuget_isolation(docs, name), + f"{workflow_name} must isolate pull-request NuGet packages", + ) assert_mutation_rejected( documents, _remove_ci_runner_override, @@ -1542,6 +1588,12 @@ def main() -> int: lambda docs, name=workflow_name: _remove_cleanup_direct_target_guard(docs, name), f"{workflow_name} cleanup must reject direct target reparse points", ) + for workflow_name in ("ci.yml", "codeql.yml"): + assert_mutation_rejected( + documents, + lambda docs, name=workflow_name: _remove_cleanup_nuget_target(docs, name), + f"{workflow_name} cleanup must remove its workspace-local NuGet packages", + ) assert_mutation_rejected( documents, _restore_lychee_action, From f5ac54fb04919b6fd80e6444bd14186e2190cb0e Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sat, 22 Aug 2026 20:22:47 +0500 Subject: [PATCH 05/22] fix(tooling): retain bounded consumer diagnostics --- .../Consumers/ConsumerScenarioRunner.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs index f6e5b71..57d9407 100644 --- a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs +++ b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs @@ -326,9 +326,20 @@ internal static ConsumerScenarioException BuildProcessFailure(DotNetProcessResul var evidence = Path.GetRelativePath(root, stderrLog).Replace('\\', '/'); if (evidence.Length > 768 || evidence.IndexOfAny(['\r', '\n']) >= 0) throw new ConsumerScenarioException("SPCONS009", "Consumer stderr evidence path is invalid."); + var prefix = $"Consumer command failed ({result.ExitCode}); stderr evidence: {evidence}"; + var diagnostic = DotNetProcessRunner.Redact( + string.IsNullOrWhiteSpace(result.StandardError) ? result.StandardOutput : result.StandardError) + .Replace('\r', ' ') + .Replace('\n', ' ') + .Trim(); + const string separator = "; diagnostic: "; + var available = 1024 - prefix.Length - separator.Length; + var suffix = available > 0 && diagnostic.Length > 0 + ? separator + diagnostic[^Math.Min(available, diagnostic.Length)..] + : string.Empty; return new ConsumerScenarioException( "SPCONS014", - $"Consumer command failed ({result.ExitCode}); stderr evidence: {evidence}"); + prefix + suffix); } private static async Task> InspectDependenciesAsync(string assetsPath, ConsumerScenario scenario, CancellationToken ct) From 14bb61a07579b12dbc54fd95f6e53b08d01fed76 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sat, 22 Aug 2026 20:55:03 +0500 Subject: [PATCH 06/22] fix(tooling): shorten consumer workspace paths --- .../Consumers/ConsumerScenarioRunner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs index 57d9407..87e8a25 100644 --- a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs +++ b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs @@ -62,7 +62,7 @@ private async Task RunScenarioAsync( CancellationToken ct) { var started = Stopwatch.StartNew(); - var runId = DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture) + "-" + Guid.NewGuid().ToString("N")[..8]; + var runId = Guid.NewGuid().ToString("N")[..8]; var relativeWorkspace = $"artifacts/consumers/{scenario.Id}/{runId}"; var workspace = Path.GetFullPath(relativeWorkspace, options.RepositoryRoot); EnsureContained(Path.GetFullPath(options.RepositoryRoot), workspace); From c08466d1dc480039f0ee962849eec86bc8f05fdf Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sun, 23 Aug 2026 01:22:42 +0500 Subject: [PATCH 07/22] ci: add targeted diagnostics and runner cleanup --- .github/workflows/ci.yml | 117 ++- .github/workflows/codeql.yml | 4 +- .github/workflows/dependency-review.yml | 4 +- .../workflows/reusable-release-validation.yml | 98 ++- docs/contributing.md | 70 ++ .../Commands/CommandLineParser.cs | 14 +- .../Consumers/ConsumerScenarioRunner.cs | 40 +- eng/SmartPipe.RepositoryChecks/Program.cs | 2 +- eng/runner/install-runner.ps1 | 87 +++ eng/runner/monitor-pr.ps1 | 145 ++++ eng/runner/post-job-cleanup.ps1 | 63 ++ eng/runner/runner-safety.ps1 | 731 ++++++++++++++++++ eng/runner/uninstall-runner.ps1 | 54 ++ eng/tests/runner-contract.Tests.ps1 | 316 ++++++++ eng/tests/workflow-contract.Tests.ps1 | 6 + eng/tests/workflow_contract_tests.py | 278 +++++-- .../Commands/CommandLineParserTests.cs | 76 ++ .../Consumers/ConsumerScenarioRunnerTests.cs | 66 ++ 18 files changed, 2049 insertions(+), 122 deletions(-) create mode 100644 eng/runner/install-runner.ps1 create mode 100644 eng/runner/monitor-pr.ps1 create mode 100644 eng/runner/post-job-cleanup.ps1 create mode 100644 eng/runner/runner-safety.ps1 create mode 100644 eng/runner/uninstall-runner.ps1 create mode 100644 eng/tests/runner-contract.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f7e9f5..19fe5fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,22 @@ name: CI on: workflow_dispatch: + inputs: + diagnostic-sha: + description: Exact 40-character commit SHA for a single-consumer diagnostic + required: false + type: string + default: '' + diagnostic-scenario: + description: Exact consumer scenario ID for a single-consumer diagnostic + required: false + type: string + default: '' + diagnostic-repeat: + description: Number of diagnostic runs (1-5) + required: false + type: string + default: '' push: branches: [ main, upd, release/2.2.0 ] pull_request: @@ -15,17 +31,17 @@ env: jobs: validation: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) uses: ./.github/workflows/reusable-release-validation.yml permissions: contents: read with: - runner-labels: ${{ github.event_name == 'pull_request' && '["self-hosted","Windows","X64"]' || '["ubuntu-latest"]' }} + runner-labels: ${{ github.event_name == 'pull_request' && '["self-hosted","Windows","X64","smartpipe-cleanup-v1"]' || '["ubuntu-latest"]' }} hosting-integration: name: Hosting integration (${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ matrix.os == 'self-hosted' && fromJSON('["self-hosted","Windows","X64"]') || matrix.os }} + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) + runs-on: ${{ matrix.os == 'self-hosted' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || matrix.os }} timeout-minutes: 20 strategy: fail-fast: false @@ -51,8 +67,8 @@ jobs: run: dotnet test --project tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Hosting.Tests.Integration.GenericHostIntegrationTests --minimum-expected-tests 1 json-file-windows: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'windows-latest' }} + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'windows-latest' }} timeout-minutes: 20 steps: @@ -107,8 +123,8 @@ jobs: baseline-contract-windows: name: Baseline contract (Windows) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'windows-latest' }} + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'windows-latest' }} timeout-minutes: 20 steps: @@ -137,11 +153,94 @@ jobs: - name: Verify 2.1.2 baseline offline run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 --offline --mode integrity + diagnostic-consumer: + name: Diagnostic consumer (${{ inputs.diagnostic-scenario }}) + if: github.event_name == 'workflow_dispatch' && (inputs.diagnostic-sha != '' || inputs.diagnostic-scenario != '' || inputs.diagnostic-repeat != '') + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] + timeout-minutes: 45 + steps: + - name: Validate diagnostic inputs + shell: pwsh + env: + DIAGNOSTIC_SHA: ${{ inputs.diagnostic-sha }} + DIAGNOSTIC_SCENARIO: ${{ inputs.diagnostic-scenario }} + DIAGNOSTIC_REPEAT: ${{ inputs.diagnostic-repeat }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:DIAGNOSTIC_SHA -notmatch '^[0-9a-f]{40}$') { throw 'diagnostic-sha must be exactly 40 lowercase hexadecimal characters.' } + if ($env:DIAGNOSTIC_SCENARIO -notmatch '^[a-z0-9-]+$') { throw 'diagnostic-scenario must contain lowercase letters, digits, or hyphens.' } + if ($env:DIAGNOSTIC_REPEAT -notmatch '^[1-5]$') { throw 'diagnostic-repeat must be an integer from 1 through 5.' } + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.diagnostic-sha }} + persist-credentials: false + fetch-depth: 1 + + - name: Verify exact diagnostic checkout + shell: pwsh + env: + DIAGNOSTIC_SHA: ${{ inputs.diagnostic-sha }} + run: | + $ErrorActionPreference = 'Stop' + $actual = (git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $actual -cne $env:DIAGNOSTIC_SHA) { throw "Checked out SHA '$actual' does not match the requested diagnostic SHA." } + + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + global-json-file: global.json + + - name: Restore locked + run: dotnet restore SmartPipe.Core.slnx --locked-mode + + - name: Build + run: dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror + + - name: Set package version + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packageVersion = (dotnet msbuild src/SmartPipe.Core/SmartPipe.Core.csproj -getProperty:Version -nologo).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($packageVersion)) { throw 'Unable to determine the package version.' } + "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Pack packages from graph + shell: pwsh + run: > + dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj + --configuration Release --no-build -- pack-packages + --mode current --configuration Release --package-version "$env:PACKAGE_VERSION" + --output artifacts/packages --manifest artifacts/packages/manifest.json + + - name: Run diagnostic consumer + shell: pwsh + env: + DIAGNOSTIC_SCENARIO: ${{ inputs.diagnostic-scenario }} + DIAGNOSTIC_REPEAT: ${{ inputs.diagnostic-repeat }} + run: | + $ErrorActionPreference = 'Stop' + $rows = [Collections.Generic.List[string]]::new() + $failed = $false + for ($pass = 1; $pass -le [int]$env:DIAGNOSTIC_REPEAT; $pass++) { + $output = (& dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --scenario $env:DIAGNOSTIC_SCENARIO --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" 2>&1 | Out-String).Trim() + $exitCode = $LASTEXITCODE + $snippet = ($output -replace '\r?\n', ' ').Trim() + if ($snippet.Length -gt 512) { $snippet = $snippet.Substring($snippet.Length - 512) } + $rows.Add("- pass ${pass}: exit=$exitCode; $snippet") + if ($exitCode -ne 0) { $failed = $true; break } + } + $summary = @('## Single-consumer diagnostic', '', "- scenario: $env:DIAGNOSTIC_SCENARIO", "- repeat requested: $env:DIAGNOSTIC_REPEAT") + $rows + $summaryText = ($summary -join [Environment]::NewLine) + if ($summaryText.Length -gt 8192) { $summaryText = $summaryText.Substring(0, 8192) + [Environment]::NewLine + '... summary truncated ...' } + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value $summaryText + if ($failed) { exit 1 } + cleanup-self-hosted: name: Cleanup self-hosted workspace if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository needs: [validation, hosting-integration, json-file-windows, baseline-contract-windows] - runs-on: [self-hosted, Windows, X64] + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] steps: - name: Cleanup generated outputs shell: pwsh diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e44a0e..82ade7a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,7 +18,7 @@ env: jobs: analyze: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'ubuntu-latest' }} + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'ubuntu-latest' }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -47,7 +47,7 @@ jobs: name: Cleanup self-hosted workspace if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository needs: [analyze] - runs-on: [self-hosted, Windows, X64] + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] steps: - name: Cleanup generated outputs shell: pwsh diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f274c32..c9483ac 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,7 +11,7 @@ permissions: jobs: dependency-review: if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: [self-hosted, Windows, X64] + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -24,7 +24,7 @@ jobs: name: Cleanup self-hosted workspace if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository needs: [dependency-review] - runs-on: [self-hosted, Windows, X64] + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] steps: - name: Cleanup generated outputs shell: pwsh diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index fc358d6..6dbcf82 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -76,6 +76,49 @@ jobs: - name: Repository baseline contract tests run: dotnet test --project tests/SmartPipe.RepositoryChecks.Tests/SmartPipe.RepositoryChecks.Tests.csproj --configuration Release --no-build --minimum-expected-tests 1 + - name: Set package version + shell: pwsh + env: + REQUESTED_PACKAGE_VERSION: ${{ inputs.package-version }} + run: | + $packageVersion = $env:REQUESTED_PACKAGE_VERSION + if ([string]::IsNullOrWhiteSpace($packageVersion)) { + $packageVersion = dotnet msbuild src/SmartPipe.Core/SmartPipe.Core.csproj -getProperty:Version -nologo + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Pack packages from graph + shell: pwsh + run: > + dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj + --configuration Release --no-build -- pack-packages + --mode current --configuration Release --package-version "$env:PACKAGE_VERSION" + --output artifacts/packages --manifest artifacts/packages/manifest.json + + - name: Provision 2.1.2 baseline packages + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- provision-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 + + - name: Verify 2.1.2 baseline offline + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 --offline --mode integrity + + - name: Verify package graph current + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-graph --mode current --packages artifacts/packages + + - name: Verify package metadata current + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-metadata --package-directory artifacts/packages --mode current --report artifacts/packages/metadata-report.json + + - name: Verify package ownership current + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-ownership --baseline eng/baselines/2.1.2 --packages artifacts/packages --mode current + + - name: Verify release versions current + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-release-version --tag "v$env:PACKAGE_VERSION" --package-directory artifacts/packages --mode current + + - name: Run current consumers + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" + - name: Core correctness regressions run: dotnet test --project tests/SmartPipe.Core.Tests/SmartPipe.Core.Tests.csproj --no-build -c Release --filter-query /[Category=CorrectnessRegression] --minimum-expected-tests 1 @@ -172,61 +215,6 @@ jobs: dotnet build benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj --no-restore -c Release -warnaserror if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Set package version - shell: pwsh - env: - REQUESTED_PACKAGE_VERSION: ${{ inputs.package-version }} - run: | - $packageVersion = $env:REQUESTED_PACKAGE_VERSION - if ([string]::IsNullOrWhiteSpace($packageVersion)) { - $packageVersion = dotnet msbuild src/SmartPipe.Core/SmartPipe.Core.csproj -getProperty:Version -nologo - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Pack packages from graph - shell: pwsh - run: > - dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj - --configuration Release --no-build -- pack-packages - --mode current --configuration Release --package-version "$env:PACKAGE_VERSION" - --output artifacts/packages --manifest artifacts/packages/manifest.json - - - name: Provision 2.1.2 baseline packages - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- provision-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 - - - name: Verify 2.1.2 baseline offline - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 --offline --mode integrity - - - name: Verify package graph current - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-graph --mode current --packages artifacts/packages - - - name: Verify package metadata current - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-metadata --package-directory artifacts/packages --mode current --report artifacts/packages/metadata-report.json - - - name: Verify package ownership current - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-ownership --baseline eng/baselines/2.1.2 --packages artifacts/packages --mode current - - - name: Verify release versions current - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-release-version --tag "v$env:PACKAGE_VERSION" --package-directory artifacts/packages --mode current - - - name: Run current consumers - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - - - name: Run Hosting consumers - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category hosting --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - - - name: Run HealthChecks consumers - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category health-checks --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - - - name: Run OpenTelemetry consumers - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category opentelemetry --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - - name: Vulnerable package scan shell: pwsh run: | diff --git a/docs/contributing.md b/docs/contributing.md index 1dac5e9..7d71acc 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -79,3 +79,73 @@ unbounded-memory symptom in progress notes. README examples are intentionally minimal. CI consumer smoke is the executable check for the public quick-start scenarios. + +## Dedicated Windows runner operations + +The same-repository Windows jobs use the exact labels +`self-hosted`, `Windows`, `X64`, and `smartpipe-cleanup-v1`. The installation +root is deliberately fixed at `C:\SmartPipe-Runner`; do not point the hook at a +developer checkout, `_tool`, the runner binaries, or a shared temporary root. + +Install or remove the repository-owned hook only while the runner is idle: + +```powershell +gh auth status +pwsh -NoProfile -File eng\runner\install-runner.ps1 +pwsh -NoProfile -File eng\runner\uninstall-runner.ps1 +``` + +The scripts resolve the exact runner name from `.runner` (`agentName`); an +optional `-RunnerName` is accepted only when it exactly matches that value. +They fail closed for missing or ambiguous configuration. The installer checks +the repository, queued/in-progress Actions runs, and remote runner state before +mutation. It writes only the hook's `.env` entry, copies the hook plus its +safety helper into the runner's `hooks` directory, registers exactly +`smartpipe-cleanup-v1` through the GitHub runner-label API while preserving +other labels, stops listeners tied to the exact root, launches one hidden +`run.cmd`, and waits for exactly one online, idle listener. Uninstall removes +only that custom label and the owned entry/copies, preserves unrelated labels +and `.env` lines, then performs the same bounded one-listener restart. A failed +operation reports recovery guidance; never convert the runner to a service as +part of this operation. + +The post-job hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout +remote, and canonicalizes every target beneath the dedicated runner root. It +removes the exact checkout and the known `SmartPipe.Core`, `SmartPipe-Core`, +`CodeQL`, and `codeql` directories below `RUNNER_TEMP`. Missing targets are +successful. Any outside path, broad root, reparse point, unsafe repository, or +deletion error fails closed before removal; the existing workflow cleanup jobs +remain as defense in depth. + +For a compact, transition-only pull-request view: + +```powershell +pwsh -NoProfile -File eng\runner\monitor-pr.ps1 -PullRequest 123 -MaxPolls 120 +``` + +The monitor uses `gh pr view`, prints only a changed head/state/merge/check +summary, and stops at `MERGED`, `CLOSED`, or the poll bound. For each newly +failed head it retrieves one failed-run log, prints a bounded first-causal +slice, and removes its task-specific temporary log directory on exit. `-Once` +is useful for a single snapshot. It does not upload logs or alter GitHub state. + +The optional diagnostic dispatch runs one exact commit and one internal +consumer scenario without changing normal push or pull-request behavior: + +```powershell +gh workflow run ci.yml --repo MrFr3di/SmartPipe.Core --ref sp220/checkpoint-d ` + -f diagnostic-sha=0123456789abcdef0123456789abcdef01234567 ` + -f diagnostic-scenario=dependency-injection-nativeaot ` + -f diagnostic-repeat=1 +``` + +The SHA must be 40 lowercase hexadecimal characters, the scenario must use +lowercase letters, digits, and hyphens, and repeat must be `1` through `5`. +The job restores, builds, and packs once, then reports bounded run snippets in +the step summary without artifacts. Normal jobs run when all three inputs are +empty. + +If rollout must be reverted, stop the idle listener, run the uninstaller, +restart the listener, and revert the workflow change with a normal commit. +Do not delete the runner root or use `git clean`; safe cleanup is intentionally +recoverable and scoped to the exact approved boundaries. diff --git a/eng/SmartPipe.RepositoryChecks/Commands/CommandLineParser.cs b/eng/SmartPipe.RepositoryChecks/Commands/CommandLineParser.cs index 90803ca..864724e 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/CommandLineParser.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/CommandLineParser.cs @@ -92,7 +92,8 @@ internal sealed record RunConsumersCommandOptions( string PackageDirectory, string PackageVersion, string ManifestPath, - string? Category) : RepositoryCheckCommand(RepositoryRoot); + string? Category, + string? Scenario) : RepositoryCheckCommand(RepositoryRoot); internal sealed record PackPackagesOptions(string RepositoryRoot, PackageGraphMode Mode, string Configuration, string PackageVersion, string OutputDirectory, string ManifestPath) : RepositoryCheckCommand(RepositoryRoot); internal sealed class CommandLineException(string message) : Exception(message); @@ -192,7 +193,7 @@ private static PackPackagesOptions ParsePackPackages(ReadOnlySpan args) private static RunConsumersCommandOptions ParseRunConsumers(ReadOnlySpan args) { - string? root = null; string? set = null; string? packages = null; string? version = null; string manifest = "eng/consumer-scenarios.json"; string? category = null; + string? root = null; string? set = null; string? packages = null; string? version = null; string manifest = "eng/consumer-scenarios.json"; string? category = null; string? scenario = null; var seen = new HashSet(StringComparer.Ordinal); for (var i = 0; i < args.Length; i += 2) { @@ -206,6 +207,7 @@ private static RunConsumersCommandOptions ParseRunConsumers(ReadOnlySpan case "--package-version": version = args[i + 1]; break; case "--manifest": manifest = args[i + 1]; break; case "--category": category = args[i + 1]; break; + case "--scenario": scenario = args[i + 1]; break; default: throw new CommandLineException($"Unknown run-consumers option '{args[i]}'."); } } @@ -218,7 +220,13 @@ private static RunConsumersCommandOptions ParseRunConsumers(ReadOnlySpan && (category.Length == 0 || category.Any(character => character is not (>= 'a' and <= 'z' or >= '0' and <= '9' or '-')))) throw new CommandLineException("Option '--category' must contain lowercase letters, digits, or hyphens."); - return new(root, set, ResolveWithinRoot(root, packages, "--package-directory"), version, Path.GetRelativePath(root, resolvedManifest).Replace('\\', '/'), category); + if (scenario is not null + && (scenario.Length == 0 + || scenario.Any(character => character is not (>= 'a' and <= 'z' or >= '0' and <= '9' or '-')))) + throw new CommandLineException("Option '--scenario' must contain lowercase letters, digits, or hyphens."); + if (category is not null && scenario is not null) + throw new CommandLineException("Options '--category' and '--scenario' are mutually exclusive."); + return new(root, set, ResolveWithinRoot(root, packages, "--package-directory"), version, Path.GetRelativePath(root, resolvedManifest).Replace('\\', '/'), category, scenario); } private static ScaffoldPackageOptions ParseScaffoldPackage(ReadOnlySpan args) diff --git a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs index 87e8a25..e74b690 100644 --- a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs +++ b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs @@ -19,7 +19,8 @@ internal sealed record RunConsumersOptions( string PackageDirectory, string PackageVersion, string ManifestPath, - string? Category = null); + string? Category = null, + string? Scenario = null); internal sealed class ConsumerScenarioRunner(DotNetProcessRunner? processRunner = null) { @@ -32,9 +33,16 @@ public async Task> RunAsync(RunConsumersOp var document = await new ConsumerScenarioLoader().LoadAsync(options.RepositoryRoot, options.ManifestPath, graph, ct).ConfigureAwait(false); var scenarios = document.Scenarios .Where(scenario => scenario.Set == options.Set - && (options.Category is null || scenario.Category == options.Category)) + && (options.Category is null || scenario.Category == options.Category) + && (options.Scenario is null || scenario.Id == options.Scenario)) .ToArray(); - if (scenarios.Length == 0) throw new ConsumerScenarioException("SPCONS010", $"Consumer set '{options.Set}' is empty."); + if (scenarios.Length == 0) + { + var selection = options.Scenario is null + ? $"Consumer set '{options.Set}' is empty." + : $"Consumer scenario '{options.Scenario}' is unknown."; + throw new ConsumerScenarioException("SPCONS010", selection); + } var centralPackages = await new CentralPackageVersionReader().VerifyAsync( options.RepositoryRoot, CentralPackageValidationMode.Current, @@ -104,6 +112,8 @@ private async Task RunScenarioAsync( var locked = restore.ToList(); locked.Remove("--use-lock-file"); locked.Add("--locked-mode"); await RunRequiredAsync("dotnet", locked, source, logs, options.RepositoryRoot, scenario.Timeout, events, ct).ConfigureAwait(false); } + if (scenario.Mode == ConsumerMode.PublishNativeAot) + ValidateNativeAotLibraryPaths(packages); string outputDirectory; IReadOnlyList? publishArguments = null; @@ -584,6 +594,30 @@ internal static void CopyTemplateDirectory(string root, string templatePath, str } private static string RuntimeIdentifier() => OperatingSystem.IsWindows() ? "win-x64" : OperatingSystem.IsLinux() ? "linux-x64" : OperatingSystem.IsMacOS() ? "osx-x64" : throw new ConsumerScenarioException("SPCONS018", "NativeAOT/trim scenario is unsupported on this OS."); + + internal static void ValidateNativeAotLibraryPaths(string packageDirectory, bool? isWindows = null) + { + if (!(isWindows ?? OperatingSystem.IsWindows())) return; + + var root = Path.GetFullPath(packageDirectory); + if (!Directory.Exists(root)) return; + foreach (var path in Directory.EnumerateFiles(root, "*.lib", SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(path); + var relative = Path.GetRelativePath(root, fullPath); + if (Path.IsPathRooted(relative) + || relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new ConsumerScenarioException("SPCONS025", "NativeAOT library path escapes the scenario package cache."); + + var effectiveLength = fullPath.Length + 1; + if (effectiveLength >= 260) + throw new ConsumerScenarioException( + "SPCONS025", + $"NativeAOT library path is too long ({effectiveLength} characters including the terminating NUL): {relative.Replace('\\', '/')}"); + } + } + internal static void ValidateBinaryCompatibilityPhases(IReadOnlyList events, int expectedReplacements) { var builds = events.Select((item, index) => (item, index)).Where(x => x.item.Phase == "process" && x.item.Command.Contains(" build ", StringComparison.Ordinal)).ToArray(); diff --git a/eng/SmartPipe.RepositoryChecks/Program.cs b/eng/SmartPipe.RepositoryChecks/Program.cs index a05029e..3b3775c 100644 --- a/eng/SmartPipe.RepositoryChecks/Program.cs +++ b/eng/SmartPipe.RepositoryChecks/Program.cs @@ -283,7 +283,7 @@ internal static async Task Main(string[] args) return ExitCodes.Success; case RunConsumersCommandOptions consumers: - var consumerResults = await new ConsumerScenarioRunner().RunAsync(new(consumers.RepositoryRoot, consumers.Set, consumers.PackageDirectory, consumers.PackageVersion, consumers.ManifestPath, consumers.Category), cancellation.Token).ConfigureAwait(false); + var consumerResults = await new ConsumerScenarioRunner().RunAsync(new(consumers.RepositoryRoot, consumers.Set, consumers.PackageDirectory, consumers.PackageVersion, consumers.ManifestPath, consumers.Category, consumers.Scenario), cancellation.Token).ConfigureAwait(false); foreach (var consumer in consumerResults) Console.WriteLine($"SP220_CONSUMER_OK scenario={consumer.Scenario} durationMs={consumer.DurationMs} dependencies={consumer.ObservedSmartPipeDependencies.Count}"); Console.WriteLine($"SP220_CONSUMERS_OK scenarios={consumerResults.Count} set={consumers.Set}"); return ExitCodes.Success; diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 new file mode 100644 index 0000000..bc07c67 --- /dev/null +++ b/eng/runner/install-runner.ps1 @@ -0,0 +1,87 @@ +[CmdletBinding()] +param( + [string] $RunnerRoot = 'C:\SmartPipe-Runner', + [string] $Repository = 'MrFr3di/SmartPipe-Core', + [string] $RunnerName = '', + [string] $GhPath = 'gh', + [string] $ListenerFixturePath = '', + [int] $ListenerTimeoutSeconds = 60, + [switch] $SkipRemoteCheck, + [switch] $SkipListenerReady, + [switch] $AllowTestRoot, + [switch] $Uninstall +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'runner-safety.ps1') + +try { + Assert-SmartPipeRepository -Repository $Repository + $runner = Get-SmartPipeFullPath -Path $RunnerRoot + if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { + throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." + } + + if (-not (Test-Path -LiteralPath $runner -PathType Container)) { + throw "Dedicated runner root is missing: $runner" + } + Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner + $resolvedRunnerName = Resolve-SmartPipeRunnerName -Root $runner -RequestedName $RunnerName + if ($SkipRemoteCheck) { + throw 'Remote idle checks cannot be skipped because runner label registration is required. Recovery: no runner files or labels were changed.' + } + + Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath + $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath + + if ($Uninstall) { + $environmentPath = Join-Path $runner '.env' + Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath + $hookDirectory = Join-Path $runner 'hooks' + foreach ($name in @('smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { + $path = Join-Path $hookDirectory $name + if (Test-Path -LiteralPath $path) { + Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } + Remove-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath + if (-not $SkipListenerReady) { + Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds + } + Write-Output "Removed SmartPipe-owned hook, environment entry, and label from $runner and restored one listener." + exit 0 + } + + $hookSource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'post-job-cleanup.ps1') + $safetySource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'runner-safety.ps1') + if (-not (Test-Path -LiteralPath $hookSource -PathType Leaf) -or + -not (Test-Path -LiteralPath $safetySource -PathType Leaf)) { + throw 'Runner hook sources are missing.' + } + + $hookDirectory = Join-Path $runner 'hooks' + if (-not (Test-Path -LiteralPath $hookDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $hookDirectory -Force | Out-Null + } + Assert-SmartPipeNoReparsePath -Path $hookDirectory -Boundary $runner + + $hookDestination = Join-Path $hookDirectory 'smartpipe-post-job-cleanup.ps1' + $safetyDestination = Join-Path $hookDirectory 'runner-safety.ps1' + Copy-Item -LiteralPath $hookSource -Destination $hookDestination -Force + Copy-Item -LiteralPath $safetySource -Destination $safetyDestination -Force + + $environmentPath = Join-Path $runner '.env' + Write-SmartPipeEnvironment -EnvironmentPath $environmentPath -HookPath $hookDestination + Add-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath + + if (-not $SkipListenerReady) { + Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds + } + Write-Output "Installed SmartPipe hook and label under $runner with one online idle listener." +} +catch { + $errorText = [string]$_.Exception.Message + Write-Error -Message "$errorText Recovery: confirm the runner and repository are idle, then inspect or rerun eng\runner\uninstall-runner.ps1; existing runner labels are never intentionally removed." + exit 1 +} diff --git a/eng/runner/monitor-pr.ps1 b/eng/runner/monitor-pr.ps1 new file mode 100644 index 0000000..bad8378 --- /dev/null +++ b/eng/runner/monitor-pr.ps1 @@ -0,0 +1,145 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [int] $PullRequest, + [string] $Repository = 'MrFr3di/SmartPipe-Core', + [string] $GhPath = 'gh', + [int] $PollSeconds = 60, + [int] $MaxPolls = 0, + [switch] $Once +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'runner-safety.ps1') + +function Get-SmartPipeCheckSummary { + param( + [Parameter(Mandatory = $true)] + [object] $Checks + ) + + $parts = [Collections.Generic.List[string]]::new() + foreach ($check in @($Checks)) { + if ($null -eq $check) { + continue + } + $properties = @($check.PSObject.Properties.Name) + $name = if ('name' -in $properties -and $null -ne $check.name) { [string]$check.name } elseif ('context' -in $properties -and $null -ne $check.context) { [string]$check.context } else { 'check' } + $state = if ('conclusion' -in $properties -and [string]$check.conclusion) { [string]$check.conclusion } elseif ('status' -in $properties -and $null -ne $check.status) { [string]$check.status } else { 'pending' } + $parts.Add("$name=$state") + } + + $summary = $parts -join ',' + if ($summary.Length -gt 512) { + return $summary.Substring(0, 512) + '...' + } + + return $summary +} + +function Write-SmartPipeFirstFailure { + param( + [Parameter(Mandatory = $true)] [string] $Head, + [Parameter(Mandatory = $true)] [string] $TemporaryRoot + ) + + $global:LASTEXITCODE = 0 + $runJson = & $GhPath run list --repo $Repository --commit $Head --status failure --limit 1 --json databaseId 2>&1 + if ($global:LASTEXITCODE -ne 0) { + Write-Output 'PR diagnostic: unable to list the failed workflow run.' + return + } + + $runs = @(($runJson -join [Environment]::NewLine) | ConvertFrom-Json) + if ($runs.Count -eq 0) { + Write-Output 'PR diagnostic: no failed workflow run is available yet.' + return + } + + $runId = [string]$runs[0].databaseId + if ($runId -notmatch '^[0-9]+$') { + Write-Output 'PR diagnostic: failed workflow run id is invalid.' + return + } + + $global:LASTEXITCODE = 0 + $failedLog = @(& $GhPath run view $runId --repo $Repository --log-failed 2>&1 | ForEach-Object { [string]$_ }) + $logExitCode = $global:LASTEXITCODE + $logPath = Join-Path $TemporaryRoot "failed-$Head-$runId.log" + [IO.File]::WriteAllLines($logPath, $failedLog) + if ($logExitCode -ne 0) { + Write-Output 'PR diagnostic: failed-step log retrieval was incomplete.' + return + } + + $index = -1 + for ($line = 0; $line -lt $failedLog.Count; $line++) { + if ($failedLog[$line] -match '(?i)(error|exception|failed|NU[0-9]{4}|SP[A-Z]+[0-9]{3})') { + $index = $line + break + } + } + if ($index -lt 0) { $index = 0 } + $last = [Math]::Min($failedLog.Count - 1, $index + 4) + $slice = if ($failedLog.Count -eq 0) { 'no failed-step output' } else { ($failedLog[$index..$last] -join ' | ').Trim() } + if ($slice.Length -gt 1024) { $slice = $slice.Substring(0, 1024) + '...' } + Write-Output "PR diagnostic: first causal slice: $slice" +} + +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-pr-monitor-$PID-$([Guid]::NewGuid().ToString('N'))" +try { + Assert-SmartPipeRepository -Repository $Repository + if ($PullRequest -lt 1) { + throw 'PullRequest must be positive.' + } + if ($PollSeconds -lt 1) { + throw 'PollSeconds must be positive.' + } + if ($MaxPolls -lt 0) { + throw 'MaxPolls cannot be negative.' + } + + New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null + $previous = $null + $diagnosedHead = '' + $poll = 0 + while ($true) { + $LASTEXITCODE = 0 + $json = & $GhPath pr view $PullRequest --repo $Repository --json state,mergeStateStatus,headRefOid,statusCheckRollup 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "gh pr view failed: $($json -join ' ')" + } + + $view = ($json -join [Environment]::NewLine) | ConvertFrom-Json + $state = [string]$view.state + $mergeState = [string]$view.mergeStateStatus + $head = [string]$view.headRefOid + $checks = Get-SmartPipeCheckSummary -Checks $view.statusCheckRollup + $signature = "$state|$mergeState|$head|$checks" + if ($signature -ne $previous) { + Write-Output "PR #$PullRequest transition: state=$state merge=$mergeState head=$head checks=$checks" + $previous = $signature + } + if ($head -ne $diagnosedHead -and $checks -match '(?i)=(FAILURE|CANCELLED|TIMED_OUT|ACTION_REQUIRED|STARTUP_FAILURE)') { + Write-SmartPipeFirstFailure -Head $head -TemporaryRoot $temporaryRoot + $diagnosedHead = $head + } + + $poll++ + if ($state -in @('MERGED', 'CLOSED') -or $Once -or ($MaxPolls -gt 0 -and $poll -ge $MaxPolls)) { + break + } + + Start-Sleep -Seconds $PollSeconds + } +} +catch { + $errorText = [string]$_.Exception.Message + Write-Error -Message $errorText + exit 1 +} +finally { + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/eng/runner/post-job-cleanup.ps1 b/eng/runner/post-job-cleanup.ps1 new file mode 100644 index 0000000..aad347a --- /dev/null +++ b/eng/runner/post-job-cleanup.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [string] $RunnerRoot = 'C:\SmartPipe-Runner', + [string] $WorkspaceRoot = $env:GITHUB_WORKSPACE, + [string] $TempRoot = $env:RUNNER_TEMP, + [string] $Repository = $env:GITHUB_REPOSITORY, + [switch] $AllowTestRoot +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'runner-safety.ps1') + +try { + Assert-SmartPipeRepository -Repository $Repository + $runner = Get-SmartPipeFullPath -Path $RunnerRoot + if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { + throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." + } + if (-not (Test-Path -LiteralPath $runner -PathType Container)) { + throw "Dedicated runner root is missing: $runner" + } + + Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner + + if ([string]::IsNullOrWhiteSpace($WorkspaceRoot)) { + throw 'GITHUB_WORKSPACE is required.' + } + + $workspace = Get-SmartPipeFullPath -Path $WorkspaceRoot + if (-not (Test-SmartPipeContainedPath -Path $workspace -Boundary $runner)) { + throw "Workspace is outside the dedicated runner root: $workspace" + } + + if (Test-Path -LiteralPath $workspace -PathType Container) { + Assert-SmartPipeWorkspaceRepository -Workspace $workspace + [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) + } + else { + Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner + } + + if (-not [string]::IsNullOrWhiteSpace($TempRoot)) { + $temp = Get-SmartPipeFullPath -Path $TempRoot + if (-not (Test-SmartPipeContainedPath -Path $temp -Boundary $runner)) { + throw "Runner temp is outside the dedicated runner root: $temp" + } + + if (Test-Path -LiteralPath $temp -PathType Container) { + Assert-SmartPipeNoReparsePath -Path $temp -Boundary $runner + foreach ($name in @('SmartPipe.Core', 'SmartPipe-Core', 'CodeQL', 'codeql')) { + $target = Join-Path $temp $name + [void](Remove-SmartPipeCleanupTarget -Path $target -Boundary $temp) + } + } + } + + Write-Output 'SmartPipe post-job cleanup completed.' +} +catch { + $errorText = [string]$_.Exception.Message + Write-Error -Message $errorText + exit 1 +} diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 new file mode 100644 index 0000000..6e7685e --- /dev/null +++ b/eng/runner/runner-safety.ps1 @@ -0,0 +1,731 @@ +Set-StrictMode -Version Latest + +$script:SmartPipeRunnerDefaultRoot = 'C:\SmartPipe-Runner' +$script:SmartPipeRunnerRepository = 'MrFr3di/SmartPipe-Core' +$script:SmartPipeRunnerLabel = 'smartpipe-cleanup-v1' + +function Get-SmartPipeFullPath { + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw 'A path is required.' + } + + try { + $fullPath = [IO.Path]::GetFullPath($Path) + } + catch { + throw "Invalid path: $Path" + } + + if ($fullPath.Length -gt 3) { + return $fullPath.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + } + + return $fullPath +} + +function Test-SmartPipeSamePath { + param( + [Parameter(Mandatory = $true)] + [string] $Left, + + [Parameter(Mandatory = $true)] + [string] $Right + ) + + return [string]::Equals( + (Get-SmartPipeFullPath -Path $Left), + (Get-SmartPipeFullPath -Path $Right), + [StringComparison]::OrdinalIgnoreCase) +} + +function Test-SmartPipeContainedPath { + param( + [Parameter(Mandatory = $true)] + [string] $Path, + + [Parameter(Mandatory = $true)] + [string] $Boundary, + + [switch] $AllowBoundary + ) + + $candidate = Get-SmartPipeFullPath -Path $Path + $boundaryPath = Get-SmartPipeFullPath -Path $Boundary + if ($AllowBoundary -and (Test-SmartPipeSamePath -Left $candidate -Right $boundaryPath)) { + return $true + } + + $prefix = "$boundaryPath$([IO.Path]::DirectorySeparatorChar)" + return $candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) +} + +function Assert-SmartPipeNoReparsePath { + param( + [Parameter(Mandatory = $true)] + [string] $Path, + + [Parameter(Mandatory = $true)] + [string] $Boundary + ) + + $candidate = Get-SmartPipeFullPath -Path $Path + $boundaryPath = Get-SmartPipeFullPath -Path $Boundary + if (-not (Test-SmartPipeContainedPath -Path $candidate -Boundary $boundaryPath -AllowBoundary)) { + throw "Path is outside the approved boundary: $candidate" + } + + $current = $candidate + while ($true) { + if (Test-Path -LiteralPath $current) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Reparse point is not an approved cleanup target: $current" + } + } + + if (Test-SmartPipeSamePath -Left $current -Right $boundaryPath) { + break + } + + $parent = Split-Path -Path $current -Parent + if ([string]::IsNullOrWhiteSpace($parent) -or (Test-SmartPipeSamePath -Left $parent -Right $current)) { + throw "Could not prove path containment: $candidate" + } + + $current = Get-SmartPipeFullPath -Path $parent + if (-not (Test-SmartPipeContainedPath -Path $current -Boundary $boundaryPath -AllowBoundary)) { + throw "Path escaped the approved boundary: $candidate" + } + } + + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + return + } + + $pending = [Collections.Generic.Stack[string]]::new() + $pending.Push($candidate) + while ($pending.Count -gt 0) { + $directory = $pending.Pop() + foreach ($child in Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) { + if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Reparse point is not an approved cleanup target: $($child.FullName)" + } + + if ($child.PSIsContainer) { + $pending.Push($child.FullName) + } + } + } +} + +function Assert-SmartPipeCleanupTarget { + param( + [Parameter(Mandatory = $true)] + [string] $Path, + + [Parameter(Mandatory = $true)] + [string] $Boundary, + + [switch] $AllowBoundary + ) + + $candidate = Get-SmartPipeFullPath -Path $Path + $boundaryPath = Get-SmartPipeFullPath -Path $Boundary + if (Test-SmartPipeSamePath -Left $candidate -Right $boundaryPath) { + throw "Cleanup target is the approved boundary itself: $candidate" + } + if (-not (Test-SmartPipeContainedPath -Path $candidate -Boundary $boundaryPath -AllowBoundary:$AllowBoundary)) { + throw "Cleanup target is outside the approved boundary: $candidate" + } + + $runnerLeaf = Split-Path -Path $candidate -Leaf + if ($runnerLeaf -in @('_tool', '_work', 'bin', 'Runner', 'externals')) { + throw "Cleanup target is too broad or protected: $candidate" + } + + $runnerRoot = Get-SmartPipeFullPath -Path $script:SmartPipeRunnerDefaultRoot + if (Test-SmartPipeSamePath -Left $candidate -Right $runnerRoot) { + throw 'The dedicated runner root is never a cleanup target.' + } + + Assert-SmartPipeNoReparsePath -Path $candidate -Boundary $Boundary + return $candidate +} + +function Remove-SmartPipeCleanupTarget { + param( + [Parameter(Mandatory = $true)] + [string] $Path, + + [Parameter(Mandatory = $true)] + [string] $Boundary, + + [switch] $AllowBoundary + ) + + $candidate = Assert-SmartPipeCleanupTarget -Path $Path -Boundary $Boundary -AllowBoundary:$AllowBoundary + if (-not (Test-Path -LiteralPath $candidate)) { + return $false + } + + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + throw "Cleanup target is not a directory: $candidate" + } + + Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction Stop + return $true +} + +function Assert-SmartPipeRepository { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string] $Repository + ) + + if (-not [string]::Equals($Repository, $script:SmartPipeRunnerRepository, [StringComparison]::OrdinalIgnoreCase)) { + throw "Unexpected repository '$Repository'." + } +} + +function Resolve-SmartPipeRunnerName { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $RequestedName = '' + ) + + $configPath = Join-Path $Root '.runner' + if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { + throw "Runner configuration is missing: $configPath" + } + + try { + $config = Get-Content -LiteralPath $configPath -Raw -ErrorAction Stop | ConvertFrom-Json + $agentNameProperty = @($config.PSObject.Properties | Where-Object { $_.Name -eq 'agentName' }) + if ($agentNameProperty.Count -ne 1 -or $null -eq $agentNameProperty[0].Value -or + $agentNameProperty[0].Value -is [Array]) { + throw 'agentName is missing or ambiguous.' + } + $configuredName = [string]$agentNameProperty[0].Value + } + catch { + throw "Runner configuration is invalid: $configPath" + } + + if ([string]::IsNullOrWhiteSpace($configuredName)) { + throw "Runner configuration has no unambiguous agentName: $configPath" + } + if (-not [string]::IsNullOrWhiteSpace($RequestedName) -and + -not [string]::Equals($RequestedName, $configuredName, [StringComparison]::Ordinal)) { + throw "Requested runner name '$RequestedName' does not match .runner agentName '$configuredName'." + } + + return $configuredName +} + +function Assert-SmartPipeWorkspaceRepository { + param( + [Parameter(Mandatory = $true)] + [string] $Workspace + ) + + $gitPath = Join-Path $Workspace '.git' + if (-not (Test-Path -LiteralPath $gitPath)) { + throw "Workspace repository metadata is missing: $Workspace" + } + + $configPath = if (Test-Path -LiteralPath $gitPath -PathType Container) { + Join-Path $gitPath 'config' + } + else { + $gitPath + } + + if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { + throw "Workspace repository configuration is missing: $Workspace" + } + + $global:LASTEXITCODE = 0 + $gitOutput = & git -C $Workspace remote get-url origin 2>&1 + $gitExitCode = $global:LASTEXITCODE + if ($gitExitCode -eq 0) { + $urls = @($gitOutput | ForEach-Object { ([string]$_).Trim() } | Where-Object { $_ -ne '' }) + if ($urls.Count -ne 1) { + throw "Workspace origin remote is ambiguous: $Workspace" + } + + Assert-SmartPipeCanonicalRemote -Url $urls[0] -Workspace $Workspace + return + } + + # Test fixtures and worktrees without a usable git executable use the + # strict INI fallback. Comments never participate in URL selection. + $section = '' + $originUrls = [Collections.Generic.List[string]]::new() + foreach ($line in (Get-Content -LiteralPath $configPath -ErrorAction Stop)) { + $text = ([string]$line).Trim() + if ($text -eq '' -or $text.StartsWith('#') -or $text.StartsWith(';')) { + continue + } + + if ($text -match '^\[remote\s+"([^"]+)"\]$') { + $section = $Matches[1] + continue + } + + if ($text -match '^(?[A-Za-z][A-Za-z0-9-]*)\s*=\s*(?\S+)$') { + if ($section -eq 'origin' -and $Matches.key -eq 'url') { + [void]$originUrls.Add($Matches.value) + } + elseif ($section -eq 'origin' -and $Matches.key -notin @('fetch', 'pushurl', 'mirror', 'tagopt')) { + throw "Unsupported origin configuration entry: $Workspace" + } + continue + } + + throw "Invalid git remote configuration: $Workspace" + } + + if ($originUrls.Count -ne 1) { + throw "Workspace origin remote is missing or ambiguous: $Workspace" + } + + Assert-SmartPipeCanonicalRemote -Url $originUrls[0] -Workspace $Workspace +} + +function Assert-SmartPipeCanonicalRemote { + param( + [Parameter(Mandatory = $true)] + [string] $Url, + + [Parameter(Mandatory = $true)] + [string] $Workspace + ) + + $normalized = $Url.Trim() + if ($normalized -match '^(?i:https://github\.com/MrFr3di/SmartPipe-Core(?:\.git)?|git@github\.com:MrFr3di/SmartPipe-Core(?:\.git)?|ssh://git@github\.com/MrFr3di/SmartPipe-Core(?:\.git)?)$') { + return + } + + throw "Workspace origin remote is not MrFr3di/SmartPipe-Core: $Workspace" +} + +function Get-SmartPipeListenerProcesses { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $FixturePath = '' + ) + + if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { + if (-not (Test-Path -LiteralPath $FixturePath -PathType Leaf)) { + return @() + } + + $text = (Get-Content -LiteralPath $FixturePath -Raw -ErrorAction Stop).Trim() + $count = 0 + if (-not [int]::TryParse($text, [Globalization.NumberStyles]::Integer, [Globalization.CultureInfo]::InvariantCulture, [ref]$count) -or $count -lt 0) { + throw "Invalid listener fixture state: $FixturePath" + } + + $fixtureListeners = [Collections.Generic.List[object]]::new() + for ($index = 1; $index -le $count; $index++) { + [void]$fixtureListeners.Add([pscustomobject]@{ + ProcessId = 0 + Name = 'Runner.Listener.fixture' + CommandLine = $Root + }) + } + return @($fixtureListeners) + } + + try { + $escapedRoot = [Regex]::Escape((Get-SmartPipeFullPath -Path $Root)) + return @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Where-Object { + $_.Name -in @('Runner.Listener.exe', 'Runner.Listener') -and + $_.CommandLine -match $escapedRoot + }) + } + catch { + if ($IsWindows) { + throw "Unable to inspect listener processes for $Root." + } + return @() + } +} + +function Stop-SmartPipeListenerProcesses { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $FixturePath = '', + + [int] $TimeoutSeconds = 20 + ) + + if ($TimeoutSeconds -lt 1) { + throw 'Listener stop timeout must be positive.' + } + + $listeners = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) + if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { + Set-Content -LiteralPath $FixturePath -Value '0' -NoNewline + return + } + + foreach ($listener in $listeners) { + if ([int]$listener.ProcessId -gt 0) { + Stop-Process -Id $listener.ProcessId -Force -ErrorAction Stop + } + } + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while (@(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath).Count -gt 0) { + if ([DateTime]::UtcNow -ge $deadline) { + throw "Runner listener did not stop within $TimeoutSeconds seconds: $Root" + } + Start-Sleep -Seconds 1 + } +} + +function Start-SmartPipeRunner { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $FixturePath = '' + ) + + $runCommand = Join-Path $Root 'run.cmd' + if (-not (Test-Path -LiteralPath $runCommand -PathType Leaf)) { + throw "Runner command is missing: $runCommand" + } + + Start-Process -FilePath $runCommand -WorkingDirectory $Root -WindowStyle Hidden | Out-Null + if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { + Set-Content -LiteralPath $FixturePath -Value '1' -NoNewline + } +} + +function Get-SmartPipeRemoteRunner { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [string] $RunnerName, + + [string] $GhPath = 'gh' + ) + + $global:LASTEXITCODE = 0 + $json = & $GhPath api "repos/$Repository/actions/runners?per_page=100" 2>&1 + $ghExitCode = $global:LASTEXITCODE + if ($ghExitCode -ne 0) { + throw "Unable to query GitHub runner state: $($json -join ' ')" + } + + $response = ($json -join [Environment]::NewLine) | ConvertFrom-Json + $runners = @($response.runners | Where-Object { $_.name -eq $RunnerName }) + if ($runners.Count -ne 1) { + throw "Expected exactly one GitHub runner named '$RunnerName'." + } + + return ,$runners[0] +} + +function Get-SmartPipeRunnerLabelNames { + param( + [Parameter(Mandatory = $true)] + [object] $Runner + ) + + $names = [Collections.Generic.List[string]]::new() + foreach ($label in @($Runner.labels)) { + if ($label -is [string]) { + $name = [string]$label + } + else { + $nameProperty = $label.PSObject.Properties['name'] + $name = if ($null -ne $nameProperty) { [string]$nameProperty.Value } else { '' } + } + if (-not [string]::IsNullOrWhiteSpace($name)) { + [void]$names.Add($name) + } + } + return $names.ToArray() +} + +function Add-SmartPipeRunnerLabel { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [object] $Runner, + + [string] $GhPath = 'gh' + ) + + $runnerId = [string]$Runner.id + if ([string]::IsNullOrWhiteSpace($runnerId) -or $runnerId -notmatch '^[0-9]+$') { + throw 'GitHub runner id is missing or invalid; refusing label mutation.' + } + + $before = @(Get-SmartPipeRunnerLabelNames -Runner $Runner) + $global:LASTEXITCODE = 0 + $json = & $GhPath api --method POST "repos/$Repository/actions/runners/$runnerId/labels" -f "labels[]=$script:SmartPipeRunnerLabel" 2>&1 + $ghExitCode = $global:LASTEXITCODE + if ($ghExitCode -ne 0) { + throw "Unable to add runner label '$script:SmartPipeRunnerLabel'. Existing labels were not intentionally removed." + } + + try { + $postResponse = ($json -join [Environment]::NewLine) | ConvertFrom-Json + $postLabels = @(Get-SmartPipeRunnerLabelNames -Runner $postResponse) + } + catch { + throw "GitHub runner label response was invalid: $($json -join ' '). Recovery: existing labels were not intentionally removed; inspect the runner before retrying." + } + if ($script:SmartPipeRunnerLabel -notin $postLabels) { + throw "GitHub did not confirm runner label '$script:SmartPipeRunnerLabel' in the mutation response." + } + + $afterRunner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName ([string]$Runner.name) -GhPath $GhPath + $after = @(Get-SmartPipeRunnerLabelNames -Runner $afterRunner) + if ($script:SmartPipeRunnerLabel -notin $after) { + throw "GitHub did not confirm runner label '$script:SmartPipeRunnerLabel'." + } + foreach ($label in $before) { + if ($label -notin $after) { + throw "Adding runner label removed existing label '$label'; refusing to continue." + } + } +} + +function Remove-SmartPipeRunnerLabel { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [object] $Runner, + + [string] $GhPath = 'gh' + ) + + $runnerId = [string]$Runner.id + if ([string]::IsNullOrWhiteSpace($runnerId) -or $runnerId -notmatch '^[0-9]+$') { + throw 'GitHub runner id is missing or invalid; refusing label mutation.' + } + + $before = @(Get-SmartPipeRunnerLabelNames -Runner $Runner) + if ($script:SmartPipeRunnerLabel -in $before) { + $global:LASTEXITCODE = 0 + $null = & $GhPath api --method DELETE "repos/$Repository/actions/runners/$runnerId/labels/$script:SmartPipeRunnerLabel" 2>&1 + $ghExitCode = $global:LASTEXITCODE + if ($ghExitCode -ne 0) { + throw "Unable to remove runner label '$script:SmartPipeRunnerLabel'." + } + } + + $afterRunner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName ([string]$Runner.name) -GhPath $GhPath + $after = @(Get-SmartPipeRunnerLabelNames -Runner $afterRunner) + if ($script:SmartPipeRunnerLabel -in $after) { + throw "GitHub still reports runner label '$script:SmartPipeRunnerLabel' after removal." + } + foreach ($label in ($before | Where-Object { $_ -ne $script:SmartPipeRunnerLabel })) { + if ($label -notin $after) { + throw "Removing runner label removed unrelated label '$label'; refusing to continue." + } + } +} + +function Assert-SmartPipeActionsRunsIdle { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [string] $GhPath = 'gh' + ) + + foreach ($status in @('queued', 'in_progress')) { + $global:LASTEXITCODE = 0 + $json = & $GhPath api "repos/$Repository/actions/runs?status=$status&per_page=100" 2>&1 + $ghExitCode = $global:LASTEXITCODE + if ($ghExitCode -ne 0) { + throw "Unable to query $status GitHub Actions runs: $($json -join ' ')" + } + + $response = ($json -join [Environment]::NewLine) | ConvertFrom-Json + if (@($response.workflow_runs).Count -gt 0) { + throw "GitHub Actions has $status runs; refusing runner mutation." + } + } +} + +function Assert-SmartPipeRemoteRunnerIdle { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [string] $RunnerName, + + [string] $GhPath = 'gh' + ) + + $runner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath + if ($runner.busy -eq $true) { + throw "Runner '$RunnerName' is busy." + } + return ,$runner +} + +function Wait-SmartPipeRunnerReady { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [string] $RunnerName, + + [string] $GhPath = 'gh', + [string] $FixturePath = '', + [int] $TimeoutSeconds = 60 + ) + + if ($TimeoutSeconds -lt 1) { + throw 'Runner readiness timeout must be positive.' + } + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ($true) { + $listeners = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) + if ($listeners.Count -gt 1) { + throw "More than one runner listener is tied to $Root." + } + + $runner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath + if ($listeners.Count -eq 1 -and [string]$runner.status -eq 'online' -and $runner.busy -eq $false) { + return + } + + if ([DateTime]::UtcNow -ge $deadline) { + throw "Runner '$RunnerName' did not become online and idle with one listener within $TimeoutSeconds seconds." + } + Start-Sleep -Seconds 1 + } +} + +function Restart-SmartPipeRunner { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [string] $RunnerName, + + [string] $GhPath = 'gh', + [string] $FixturePath = '', + [int] $TimeoutSeconds = 60 + ) + + Stop-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath + Start-SmartPipeRunner -Root $Root -FixturePath $FixturePath + Wait-SmartPipeRunnerReady -Root $Root -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath -FixturePath $FixturePath -TimeoutSeconds $TimeoutSeconds +} + +function Get-SmartPipeOwnedEnvironment { + param( + [Parameter(Mandatory = $true)] + [string] $EnvironmentPath + ) + + if (Test-Path -LiteralPath $EnvironmentPath -PathType Leaf) { + $raw = Get-Content -LiteralPath $EnvironmentPath -Raw -ErrorAction Stop + if ([string]::IsNullOrEmpty($raw)) { + return ,([Collections.Generic.List[string]]::new()) + } + + $lines = [Collections.Generic.List[string]]::new() + $rawLines = @($raw -split '\r?\n') + if ($rawLines.Count -gt 0 -and $rawLines[$rawLines.Count - 1] -eq '') { + $rawLines = if ($rawLines.Count -eq 1) { @() } else { $rawLines[0..($rawLines.Count - 2)] } + } + foreach ($line in $rawLines) { + [void]$lines.Add([string]$line) + } + return ,$lines + } + + return ,([Collections.Generic.List[string]]::new()) +} + +function Write-SmartPipeEnvironment { + param( + [Parameter(Mandatory = $true)] + [string] $EnvironmentPath, + + [Parameter(Mandatory = $true)] + [string] $HookPath + ) + + $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath + $owned = @{ + 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED' = $HookPath + } + + foreach ($key in $owned.Keys) { + for ($index = $lines.Count - 1; $index -ge 0; $index--) { + if ($lines[$index] -match "^\s*${key}=") { + $lines.RemoveAt($index) + } + } + + $lines.Add("$key=$($owned[$key])") + } + + $temporaryPath = "$EnvironmentPath.smartpipe.tmp" + [IO.File]::WriteAllText($temporaryPath, (($lines -join [Environment]::NewLine) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporaryPath -Destination $EnvironmentPath -Force +} + +function Remove-SmartPipeEnvironment { + param( + [Parameter(Mandatory = $true)] + [string] $EnvironmentPath + ) + + if (-not (Test-Path -LiteralPath $EnvironmentPath -PathType Leaf)) { + return + } + + $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath + $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_COMPLETED') + for ($index = $lines.Count - 1; $index -ge 0; $index--) { + foreach ($key in $ownedKeys) { + if ($lines[$index] -match "^\s*${key}=") { + $lines.RemoveAt($index) + break + } + } + } + + [IO.File]::WriteAllText($EnvironmentPath, (($lines -join [Environment]::NewLine) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) +} diff --git a/eng/runner/uninstall-runner.ps1 b/eng/runner/uninstall-runner.ps1 new file mode 100644 index 0000000..fc29aad --- /dev/null +++ b/eng/runner/uninstall-runner.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param( + [string] $RunnerRoot = 'C:\SmartPipe-Runner', + [string] $Repository = 'MrFr3di/SmartPipe-Core', + [string] $RunnerName = '', + [string] $GhPath = 'gh', + [string] $ListenerFixturePath = '', + [int] $ListenerTimeoutSeconds = 60, + [switch] $SkipListenerReady, + [switch] $AllowTestRoot +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'runner-safety.ps1') + +try { + Assert-SmartPipeRepository -Repository $Repository + $runner = Get-SmartPipeFullPath -Path $RunnerRoot + if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { + throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." + } + + if (-not (Test-Path -LiteralPath $runner -PathType Container)) { + Write-Output "Runner root is already absent: $runner" + exit 0 + } + Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner + $resolvedRunnerName = Resolve-SmartPipeRunnerName -Root $runner -RequestedName $RunnerName + Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath + $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath + + $environmentPath = Join-Path $runner '.env' + Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath + + $hookDirectory = Join-Path $runner 'hooks' + foreach ($name in @('smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { + $path = Join-Path $hookDirectory $name + if (Test-Path -LiteralPath $path) { + Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } + Remove-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath + + if (-not $SkipListenerReady) { + Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds + } + Write-Output "Removed SmartPipe-owned hook, environment entry, and label from $runner and restored one listener." +} +catch { + $errorText = [string]$_.Exception.Message + Write-Error -Message "$errorText Recovery: confirm the runner and repository are idle, then inspect or rerun eng\runner\uninstall-runner.ps1; unrelated runner labels are never removed." + exit 1 +} diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 new file mode 100644 index 0000000..651e882 --- /dev/null +++ b/eng/tests/runner-contract.Tests.ps1 @@ -0,0 +1,316 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$runnerScriptRoot = Join-Path $PSScriptRoot '..\runner' +$cleanupScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'post-job-cleanup.ps1')) +$installScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'install-runner.ps1')) +$uninstallScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'uninstall-runner.ps1')) +$monitorScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'monitor-pr.ps1')) + +function Assert-RunnerEqual { + param( + [Parameter(Mandatory = $true)] $Actual, + [Parameter(Mandatory = $true)] $Expected, + [Parameter(Mandatory = $true)] [string] $Message + ) + + if ($Actual -ne $Expected) { + throw "$Message (actual: '$Actual'; expected: '$Expected')" + } +} + +function Assert-RunnerTrue { + param( + [Parameter(Mandatory = $true)] [bool] $Condition, + [Parameter(Mandatory = $true)] [string] $Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Invoke-RunnerScript { + param( + [Parameter(Mandatory = $true)] [string] $ScriptPath, + [Parameter(Mandatory = $true)] [string[]] $Arguments + ) + + $output = & pwsh -NoProfile -File $ScriptPath @Arguments 2>&1 + [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = ($output | Out-String).Trim() + } +} + +$fixture = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-contract-$([Guid]::NewGuid().ToString('N'))" +$runnerRoot = Join-Path $fixture 'SmartPipe-Runner' +$workspace = Join-Path $runnerRoot '_work\SmartPipe.Core\SmartPipe.Core' +$tempRoot = Join-Path $runnerRoot '_temp' +$toolRoot = Join-Path $runnerRoot '_tool' +$sibling = Join-Path $runnerRoot '_work\Other.Repo\Other.Repo' + +try { + New-Item -ItemType Directory -Path $workspace, $tempRoot, $toolRoot, $sibling -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $workspace '.git'), (Join-Path $tempRoot 'SmartPipe.Core'), (Join-Path $tempRoot 'CodeQL') -Force | Out-Null + @' +{"agentName":"SmartPipe-Runner"} +'@ | Set-Content -LiteralPath (Join-Path $runnerRoot '.runner') + @' +[remote "origin"] + url = https://github.com/MrFr3di/SmartPipe-Core.git +'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') + 'workspace output' | Set-Content -LiteralPath (Join-Path $workspace 'output.txt') + 'tool must survive' | Set-Content -LiteralPath (Join-Path $toolRoot 'preserve.txt') + 'sibling must survive' | Set-Content -LiteralPath (Join-Path $sibling 'preserve.txt') + 'known temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'SmartPipe.Core\cache.txt') + 'known codeql temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'CodeQL\cache.txt') + 'unrelated temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'unrelated.tmp') + + $cleanup = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $workspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Post-job cleanup must succeed for a valid checkout. $($cleanup.Output)" + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath $workspace)) -Message 'The exact checkout must be removed.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $toolRoot 'preserve.txt')) -Message '_tool must be preserved.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $sibling 'preserve.txt')) -Message 'Sibling repositories must be preserved.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $tempRoot 'unrelated.tmp')) -Message 'Unrelated temp files must be preserved.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'SmartPipe.Core'))) -Message 'Known SmartPipe temp must be removed.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'CodeQL'))) -Message 'Known CodeQL temp must be removed.' + + $absentWorkspace = Join-Path $runnerRoot '_work\SmartPipe.Core\absent' + $absent = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $absentWorkspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $absent.ExitCode -Expected 0 -Message 'Absent cleanup targets must be successful.' + + New-Item -ItemType Directory -Path $workspace, (Join-Path $workspace '.git') -Force | Out-Null + @' +[remote "origin"] + url = https://github.com/example/other.git +# https://github.com/MrFr3di/SmartPipe-Core.git +[remote "upstream"] + url = https://github.com/MrFr3di/SmartPipe-Core.git +'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') + $wrongRepo = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $workspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($wrongRepo.ExitCode -ne 0) -Message 'A checkout with a commented or secondary canonical remote must fail closed.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace) -Message 'A rejected checkout must not be deleted.' + + $outside = Join-Path $fixture 'outside' + New-Item -ItemType Directory -Path $outside -Force | Out-Null + $outsideResult = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $outside, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($outsideResult.ExitCode -ne 0) -Message 'A workspace outside the runner root must fail closed.' + + Remove-Item -LiteralPath $workspace -Recurse -Force + New-Item -ItemType Directory -Path $workspace, (Join-Path $workspace '.git') -Force | Out-Null + @' +[remote "origin"] + url = https://github.com/MrFr3di/SmartPipe-Core.git +'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') + + $reparseCreated = $false + try { + New-Item -ItemType SymbolicLink -Path (Join-Path $workspace 'reparse') -Target $sibling -Force -ErrorAction Stop | Out-Null + $reparseCreated = $true + } + catch { + Write-Output 'Runner contract: symbolic-link fixture unavailable; reparse refusal remains covered by workflow cleanup contracts.' + } + if ($reparseCreated) { + $reparse = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $workspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($reparse.ExitCode -ne 0) -Message 'A reparse point must fail closed.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace) -Message 'A reparse rejection must preserve the checkout.' + } + + Remove-Item -LiteralPath $workspace -Recurse -Force + @' +@echo off +exit /b 0 +'@ | Set-Content -LiteralPath (Join-Path $runnerRoot 'run.cmd') + $listenerFixture = Join-Path $fixture 'listener.count' + '1' | Set-Content -LiteralPath $listenerFixture -NoNewline + $runnerGh = Join-Path $fixture 'runner-gh.ps1' +$queuedFlag = Join-Path $fixture 'queued.flag' +$inProgressFlag = Join-Path $fixture 'in-progress.flag' +$offlineFlag = Join-Path $fixture 'offline.flag' + $labelState = Join-Path $fixture 'runner-labels.json' + @('self-hosted', 'Windows', 'X64', 'existing-label') | ConvertTo-Json -Compress | Set-Content -LiteralPath $labelState + @' +param([Parameter(ValueFromRemainingArguments = $true)][string[]] $Arguments) +$joined = $Arguments -join ' ' +$labels = @((Get-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE -Raw | ConvertFrom-Json)) +if ($joined -like '*actions/runners/42/labels/smartpipe-cleanup-v1*') { + $labels = @($labels | Where-Object { $_ -ne 'smartpipe-cleanup-v1' }) + $labels | ConvertTo-Json -Compress | Set-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE + $response = @{ labels = @($labels | ForEach-Object { @{ name = $_ } }) } +} +elseif ($joined -like '*actions/runners/42/labels*') { + if ('smartpipe-cleanup-v1' -notin $labels) { $labels += 'smartpipe-cleanup-v1' } + Remove-Item -LiteralPath $env:SMARTPIPE_OFFLINE_FLAG -Force -ErrorAction SilentlyContinue + $labels | ConvertTo-Json -Compress | Set-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE + $response = @{ labels = @($labels | ForEach-Object { @{ name = $_ } }) } +} +elseif ($joined -like '*actions/runs?status=queued*') { + if (Test-Path -LiteralPath $env:SMARTPIPE_QUEUED_FLAG) { $response = @{ workflow_runs = @(@{ id = 1 }) } } else { $response = @{ workflow_runs = @() } } +} +elseif ($joined -like '*actions/runs?status=in_progress*') { + if (Test-Path -LiteralPath $env:SMARTPIPE_IN_PROGRESS_FLAG) { $response = @{ workflow_runs = @(@{ id = 2 }) } } else { $response = @{ workflow_runs = @() } } +} +elseif ($joined -like '*actions/runners?*') { + $labelObjects = @($labels | ForEach-Object { @{ name = $_ } }) + $runnerStatus = if (Test-Path -LiteralPath $env:SMARTPIPE_OFFLINE_FLAG) { 'offline' } else { 'online' } + $response = @{ runners = @(@{ id = 42; name = 'SmartPipe-Runner'; status = $runnerStatus; busy = $false; labels = $labelObjects }) } +} +elseif ($null -eq $response) { + throw "Unexpected fake gh request: $joined" +} + $response | ConvertTo-Json -Depth 5 -Compress +'@ | Set-Content -LiteralPath $runnerGh + $env:SMARTPIPE_QUEUED_FLAG = $queuedFlag + $env:SMARTPIPE_IN_PROGRESS_FLAG = $inProgressFlag + $env:SMARTPIPE_OFFLINE_FLAG = $offlineFlag + $env:SMARTPIPE_LABEL_STATE = $labelState + $environment = Join-Path $runnerRoot '.env' +@' +UNRELATED_ENV=preserve +'@ | Set-Content -LiteralPath $environment + + New-Item -ItemType File -Path $queuedFlag -Force | Out-Null + $queuedInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-RunnerName', 'SmartPipe-Runner', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($queuedInstall.ExitCode -ne 0) -Message "Installer must refuse queued Actions runs before mutation. $($queuedInstall.Output)" + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Queued-run refusal must not copy the hook.' + Remove-Item -LiteralPath $queuedFlag -Force + + New-Item -ItemType File -Path $inProgressFlag -Force | Out-Null + $inProgressInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-RunnerName', 'SmartPipe-Runner', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($inProgressInstall.ExitCode -ne 0) -Message "Installer must refuse in-progress Actions runs before mutation. $($inProgressInstall.Output)" + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'In-progress refusal must not copy the hook.' + Remove-Item -LiteralPath $inProgressFlag -Force + + New-Item -ItemType File -Path $offlineFlag -Force | Out-Null + $install = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $install.ExitCode -Expected 0 -Message "Installer must accept an idle fixture root and restore one listener. $($install.Output)" + Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Successful installation must leave exactly one listener fixture.' + $labelsAfterInstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) + Assert-RunnerTrue -Condition ('smartpipe-cleanup-v1' -in $labelsAfterInstall) -Message 'Installer must register the cleanup label through GitHub.' + Assert-RunnerTrue -Condition ('existing-label' -in $labelsAfterInstall) -Message 'Installer must preserve unrelated runner labels.' + $installAgain = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-RunnerName', 'SmartPipe-Runner', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $installAgain.ExitCode -Expected 0 -Message 'Installer must be idempotent.' + $environmentLines = @(Get-Content -LiteralPath $environment) + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 1 -Message 'Hook environment entry must be unique.' + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^SMARTPIPE_CLEANUP_LABEL=' }).Count -Expected 0 -Message 'Runner labels must not be represented by an environment marker.' + Assert-RunnerTrue -Condition (@($environmentLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Installer must preserve unrelated environment entries.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1')) -Message 'Installer must copy the hook.' + + $uninstall = Invoke-RunnerScript -ScriptPath $uninstallScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $uninstall.ExitCode -Expected 0 -Message "Uninstaller must succeed and restore one listener. $($uninstall.Output)" + Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Uninstall must leave exactly one listener fixture.' + $uninstalledLines = @(Get-Content -LiteralPath $environment) + Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' + Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Uninstaller must preserve unrelated environment entries.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' + $labelsAfterUninstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) + Assert-RunnerTrue -Condition ('smartpipe-cleanup-v1' -notin $labelsAfterUninstall) -Message 'Uninstaller must remove only the owned cleanup label.' + Assert-RunnerTrue -Condition ('existing-label' -in $labelsAfterUninstall) -Message 'Uninstaller must preserve unrelated runner labels.' + + $fakeGh = Join-Path $fixture 'fake-gh.ps1' + $fakeCount = Join-Path $fixture 'fake-gh.count' + @' +param([Parameter(ValueFromRemainingArguments = $true)][string[]] $Arguments) +$joined = $Arguments -join ' ' +if ($joined -like '*run list*') { + @(@{ databaseId = 99 }) | ConvertTo-Json -Compress + exit 0 +} +if ($joined -like '*run view*') { + "build error $([string]::new('x', 1400))" + exit 0 +} +$count = if (Test-Path -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT) { [int](Get-Content -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT) } else { 0 } +Set-Content -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT -Value ($count + 1) +@{ state = 'OPEN'; mergeStateStatus = 'DIRTY'; headRefOid = '0123456789abcdef0123456789abcdef01234567'; statusCheckRollup = @(@{ name = 'build'; status = 'COMPLETED'; conclusion = 'FAILURE' }) } | ConvertTo-Json -Compress +'@ | Set-Content -LiteralPath $fakeGh + $env:SMARTPIPE_FAKE_GH_COUNT = $fakeCount + $monitor = Invoke-RunnerScript -ScriptPath $monitorScript -Arguments @( + '-PullRequest', '42', + '-Repository', 'MrFr3di/SmartPipe-Core', + '-GhPath', $fakeGh, + '-PollSeconds', '1', + '-MaxPolls', '2' + ) + Remove-Item Env:\SMARTPIPE_FAKE_GH_COUNT -ErrorAction SilentlyContinue + Assert-RunnerEqual -Actual $monitor.ExitCode -Expected 0 -Message "PR monitor fixture must succeed. $($monitor.Output)" + Assert-RunnerEqual -Actual @($monitor.Output -split '\r?\n' | Where-Object { $_ -match '^PR #42 transition:' }).Count -Expected 1 -Message 'PR monitor must emit only state transitions.' + $diagnosticLines = @($monitor.Output -split '\r?\n' | Where-Object { $_ -match '^PR diagnostic: first causal slice:' }) + Assert-RunnerEqual -Actual $diagnosticLines.Count -Expected 1 -Message 'PR monitor must emit one first-causal slice per failed head.' + Assert-RunnerTrue -Condition ($diagnosticLines[0].Length -le 1070) -Message 'PR monitor causal output must remain bounded.' + + Write-Output 'Runner contract tests passed (cleanup containment, lifecycle idempotence, and transition-only monitoring).' +} +finally { + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item Env:\SMARTPIPE_FAKE_GH_COUNT -ErrorAction SilentlyContinue + Remove-Item Env:\SMARTPIPE_QUEUED_FLAG -ErrorAction SilentlyContinue + Remove-Item Env:\SMARTPIPE_IN_PROGRESS_FLAG -ErrorAction SilentlyContinue + Remove-Item Env:\SMARTPIPE_OFFLINE_FLAG -ErrorAction SilentlyContinue +} diff --git a/eng/tests/workflow-contract.Tests.ps1 b/eng/tests/workflow-contract.Tests.ps1 index c3920fd..dcf05ae 100644 --- a/eng/tests/workflow-contract.Tests.ps1 +++ b/eng/tests/workflow-contract.Tests.ps1 @@ -7,3 +7,9 @@ python $testScript if ($LASTEXITCODE -ne 0) { throw "Workflow contract tests failed with exit code $LASTEXITCODE." } + +$runnerTestScript = Join-Path $PSScriptRoot 'runner-contract.Tests.ps1' +pwsh -NoProfile -File $runnerTestScript +if ($LASTEXITCODE -ne 0) { + throw "Runner contract tests failed with exit code $LASTEXITCODE." +} diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 66f45b9..7e372db 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -31,8 +31,8 @@ ) } SHA_REF = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") -SELF_HOSTED_WINDOWS = ["self-hosted", "Windows", "X64"] -SELF_HOSTED_WINDOWS_JSON = '["self-hosted","Windows","X64"]' +SELF_HOSTED_WINDOWS = ["self-hosted", "Windows", "X64", "smartpipe-cleanup-v1"] +SELF_HOSTED_WINDOWS_JSON = '["self-hosted","Windows","X64","smartpipe-cleanup-v1"]' SAME_REPOSITORY_PR_GUARD = ( "github.event_name != 'pull_request' || " "github.event.pull_request.head.repo.full_name == github.repository" @@ -46,19 +46,30 @@ "always() && github.event_name == 'pull_request' && " "github.event.pull_request.head.repo.full_name == github.repository" ) +DIAGNOSTIC_INPUTS_EMPTY_GUARD = ( + "(github.event_name != 'workflow_dispatch' || " + "(inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && " + "inputs.diagnostic-repeat == ''))" +) +CI_NORMAL_GUARD = f"({SAME_REPOSITORY_PR_GUARD}) && {DIAGNOSTIC_INPUTS_EMPTY_GUARD}" +DIAGNOSTIC_GUARD = ( + "github.event_name == 'workflow_dispatch' && " + "(inputs.diagnostic-sha != '' || inputs.diagnostic-scenario != '' || " + "inputs.diagnostic-repeat != '')" +) CI_VALIDATION_RUNNER_INPUT = ( "${{ github.event_name == 'pull_request' && " - "'[\"self-hosted\",\"Windows\",\"X64\"]' || " + "'[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]' || " "'[\"ubuntu-latest\"]' }}" ) CI_WINDOWS_RUNNER = ( "${{ github.event_name == 'pull_request' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || " + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " "'windows-latest' }}" ) CODEQL_RUNNER = ( "${{ github.event_name == 'pull_request' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || " + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " "'ubuntu-latest' }}" ) CODEQL_PR_RAM = ( @@ -78,7 +89,7 @@ HOSTING_NAME = "${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}" HOSTING_RUNNER = ( "${{ matrix.os == 'self-hosted' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || matrix.os }}" + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || matrix.os }}" ) HOSTING_MATRIX = ( "${{ fromJSON(github.event_name == 'pull_request' && " @@ -134,12 +145,87 @@ def assert_nuget_isolation_contract(workflow: dict, workflow_name: str) -> None: f"{workflow_name} must isolate pull-request NuGet packages inside GITHUB_WORKSPACE.") +def assert_diagnostic_contract(ci: dict) -> None: + dispatch = ci.get("on", {}).get("workflow_dispatch", {}) + inputs = dispatch.get("inputs", {}) if isinstance(dispatch, dict) else {} + require(set(inputs) == {"diagnostic-sha", "diagnostic-scenario", "diagnostic-repeat"}, + "CI diagnostic dispatch must expose exactly SHA, scenario, and repeat inputs.") + for name in inputs: + definition = inputs[name] + require(definition.get("required") is False + and definition.get("type") == "string" + and definition.get("default") == "", + f"CI diagnostic input {name} must be an optional empty string.") + + job = ci["jobs"].get("diagnostic-consumer") + require(isinstance(job, dict), "CI must define the optional diagnostic-consumer job.") + require(job.get("if") == DIAGNOSTIC_GUARD, + "Diagnostic consumer must run only for a workflow dispatch with diagnostic input.") + require_self_hosted_windows(job, "Diagnostic consumer") + diagnostic_steps = steps(job, "diagnostic-consumer") + validation = named_step(diagnostic_steps, "Validate diagnostic inputs") + validation_script = str(validation.get("run", "")) + for token in ("^[0-9a-f]{40}$", "^[a-z0-9-]+$", "^[1-5]$"): + require(token in validation_script, + f"Diagnostic input validation must enforce {token}.") + checkout = next( + step for step in diagnostic_steps + if str(step.get("uses", "")).startswith("actions/checkout") + ) + require(checkout.get("with", {}).get("ref") == "${{ inputs.diagnostic-sha }}" + and checkout.get("with", {}).get("persist-credentials") is False, + "Diagnostic consumer must checkout the exact requested SHA without credentials.") + verify = named_step(diagnostic_steps, "Verify exact diagnostic checkout") + require("git rev-parse HEAD" in str(verify.get("run", "")) + and "DIAGNOSTIC_SHA" in str(verify.get("run", "")), + "Diagnostic consumer must verify the checked out commit SHA.") + restore = named_step(diagnostic_steps, "Restore locked") + require(str(restore.get("run", "")).strip() == "dotnet restore SmartPipe.Core.slnx --locked-mode", + "Diagnostic consumer must perform one locked solution restore.") + build = named_step(diagnostic_steps, "Build") + require("--no-restore" in str(build.get("run", "")) + and "dotnet build SmartPipe.Core.slnx" in str(build.get("run", "")), + "Diagnostic consumer must build once after restore.") + pack = named_step(diagnostic_steps, "Pack packages from graph") + pack_run = str(pack.get("run", "")) + require("pack-packages" in pack_run + and "--output artifacts/packages" in pack_run + and "--manifest artifacts/packages/manifest.json" in pack_run, + "Diagnostic consumer must pack once from the package graph.") + run = named_step(diagnostic_steps, "Run diagnostic consumer") + run_script = str(run.get("run", "")) + require("--scenario $env:DIAGNOSTIC_SCENARIO" in run_script + and "DIAGNOSTIC_REPEAT" in run_script + and "for ($pass = 1;" in run_script + and "$pass -le [int]$env:DIAGNOSTIC_REPEAT" in run_script, + "Diagnostic consumer must invoke exactly the selected scenario one to five times.") + require("GITHUB_STEP_SUMMARY" in run_script + and "8192" in run_script + and "upload-artifact" not in "\n".join( + str(step) for step in diagnostic_steps + ), + "Diagnostic consumer must write only a bounded summary and no artifact upload.") + commands = [command for command in runs(diagnostic_steps) + if "dotnet restore SmartPipe.Core.slnx" in command + or "dotnet build SmartPipe.Core.slnx" in command + or "pack-packages" in command] + require(sum("dotnet restore SmartPipe.Core.slnx" in command for command in commands) == 1 + and sum("dotnet build SmartPipe.Core.slnx" in command for command in commands) == 1 + and sum("pack-packages" in command for command in commands) == 1, + "Diagnostic consumer must restore, build, and pack exactly once.") + + def require_same_repository_pr_guard(job: dict, label: str, allow_non_pr: bool = True) -> None: expected = SAME_REPOSITORY_PR_GUARD if allow_non_pr else PULL_REQUEST_SAME_REPOSITORY_GUARD require(job.get("if") == expected, f"{label} must use the same-repository pull_request guard.") +def require_ci_normal_job_guard(job: dict, label: str) -> None: + require(job.get("if") == CI_NORMAL_GUARD, + f"{label} must retain the same-repository guard and skip only diagnostic dispatches.") + + def assert_cleanup_job( workflow: dict, workflow_name: str, @@ -382,8 +468,6 @@ def assert_repository_checks_profile( "Provision 2.1.2 baseline packages", "Verify package graph current", "Verify package metadata current", "Verify package ownership current", "Verify release versions current", "Run current consumers", - "Run Hosting consumers", "Run HealthChecks consumers", - "Run OpenTelemetry consumers", "Vulnerable package scan", "Verify direct production audit policy", "Deprecated package scan", "Outdated package report", "Upload immutable packages and reports", @@ -581,10 +665,32 @@ def validate(documents: dict[str, dict]) -> None: branches = ci.get("on", {}).get(event, {}).get("branches", []) require("release/2.2.0" in branches, f"CI {event} must include release/2.2.0.") + assert_diagnostic_contract(ci) expected_triggers = { "ci.yml": { - "workflow_dispatch": None, + "workflow_dispatch": { + "inputs": { + "diagnostic-sha": { + "description": "Exact 40-character commit SHA for a single-consumer diagnostic", + "required": False, + "type": "string", + "default": "", + }, + "diagnostic-scenario": { + "description": "Exact consumer scenario ID for a single-consumer diagnostic", + "required": False, + "type": "string", + "default": "", + }, + "diagnostic-repeat": { + "description": "Number of diagnostic runs (1-5)", + "required": False, + "type": "string", + "default": "", + }, + }, + }, "push": {"branches": ["main", "upd", "release/2.2.0"]}, "pull_request": { "branches": ["main", "upd", "release/2.2.0", "sp220/checkpoint-c", "sp220/checkpoint-d"] @@ -675,8 +781,7 @@ def validate(documents: dict[str, dict]) -> None: "Pack packages from graph", "Provision 2.1.2 baseline packages", "Verify package graph current", "Verify package metadata current", "Verify package ownership current", "Verify release versions current", - "Run current consumers", "Run Hosting consumers", "Run HealthChecks consumers", - "Run OpenTelemetry consumers", "Vulnerable package scan", + "Run current consumers", "Vulnerable package scan", "Verify direct production audit policy", "Deprecated package scan", "Outdated package report", "Docs link check", "Docs link check (Windows)", "Upload immutable packages and reports", @@ -685,17 +790,25 @@ def validate(documents: dict[str, dict]) -> None: named_step(reusable_steps, name) gate_order = [ "Restore locked", "Build", "Verify RepositoryChecks profile", - "Test and benchmark warning gate", "Pack packages from graph", + "Pack packages from graph", "Provision 2.1.2 baseline packages", "Verify package graph current", "Verify package metadata current", "Verify package ownership current", - "Verify release versions current", "Run current consumers", "Run HealthChecks consumers", - "Run OpenTelemetry consumers", "Vulnerable package scan", "Verify direct production audit policy", + "Verify release versions current", "Run current consumers", + "Test and benchmark warning gate", "Vulnerable package scan", "Verify direct production audit policy", "Deprecated package scan", "Outdated package report", "Upload immutable packages and reports", ] gate_indexes = [reusable_steps.index(named_step(reusable_steps, name)) for name in gate_order] require(gate_indexes == sorted(gate_indexes), "Reusable package gates must follow the required order.") + wide_tests_index = reusable_steps.index(named_step(reusable_steps, "Core correctness regressions")) + for name in ( + "Pack packages from graph", "Verify package graph current", + "Verify package metadata current", "Verify package ownership current", + "Verify release versions current", "Run current consumers", + ): + require(reusable_steps.index(named_step(reusable_steps, name)) < wide_tests_index, + f"{name} must run before wide tests.") reusable_text = "\n".join(reusable_runs) pack_run = str(named_step(reusable_steps, "Pack packages from graph").get("run", "")) for token in ("pack-packages", "--mode current", "--configuration Release", @@ -704,15 +817,16 @@ def validate(documents: dict[str, dict]) -> None: require(token in pack_run, f"Graph-driven pack step must contain '{token}'.") require(reusable_text.count("pack-packages") == 1, "Reusable validation must invoke pack-packages exactly once.") - hosting_consumers = str(named_step(reusable_steps, "Run Hosting consumers").get("run", "")) - require("run-consumers" in hosting_consumers and "--category hosting" in hosting_consumers, - "Reusable validation must execute the Hosting consumer category.") - health_checks_consumers = str(named_step(reusable_steps, "Run HealthChecks consumers").get("run", "")) - require("run-consumers" in health_checks_consumers and "--category health-checks" in health_checks_consumers, - "Reusable validation must execute the HealthChecks consumer category.") - opentelemetry_consumers = str(named_step(reusable_steps, "Run OpenTelemetry consumers").get("run", "")) - require("run-consumers" in opentelemetry_consumers and "--category opentelemetry" in opentelemetry_consumers, - "Reusable validation must execute the OpenTelemetry consumer category.") + current_consumers = [ + str(step.get("run", "")) + for step in reusable_steps + if "run-consumers" in str(step.get("run", "")) + and "--set current" in str(step.get("run", "")) + ] + require(len(current_consumers) == 1 + and "--category" not in current_consumers[0] + and "--scenario" not in current_consumers[0], + "Reusable validation must execute exactly one full current consumer run.") concurrency_job = reusable["jobs"].get("health-checks-concurrency") require(isinstance(concurrency_job, dict), "Reusable validation must define the HealthChecks concurrency OS matrix.") @@ -768,7 +882,7 @@ def validate(documents: dict[str, dict]) -> None: require(validation == { "uses": "./.github/workflows/reusable-release-validation.yml", "permissions": {"contents": "read"}, - "if": SAME_REPOSITORY_PR_GUARD, + "if": CI_NORMAL_GUARD, "with": {"runner-labels": CI_VALIDATION_RUNNER_INPUT}, }, "CI validation must be the exact reusable workflow caller with read-only contents permission.") pull_request = ci.get("on", {}).get("pull_request", {}) @@ -778,7 +892,7 @@ def validate(documents: dict[str, dict]) -> None: require(isinstance(hosting_integration, dict) and hosting_integration.get("name") == f"Hosting integration ({HOSTING_NAME})", "CI must preserve the Hosting integration check name across event routes.") - require_same_repository_pr_guard(hosting_integration, "Hosting integration") + require_ci_normal_job_guard(hosting_integration, "Hosting integration") require_runner_expression(hosting_integration, HOSTING_RUNNER, "Hosting integration") hosting_strategy = hosting_integration.get("strategy") require(isinstance(hosting_strategy, dict) @@ -795,7 +909,7 @@ def validate(documents: dict[str, dict]) -> None: windows = ci["jobs"].get("json-file-windows") require(isinstance(windows, dict), "CI must define the Windows JSON lane.") require_runner_expression(windows, CI_WINDOWS_RUNNER, "Windows JSON lane") - require_same_repository_pr_guard(windows, "Windows JSON lane") + require_ci_normal_job_guard(windows, "Windows JSON lane") windows_steps = steps(windows, "json-file-windows") windows_runs = runs(windows_steps) windows_restores = [command for command in windows_runs if "dotnet restore SmartPipe.Core.slnx" in command] @@ -816,7 +930,7 @@ def validate(documents: dict[str, dict]) -> None: and baseline_windows.get("name") == "Baseline contract (Windows)", "CI must define the uniquely named Windows baseline contract job.") require_runner_expression(baseline_windows, CI_WINDOWS_RUNNER, "Windows baseline contract lane") - require_same_repository_pr_guard(baseline_windows, "Windows baseline contract lane") + require_ci_normal_job_guard(baseline_windows, "Windows baseline contract lane") baseline_windows_steps = steps(baseline_windows, "Windows baseline contract lane") checkout = baseline_windows_steps[0] require(str(checkout.get("uses", "")).startswith("actions/checkout") @@ -1145,6 +1259,56 @@ def _remove_ci_runner_override(documents: dict[str, dict]) -> None: del documents["ci.yml"]["jobs"]["validation"]["with"]["runner-labels"] +def _remove_diagnostic_input(documents: dict[str, dict]) -> None: + del documents["ci.yml"]["on"]["workflow_dispatch"]["inputs"]["diagnostic-sha"] + + +def _make_diagnostic_hosted(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["diagnostic-consumer"]["runs-on"] = "windows-latest" + + +def _make_ci_normal_job_diagnostic_capable(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["json-file-windows"]["if"] = SAME_REPOSITORY_PR_GUARD + + +def _remove_diagnostic_sha_validation(documents: dict[str, dict]) -> None: + step = named_step( + documents["ci.yml"]["jobs"]["diagnostic-consumer"]["steps"], + "Validate diagnostic inputs", + ) + step["run"] = str(step["run"]).replace("^[0-9a-f]{40}", "^[0-9a-f]+") + + +def _remove_diagnostic_exact_checkout(documents: dict[str, dict]) -> None: + checkout = next( + step for step in documents["ci.yml"]["jobs"]["diagnostic-consumer"]["steps"] + if str(step.get("uses", "")).startswith("actions/checkout") + ) + checkout["with"]["ref"] = "main" + + +def _remove_diagnostic_repeat_bound(documents: dict[str, dict]) -> None: + step = named_step( + documents["ci.yml"]["jobs"]["diagnostic-consumer"]["steps"], + "Validate diagnostic inputs", + ) + step["run"] = str(step["run"]).replace("^[1-5]$", "^[0-9]+$") + + +def _duplicate_current_consumer_run(documents: dict[str, dict]) -> None: + job = documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"] + current = named_step(job["steps"], "Run current consumers") + job["steps"].append(copy.deepcopy(current)) + + +def _move_current_consumer_after_wide_tests(documents: dict[str, dict]) -> None: + job = documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"] + current = named_step(job["steps"], "Run current consumers") + job["steps"].remove(current) + wide = job["steps"].index(named_step(job["steps"], "Core correctness regressions")) + job["steps"].insert(wide + 1, current) + + def _change_runner_default(documents: dict[str, dict]) -> None: documents["reusable-release-validation.yml"]["on"]["workflow_call"]["inputs"]["runner-labels"][ "default" @@ -1312,6 +1476,46 @@ def assert_mutation_rejected(documents: dict[str, dict], mutate, expected: str) def main() -> int: documents = load_workflows() validate(documents) + assert_mutation_rejected( + documents, + _remove_diagnostic_input, + "exactly SHA, scenario, and repeat inputs", + ) + assert_mutation_rejected( + documents, + _make_diagnostic_hosted, + "must target the self-hosted Windows X64 runner labels", + ) + assert_mutation_rejected( + documents, + _make_ci_normal_job_diagnostic_capable, + "skip only diagnostic dispatches", + ) + assert_mutation_rejected( + documents, + _remove_diagnostic_sha_validation, + "^[0-9a-f]{40}$", + ) + assert_mutation_rejected( + documents, + _remove_diagnostic_exact_checkout, + "exact requested SHA", + ) + assert_mutation_rejected( + documents, + _remove_diagnostic_repeat_bound, + "^[1-5]$", + ) + assert_mutation_rejected( + documents, + _duplicate_current_consumer_run, + "Expected exactly one step named 'Run current consumers'", + ) + assert_mutation_rejected( + documents, + _move_current_consumer_after_wide_tests, + "must run before wide tests", + ) assert_mutation_rejected( documents, lambda docs: _remove_reusable_step(docs, "Verify RepositoryChecks profile"), @@ -1340,11 +1544,6 @@ def main() -> int: lambda docs: _remove_reusable_step(docs, "Pack packages from graph"), "Pack packages from graph", ) - assert_mutation_rejected( - documents, - lambda docs: _remove_reusable_step(docs, "Run HealthChecks consumers"), - "Run HealthChecks consumers", - ) assert_mutation_rejected( documents, lambda docs: _remove_reusable_step(docs, "Verify direct production audit policy"), @@ -1446,11 +1645,6 @@ def main() -> int: lambda docs: _remove_reusable_step(docs, "Hosting tests"), "Hosting tests", ) - assert_mutation_rejected( - documents, - lambda docs: _remove_reusable_step(docs, "Run Hosting consumers"), - "Run Hosting consumers", - ) assert_mutation_rejected( documents, _use_hosted_runner_for_required_lanes, @@ -1614,16 +1808,6 @@ def main() -> int: _move_graph_before_integrity, "required order", ) - assert_mutation_rejected( - documents, - _move_opentelemetry_consumers_before_pack, - "required order", - ) - assert_mutation_rejected( - documents, - lambda docs: _remove_reusable_step(docs, "Run OpenTelemetry consumers"), - "Run OpenTelemetry consumers", - ) assert_mutation_rejected( documents, _duplicate_upload, diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Commands/CommandLineParserTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Commands/CommandLineParserTests.cs index 1c4f3c5..3fcaef6 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Commands/CommandLineParserTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Commands/CommandLineParserTests.cs @@ -319,6 +319,82 @@ public void Parse_RunConsumersAcceptsHostingCategory() ])); Assert.Equal("hosting", command.Category); + Assert.Null(command.Scenario); + } + + [Fact] + public void Parse_RunConsumersAcceptsExactScenario() + { + using var repository = new CommandRepository(); + + var command = Assert.IsType(CommandLineParser.Parse( + [ + "run-consumers", "--repo-root", repository.Path, + "--set", "current", + "--package-directory", "packages", + "--package-version", "2.2.0", + "--scenario", "dependency-injection-nativeaot", + ])); + + Assert.Equal("dependency-injection-nativeaot", command.Scenario); + Assert.Null(command.Category); + } + + [Fact] + public void Parse_RunConsumersRejectsCategoryAndScenarioTogether() + { + using var repository = new CommandRepository(); + + var error = Assert.Throws(() => CommandLineParser.Parse( + [ + "run-consumers", "--repo-root", repository.Path, + "--set", "current", + "--package-directory", "packages", + "--package-version", "2.2.0", + "--category", "hosting", + "--scenario", "hosting-direct", + ])); + + Assert.Equal("Options '--category' and '--scenario' are mutually exclusive.", error.Message); + } + + [Theory] + [InlineData("Dependency-Injection")] + [InlineData("dependency_injection")] + [InlineData("dependency.injection")] + [InlineData("")] + public void Parse_RunConsumersRejectsMalformedScenario(string scenario) + { + using var repository = new CommandRepository(); + + var error = Assert.Throws(() => CommandLineParser.Parse( + [ + "run-consumers", "--repo-root", repository.Path, + "--set", "current", + "--package-directory", "packages", + "--package-version", "2.2.0", + "--scenario", scenario, + ])); + + Assert.Equal("Option '--scenario' must contain lowercase letters, digits, or hyphens.", error.Message); + } + + [Fact] + public void Parse_RunConsumersRejectsDuplicateScenarioOption() + { + using var repository = new CommandRepository(); + + var error = Assert.Throws(() => CommandLineParser.Parse( + [ + "run-consumers", "--repo-root", repository.Path, + "--set", "current", + "--package-directory", "packages", + "--package-version", "2.2.0", + "--scenario", "core-direct", + "--scenario", "json-direct", + ])); + + Assert.Equal("Duplicate option '--scenario'.", error.Message); } private static string[] CaptureArgs(string repositoryRoot) diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs index 88080d7..511a888 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs @@ -12,6 +12,60 @@ namespace SmartPipe.RepositoryChecks.Tests.Consumers; [Collection(ExternalProcessCollection.Name)] public sealed class ConsumerScenarioRunnerTests { + [Fact] + public void NativeAotLibraryPreflight_IsNoOpOutsideWindows() + { + using var fixture = new RepositoryTestDirectory(); + var path = WriteLibraryAtEffectiveLength(fixture.Path, 260); + + ConsumerScenarioRunner.ValidateNativeAotLibraryPaths(fixture.Path, isWindows: false); + + Assert.True(File.Exists(path)); + } + + [Fact] + public void NativeAotLibraryPreflight_AllowsEffectiveLengthBelowWindowsLimit() + { + using var fixture = new RepositoryTestDirectory(); + WriteLibraryAtEffectiveLength(fixture.Path, 258); + + ConsumerScenarioRunner.ValidateNativeAotLibraryPaths(fixture.Path, isWindows: true); + } + + [Fact] + public void NativeAotLibraryPreflight_RejectsEffectiveLengthAtWindowsLimitWithoutAbsolutePath() + { + using var fixture = new RepositoryTestDirectory(); + var path = WriteLibraryAtEffectiveLength(fixture.Path, 260); + + var error = Assert.Throws(() => + ConsumerScenarioRunner.ValidateNativeAotLibraryPaths(fixture.Path, isWindows: true)); + + Assert.Equal("SPCONS025", error.Code); + Assert.Contains("260", error.Message, StringComparison.Ordinal); + Assert.Contains(Path.GetRelativePath(fixture.Path, path).Replace('\\', '/'), error.Message, StringComparison.Ordinal); + Assert.DoesNotContain(fixture.Path, error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RunConsumers_UnknownScenarioUsesExistingSelectionDiagnostic() + { + var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); + var options = new RunConsumersOptions( + root, + "current", + Path.Combine(root, "artifacts", "packages"), + "2.2.0", + "eng/consumer-scenarios.json", + Scenario: "does-not-exist"); + + var error = await Assert.ThrowsAsync(() => + new ConsumerScenarioRunner().RunAsync(options, TestContext.Current.CancellationToken)); + + Assert.Equal("SPCONS010", error.Code); + Assert.Contains("does-not-exist", error.Message, StringComparison.Ordinal); + } + [Fact] public void ProcessFailure_IsBoundedSingleLineAndPointsToRelativeRetainedEvidence() { @@ -494,6 +548,18 @@ private static string FixtureExecutable() "SmartPipe.RepositoryChecks.ProcessFixture" + (OperatingSystem.IsWindows() ? ".exe" : string.Empty)); } + private static string WriteLibraryAtEffectiveLength(string root, int effectiveLength) + { + var relativeLength = effectiveLength - Path.GetFullPath(root).Length - 2; + var directoryLength = relativeLength - "native.lib".Length - 1; + Assert.InRange(directoryLength, 1, 240); + var path = Path.Combine(root, new string('d', directoryLength), "native.lib"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, []); + Assert.Equal(effectiveLength, Path.GetFullPath(path).Length + 1); + return path; + } + private static ExpectedPublishDiagnostic DiagnosticExpectation() => new() { Code = "IL2026", From 6ef844c0d741aceffa19af108e0517b6c7f69e1e Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sun, 23 Aug 2026 01:39:19 +0500 Subject: [PATCH 08/22] fix(ci): harden self-hosted runner lifecycle --- docs/contributing.md | 5 ++++- eng/runner/install-runner.ps1 | 3 ++- eng/runner/post-job-cleanup.ps1 | 2 ++ eng/runner/runner-safety.ps1 | 8 ++++++-- eng/tests/runner-contract.Tests.ps1 | 28 +++++++++++++++++++++++----- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 7d71acc..b1fe4bd 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -107,7 +107,10 @@ other labels, stops listeners tied to the exact root, launches one hidden only that custom label and the owned entry/copies, preserves unrelated labels and `.env` lines, then performs the same bounded one-listener restart. A failed operation reports recovery guidance; never convert the runner to a service as -part of this operation. +part of this operation. The second owned `.env` entry points +`DOTNET_INSTALL_DIR` at `_work\_tool\dotnet`, giving `actions/setup-dotnet` a +writable persistent directory without granting access to +`C:\Program Files\dotnet`. The post-job hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout remote, and canonicalizes every target beneath the dedicated runner root. It diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 index bc07c67..5e8ff82 100644 --- a/eng/runner/install-runner.ps1 +++ b/eng/runner/install-runner.ps1 @@ -72,7 +72,8 @@ try { Copy-Item -LiteralPath $safetySource -Destination $safetyDestination -Force $environmentPath = Join-Path $runner '.env' - Write-SmartPipeEnvironment -EnvironmentPath $environmentPath -HookPath $hookDestination + $dotnetInstallDirectory = Join-Path $runner '_work\_tool\dotnet' + Write-SmartPipeEnvironment -EnvironmentPath $environmentPath -HookPath $hookDestination -DotNetInstallDirectory $dotnetInstallDirectory Add-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath if (-not $SkipListenerReady) { diff --git a/eng/runner/post-job-cleanup.ps1 b/eng/runner/post-job-cleanup.ps1 index aad347a..8f34f82 100644 --- a/eng/runner/post-job-cleanup.ps1 +++ b/eng/runner/post-job-cleanup.ps1 @@ -21,6 +21,8 @@ try { } Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner + Set-Location -LiteralPath $runner + [Environment]::CurrentDirectory = $runner if ([string]::IsNullOrWhiteSpace($WorkspaceRoot)) { throw 'GITHUB_WORKSPACE is required.' diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 index 6e7685e..d82b0ff 100644 --- a/eng/runner/runner-safety.ps1 +++ b/eng/runner/runner-safety.ps1 @@ -683,12 +683,16 @@ function Write-SmartPipeEnvironment { [string] $EnvironmentPath, [Parameter(Mandatory = $true)] - [string] $HookPath + [string] $HookPath, + + [Parameter(Mandatory = $true)] + [string] $DotNetInstallDirectory ) $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath $owned = @{ 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED' = $HookPath + 'DOTNET_INSTALL_DIR' = $DotNetInstallDirectory } foreach ($key in $owned.Keys) { @@ -717,7 +721,7 @@ function Remove-SmartPipeEnvironment { } $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath - $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_COMPLETED') + $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR') for ($index = $lines.Count - 1; $index -ge 0; $index--) { foreach ($key in $ownedKeys) { if ($lines[$index] -match "^\s*${key}=") { diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index 651e882..97f06fa 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -34,12 +34,29 @@ function Assert-RunnerTrue { function Invoke-RunnerScript { param( [Parameter(Mandatory = $true)] [string] $ScriptPath, - [Parameter(Mandatory = $true)] [string[]] $Arguments + [Parameter(Mandatory = $true)] [string[]] $Arguments, + [string] $WorkingDirectory = '' ) - $output = & pwsh -NoProfile -File $ScriptPath @Arguments 2>&1 + if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { + $output = & pwsh -NoProfile -File $ScriptPath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } + else { + $captureId = [Guid]::NewGuid().ToString('N') + $stdoutPath = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-$captureId.out" + $stderrPath = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-$captureId.err" + try { + $process = Start-Process -FilePath pwsh -ArgumentList (@('-NoProfile', '-File', $ScriptPath) + $Arguments) -WorkingDirectory $WorkingDirectory -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru + $output = @((Get-Content -LiteralPath $stdoutPath -ErrorAction SilentlyContinue), (Get-Content -LiteralPath $stderrPath -ErrorAction SilentlyContinue)) + $exitCode = $process.ExitCode + } + finally { + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } + } [pscustomobject]@{ - ExitCode = $LASTEXITCODE + ExitCode = $exitCode Output = ($output | Out-String).Trim() } } @@ -68,7 +85,7 @@ try { 'known codeql temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'CodeQL\cache.txt') 'unrelated temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'unrelated.tmp') - $cleanup = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $cleanup = Invoke-RunnerScript -ScriptPath $cleanupScript -WorkingDirectory $workspace -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $workspace, '-TempRoot', $tempRoot, @@ -252,6 +269,7 @@ UNRELATED_ENV=preserve Assert-RunnerEqual -Actual $installAgain.ExitCode -Expected 0 -Message 'Installer must be idempotent.' $environmentLines = @(Get-Content -LiteralPath $environment) Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 1 -Message 'Hook environment entry must be unique.' + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^DOTNET_INSTALL_DIR=' }).Count -Expected 1 -Message '.NET install directory entry must be unique.' Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^SMARTPIPE_CLEANUP_LABEL=' }).Count -Expected 0 -Message 'Runner labels must not be represented by an environment marker.' Assert-RunnerTrue -Condition (@($environmentLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Installer must preserve unrelated environment entries.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1')) -Message 'Installer must copy the hook.' @@ -266,7 +284,7 @@ UNRELATED_ENV=preserve Assert-RunnerEqual -Actual $uninstall.ExitCode -Expected 0 -Message "Uninstaller must succeed and restore one listener. $($uninstall.Output)" Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Uninstall must leave exactly one listener fixture.' $uninstalledLines = @(Get-Content -LiteralPath $environment) - Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' + Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^(ACTIONS_RUNNER_HOOK_JOB_COMPLETED|DOTNET_INSTALL_DIR)=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Uninstaller must preserve unrelated environment entries.' Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' $labelsAfterUninstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) From a2f360b7e7703113c1e7f9b286bf41e58be94d5f Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 00:18:14 +0500 Subject: [PATCH 09/22] fix(ci): replace unavailable hosted security gates --- .github/workflows/dependency-review.yml | 79 ++++++------- README.md | 2 +- docs/plans/2.2.0-extension-architecture.md | 2 +- .../2.2.0/SP220-00-governance-and-baseline.md | 2 +- eng/tests/workflow_contract_tests.py | 110 +++++++++++++++--- 5 files changed, 138 insertions(+), 57 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c9483ac..0d77c8e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,4 +1,4 @@ -name: Dependency Review +name: Repository security audit on: pull_request: @@ -6,52 +6,53 @@ on: permissions: contents: read - pull-requests: read jobs: - dependency-review: + repository-security-audit: + name: Repository security audit if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] + runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Dependency review - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + global-json-file: global.json - cleanup-self-hosted: - name: Cleanup self-hosted workspace - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - needs: [dependency-review] - runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] - steps: - - name: Cleanup generated outputs + - name: Restore locked + shell: pwsh + run: | + dotnet restore SmartPipe.Core.slnx --locked-mode + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Build repository checks + shell: pwsh + run: | + dotnet build eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-restore -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify repository package contracts + shell: pwsh + run: | + dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build --no-restore -- verify --profile sp220-05 --format github --failures-only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Vulnerable package scan + shell: pwsh + run: | + New-Item -ItemType Directory -Path artifacts/audit -Force | Out-Null + dotnet package list --project SmartPipe.Core.slnx --vulnerable --include-transitive --format json --output-version 1 --no-restore > artifacts/audit/vulnerable.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify direct production audit policy + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build --no-restore -- verify-nuget-audit --repo-root . --report artifacts/audit/vulnerable.json + + - name: Deprecated package scan shell: pwsh run: | - $ErrorActionPreference = 'Stop' - if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is required.' } - $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) - if ((Get-Item -LiteralPath $workspace -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Workspace is a reparse point.' } - $prefix = "$workspace$([IO.Path]::DirectorySeparatorChar)" - $targets = [Collections.Generic.List[string]]::new() - $targets.Add((Join-Path $workspace 'artifacts')) - $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) - $pending = [Collections.Generic.Stack[string]]::new() - $pending.Push($workspace) - while ($pending.Count) { - foreach ($directory in Get-ChildItem -LiteralPath $pending.Pop() -Force -Directory) { - if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } - if ($directory.Name -in 'bin', 'obj') { $targets.Add($directory.FullName) } - else { $pending.Push($directory.FullName) } - } - } - foreach ($target in $targets | Sort-Object Length -Descending -Unique) { - $fullPath = [IO.Path]::GetFullPath($target) - if (!$fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Outside workspace: $fullPath" } - if (Test-Path -LiteralPath $fullPath -PathType Container) { - if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point: $fullPath" } - if (Get-ChildItem -LiteralPath $fullPath -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { throw "Reparse point: $fullPath" } - Remove-Item -LiteralPath $fullPath -Recurse -Force - } - } + dotnet package list --project SmartPipe.Core.slnx --deprecated --include-transitive --format json --output-version 1 --no-restore > artifacts/audit/deprecated.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/README.md b/README.md index 5984acb..871f969 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ stage handling, observer events, metrics snapshots, and dead-letter records with replay context. It is not a distributed workflow engine, message broker, durable queue, or exactly-once delivery system. -[![CI](https://github.com/MrFr3di/SmartPipe-Core/actions/workflows/ci.yml/badge.svg)](https://github.com/MrFr3di/SmartPipe-Core/actions) +[CI workflow](.github/workflows/ci.yml) [![NuGet Core](https://img.shields.io/nuget/v/SmartPipe.Core.svg)](https://www.nuget.org/packages/SmartPipe.Core) [![NuGet Extensions](https://img.shields.io/nuget/v/SmartPipe.Extensions.svg)](https://www.nuget.org/packages/SmartPipe.Extensions) [![NuGet JSON Extensions](https://img.shields.io/nuget/v/SmartPipe.Extensions.Json.svg)](https://www.nuget.org/packages/SmartPipe.Extensions.Json) diff --git a/docs/plans/2.2.0-extension-architecture.md b/docs/plans/2.2.0-extension-architecture.md index 35d692a..5e13292 100644 --- a/docs/plans/2.2.0-extension-architecture.md +++ b/docs/plans/2.2.0-extension-architecture.md @@ -1838,7 +1838,7 @@ Checkpoint G: SP220-16 + 17 18. [Polly — DI pipeline registry](https://github.com/App-vNext/Polly/blob/main/src/Polly.Extensions/DependencyInjection/PollyServiceCollectionExtensions.cs) 19. [Serilog.Extensions.Hosting](https://github.com/serilog/serilog-extensions-hosting) 20. [MassTransit EntityFrameworkCore integration](https://github.com/MassTransit/MassTransit/tree/develop/src/Persistence/MassTransit.EntityFrameworkCoreIntegration) -21. [SmartPipe.Core baseline](https://github.com/MrFr3di/SmartPipe-Core/tree/8e79902d22de714f493582946f7c260462b0895e) +21. SmartPipe.Core baseline commit `8e79902d22de714f493582946f7c260462b0895e`; [tracked baseline manifest](../../eng/baselines/2.1.2/manifest.json) # 36. Финальная директива diff --git a/docs/plans/2.2.0/SP220-00-governance-and-baseline.md b/docs/plans/2.2.0/SP220-00-governance-and-baseline.md index 8130286..2df74ee 100644 --- a/docs/plans/2.2.0/SP220-00-governance-and-baseline.md +++ b/docs/plans/2.2.0/SP220-00-governance-and-baseline.md @@ -2248,7 +2248,7 @@ SP220-01 may extend `SmartPipe.RepositoryChecks` with package graph allowlists a 5. GitHub Docs — protected branches: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches 6. GitHub Docs — repository rulesets: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets 7. GitHub Docs — security hardening for Actions: https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions -8. SmartPipe repository baseline commit: https://github.com/MrFr3di/SmartPipe-Core/commit/8e79902d22de714f493582946f7c260462b0895e +8. SmartPipe repository baseline commit: `8e79902d22de714f493582946f7c260462b0895e`; tracked baseline manifest: [eng/baselines/2.1.2/manifest.json](../../../eng/baselines/2.1.2/manifest.json) --- diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 7e372db..0105b1d 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -278,6 +278,76 @@ def assert_cleanup_job( f"{workflow_name} cleanup must check direct target reparse points before recursion.") +def assert_repository_security_audit_contract(workflow: dict) -> None: + require(workflow.get("name") == "Repository security audit", + "Dependency Review workflow must identify the repository-controlled security audit.") + require(workflow.get("permissions") == {"contents": "read"}, + "Repository security audit must request only read access to repository contents.") + jobs = workflow.get("jobs", {}) + require("cleanup-self-hosted" not in jobs, + "Hosted repository security audit must not depend on self-hosted cleanup.") + job = jobs.get("repository-security-audit") + require(isinstance(job, dict), + "Dependency Review workflow must define repository-security-audit.") + require(job.get("name") == "Repository security audit", + "Repository security audit must preserve its distinct check name.") + require(job.get("if") == PULL_REQUEST_SAME_REPOSITORY_GUARD, + "Repository security audit must run only for same-repository pull requests.") + require(job.get("runs-on") == "ubuntu-latest", + "Repository security audit must use hosted Linux.") + require("self-hosted" not in str(job.get("runs-on", "")), + "Repository security audit must not use a self-hosted runner.") + + job_steps = steps(job, "Repository security audit") + checkouts = [step for step in job_steps + if str(step.get("uses", "")).startswith("actions/checkout")] + require(len(checkouts) == 1 + and checkouts[0].get("with", {}).get("persist-credentials") is False, + "Repository security audit checkout must be pinned and credential-free.") + setup = [step for step in job_steps + if str(step.get("uses", "")).startswith("actions/setup-dotnet")] + require(len(setup) == 1 + and setup[0].get("with", {}).get("global-json-file") == "global.json", + "Repository security audit setup-dotnet must use global.json as the SDK source.") + require(not any("actions/dependency-review-action" in str(step.get("uses", "")) + for step in job_steps), + "Repository security audit must not claim hosted Dependency Review execution.") + require(not any(step.get("continue-on-error") for step in job_steps), + "Repository security audit must fail closed without continue-on-error.") + + restore = named_step(job_steps, "Restore locked") + require("dotnet restore SmartPipe.Core.slnx --locked-mode" in str(restore.get("run", "")), + "Repository security audit must perform locked restore.") + build = named_step(job_steps, "Build repository checks") + require(build.get("shell") == "pwsh" + and "dotnet build eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj " + "--configuration Release --no-restore -warnaserror" in str(build.get("run", "")), + "Repository security audit must build RepositoryChecks with warnings as errors.") + profile = named_step(job_steps, "Verify repository package contracts") + require(profile.get("shell") == "pwsh" + and "dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj " + "--configuration Release --no-build --no-restore -- verify --profile sp220-05 " + "--format github --failures-only" in str(profile.get("run", "")), + "Repository security audit must run the strict repository package profile.") + vulnerable = named_step(job_steps, "Vulnerable package scan") + require(vulnerable.get("shell") == "pwsh" + and "dotnet package list --project SmartPipe.Core.slnx --vulnerable " + "--include-transitive --format json --output-version 1 --no-restore" in str(vulnerable.get("run", "")) + and "artifacts/audit/vulnerable.json" in str(vulnerable.get("run", "")), + "Repository security audit must produce a strict vulnerable package report.") + audit = named_step(job_steps, "Verify direct production audit policy") + require(audit.get("shell") == "pwsh" + and "verify-nuget-audit" in str(audit.get("run", "")) + and "--report artifacts/audit/vulnerable.json" in str(audit.get("run", "")), + "Repository security audit must enforce the repository NuGet audit policy.") + deprecated = named_step(job_steps, "Deprecated package scan") + require(deprecated.get("shell") == "pwsh" + and "dotnet package list --project SmartPipe.Core.slnx --deprecated " + "--include-transitive --format json --output-version 1 --no-restore" in str(deprecated.get("run", "")) + and "artifacts/audit/deprecated.json" in str(deprecated.get("run", "")), + "Repository security audit must report deprecated packages without suppressing failures.") + + def assert_reusable_windows_shell_contract(reusable_steps: list[dict]) -> None: release_version = named_step(reusable_steps, "Test release version validation") release_run = str(release_version.get("run", "")) @@ -594,6 +664,18 @@ def assert_link_check_exclusion_scoped() -> None: "lychee.toml must not contain a broad nuget.org exclusion.") +def assert_private_repository_docs_links_are_local() -> None: + private_repository_prefix = "https://github.com/MrFr3di/SmartPipe-Core/" + sources = ( + ROOT / "README.md", + ROOT / "docs" / "plans" / "2.2.0-extension-architecture.md", + ROOT / "docs" / "plans" / "2.2.0" / "SP220-00-governance-and-baseline.md", + ) + for source in sources: + require(private_repository_prefix not in source.read_text(encoding="utf-8"), + f"{source.relative_to(ROOT)} must use local links for private repository references.") + + def assert_consumer_contract() -> None: manifest_path = ROOT / "eng" / "consumer-scenarios.json" document = json.loads(manifest_path.read_text(encoding="utf-8")) @@ -976,12 +1058,7 @@ def validate(documents: dict[str, dict]) -> None: CLEANUP_PULL_REQUEST_GUARD, cleanup_nuget=True, ) - assert_cleanup_job( - dependency_review, - "dependency-review.yml", - ["dependency-review"], - CLEANUP_PULL_REQUEST_GUARD, - ) + assert_repository_security_audit_contract(dependency_review) assert_nuget_isolation_contract(ci, "ci.yml") assert_nuget_isolation_contract(codeql, "codeql.yml") @@ -990,12 +1067,6 @@ def validate(documents: dict[str, dict]) -> None: require_runner_expression(codeql_job, CODEQL_RUNNER, "CodeQL analyze") require_same_repository_pr_guard(codeql_job, "CodeQL analyze") assert_codeql_resource_contract(codeql_job) - dependency_review_job = dependency_review["jobs"].get("dependency-review") - require(isinstance(dependency_review_job, dict), - "Dependency Review must define the dependency-review job.") - require_self_hosted_windows(dependency_review_job, "Dependency Review") - require_same_repository_pr_guard(dependency_review_job, "Dependency Review", allow_non_pr=False) - all_runs = windows_runs + hosting_runs + reusable_runs filtered = [command for command in all_runs if "--filter-class" in command or "--filter-query" in command] @@ -1008,6 +1079,7 @@ def validate(documents: dict[str, dict]) -> None: assert_persist_credentials_disabled(documents) assert_setup_dotnet_uses_global_json(documents) assert_link_check_exclusion_scoped() + assert_private_repository_docs_links_are_local() assert_consumer_contract() version = publish["jobs"].get("version") @@ -1188,12 +1260,15 @@ def _use_hosted_runner_for_required_lanes(documents: dict[str, dict]) -> None: ("reusable-release-validation.yml", "build-test-pack"), ("reusable-release-validation.yml", "health-checks-concurrency"), ("codeql.yml", "analyze"), - ("dependency-review.yml", "dependency-review"), ) for workflow_name, job_name in lanes: documents[workflow_name]["jobs"][job_name]["runs-on"] = "windows-latest" +def _make_repository_security_audit_self_hosted(documents: dict[str, dict]) -> None: + documents["dependency-review.yml"]["jobs"]["repository-security-audit"]["runs-on"] = SELF_HOSTED_WINDOWS + + def _make_ci_validation_always_self_hosted(documents: dict[str, dict]) -> None: documents["ci.yml"]["jobs"]["validation"]["with"]["runner-labels"] = SELF_HOSTED_WINDOWS_JSON @@ -1650,6 +1725,11 @@ def main() -> int: _use_hosted_runner_for_required_lanes, "runner-labels workflow input", ) + assert_mutation_rejected( + documents, + _make_repository_security_audit_self_hosted, + "must use hosted Linux", + ) assert_mutation_rejected( documents, _make_ci_validation_always_self_hosted, @@ -1765,7 +1845,7 @@ def main() -> int: _remove_ci_cleanup_job, "must define cleanup-self-hosted", ) - for workflow_name in ("ci.yml", "codeql.yml", "dependency-review.yml"): + for workflow_name in ("ci.yml", "codeql.yml"): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _make_cleanup_non_pr_capable(docs, name), @@ -1776,7 +1856,7 @@ def main() -> int: _make_ci_cleanup_delete_workspace_root, "must not delete the workspace root", ) - for workflow_name in ("ci.yml", "codeql.yml", "dependency-review.yml"): + for workflow_name in ("ci.yml", "codeql.yml"): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_cleanup_direct_target_guard(docs, name), From 0eca5db4833ba5f95ff58b5450a03210a97ecf04 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 01:28:31 +0500 Subject: [PATCH 10/22] fix(ci): move cleanup before self-hosted jobs --- .github/workflows/codeql.yml | 65 ++------- docs/contributing.md | 16 +- .../2.2.0-branch-and-review-policy.md | 2 +- .../Commands/BaselineCaptureService.cs | 7 +- .../Commands/BaselineVerificationService.cs | 28 +++- eng/baselines/README.md | 5 +- eng/runner/install-runner.ps1 | 12 +- ...-job-cleanup.ps1 => job-start-cleanup.ps1} | 2 +- eng/runner/runner-safety.ps1 | 8 +- eng/runner/uninstall-runner.ps1 | 2 +- eng/tests/runner-contract.Tests.ps1 | 32 ++-- eng/tests/workflow_contract_tests.py | 137 +++++++----------- .../Commands/BaselineOrchestrationTests.cs | 91 +++++++++++- 13 files changed, 227 insertions(+), 180 deletions(-) rename eng/runner/{post-job-cleanup.ps1 => job-start-cleanup.ps1} (97%) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 82ade7a..5327eed 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,4 @@ -name: CodeQL +name: Hosted .NET static analysis on: push: @@ -10,15 +10,11 @@ on: permissions: contents: read - security-events: write - -env: - NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} jobs: analyze: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'ubuntu-latest' }} + name: Hosted .NET static analysis + runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -29,53 +25,14 @@ jobs: with: global-json-file: global.json - - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - with: - languages: csharp - - - name: Build - run: dotnet build SmartPipe.Core.slnx -c Release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - with: - ram: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '16384' || '' }} - threads: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '2' || '' }} + - name: Restore locked + shell: pwsh + run: | + dotnet restore SmartPipe.Core.slnx --locked-mode + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cleanup-self-hosted: - name: Cleanup self-hosted workspace - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - needs: [analyze] - runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] - steps: - - name: Cleanup generated outputs + - name: Build static analysis shell: pwsh run: | - $ErrorActionPreference = 'Stop' - if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is required.' } - $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) - if ((Get-Item -LiteralPath $workspace -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Workspace is a reparse point.' } - $prefix = "$workspace$([IO.Path]::DirectorySeparatorChar)" - $targets = [Collections.Generic.List[string]]::new() - $targets.Add((Join-Path $workspace 'artifacts')) - $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) - $targets.Add((Join-Path $workspace '.nuget')) - $pending = [Collections.Generic.Stack[string]]::new() - $pending.Push($workspace) - while ($pending.Count) { - foreach ($directory in Get-ChildItem -LiteralPath $pending.Pop() -Force -Directory) { - if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } - if ($directory.Name -in 'bin', 'obj') { $targets.Add($directory.FullName) } - else { $pending.Push($directory.FullName) } - } - } - foreach ($target in $targets | Sort-Object Length -Descending -Unique) { - $fullPath = [IO.Path]::GetFullPath($target) - if (!$fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Outside workspace: $fullPath" } - if (Test-Path -LiteralPath $fullPath -PathType Container) { - if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point: $fullPath" } - if (Get-ChildItem -LiteralPath $fullPath -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { throw "Reparse point: $fullPath" } - Remove-Item -LiteralPath $fullPath -Recurse -Force - } - } + dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/docs/contributing.md b/docs/contributing.md index b1fe4bd..bb68ac0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -111,14 +111,18 @@ part of this operation. The second owned `.env` entry points `DOTNET_INSTALL_DIR` at `_work\_tool\dotnet`, giving `actions/setup-dotnet` a writable persistent directory without granting access to `C:\Program Files\dotnet`. +The hook entry is `ACTIONS_RUNNER_HOOK_JOB_STARTED`; upgrades remove the legacy +`ACTIONS_RUNNER_HOOK_JOB_COMPLETED` entry and hook copy before writing the new +owned state. -The post-job hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout +The job-start hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout remote, and canonicalizes every target beneath the dedicated runner root. It -removes the exact checkout and the known `SmartPipe.Core`, `SmartPipe-Core`, -`CodeQL`, and `codeql` directories below `RUNNER_TEMP`. Missing targets are -successful. Any outside path, broad root, reparse point, unsafe repository, or -deletion error fails closed before removal; the existing workflow cleanup jobs -remain as defense in depth. +runs before the next job starts, after the runner has completed the previous +job's process cleanup, and removes the exact prior checkout plus the known +`SmartPipe.Core`, `SmartPipe-Core`, `CodeQL`, and `codeql` directories below +`RUNNER_TEMP`. Missing targets are successful. Any outside path, broad root, +reparse point, unsafe repository, or deletion error fails closed before +removal; the existing workflow cleanup jobs remain as defense in depth. For a compact, transition-only pull-request view: diff --git a/docs/governance/2.2.0-branch-and-review-policy.md b/docs/governance/2.2.0-branch-and-review-policy.md index d81303c..4ff6d50 100644 --- a/docs/governance/2.2.0-branch-and-review-policy.md +++ b/docs/governance/2.2.0-branch-and-review-policy.md @@ -42,7 +42,7 @@ The repository owner or administrator applies and verifies an active GitHub rule | Conversation resolution | Required | | Status checks | Required | | Branch currentness | Required, or enforced by merge queue | -| Checks | `CI / validation`, Windows JSON lane, CodeQL, Dependency Review, baseline contract | +| Checks | `CI / validation`, Windows JSON lane, Hosted .NET static analysis, Repository security audit, baseline contract | | Linear history | Disabled while merge commits are required for reviewed hotfix synchronization | | Bypass | Repository owner only; audited as described below | diff --git a/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs b/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs index 050e924..d3b38f1 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs @@ -325,7 +325,12 @@ private static async Task> ReadWorkflowEvidenceA } var workflows = new List(3); - foreach (var requiredName in new[] { "CI", "CodeQL", "Dependency Review" }) + foreach (var requiredName in new[] + { + "CI", + "Hosted .NET static analysis", + "Repository security audit", + }) { var successful = runs.Where(run => string.Equals(run.WorkflowName, requiredName, StringComparison.Ordinal) diff --git a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs index 7b6db9f..ec85418 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs @@ -45,11 +45,25 @@ internal sealed class BaselineVerificationService private const string TargetRelease = "2.2.0"; private const string SolutionPath = "SmartPipe.Core.slnx"; private static readonly TimeSpan ProcessTimeout = TimeSpan.FromMinutes(2); - private static readonly (string Name, string Path, string[] Events)[] RequiredWorkflowFiles = + private static readonly string[] HistoricalManifestWorkflowNames = + [ + "CI", + "CodeQL", + "Dependency Review", + ]; + + private static readonly string[] CurrentManifestWorkflowNames = + [ + "CI", + "Hosted .NET static analysis", + "Repository security audit", + ]; + + private static readonly (string Name, string Path, string[] Events)[] CurrentWorkflowPolicy = [ ("CI", ".github/workflows/ci.yml", ["push", "pull_request"]), - ("CodeQL", ".github/workflows/codeql.yml", ["push", "pull_request"]), - ("Dependency Review", ".github/workflows/dependency-review.yml", ["pull_request"]), + ("Hosted .NET static analysis", ".github/workflows/codeql.yml", ["push", "pull_request"]), + ("Repository security audit", ".github/workflows/dependency-review.yml", ["pull_request"]), ]; private readonly IProcessRunner _processRunner; @@ -118,9 +132,11 @@ internal async Task VerifyAsync( var workflowNames = manifest.Repository.RequiredWorkflows .Select(static workflow => workflow.Name) .ToHashSet(StringComparer.Ordinal); - if (RequiredWorkflowFiles.Any(workflow => !workflowNames.Contains(workflow.Name))) + if (!workflowNames.SetEquals(HistoricalManifestWorkflowNames) + && !workflowNames.SetEquals(CurrentManifestWorkflowNames)) { - throw new JsonException("Manifest workflow evidence must include CI, CodeQL, and Dependency Review."); + throw new JsonException( + "Manifest workflow evidence must contain exactly either CI, CodeQL, and Dependency Review or CI, Hosted .NET static analysis, and Repository security audit."); } // Resolve and de-alias every referenced path before any package, process, or repository work. @@ -313,7 +329,7 @@ internal async Task VerifyAsync( } var releaseBranch = $"release/{manifest.TargetRelease}"; - foreach (var workflow in RequiredWorkflowFiles) + foreach (var workflow in CurrentWorkflowPolicy) { var path = RepositoryPaths.ResolveWithinRoot(options.RepositoryRoot, workflow.Path, "workflow"); if (!WorkflowPolicyContainsBranch(path, workflow.Events, releaseBranch)) diff --git a/eng/baselines/README.md b/eng/baselines/README.md index c0200e1..203ac38 100644 --- a/eng/baselines/README.md +++ b/eng/baselines/README.md @@ -26,8 +26,9 @@ The manifest rejects unknown properties and schema versions. `repository.capture - `SPB007`-`SPB010`: package hash, signature, identity/assets, or dependencies mismatch; - `SPB014`: public API snapshot mismatch; - `SPB015`: repository dependency snapshot mismatch; -- `SPB016`: required release branch missing from CI, CodeQL, or Dependency Review workflow policy. +- `SPB016`: required release branch missing from CI, Hosted .NET static analysis, or Repository security audit workflow policy. Offline verification never fetches packages. It requires the capture commit to exist and be an ancestor of current HEAD, failing closed for unrelated or missing/shallow history. It hashes package bytes before signature or archive inspection and ignores unreferenced files in the baseline directory. -Capture consumes the literal JSON array produced by `gh run list --json databaseId,workflowName,headSha,status,conclusion,url,event,createdAt`. Every returned run must target the requested capture commit; that `headSha` is retained in each workflow manifest entry, and exactly one completed successful run is required for each of CI, CodeQL, and Dependency Review. Workflow policy verification uses a bounded parser for the repository's current YAML shape and checks `release/2.2.0` in the actual `on.push.branches` and/or `on.pull_request.branches` lists; comments, environment values, and step text do not count. +Capture consumes the literal JSON array produced by `gh run list --json databaseId,workflowName,headSha,status,conclusion,url,event,createdAt`. Every returned run must target the requested capture commit; that `headSha` is retained in each workflow manifest entry, and exactly one completed successful run is required for each of CI, Hosted .NET static analysis, and Repository security audit. Workflow policy verification uses a bounded parser for the repository's current YAML shape and checks `release/2.2.0` in the actual `on.push.branches` and/or `on.pull_request.branches` lists; comments, environment values, and step text do not count. +Capture requires those current check names exactly and persists those literal names. Offline verification accepts only a complete historical manifest set (`CI`, `CodeQL`, `Dependency Review`) or a complete current set; mixed or extra workflow identities fail closed. diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 index 5e8ff82..7dfbd4e 100644 --- a/eng/runner/install-runner.ps1 +++ b/eng/runner/install-runner.ps1 @@ -38,7 +38,7 @@ try { $environmentPath = Join-Path $runner '.env' Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath $hookDirectory = Join-Path $runner 'hooks' - foreach ($name in @('smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { + foreach ($name in @('smartpipe-job-start-cleanup.ps1', 'smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { $path = Join-Path $hookDirectory $name if (Test-Path -LiteralPath $path) { Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner @@ -53,7 +53,7 @@ try { exit 0 } - $hookSource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'post-job-cleanup.ps1') + $hookSource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'job-start-cleanup.ps1') $safetySource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'runner-safety.ps1') if (-not (Test-Path -LiteralPath $hookSource -PathType Leaf) -or -not (Test-Path -LiteralPath $safetySource -PathType Leaf)) { @@ -66,7 +66,13 @@ try { } Assert-SmartPipeNoReparsePath -Path $hookDirectory -Boundary $runner - $hookDestination = Join-Path $hookDirectory 'smartpipe-post-job-cleanup.ps1' + $legacyHookDestination = Join-Path $hookDirectory 'smartpipe-post-job-cleanup.ps1' + if (Test-Path -LiteralPath $legacyHookDestination) { + Assert-SmartPipeNoReparsePath -Path $legacyHookDestination -Boundary $runner + Remove-Item -LiteralPath $legacyHookDestination -Force -ErrorAction Stop + } + + $hookDestination = Join-Path $hookDirectory 'smartpipe-job-start-cleanup.ps1' $safetyDestination = Join-Path $hookDirectory 'runner-safety.ps1' Copy-Item -LiteralPath $hookSource -Destination $hookDestination -Force Copy-Item -LiteralPath $safetySource -Destination $safetyDestination -Force diff --git a/eng/runner/post-job-cleanup.ps1 b/eng/runner/job-start-cleanup.ps1 similarity index 97% rename from eng/runner/post-job-cleanup.ps1 rename to eng/runner/job-start-cleanup.ps1 index 8f34f82..9cc69fa 100644 --- a/eng/runner/post-job-cleanup.ps1 +++ b/eng/runner/job-start-cleanup.ps1 @@ -56,7 +56,7 @@ try { } } - Write-Output 'SmartPipe post-job cleanup completed.' + Write-Output 'SmartPipe job-start cleanup completed.' } catch { $errorText = [string]$_.Exception.Message diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 index d82b0ff..241cda8 100644 --- a/eng/runner/runner-safety.ps1 +++ b/eng/runner/runner-safety.ps1 @@ -691,17 +691,19 @@ function Write-SmartPipeEnvironment { $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath $owned = @{ - 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED' = $HookPath + 'ACTIONS_RUNNER_HOOK_JOB_STARTED' = $HookPath 'DOTNET_INSTALL_DIR' = $DotNetInstallDirectory } - foreach ($key in $owned.Keys) { + foreach ($key in @('ACTIONS_RUNNER_HOOK_JOB_STARTED', 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR')) { for ($index = $lines.Count - 1; $index -ge 0; $index--) { if ($lines[$index] -match "^\s*${key}=") { $lines.RemoveAt($index) } } + } + foreach ($key in $owned.Keys) { $lines.Add("$key=$($owned[$key])") } @@ -721,7 +723,7 @@ function Remove-SmartPipeEnvironment { } $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath - $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR') + $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_STARTED', 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR') for ($index = $lines.Count - 1; $index -ge 0; $index--) { foreach ($key in $ownedKeys) { if ($lines[$index] -match "^\s*${key}=") { diff --git a/eng/runner/uninstall-runner.ps1 b/eng/runner/uninstall-runner.ps1 index fc29aad..76e3484 100644 --- a/eng/runner/uninstall-runner.ps1 +++ b/eng/runner/uninstall-runner.ps1 @@ -33,7 +33,7 @@ try { Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath $hookDirectory = Join-Path $runner 'hooks' - foreach ($name in @('smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { + foreach ($name in @('smartpipe-job-start-cleanup.ps1', 'smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { $path = Join-Path $hookDirectory $name if (Test-Path -LiteralPath $path) { Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index 97f06fa..cd01c01 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -3,7 +3,7 @@ param() $ErrorActionPreference = 'Stop' $runnerScriptRoot = Join-Path $PSScriptRoot '..\runner' -$cleanupScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'post-job-cleanup.ps1')) +$jobStartScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'job-start-cleanup.ps1')) $installScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'install-runner.ps1')) $uninstallScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'uninstall-runner.ps1')) $monitorScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'monitor-pr.ps1')) @@ -85,14 +85,14 @@ try { 'known codeql temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'CodeQL\cache.txt') 'unrelated temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'unrelated.tmp') - $cleanup = Invoke-RunnerScript -ScriptPath $cleanupScript -WorkingDirectory $workspace -Arguments @( + $cleanup = Invoke-RunnerScript -ScriptPath $jobStartScript -WorkingDirectory $workspace -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $workspace, '-TempRoot', $tempRoot, '-Repository', 'MrFr3di/SmartPipe-Core', '-AllowTestRoot' ) - Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Post-job cleanup must succeed for a valid checkout. $($cleanup.Output)" + Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Job-start cleanup must succeed for a valid checkout. $($cleanup.Output)" Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath $workspace)) -Message 'The exact checkout must be removed.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $toolRoot 'preserve.txt')) -Message '_tool must be preserved.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $sibling 'preserve.txt')) -Message 'Sibling repositories must be preserved.' @@ -101,7 +101,7 @@ try { Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'CodeQL'))) -Message 'Known CodeQL temp must be removed.' $absentWorkspace = Join-Path $runnerRoot '_work\SmartPipe.Core\absent' - $absent = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $absent = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $absentWorkspace, '-TempRoot', $tempRoot, @@ -118,7 +118,7 @@ try { [remote "upstream"] url = https://github.com/MrFr3di/SmartPipe-Core.git '@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') - $wrongRepo = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $wrongRepo = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $workspace, '-TempRoot', $tempRoot, @@ -130,7 +130,7 @@ try { $outside = Join-Path $fixture 'outside' New-Item -ItemType Directory -Path $outside -Force | Out-Null - $outsideResult = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $outsideResult = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $outside, '-TempRoot', $tempRoot, @@ -155,7 +155,7 @@ try { Write-Output 'Runner contract: symbolic-link fixture unavailable; reparse refusal remains covered by workflow cleanup contracts.' } if ($reparseCreated) { - $reparse = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $reparse = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $workspace, '-TempRoot', $tempRoot, @@ -217,7 +217,10 @@ elseif ($null -eq $response) { $environment = Join-Path $runnerRoot '.env' @' UNRELATED_ENV=preserve +ACTIONS_RUNNER_HOOK_JOB_COMPLETED=C:\legacy\smartpipe-post-job-cleanup.ps1 '@ | Set-Content -LiteralPath $environment + New-Item -ItemType Directory -Path (Join-Path $runnerRoot 'hooks') -Force | Out-Null + 'legacy hook' | Set-Content -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1') New-Item -ItemType File -Path $queuedFlag -Force | Out-Null $queuedInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( @@ -229,7 +232,7 @@ UNRELATED_ENV=preserve '-AllowTestRoot' ) Assert-RunnerTrue -Condition ($queuedInstall.ExitCode -ne 0) -Message "Installer must refuse queued Actions runs before mutation. $($queuedInstall.Output)" - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Queued-run refusal must not copy the hook.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'Queued-run refusal must not copy the hook.' Remove-Item -LiteralPath $queuedFlag -Force New-Item -ItemType File -Path $inProgressFlag -Force | Out-Null @@ -242,7 +245,7 @@ UNRELATED_ENV=preserve '-AllowTestRoot' ) Assert-RunnerTrue -Condition ($inProgressInstall.ExitCode -ne 0) -Message "Installer must refuse in-progress Actions runs before mutation. $($inProgressInstall.Output)" - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'In-progress refusal must not copy the hook.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'In-progress refusal must not copy the hook.' Remove-Item -LiteralPath $inProgressFlag -Force New-Item -ItemType File -Path $offlineFlag -Force | Out-Null @@ -268,11 +271,13 @@ UNRELATED_ENV=preserve ) Assert-RunnerEqual -Actual $installAgain.ExitCode -Expected 0 -Message 'Installer must be idempotent.' $environmentLines = @(Get-Content -LiteralPath $environment) - Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 1 -Message 'Hook environment entry must be unique.' + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_STARTED=' }).Count -Expected 1 -Message 'Job-start hook environment entry must be unique.' + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 0 -Message 'Legacy job-completed hook environment entry must be removed.' Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^DOTNET_INSTALL_DIR=' }).Count -Expected 1 -Message '.NET install directory entry must be unique.' Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^SMARTPIPE_CLEANUP_LABEL=' }).Count -Expected 0 -Message 'Runner labels must not be represented by an environment marker.' Assert-RunnerTrue -Condition (@($environmentLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Installer must preserve unrelated environment entries.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1')) -Message 'Installer must copy the hook.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1')) -Message 'Installer must copy the job-start hook.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Installer must remove the legacy hook copy.' $uninstall = Invoke-RunnerScript -ScriptPath $uninstallScript -Arguments @( '-RunnerRoot', $runnerRoot, @@ -284,9 +289,10 @@ UNRELATED_ENV=preserve Assert-RunnerEqual -Actual $uninstall.ExitCode -Expected 0 -Message "Uninstaller must succeed and restore one listener. $($uninstall.Output)" Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Uninstall must leave exactly one listener fixture.' $uninstalledLines = @(Get-Content -LiteralPath $environment) - Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^(ACTIONS_RUNNER_HOOK_JOB_COMPLETED|DOTNET_INSTALL_DIR)=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' + Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^(ACTIONS_RUNNER_HOOK_JOB_STARTED|ACTIONS_RUNNER_HOOK_JOB_COMPLETED|DOTNET_INSTALL_DIR)=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Uninstaller must preserve unrelated environment entries.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the legacy hook copy.' $labelsAfterUninstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) Assert-RunnerTrue -Condition ('smartpipe-cleanup-v1' -notin $labelsAfterUninstall) -Message 'Uninstaller must remove only the owned cleanup label.' Assert-RunnerTrue -Condition ('existing-label' -in $labelsAfterUninstall) -Message 'Uninstaller must preserve unrelated runner labels.' diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 0105b1d..5b5926f 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -67,21 +67,6 @@ "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " "'windows-latest' }}" ) -CODEQL_RUNNER = ( - "${{ github.event_name == 'pull_request' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " - "'ubuntu-latest' }}" -) -CODEQL_PR_RAM = ( - "${{ github.event_name == 'pull_request' && " - "github.event.pull_request.head.repo.full_name == github.repository && " - "'16384' || '' }}" -) -CODEQL_PR_THREADS = ( - "${{ github.event_name == 'pull_request' && " - "github.event.pull_request.head.repo.full_name == github.repository && " - "'2' || '' }}" -) NUGET_PACKAGES_PR = ( "${{ github.event_name == 'pull_request' && " "format('{0}/.nuget/packages', github.workspace) || '' }}" @@ -129,13 +114,46 @@ def require_runner_expression(job: dict, expected: str, label: str) -> None: f"{label} must use the event-aware runner expression.") -def assert_codeql_resource_contract(job: dict) -> None: - analysis = named_step(steps(job, "CodeQL analyze"), "Perform CodeQL Analysis") - inputs = analysis.get("with") - require(isinstance(inputs, dict) - and inputs.get("ram") == CODEQL_PR_RAM - and inputs.get("threads") == CODEQL_PR_THREADS, - "CodeQL analyze resource cap must be limited to same-repository Windows pull requests.") +def assert_static_analysis_contract(workflow: dict) -> None: + require(workflow.get("name") == "Hosted .NET static analysis", + "Static-analysis workflow must identify the hosted .NET analyzer check honestly.") + require(workflow.get("permissions") == {"contents": "read"}, + "Static analysis must request only read access to repository contents.") + serialized = json.dumps(workflow).lower() + for forbidden in ("security-events", "codeql", "self-hosted", "cleanup-self-hosted"): + require(forbidden not in serialized, + f"Hosted static analysis must not retain {forbidden} configuration.") + + jobs = workflow.get("jobs", {}) + require(set(jobs) == {"analyze"}, + "Hosted static analysis must define only the analyzer job.") + job = jobs["analyze"] + require(job.get("name") == "Hosted .NET static analysis", + "Static analysis job must preserve its distinct check name.") + require(job.get("runs-on") == "ubuntu-latest", + "Static analysis must use hosted Linux.") + static_steps = steps(job, "Hosted .NET static analysis") + checkout = next( + step for step in static_steps + if str(step.get("uses", "")).startswith("actions/checkout") + ) + require(checkout.get("with", {}).get("persist-credentials") is False, + "Static analysis checkout must disable persisted credentials.") + setup = named_step(static_steps, "Setup .NET") + require(setup.get("with", {}).get("global-json-file") == "global.json", + "Static analysis must use the pinned SDK from global.json.") + restore = named_step(static_steps, "Restore locked") + restore_run = str(restore.get("run", "")) + require(restore.get("shell") == "pwsh" + and "dotnet restore SmartPipe.Core.slnx --locked-mode" in restore_run + and NATIVE_FAIL_FAST_GUARD in restore_run, + "Static analysis must perform a fail-closed locked restore.") + build = named_step(static_steps, "Build static analysis") + build_run = str(build.get("run", "")) + require(build.get("shell") == "pwsh" + and "dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror" in build_run + and NATIVE_FAIL_FAST_GUARD in build_run, + "Static analysis must use the existing analyzers with a fail-closed warnings-as-errors build.") def assert_nuget_isolation_contract(workflow: dict, workflow_name: str) -> None: @@ -728,13 +746,13 @@ def assert_consumer_contract() -> None: def validate(documents: dict[str, dict]) -> None: reusable = documents["reusable-release-validation.yml"] ci = documents["ci.yml"] - codeql = documents["codeql.yml"] + static_analysis = documents["codeql.yml"] dependency_review = documents["dependency-review.yml"] publish = documents["publish-nuget.yml"] for workflow_name, workflow in ( ("ci.yml", ci), - ("codeql.yml", codeql), + ("codeql.yml", static_analysis), ("dependency-review.yml", dependency_review), ): branches = workflow.get("on", {}).get("pull_request", {}).get("branches", []) @@ -1051,22 +1069,9 @@ def validate(documents: dict[str, dict]) -> None: CLEANUP_PULL_REQUEST_GUARD, cleanup_nuget=True, ) - assert_cleanup_job( - codeql, - "codeql.yml", - ["analyze"], - CLEANUP_PULL_REQUEST_GUARD, - cleanup_nuget=True, - ) assert_repository_security_audit_contract(dependency_review) assert_nuget_isolation_contract(ci, "ci.yml") - assert_nuget_isolation_contract(codeql, "codeql.yml") - - codeql_job = codeql["jobs"].get("analyze") - require(isinstance(codeql_job, dict), "CodeQL must define the analyze job.") - require_runner_expression(codeql_job, CODEQL_RUNNER, "CodeQL analyze") - require_same_repository_pr_guard(codeql_job, "CodeQL analyze") - assert_codeql_resource_contract(codeql_job) + assert_static_analysis_contract(static_analysis) all_runs = windows_runs + hosting_runs + reusable_runs filtered = [command for command in all_runs if "--filter-class" in command or "--filter-query" in command] @@ -1259,7 +1264,6 @@ def _use_hosted_runner_for_required_lanes(documents: dict[str, dict]) -> None: ("ci.yml", "baseline-contract-windows"), ("reusable-release-validation.yml", "build-test-pack"), ("reusable-release-validation.yml", "health-checks-concurrency"), - ("codeql.yml", "analyze"), ) for workflow_name, job_name in lanes: documents[workflow_name]["jobs"][job_name]["runs-on"] = "windows-latest" @@ -1292,36 +1296,10 @@ def _make_ci_baseline_always_self_hosted(documents: dict[str, dict]) -> None: documents["ci.yml"]["jobs"]["baseline-contract-windows"]["runs-on"] = SELF_HOSTED_WINDOWS -def _make_codeql_always_self_hosted(documents: dict[str, dict]) -> None: +def _make_static_analysis_always_self_hosted(documents: dict[str, dict]) -> None: documents["codeql.yml"]["jobs"]["analyze"]["runs-on"] = SELF_HOSTED_WINDOWS -def _remove_codeql_resource_cap(documents: dict[str, dict]) -> None: - analysis = named_step( - documents["codeql.yml"]["jobs"]["analyze"]["steps"], - "Perform CodeQL Analysis", - ) - analysis["with"].pop("ram", None) - - -def _make_codeql_resource_cap_unconditional(documents: dict[str, dict]) -> None: - analysis = named_step( - documents["codeql.yml"]["jobs"]["analyze"]["steps"], - "Perform CodeQL Analysis", - ) - analysis["with"]["ram"] = "16384" - analysis["with"]["threads"] = "2" - - -def _make_codeql_resource_cap_linux_wide(documents: dict[str, dict]) -> None: - analysis = named_step( - documents["codeql.yml"]["jobs"]["analyze"]["steps"], - "Perform CodeQL Analysis", - ) - analysis["with"]["ram"] = str(analysis["with"]["ram"]).replace("|| ''", "|| '16384'") - analysis["with"]["threads"] = str(analysis["with"]["threads"]).replace("|| ''", "|| '2'") - - def _remove_nuget_isolation(documents: dict[str, dict], workflow_name: str) -> None: documents[workflow_name]["env"].pop("NUGET_PACKAGES", None) @@ -1757,25 +1735,10 @@ def main() -> int: ) assert_mutation_rejected( documents, - _make_codeql_always_self_hosted, - "event-aware runner expression", - ) - assert_mutation_rejected( - documents, - _remove_codeql_resource_cap, - "CodeQL analyze resource cap", - ) - assert_mutation_rejected( - documents, - _make_codeql_resource_cap_unconditional, - "CodeQL analyze resource cap", - ) - assert_mutation_rejected( - documents, - _make_codeql_resource_cap_linux_wide, - "CodeQL analyze resource cap", + _make_static_analysis_always_self_hosted, + "must not retain self-hosted", ) - for workflow_name in ("ci.yml", "codeql.yml", "reusable-release-validation.yml"): + for workflow_name in ("ci.yml", "reusable-release-validation.yml"): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_nuget_isolation(docs, name), @@ -1845,7 +1808,7 @@ def main() -> int: _remove_ci_cleanup_job, "must define cleanup-self-hosted", ) - for workflow_name in ("ci.yml", "codeql.yml"): + for workflow_name in ("ci.yml",): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _make_cleanup_non_pr_capable(docs, name), @@ -1856,13 +1819,13 @@ def main() -> int: _make_ci_cleanup_delete_workspace_root, "must not delete the workspace root", ) - for workflow_name in ("ci.yml", "codeql.yml"): + for workflow_name in ("ci.yml",): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_cleanup_direct_target_guard(docs, name), f"{workflow_name} cleanup must reject direct target reparse points", ) - for workflow_name in ("ci.yml", "codeql.yml"): + for workflow_name in ("ci.yml",): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_cleanup_nuget_target(docs, name), diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs index 177b94d..8edf644 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs @@ -111,6 +111,22 @@ public async Task Capture_PersistsExactCaptureAndWorkflowCommitIdentity() Assert.Null(root["repository"]!["commitSha"]); } + [Fact] + public async Task Capture_AcceptsAndPersistsCurrentWorkflowNames() + { + using var scenario = new BaselineScenario(); + + await scenario.CaptureAsync(TestContext.Current.CancellationToken); + var root = JsonNode.Parse(await File.ReadAllTextAsync( + scenario.ManifestPath, TestContext.Current.CancellationToken))!.AsObject(); + + Assert.Equal( + ["CI", "Hosted .NET static analysis", "Repository security audit"], + root["repository"]!["requiredWorkflows"]!.AsArray() + .Select(workflow => workflow!["name"]!.GetValue()) + .Order(StringComparer.Ordinal)); + } + [Fact] public async Task DescendantGovernanceHead_VerifiesByCaptureCommitAncestry() { @@ -267,6 +283,18 @@ public async Task Capture_RejectsDuplicateSuccessfulWorkflowEvidenceAsAmbiguous( Assert.Contains("exactly one", exception.Message, StringComparison.Ordinal); } + [Fact] + public async Task Capture_RejectsHistoricalSecurityWorkflowName() + { + using var scenario = new BaselineScenario(); + scenario.WriteWorkflowEvidence("historical-security-name"); + + var exception = await Assert.ThrowsAsync( + () => scenario.CaptureAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("Repository security audit", exception.Message, StringComparison.Ordinal); + } + [Fact] public async Task FailedCapture_DoesNotReplaceExistingBaseline() { @@ -309,6 +337,62 @@ public async Task ManifestMutation_Fails() Assert.Contains(result.Diagnostics, item => item.Code == "SPB001"); } + [Fact] + public async Task HistoricalManifestWorkflowNamesReachNormalIntegrityDiagnostics() + { + using var scenario = new BaselineScenario(); + await scenario.CaptureAsync(TestContext.Current.CancellationToken); + var manifest = BaselineManifestSerializer.Deserialize( + await File.ReadAllTextAsync(scenario.ManifestPath, TestContext.Current.CancellationToken)); + var historicalManifest = manifest with + { + Repository = manifest.Repository with + { + RequiredWorkflows = manifest.Repository.RequiredWorkflows + .Select(workflow => workflow with + { + Name = workflow.Name switch + { + "Hosted .NET static analysis" => "CodeQL", + "Repository security audit" => "Dependency Review", + _ => workflow.Name, + }, + }) + .ToArray(), + }, + }; + await BaselineManifestSerializer.WriteAsync( + scenario.ManifestPath, historicalManifest, TestContext.Current.CancellationToken); + await File.WriteAllBytesAsync( + Path.Combine(scenario.BaselinePath, "baseline-report.md"), + BaselineReport.Create(historicalManifest), TestContext.Current.CancellationToken); + await File.AppendAllTextAsync(scenario.PublicApiPath, "\nHistorical.Api", TestContext.Current.CancellationToken); + + var result = await scenario.VerifyAsync(); + + Assert.DoesNotContain(result.Diagnostics, item => item.Code == "SPB001"); + Assert.Contains(result.Diagnostics, item => item.Code == "SPB014"); + } + + [Theory] + [InlineData("CodeQL")] + [InlineData("Unexpected workflow")] + public async Task NonCompleteManifestWorkflowNamesFailSchemaValidation(string replacementName) + { + using var scenario = new BaselineScenario(); + await scenario.CaptureAsync(TestContext.Current.CancellationToken); + var root = JsonNode.Parse(await File.ReadAllTextAsync( + scenario.ManifestPath, TestContext.Current.CancellationToken))!.AsObject(); + var workflows = root["repository"]!["requiredWorkflows"]!.AsArray(); + workflows.Single(workflow => workflow!["name"]!.GetValue() == "Hosted .NET static analysis")!["name"] = replacementName; + await File.WriteAllTextAsync( + scenario.ManifestPath, root.ToJsonString(), TestContext.Current.CancellationToken); + + var result = await scenario.VerifyAsync(); + + Assert.Contains(result.Diagnostics, item => item.Code == "SPB001"); + } + [Fact] public async Task PackageByteMutation_FailsBeforeParsing() { @@ -709,6 +793,9 @@ public void WriteWorkflowEvidence(string mutation) var ciSha = mutation == "mixed-sha" ? new string('a', 40) : Sha; var ciStatus = mutation == "pending" ? "in_progress" : "completed"; var ciConclusion = mutation == "pending" ? string.Empty : mutation == "failed" ? "failure" : "success"; + var securityWorkflowName = mutation == "historical-security-name" + ? "Dependency Review" + : "Repository security audit"; var extra = mutation switch { "extra-pending" => $$""" @@ -728,8 +815,8 @@ public void WriteWorkflowEvidence(string mutation) var evidence = $$""" [ {"databaseId":1,"workflowName":"CI","headSha":"{{ciSha}}","status":"{{ciStatus}}","conclusion":"{{ciConclusion}}","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/1","event":"push","createdAt":"2026-07-17T00:00:00Z"}, - {"databaseId":2,"workflowName":"CodeQL","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/2","event":"push","createdAt":"2026-07-17T00:01:00Z"}, - {"databaseId":3,"workflowName":"Dependency Review","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/3","event":"pull_request","createdAt":"2026-07-17T00:02:00Z"}{{extra}} + {"databaseId":2,"workflowName":"Hosted .NET static analysis","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/2","event":"push","createdAt":"2026-07-17T00:01:00Z"}, + {"databaseId":3,"workflowName":"{{securityWorkflowName}}","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/3","event":"pull_request","createdAt":"2026-07-17T00:02:00Z"}{{extra}} ] """; File.WriteAllText(WorkflowEvidencePath, evidence); From c27b9a344bcfbbc9e87eef0c04c2d8ba4526eda9 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 01:57:24 +0500 Subject: [PATCH 11/22] fix(ci): fail closed on ambiguous runner listeners --- docs/contributing.md | 5 +- eng/runner/install-runner.ps1 | 1 + eng/runner/runner-safety.ps1 | 151 ++++++++++++++++++++++++---- eng/runner/uninstall-runner.ps1 | 1 + eng/tests/runner-contract.Tests.ps1 | 18 ++++ 5 files changed, 155 insertions(+), 21 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index bb68ac0..a82189e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -113,7 +113,10 @@ writable persistent directory without granting access to `C:\Program Files\dotnet`. The hook entry is `ACTIONS_RUNNER_HOOK_JOB_STARTED`; upgrades remove the legacy `ACTIONS_RUNNER_HOOK_JOB_COMPLETED` entry and hook copy before writing the new -owned state. +owned state. Before any file, label, stop, or restart mutation, every +`Runner.Listener.exe` must be classifiable to this exact root. Missing or +unreadable process metadata and listeners belonging to another root fail closed +with their PIDs; ambiguous listeners are never stopped automatically. The job-start hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout remote, and canonicalizes every target beneath the dedicated runner root. It diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 index 7dfbd4e..9931e1b 100644 --- a/eng/runner/install-runner.ps1 +++ b/eng/runner/install-runner.ps1 @@ -33,6 +33,7 @@ try { Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath + Assert-SmartPipeListenerSafety -Root $runner -FixturePath $ListenerFixturePath if ($Uninstall) { $environmentPath = Join-Path $runner '.env' diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 index 241cda8..daf99a3 100644 --- a/eng/runner/runner-safety.ps1 +++ b/eng/runner/runner-safety.ps1 @@ -317,6 +317,48 @@ function Assert-SmartPipeCanonicalRemote { throw "Workspace origin remote is not MrFr3di/SmartPipe-Core: $Workspace" } +function Get-SmartPipeListenerClassification { + param( + [Parameter(Mandatory = $true)] + [object] $Listener, + + [Parameter(Mandatory = $true)] + [string] $Root + ) + + $runnerRoot = Get-SmartPipeFullPath -Path $Root + $executablePath = '' + $executableReadable = $true + try { + $executablePath = [string]$Listener.ExecutablePath + } + catch { + $executableReadable = $false + } + + if (-not $executableReadable -or [string]::IsNullOrWhiteSpace($executablePath)) { + return 'unclassified' + } + try { + if (-not [IO.Path]::IsPathFullyQualified($executablePath)) { + return 'unclassified' + } + } + catch { + return 'unclassified' + } + + try { + if (Test-SmartPipeContainedPath -Path $executablePath -Boundary $runnerRoot) { + return 'exact' + } + return 'outside' + } + catch { + return 'unclassified' + } +} + function Get-SmartPipeListenerProcesses { param( [Parameter(Mandatory = $true)] @@ -325,41 +367,110 @@ function Get-SmartPipeListenerProcesses { [string] $FixturePath = '' ) + $listenerRecords = @() if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { if (-not (Test-Path -LiteralPath $FixturePath -PathType Leaf)) { return @() } $text = (Get-Content -LiteralPath $FixturePath -Raw -ErrorAction Stop).Trim() - $count = 0 - if (-not [int]::TryParse($text, [Globalization.NumberStyles]::Integer, [Globalization.CultureInfo]::InvariantCulture, [ref]$count) -or $count -lt 0) { - throw "Invalid listener fixture state: $FixturePath" + $runnerRoot = Get-SmartPipeFullPath -Path $Root + $fixtureExecutable = Join-Path $runnerRoot 'bin\Runner.Listener.exe' + if ($text -eq 'unclassified-duplicate') { + $listenerRecords = @( + [pscustomobject]@{ + ProcessId = 4101 + Name = 'Runner.Listener.exe' + ExecutablePath = $fixtureExecutable + CommandLine = $fixtureExecutable + }, + [pscustomobject]@{ + ProcessId = 4102 + Name = 'Runner.Listener.exe' + ExecutablePath = $null + CommandLine = "-RunnerRoot $runnerRoot" + } + ) } + else { + $count = 0 + if (-not [int]::TryParse($text, [Globalization.NumberStyles]::Integer, [Globalization.CultureInfo]::InvariantCulture, [ref]$count) -or $count -lt 0) { + throw "Invalid listener fixture state: $FixturePath" + } - $fixtureListeners = [Collections.Generic.List[object]]::new() - for ($index = 1; $index -le $count; $index++) { - [void]$fixtureListeners.Add([pscustomobject]@{ - ProcessId = 0 - Name = 'Runner.Listener.fixture' - CommandLine = $Root + $fixtureListeners = [Collections.Generic.List[object]]::new() + for ($index = 1; $index -le $count; $index++) { + [void]$fixtureListeners.Add([pscustomobject]@{ + ProcessId = 0 + Name = 'Runner.Listener.exe' + ExecutablePath = $fixtureExecutable + CommandLine = $fixtureExecutable + }) + } + $listenerRecords = @($fixtureListeners) + } + } + else { + try { + $listenerRecords = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Where-Object { + $_.Name -in @('Runner.Listener.exe', 'Runner.Listener') }) } - return @($fixtureListeners) + catch { + if ($IsWindows) { + throw "Unable to inspect listener processes for $Root." + } + return @() + } } - try { - $escapedRoot = [Regex]::Escape((Get-SmartPipeFullPath -Path $Root)) - return @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Where-Object { - $_.Name -in @('Runner.Listener.exe', 'Runner.Listener') -and - $_.CommandLine -match $escapedRoot - }) + $exactListeners = [Collections.Generic.List[object]]::new() + $unclassifiedIds = [Collections.Generic.List[string]]::new() + $outsideIds = [Collections.Generic.List[string]]::new() + foreach ($listener in $listenerRecords) { + $processId = $null + try { + $processId = $listener.ProcessId + } + catch { + $processId = $null + } + $processIdText = if ($null -eq $processId -or [string]::IsNullOrWhiteSpace([string]$processId)) { 'unknown' } else { [string]$processId } + $classification = Get-SmartPipeListenerClassification -Listener $listener -Root $Root + if ($classification -eq 'exact') { + [void]$exactListeners.Add($listener) + } + elseif ($classification -eq 'outside') { + [void]$outsideIds.Add($processIdText) + } + else { + [void]$unclassifiedIds.Add($processIdText) + } } - catch { - if ($IsWindows) { - throw "Unable to inspect listener processes for $Root." + + if ($unclassifiedIds.Count -gt 0 -or $outsideIds.Count -gt 0) { + $details = [Collections.Generic.List[string]]::new() + if ($unclassifiedIds.Count -gt 0) { + [void]$details.Add("unclassified Runner.Listener PID(s): $($unclassifiedIds -join ', ')") } - return @() + if ($outsideIds.Count -gt 0) { + [void]$details.Add("Runner.Listener outside '$Root' PID(s): $($outsideIds -join ', ')") + } + throw "Runner listener safety check failed for '$Root': $($details -join '; '). No listener was stopped." } + + return @($exactListeners) +} + +function Assert-SmartPipeListenerSafety { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $FixturePath = '' + ) + + $null = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) } function Stop-SmartPipeListenerProcesses { diff --git a/eng/runner/uninstall-runner.ps1 b/eng/runner/uninstall-runner.ps1 index 76e3484..032485a 100644 --- a/eng/runner/uninstall-runner.ps1 +++ b/eng/runner/uninstall-runner.ps1 @@ -28,6 +28,7 @@ try { $resolvedRunnerName = Resolve-SmartPipeRunnerName -Root $runner -RequestedName $RunnerName Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath + Assert-SmartPipeListenerSafety -Root $runner -FixturePath $ListenerFixturePath $environmentPath = Join-Path $runner '.env' Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index cd01c01..2573dd0 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -279,6 +279,24 @@ ACTIONS_RUNNER_HOOK_JOB_COMPLETED=C:\legacy\smartpipe-post-job-cleanup.ps1 Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1')) -Message 'Installer must copy the job-start hook.' Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Installer must remove the legacy hook copy.' + $environmentBeforeAmbiguous = Get-Content -LiteralPath $environment -Raw + $labelsBeforeAmbiguous = Get-Content -LiteralPath $labelState -Raw + 'unclassified-duplicate' | Set-Content -LiteralPath $listenerFixture -NoNewline + $ambiguousInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-RunnerName', 'SmartPipe-Runner', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($ambiguousInstall.ExitCode -ne 0) -Message "Installer must refuse an unclassified duplicate before mutation. $($ambiguousInstall.Output)" + Assert-RunnerTrue -Condition ($ambiguousInstall.Output -match '4102') -Message "Unclassified listener diagnostics must report the exact PID. $($ambiguousInstall.Output)" + Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected 'unclassified-duplicate' -Message 'Unclassified duplicate refusal must not stop or rewrite the listener fixture.' + Assert-RunnerEqual -Actual (Get-Content -LiteralPath $environment -Raw) -Expected $environmentBeforeAmbiguous -Message 'Unclassified duplicate refusal must precede environment mutation.' + Assert-RunnerEqual -Actual (Get-Content -LiteralPath $labelState -Raw) -Expected $labelsBeforeAmbiguous -Message 'Unclassified duplicate refusal must precede label mutation.' + '1' | Set-Content -LiteralPath $listenerFixture -NoNewline + $uninstall = Invoke-RunnerScript -ScriptPath $uninstallScript -Arguments @( '-RunnerRoot', $runnerRoot, '-Repository', 'MrFr3di/SmartPipe-Core', From 496deffb892337bf667d8cb06240029f4d76c12e Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 02:08:56 +0500 Subject: [PATCH 12/22] fix(ci): recreate workspace after pre-job cleanup --- docs/contributing.md | 10 ++++++---- eng/runner/job-start-cleanup.ps1 | 12 ++++++++++++ eng/tests/runner-contract.Tests.ps1 | 7 ++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index a82189e..fb8205d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -121,11 +121,13 @@ with their PIDs; ambiguous listeners are never stopped automatically. The job-start hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout remote, and canonicalizes every target beneath the dedicated runner root. It runs before the next job starts, after the runner has completed the previous -job's process cleanup, and removes the exact prior checkout plus the known +job's process cleanup, removes the exact prior checkout, and recreates its +empty workspace directory before the next checkout. It also removes the known `SmartPipe.Core`, `SmartPipe-Core`, `CodeQL`, and `codeql` directories below -`RUNNER_TEMP`. Missing targets are successful. Any outside path, broad root, -reparse point, unsafe repository, or deletion error fails closed before -removal; the existing workflow cleanup jobs remain as defense in depth. +`RUNNER_TEMP`. Missing temp targets are successful. Any outside path, broad +root, reparse point, unsafe repository, non-empty recreation, or deletion error +fails closed before removal; the existing workflow cleanup jobs remain as +defense in depth. For a compact, transition-only pull-request view: diff --git a/eng/runner/job-start-cleanup.ps1 b/eng/runner/job-start-cleanup.ps1 index 9cc69fa..e153452 100644 --- a/eng/runner/job-start-cleanup.ps1 +++ b/eng/runner/job-start-cleanup.ps1 @@ -37,10 +37,22 @@ try { Assert-SmartPipeWorkspaceRepository -Workspace $workspace [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) } + elseif (Test-Path -LiteralPath $workspace) { + throw "Workspace path is not a directory: $workspace" + } else { Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner } + New-Item -ItemType Directory -Path $workspace -ErrorAction Stop | Out-Null + Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner + if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { + throw "Workspace directory was not created: $workspace" + } + if (@(Get-ChildItem -LiteralPath $workspace -Force -ErrorAction Stop).Count -ne 0) { + throw "Workspace directory is not empty after cleanup: $workspace" + } + if (-not [string]::IsNullOrWhiteSpace($TempRoot)) { $temp = Get-SmartPipeFullPath -Path $TempRoot if (-not (Test-SmartPipeContainedPath -Path $temp -Boundary $runner)) { diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index 2573dd0..75c3d84 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -93,7 +93,10 @@ try { '-AllowTestRoot' ) Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Job-start cleanup must succeed for a valid checkout. $($cleanup.Output)" - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath $workspace)) -Message 'The exact checkout must be removed.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace -PathType Container) -Message 'The exact workspace directory must be recreated.' + Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $workspace -Force).Count -Expected 0 -Message 'The recreated workspace must be empty.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $workspace '.git'))) -Message 'The recreated workspace must not retain .git.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $workspace 'output.txt'))) -Message 'The recreated workspace must not retain stale files.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $toolRoot 'preserve.txt')) -Message '_tool must be preserved.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $sibling 'preserve.txt')) -Message 'Sibling repositories must be preserved.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $tempRoot 'unrelated.tmp')) -Message 'Unrelated temp files must be preserved.' @@ -109,6 +112,8 @@ try { '-AllowTestRoot' ) Assert-RunnerEqual -Actual $absent.ExitCode -Expected 0 -Message 'Absent cleanup targets must be successful.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath $absentWorkspace -PathType Container) -Message 'An absent workspace must be recreated.' + Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $absentWorkspace -Force).Count -Expected 0 -Message 'A recreated absent workspace must be empty.' New-Item -ItemType Directory -Path $workspace, (Join-Path $workspace '.git') -Force | Out-Null @' From aec9921b30e2cbdcce548f975c8a2829cecdbc63 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 02:21:39 +0500 Subject: [PATCH 13/22] fix(ci): accept empty pre-job workspace --- docs/contributing.md | 4 +++- eng/runner/job-start-cleanup.ps1 | 11 ++++++++--- eng/tests/runner-contract.Tests.ps1 | 10 ++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index fb8205d..4e3bfc5 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -126,7 +126,9 @@ empty workspace directory before the next checkout. It also removes the known `SmartPipe.Core`, `SmartPipe-Core`, `CodeQL`, and `codeql` directories below `RUNNER_TEMP`. Missing temp targets are successful. Any outside path, broad root, reparse point, unsafe repository, non-empty recreation, or deletion error -fails closed before removal; the existing workflow cleanup jobs remain as +fails closed before removal. An existing empty workspace is accepted +idempotently; any non-empty workspace must pass the exact repository/origin +authorization before removal. The existing workflow cleanup jobs remain as defense in depth. For a compact, transition-only pull-request view: diff --git a/eng/runner/job-start-cleanup.ps1 b/eng/runner/job-start-cleanup.ps1 index e153452..e451cba 100644 --- a/eng/runner/job-start-cleanup.ps1 +++ b/eng/runner/job-start-cleanup.ps1 @@ -34,8 +34,11 @@ try { } if (Test-Path -LiteralPath $workspace -PathType Container) { - Assert-SmartPipeWorkspaceRepository -Workspace $workspace - [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) + Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner + if (@(Get-ChildItem -LiteralPath $workspace -Force -ErrorAction Stop).Count -gt 0) { + Assert-SmartPipeWorkspaceRepository -Workspace $workspace + [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) + } } elseif (Test-Path -LiteralPath $workspace) { throw "Workspace path is not a directory: $workspace" @@ -44,7 +47,9 @@ try { Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner } - New-Item -ItemType Directory -Path $workspace -ErrorAction Stop | Out-Null + if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { + New-Item -ItemType Directory -Path $workspace -ErrorAction Stop | Out-Null + } Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { throw "Workspace directory was not created: $workspace" diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index 75c3d84..b1c16bb 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -103,6 +103,16 @@ try { Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'SmartPipe.Core'))) -Message 'Known SmartPipe temp must be removed.' Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'CodeQL'))) -Message 'Known CodeQL temp must be removed.' + $emptyCleanup = Invoke-RunnerScript -ScriptPath $jobStartScript -WorkingDirectory $workspace -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $workspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $emptyCleanup.ExitCode -Expected 0 -Message "An existing empty workspace must be idempotently clean. $($emptyCleanup.Output)" + Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $workspace -Force).Count -Expected 0 -Message 'An idempotent empty workspace must remain empty.' + $absentWorkspace = Join-Path $runnerRoot '_work\SmartPipe.Core\absent' $absent = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, From 4be8f98700b974b1bef3dc002bba94df8aecd9ee Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 02:55:43 +0500 Subject: [PATCH 14/22] fix(ci): avoid unused PR artifact uploads --- .../workflows/reusable-release-validation.yml | 1 + docs/architecture/package-infrastructure.md | 5 +++- eng/tests/workflow_contract_tests.py | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index 6dbcf82..1232a5f 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -260,6 +260,7 @@ jobs: if ($exitCode -ne 0) { exit $exitCode } - name: Upload immutable packages and reports + if: github.event_name != 'pull_request' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ inputs.artifact-name }} diff --git a/docs/architecture/package-infrastructure.md b/docs/architecture/package-infrastructure.md index 56071a2..86ec304 100644 --- a/docs/architecture/package-infrastructure.md +++ b/docs/architecture/package-infrastructure.md @@ -74,7 +74,10 @@ The profile replaces duplicate central-package and project checks only. Packing, baseline provisioning/offline verification, package metadata and ownership, consumers, audit, and artifact upload remain specialized workflow gates. Consumer artifacts upload `result.json`; retained bounded logs stay in -the local job workspace. +the local job workspace. Pull-request validation skips only the shared artifact +upload to avoid account-level storage quota failures; package/report generation +and all preceding gates remain required. Push, dispatch, and release validation +continue to require the fail-closed artifact for publish consumption. ## Agent context and exact-tree evidence diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 5b5926f..4411a9d 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -965,6 +965,8 @@ def validate(documents: dict[str, dict]) -> None: and "--report artifacts/audit/vulnerable.json" in audit_policy_run, "Reusable validation must enforce the direct production audit policy from the vulnerable JSON report.") upload = named_step(reusable_steps, "Upload immutable packages and reports") + require(upload.get("if") == "github.event_name != 'pull_request'", + "Reusable validation artifact upload must skip only pull_request events and remain required for non-PR events.") require(upload.get("with", {}).get("name") == "${{ inputs.artifact-name }}", "Reusable validation must upload the caller-selected artifact name.") upload_path = str(upload.get("with", {}).get("path", "")) @@ -1509,6 +1511,22 @@ def _duplicate_upload(documents: dict[str, dict]) -> None: job_steps.append(copy.deepcopy(named_step(job_steps, "Upload immutable packages and reports"))) +def _remove_upload_event_guard(documents: dict[str, dict]) -> None: + upload = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Upload immutable packages and reports", + ) + upload.pop("if", None) + + +def _restrict_upload_to_push(documents: dict[str, dict]) -> None: + upload = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Upload immutable packages and reports", + ) + upload["if"] = "github.event_name == 'push'" + + def _hardcode_publish_package(documents: dict[str, dict]) -> None: publish_steps = documents["publish-nuget.yml"]["jobs"]["publish"]["steps"] push = named_step(publish_steps, "Publish packages in dependency order") @@ -1856,6 +1874,16 @@ def main() -> int: _duplicate_upload, "exactly one step named 'Upload immutable packages and reports'", ) + assert_mutation_rejected( + documents, + _remove_upload_event_guard, + "skip only pull_request events and remain required for non-PR events", + ) + assert_mutation_rejected( + documents, + _restrict_upload_to_push, + "skip only pull_request events and remain required for non-PR events", + ) assert_mutation_rejected( documents, _hardcode_publish_package, From 0a8adc23d4561bd22836b5e8c0487ff915fe2eb6 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 21:19:50 +0500 Subject: [PATCH 15/22] build(deps): apply .NET 10.0.11 servicing --- Directory.Packages.props | 24 +- .../SmartPipe.Benchmarks/packages.lock.json | 22 +- docs/contributing/package-authoring.md | 7 + .../Commands/BaselineVerificationService.cs | 21 +- eng/baselines/README.md | 2 + global.json | 2 +- src/SmartPipe.Core/packages.lock.json | 20 +- .../packages.lock.json | 22 +- .../packages.lock.json | 22 +- .../packages.lock.json | 22 +- .../packages.lock.json | 92 ++--- .../packages.lock.json | 74 ++-- .../packages.lock.json | 22 +- .../packages.lock.json | 22 +- .../packages.lock.json | 22 +- .../packages.lock.json | 22 +- src/SmartPipe.Extensions/packages.lock.json | 214 +++++------ tests/SmartPipe.Core.Tests/packages.lock.json | 16 +- .../packages.lock.json | 16 +- .../packages.lock.json | 16 +- .../packages.lock.json | 26 +- .../packages.lock.json | 348 +++++++++--------- .../packages.lock.json | 330 ++++++++--------- .../packages.lock.json | 18 +- .../packages.lock.json | 18 +- .../packages.lock.json | 22 +- .../packages.lock.json | 292 +++++++-------- .../packages.lock.json | 16 +- .../Commands/BaselineOrchestrationTests.cs | 13 + .../PackageInfrastructureGapTests.cs | 2 +- 30 files changed, 895 insertions(+), 870 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6d0389c..5cf3f3b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,18 +12,18 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/benchmarks/SmartPipe.Benchmarks/packages.lock.json b/benchmarks/SmartPipe.Benchmarks/packages.lock.json index 213bd87..7da8b63 100644 --- a/benchmarks/SmartPipe.Benchmarks/packages.lock.json +++ b/benchmarks/SmartPipe.Benchmarks/packages.lock.json @@ -142,7 +142,7 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.channels": { @@ -154,7 +154,7 @@ "smartpipe.extensions.logging": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, @@ -166,7 +166,7 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "CentralTransitive", - "requested": "[10.0.8, )", + "requested": "[10.0.11, )", "resolved": "6.0.0", "contentHash": "k6PWQMuoBDGGHOQTtyois2u4AwyVcIwL2LaSLlTZQm2CYcJ1pxbt6jfAnpWmzENA/wfrYRI/X9DTLoUkE4AsLw==", "dependencies": { @@ -175,22 +175,22 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "CentralTransitive", - "requested": "[10.0.8, )", + "requested": "[10.0.11, )", "resolved": "6.0.0", "contentHash": "dzXN0+V1AyjOe2xcJ86Qbo233KHuLEY0njf/P2Kw8SfJU+d45HNS2ctJdnEnrWbM9Ye2eFgaC5Mj9otRMU6IsQ==", "dependencies": { diff --git a/docs/contributing/package-authoring.md b/docs/contributing/package-authoring.md index 117a13d..88f55bb 100644 --- a/docs/contributing/package-authoring.md +++ b/docs/contributing/package-authoring.md @@ -12,6 +12,13 @@ Include="..."`; do not add a local `Version`, `VersionOverride`, range, or floating version. Transitive pinning is disabled, so a direct reference must be declared when the package is part of a package's supported contract. +For an approved .NET servicing update, change only the named CPM entries with +the pinned SDK from `global.json`, regenerate every tracked lock file with +normal restore, keep the generated locks as UTF-8 without BOM using LF line +endings, and finish with locked restore. Do not hand-edit lock files or upgrade +unrelated analyzers, test infrastructure, coverage tools, or third-party +packages. + Run `verify-central-packages --mode current` and restore with locked mode after changing the manifest. Release mode treats unused versions and inventory drift as errors. diff --git a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs index ec85418..9662e5b 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs @@ -187,18 +187,21 @@ internal async Task VerifyAsync( diagnostics.Add(new("SPB003", $"Capture commit is not an ancestor of HEAD or is unavailable: {exception.Message}")); } - try + if (options.Mode == BaselineVerificationMode.Full) { - var actualSdk = ReadSdkVersion(options.RepositoryRoot); - if (!string.Equals(actualSdk, manifest.Repository.SdkVersion, StringComparison.Ordinal)) + try { - diagnostics.Add(new("SPB004", "global.json SDK mismatch", manifest.Repository.SdkVersion, actualSdk)); + var actualSdk = ReadSdkVersion(options.RepositoryRoot); + if (!string.Equals(actualSdk, manifest.Repository.SdkVersion, StringComparison.Ordinal)) + { + diagnostics.Add(new("SPB004", "global.json SDK mismatch", manifest.Repository.SdkVersion, actualSdk)); + } + } + catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException + or InvalidDataException or KeyNotFoundException or InvalidOperationException) + { + diagnostics.Add(new("SPB004", $"global.json SDK could not be read: {exception.Message}")); } - } - catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException - or InvalidDataException or KeyNotFoundException or InvalidOperationException) - { - diagnostics.Add(new("SPB004", $"global.json SDK could not be read: {exception.Message}")); } var snapshotFiles = new[] diff --git a/eng/baselines/README.md b/eng/baselines/README.md index 203ac38..e1e8b70 100644 --- a/eng/baselines/README.md +++ b/eng/baselines/README.md @@ -30,5 +30,7 @@ The manifest rejects unknown properties and schema versions. `repository.capture Offline verification never fetches packages. It requires the capture commit to exist and be an ancestor of current HEAD, failing closed for unrelated or missing/shallow history. It hashes package bytes before signature or archive inspection and ignores unreferenced files in the baseline directory. +Full verification also compares the current `global.json` SDK exactly and emits `SPB004` for a mismatch. Integrity verification keeps the manifest, snapshot, report, package, ancestry, and workflow-policy checks, but deliberately skips the current SDK comparison so an approved servicing SDK update does not invalidate the immutable 2.1.2 baseline. + Capture consumes the literal JSON array produced by `gh run list --json databaseId,workflowName,headSha,status,conclusion,url,event,createdAt`. Every returned run must target the requested capture commit; that `headSha` is retained in each workflow manifest entry, and exactly one completed successful run is required for each of CI, Hosted .NET static analysis, and Repository security audit. Workflow policy verification uses a bounded parser for the repository's current YAML shape and checks `release/2.2.0` in the actual `on.push.branches` and/or `on.pull_request.branches` lists; comments, environment values, and step text do not count. Capture requires those current check names exactly and persists those literal names. Offline verification accepts only a complete historical manifest set (`CI`, `CodeQL`, `Dependency Review`) or a complete current set; mixed or extra workflow identities fail closed. diff --git a/global.json b/global.json index 51b0815..e9f03fe 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.302", + "version": "10.0.303", "rollForward": "disable" }, "test": { diff --git a/src/SmartPipe.Core/packages.lock.json b/src/SmartPipe.Core/packages.lock.json index ca6fa24..a41fec5 100644 --- a/src/SmartPipe.Core/packages.lock.json +++ b/src/SmartPipe.Core/packages.lock.json @@ -10,24 +10,24 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" } } } diff --git a/src/SmartPipe.Extensions.Channels/packages.lock.json b/src/SmartPipe.Extensions.Channels/packages.lock.json index 6ce8da1..75b31c3 100644 --- a/src/SmartPipe.Extensions.Channels/packages.lock.json +++ b/src/SmartPipe.Extensions.Channels/packages.lock.json @@ -10,29 +10,29 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/src/SmartPipe.Extensions.DataAnnotations/packages.lock.json b/src/SmartPipe.Extensions.DataAnnotations/packages.lock.json index c23e00b..5445aca 100644 --- a/src/SmartPipe.Extensions.DataAnnotations/packages.lock.json +++ b/src/SmartPipe.Extensions.DataAnnotations/packages.lock.json @@ -10,14 +10,14 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.transforms": { @@ -28,17 +28,17 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/src/SmartPipe.Extensions.DependencyInjection/packages.lock.json b/src/SmartPipe.Extensions.DependencyInjection/packages.lock.json index f4f0a8f..753c4cb 100644 --- a/src/SmartPipe.Extensions.DependencyInjection/packages.lock.json +++ b/src/SmartPipe.Extensions.DependencyInjection/packages.lock.json @@ -10,29 +10,29 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/src/SmartPipe.Extensions.HealthChecks/packages.lock.json b/src/SmartPipe.Extensions.HealthChecks/packages.lock.json index f48c8c3..9b90ba7 100644 --- a/src/SmartPipe.Extensions.HealthChecks/packages.lock.json +++ b/src/SmartPipe.Extensions.HealthChecks/packages.lock.json @@ -10,106 +10,106 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Diagnostics.HealthChecks": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "IlXZUZIA9b99yQURc8jOLmmS6GD42vj6t+LqWs6Dd+naggObSbEXDw8DU8DUu2tL8CnyG96F4pDLhjd7BmZPAQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "fzUGzOCLquuyIxVVvvcw9h8KaUrRZ/cGTai2gBDQ1YxbdH/8eZnSyoOu+PQJYoD+RUcgTTlVe6D4+FrcaUOR9w==", "dependencies": { - "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "0j06qq5I1YGSIi4M86UaVITBprvn3bTWKmQiLDNEqnZ2d+UxaFb+tVqemxVzr5lkr6GTR83ExUfSIpCnBaTZFA==" + "resolved": "10.0.11", + "contentHash": "5DCBP95vpklAkEfIbT2yzunianCeRtjaLrmAwoIgTf+xOJGFMmkAut/p3R2YmZL7TM1dnV81adGIg4m7Kg6wUw==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.11", + "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/src/SmartPipe.Extensions.Hosting/packages.lock.json b/src/SmartPipe.Extensions.Hosting/packages.lock.json index 49db532..710677b 100644 --- a/src/SmartPipe.Extensions.Hosting/packages.lock.json +++ b/src/SmartPipe.Extensions.Hosting/packages.lock.json @@ -10,89 +10,89 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.11", + "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "Microsoft.Extensions.Options": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } } } diff --git a/src/SmartPipe.Extensions.Json/packages.lock.json b/src/SmartPipe.Extensions.Json/packages.lock.json index 4f2d8ef..9eb5bac 100644 --- a/src/SmartPipe.Extensions.Json/packages.lock.json +++ b/src/SmartPipe.Extensions.Json/packages.lock.json @@ -10,30 +10,30 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" } } } diff --git a/src/SmartPipe.Extensions.Logging/packages.lock.json b/src/SmartPipe.Extensions.Logging/packages.lock.json index 4f2d8ef..9eb5bac 100644 --- a/src/SmartPipe.Extensions.Logging/packages.lock.json +++ b/src/SmartPipe.Extensions.Logging/packages.lock.json @@ -10,30 +10,30 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" } } } diff --git a/src/SmartPipe.Extensions.OpenTelemetry/packages.lock.json b/src/SmartPipe.Extensions.OpenTelemetry/packages.lock.json index 764bc2f..620bbad 100644 --- a/src/SmartPipe.Extensions.OpenTelemetry/packages.lock.json +++ b/src/SmartPipe.Extensions.OpenTelemetry/packages.lock.json @@ -10,9 +10,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "OpenTelemetry.Api.ProviderBuilderExtensions": { "type": "Direct", @@ -32,22 +32,22 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/src/SmartPipe.Extensions.Transforms/packages.lock.json b/src/SmartPipe.Extensions.Transforms/packages.lock.json index 6ce8da1..75b31c3 100644 --- a/src/SmartPipe.Extensions.Transforms/packages.lock.json +++ b/src/SmartPipe.Extensions.Transforms/packages.lock.json @@ -10,29 +10,29 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/src/SmartPipe.Extensions/packages.lock.json b/src/SmartPipe.Extensions/packages.lock.json index 653373e..0a07bca 100644 --- a/src/SmartPipe.Extensions/packages.lock.json +++ b/src/SmartPipe.Extensions/packages.lock.json @@ -31,72 +31,72 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.8", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.HealthChecks": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "IlXZUZIA9b99yQURc8jOLmmS6GD42vj6t+LqWs6Dd+naggObSbEXDw8DU8DUu2tL8CnyG96F4pDLhjd7BmZPAQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "fzUGzOCLquuyIxVVvvcw9h8KaUrRZ/cGTai2gBDQ1YxbdH/8eZnSyoOu+PQJYoD+RUcgTTlVe6D4+FrcaUOR9w==", "dependencies": { - "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Http": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "/9LU/KWJOrtZJB9ymPjcARDyjp679BvBA/aSncv2Kt84WlSKz767HtxHg8EFsu8n21BMLZi+5XxlkKbLwfn4iA==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "ujx8RvcKzkxPFBguwgiygbwWVHVK0P7HFlNJ6I0JBRb28tzwe42jixBoA+dGmqG9IHepPVcm04vv2IDnU93ekA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Resilience": { @@ -120,32 +120,32 @@ }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "jbKDXWPZQhuPHygMnwzNOqxBADVcpRVytcKYZsA++QqhPkpF93Ta8o5mbJQGrARSjlkr9WtOaADV97EDMOZ7DA==" + "resolved": "10.0.11", + "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "M3BZ8JH8rB6BE7dO2g9iVbrHLnEz9wMXT6q+tDR6Nq3gyP3KmBj5OTiZGxyF3vesjOQNKanYoPGSNBR4kR2llg==" + "resolved": "10.0.11", + "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", + "resolved": "10.0.11", + "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "sYMYQjNprfqPTryuLNnr0/AOtnhlfuZ0ZxyOV0d3AXOEL8j9KV0EbelpZYyIatT2hJiaSGO9XGr5YDRsh22OfQ==", + "resolved": "10.0.11", + "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Compliance.Abstractions": { @@ -159,47 +159,47 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { @@ -212,25 +212,25 @@ }, "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "0j06qq5I1YGSIi4M86UaVITBprvn3bTWKmQiLDNEqnZ2d+UxaFb+tVqemxVzr5lkr6GTR83ExUfSIpCnBaTZFA==" + "resolved": "10.0.11", + "contentHash": "5DCBP95vpklAkEfIbT2yzunianCeRtjaLrmAwoIgTf+xOJGFMmkAut/p3R2YmZL7TM1dnV81adGIg4m7Kg6wUw==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.11", + "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.ObjectPool": { @@ -240,20 +240,20 @@ }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "Microsoft.Extensions.Telemetry.Abstractions": { "type": "Transitive", @@ -298,7 +298,7 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.channels": { @@ -317,16 +317,16 @@ "smartpipe.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "smartpipe.extensions.hosting": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )", "SmartPipe.Extensions.DependencyInjection": "[2.2.0, )" } @@ -334,14 +334,14 @@ "smartpipe.extensions.json": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "smartpipe.extensions.logging": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, @@ -353,18 +353,18 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" } } } diff --git a/tests/SmartPipe.Core.Tests/packages.lock.json b/tests/SmartPipe.Core.Tests/packages.lock.json index a335046..499749e 100644 --- a/tests/SmartPipe.Core.Tests/packages.lock.json +++ b/tests/SmartPipe.Core.Tests/packages.lock.json @@ -213,22 +213,22 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/tests/SmartPipe.Extensions.Channels.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Channels.Tests/packages.lock.json index d755454..873a3df 100644 --- a/tests/SmartPipe.Extensions.Channels.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.Channels.Tests/packages.lock.json @@ -152,7 +152,7 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.channels": { @@ -163,17 +163,17 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/tests/SmartPipe.Extensions.DataAnnotations.Tests/packages.lock.json b/tests/SmartPipe.Extensions.DataAnnotations.Tests/packages.lock.json index 3889cd1..f88e542 100644 --- a/tests/SmartPipe.Extensions.DataAnnotations.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.DataAnnotations.Tests/packages.lock.json @@ -152,7 +152,7 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.dataannotations": { @@ -170,17 +170,17 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/tests/SmartPipe.Extensions.DependencyInjection.Tests/packages.lock.json b/tests/SmartPipe.Extensions.DependencyInjection.Tests/packages.lock.json index 84fd9dd..2cffe57 100644 --- a/tests/SmartPipe.Extensions.DependencyInjection.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.DependencyInjection.Tests/packages.lock.json @@ -4,11 +4,11 @@ "net10.0": { "Microsoft.Extensions.DependencyInjection": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.NET.Test.Sdk": { @@ -161,29 +161,29 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/tests/SmartPipe.Extensions.HealthChecks.Tests/packages.lock.json b/tests/SmartPipe.Extensions.HealthChecks.Tests/packages.lock.json index 36bea8d..426a3d4 100644 --- a/tests/SmartPipe.Extensions.HealthChecks.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.HealthChecks.Tests/packages.lock.json @@ -4,50 +4,50 @@ "net10.0": { "Microsoft.Extensions.DependencyInjection": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Hosting": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.8", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8", - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Logging.Console": "10.0.8", - "Microsoft.Extensions.Logging.Debug": "10.0.8", - "Microsoft.Extensions.Logging.EventLog": "10.0.8", - "Microsoft.Extensions.Logging.EventSource": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eIDa/Rl+93aj17gMlFsJJx+LhBvb3CP0Mu1PeVYkDp2Y3S4Jock8UynfGQEcx7lrlq+gKW+ECQJHbro/LTPDEQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.11", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.11", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.11", + "Microsoft.Extensions.Configuration.Json": "10.0.11", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.11", + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Configuration": "10.0.11", + "Microsoft.Extensions.Logging.Console": "10.0.11", + "Microsoft.Extensions.Logging.Debug": "10.0.11", + "Microsoft.Extensions.Logging.EventLog": "10.0.11", + "Microsoft.Extensions.Logging.EventSource": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.NET.Test.Sdk": { @@ -88,204 +88,204 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==", + "resolved": "10.0.11", + "contentHash": "1KHr/1L56llwQ/yI0tAisEA31UpPsn8aasjASIwELOaN4JIUcbjuQBMdFOIzfNBBeULoUa0XfBe5QDtRRUY+fg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "bVGqctAfPGfTxJvNp8pMshtvpsUj6r6JkeiCNVIGVYO5gBxuxdN0Lbr25kEvE/zXdctkEc44g8HssnPgDnFGVA==", + "resolved": "10.0.11", + "contentHash": "KICyU3eVi5jvloKm01EXV69L97H/zkhISVtV98cIuzuFOxNx3xTUVcXqvWTz3aq7OvUuDB/MFlPFjmxRaKF7/A==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "1g9mzuu8gIHkjYb0jLxOTQVl/QDG5nn0b0JzgT/gbgNKr6gXZzxOHRAsdYRc1eDApB7LdHR8uK5vQrNjIQdRrQ==", + "resolved": "10.0.11", + "contentHash": "mDW7KVFB05M6jiRUyaZiOMWhS31n5HlSZwoYctHAZAucD4sMDJ70IxOmkGDt6RpstchD+keWBjhdzcMpSkWvWQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "KLtAZ6A38s1pIfCO2ns6aG14NNGMYNZ4PBYfFK4M+R4A+xuSc6oklhqDcpHZxvDpyBWeFtR5C8iQBw2ng8tUHQ==", + "resolved": "10.0.11", + "contentHash": "nSPrT8U/cNoB4coqkmnanAMK9PsL7lsjG+LLUKEwHRFwS6E78b8S1wdv/y88EOxBhasWov1rLd7RTHmmsYPOLg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "6XTfFOnf27WY8kEeZkTZ4YNn0t+imgvdQ0YaAdR4vgURKATo9bCaVJ1KB71IOJAQtJP7Elb53VHlTNXg2CtSsA==", + "resolved": "10.0.11", + "contentHash": "BRliLdUowglV8GS+J1G/QsSofCJYYFg3U8QZx0ACRn+a91az/Qnpy+h6PyHS94WgV2TSazX5D/cuuk6wnCJatw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Json": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11" } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "0j06qq5I1YGSIi4M86UaVITBprvn3bTWKmQiLDNEqnZ2d+UxaFb+tVqemxVzr5lkr6GTR83ExUfSIpCnBaTZFA==" + "resolved": "10.0.11", + "contentHash": "5DCBP95vpklAkEfIbT2yzunianCeRtjaLrmAwoIgTf+xOJGFMmkAut/p3R2YmZL7TM1dnV81adGIg4m7Kg6wUw==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.11", + "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "GkPvQe6IdidLu6Q3Lw6+B8NJpW8feW8czZ5mBKt5rXM/x8MvZfEp5WvAsjznzDGd23chIDrW0b2mmt+ScnEgiw==", + "resolved": "10.0.11", + "contentHash": "Tq/UqMaczePv9yWwSsJZRgKtgA46djVR5xHj/lZBCueQ3ag8f9v5mu0EdhrNx7tXxNk+Y9OurG2oKuSKINjr0A==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw==" + "resolved": "10.0.11", + "contentHash": "2i6rtW/B5rCnWCnhdmWWEmaM9O0HD0zsPY9eRqa++y4tclI3Uw8zvGbBvhY/LjAdtf8gUHhUPcAWj3DRlWMXmQ==" }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", + "resolved": "10.0.11", + "contentHash": "S7LvLeVHKNPaY2NMyxW7c2TBGsLgxoSUBCV5Ev5iN8kgC7EPR2UB7eW7vHsElGMcIUDwRmoxLfvGDynCn3q6EA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==", + "resolved": "10.0.11", + "contentHash": "wr+j1bjdFXhc8lKTLoq+RbwFM8M+orcMS9xrcqLmDGOxJcXpKizEeE5h6v/GKwCZV02FmhaA7OlNjoq072jZpQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==", + "resolved": "10.0.11", + "contentHash": "Eck9GpCCpvZ3f6L7IUlN+mPtRVefnf7PsiIG5vi61QawPtLNCEAv2TPD/M3SojcU0PFaef+BxiVGPOShFHtDog==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "System.Diagnostics.EventLog": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "System.Diagnostics.EventLog": "10.0.11" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==", + "resolved": "10.0.11", + "contentHash": "hs6QWECLLohi2VKqUvSGRUvrg7eXR1DqKL95Jrtz3cdD2g2nBA+yJPdRQLZ7SLmnTZWycxfMDK2s0ho+rfst5w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", @@ -343,8 +343,8 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+Ro7WgIom+BDNH+YhTuZKL6QJ0ctfOpTyfUG/h3aU5KwXt3OaNf0wYWrTvoBUj+34Dy5V8dN9yCco1hAJQ4txw==" + "resolved": "10.0.11", + "contentHash": "QTXEoQBzz00SFWbo7nAg1Ogd4f99lwqcO9uAJ7MYSLEUR28f6As32QktrqG2Fr9cfAfd1GjLyGYspE7Ipj7P6w==" }, "xunit.analyzers": { "type": "Transitive", @@ -406,78 +406,78 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "smartpipe.extensions.healthchecks": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.8, )", - "Microsoft.Extensions.Options": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.11, )", + "Microsoft.Extensions.Options": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )", "SmartPipe.Extensions.DependencyInjection": "[2.2.0, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Diagnostics.HealthChecks": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "IlXZUZIA9b99yQURc8jOLmmS6GD42vj6t+LqWs6Dd+naggObSbEXDw8DU8DUu2tL8CnyG96F4pDLhjd7BmZPAQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "fzUGzOCLquuyIxVVvvcw9h8KaUrRZ/cGTai2gBDQ1YxbdH/8eZnSyoOu+PQJYoD+RUcgTTlVe6D4+FrcaUOR9w==", "dependencies": { - "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging.Console": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "dFc0yDudyD1iIg6z9XT7ofsT3hVO7Y4ylrxGHIVRR0GaZ4CUk4ujOrMoy7wWEdNHZhvJooySg6hZpOxyS8zEVA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Configuration": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } } } diff --git a/tests/SmartPipe.Extensions.Hosting.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Hosting.Tests/packages.lock.json index 21fb161..4022b96 100644 --- a/tests/SmartPipe.Extensions.Hosting.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.Hosting.Tests/packages.lock.json @@ -4,41 +4,41 @@ "net10.0": { "Microsoft.Extensions.DependencyInjection": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Hosting": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.8", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8", - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Logging.Console": "10.0.8", - "Microsoft.Extensions.Logging.Debug": "10.0.8", - "Microsoft.Extensions.Logging.EventLog": "10.0.8", - "Microsoft.Extensions.Logging.EventSource": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eIDa/Rl+93aj17gMlFsJJx+LhBvb3CP0Mu1PeVYkDp2Y3S4Jock8UynfGQEcx7lrlq+gKW+ECQJHbro/LTPDEQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.11", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.11", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.11", + "Microsoft.Extensions.Configuration.Json": "10.0.11", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.11", + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Configuration": "10.0.11", + "Microsoft.Extensions.Logging.Console": "10.0.11", + "Microsoft.Extensions.Logging.Debug": "10.0.11", + "Microsoft.Extensions.Logging.EventLog": "10.0.11", + "Microsoft.Extensions.Logging.EventSource": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.NET.Test.Sdk": { @@ -79,199 +79,199 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==", + "resolved": "10.0.11", + "contentHash": "1KHr/1L56llwQ/yI0tAisEA31UpPsn8aasjASIwELOaN4JIUcbjuQBMdFOIzfNBBeULoUa0XfBe5QDtRRUY+fg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "bVGqctAfPGfTxJvNp8pMshtvpsUj6r6JkeiCNVIGVYO5gBxuxdN0Lbr25kEvE/zXdctkEc44g8HssnPgDnFGVA==", + "resolved": "10.0.11", + "contentHash": "KICyU3eVi5jvloKm01EXV69L97H/zkhISVtV98cIuzuFOxNx3xTUVcXqvWTz3aq7OvUuDB/MFlPFjmxRaKF7/A==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "1g9mzuu8gIHkjYb0jLxOTQVl/QDG5nn0b0JzgT/gbgNKr6gXZzxOHRAsdYRc1eDApB7LdHR8uK5vQrNjIQdRrQ==", + "resolved": "10.0.11", + "contentHash": "mDW7KVFB05M6jiRUyaZiOMWhS31n5HlSZwoYctHAZAucD4sMDJ70IxOmkGDt6RpstchD+keWBjhdzcMpSkWvWQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "KLtAZ6A38s1pIfCO2ns6aG14NNGMYNZ4PBYfFK4M+R4A+xuSc6oklhqDcpHZxvDpyBWeFtR5C8iQBw2ng8tUHQ==", + "resolved": "10.0.11", + "contentHash": "nSPrT8U/cNoB4coqkmnanAMK9PsL7lsjG+LLUKEwHRFwS6E78b8S1wdv/y88EOxBhasWov1rLd7RTHmmsYPOLg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "6XTfFOnf27WY8kEeZkTZ4YNn0t+imgvdQ0YaAdR4vgURKATo9bCaVJ1KB71IOJAQtJP7Elb53VHlTNXg2CtSsA==", + "resolved": "10.0.11", + "contentHash": "BRliLdUowglV8GS+J1G/QsSofCJYYFg3U8QZx0ACRn+a91az/Qnpy+h6PyHS94WgV2TSazX5D/cuuk6wnCJatw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Json": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11" } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.11", + "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "GkPvQe6IdidLu6Q3Lw6+B8NJpW8feW8czZ5mBKt5rXM/x8MvZfEp5WvAsjznzDGd23chIDrW0b2mmt+ScnEgiw==", + "resolved": "10.0.11", + "contentHash": "Tq/UqMaczePv9yWwSsJZRgKtgA46djVR5xHj/lZBCueQ3ag8f9v5mu0EdhrNx7tXxNk+Y9OurG2oKuSKINjr0A==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw==" + "resolved": "10.0.11", + "contentHash": "2i6rtW/B5rCnWCnhdmWWEmaM9O0HD0zsPY9eRqa++y4tclI3Uw8zvGbBvhY/LjAdtf8gUHhUPcAWj3DRlWMXmQ==" }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", + "resolved": "10.0.11", + "contentHash": "S7LvLeVHKNPaY2NMyxW7c2TBGsLgxoSUBCV5Ev5iN8kgC7EPR2UB7eW7vHsElGMcIUDwRmoxLfvGDynCn3q6EA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==", + "resolved": "10.0.11", + "contentHash": "wr+j1bjdFXhc8lKTLoq+RbwFM8M+orcMS9xrcqLmDGOxJcXpKizEeE5h6v/GKwCZV02FmhaA7OlNjoq072jZpQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==", + "resolved": "10.0.11", + "contentHash": "Eck9GpCCpvZ3f6L7IUlN+mPtRVefnf7PsiIG5vi61QawPtLNCEAv2TPD/M3SojcU0PFaef+BxiVGPOShFHtDog==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "System.Diagnostics.EventLog": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "System.Diagnostics.EventLog": "10.0.11" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==", + "resolved": "10.0.11", + "contentHash": "hs6QWECLLohi2VKqUvSGRUvrg7eXR1DqKL95Jrtz3cdD2g2nBA+yJPdRQLZ7SLmnTZWycxfMDK2s0ho+rfst5w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", @@ -329,8 +329,8 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+Ro7WgIom+BDNH+YhTuZKL6QJ0ctfOpTyfUG/h3aU5KwXt3OaNf0wYWrTvoBUj+34Dy5V8dN9yCco1hAJQ4txw==" + "resolved": "10.0.11", + "contentHash": "QTXEoQBzz00SFWbo7nAg1Ogd4f99lwqcO9uAJ7MYSLEUR28f6As32QktrqG2Fr9cfAfd1GjLyGYspE7Ipj7P6w==" }, "xunit.analyzers": { "type": "Transitive", @@ -392,75 +392,75 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "smartpipe.extensions.hosting": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )", "SmartPipe.Extensions.DependencyInjection": "[2.2.0, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging.Console": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "dFc0yDudyD1iIg6z9XT7ofsT3hVO7Y4ylrxGHIVRR0GaZ4CUk4ujOrMoy7wWEdNHZhvJooySg6hZpOxyS8zEVA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Configuration": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } } } diff --git a/tests/SmartPipe.Extensions.Json.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Json.Tests/packages.lock.json index cf7abb7..8448559 100644 --- a/tests/SmartPipe.Extensions.Json.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.Json.Tests/packages.lock.json @@ -10,11 +10,11 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.NET.Test.Sdk": { @@ -195,21 +195,21 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.json": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" } } } diff --git a/tests/SmartPipe.Extensions.Logging.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Logging.Tests/packages.lock.json index 0e312c3..fb89229 100644 --- a/tests/SmartPipe.Extensions.Logging.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.Logging.Tests/packages.lock.json @@ -4,11 +4,11 @@ "net10.0": { "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.NET.Test.Sdk": { @@ -161,21 +161,21 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.logging": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" } } } diff --git a/tests/SmartPipe.Extensions.OpenTelemetry.Tests/packages.lock.json b/tests/SmartPipe.Extensions.OpenTelemetry.Tests/packages.lock.json index ea1ade4..00d422e 100644 --- a/tests/SmartPipe.Extensions.OpenTelemetry.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.OpenTelemetry.Tests/packages.lock.json @@ -288,7 +288,7 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.opentelemetry": { @@ -300,7 +300,7 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "CentralTransitive", - "requested": "[10.0.8, )", + "requested": "[10.0.11, )", "resolved": "10.0.0", "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", "dependencies": { @@ -309,13 +309,13 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", + "requested": "[10.0.11, )", "resolved": "10.0.0", "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", "dependencies": { @@ -328,16 +328,16 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "CentralTransitive", - "requested": "[10.0.8, )", + "requested": "[10.0.11, )", "resolved": "10.0.0", "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", "dependencies": { diff --git a/tests/SmartPipe.Extensions.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Tests/packages.lock.json index 9de86df..0d76fa6 100644 --- a/tests/SmartPipe.Extensions.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.Tests/packages.lock.json @@ -10,46 +10,46 @@ }, "Microsoft.Data.Sqlite": { "type": "Direct", - "requested": "[10.0.9, )", - "resolved": "10.0.9", - "contentHash": "/eBwiZPcNisn0qZX+Zk4YCftlK/vnoWqv7hHnmSk8MjPxFdYYkmPObpogT0MfCCWN6oAIZnMCo0SoOtZlbbmgQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "7je7UELzm131GiLYc4PpZvfKXIgIyzPM+v+tjcd/nbnuWRfgcONYKzDTqJlURxwVCFsVnlpmq6y6yn4qvR8QXQ==", "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.9", - "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", - "SQLitePCLRaw.core": "2.1.11" + "Microsoft.Data.Sqlite.Core": "10.0.11", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.12", + "SQLitePCLRaw.core": "2.1.12" } }, "Microsoft.EntityFrameworkCore.InMemory": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "C3T9khx1oiLPrS6ehoSnZptiEuTOIaX60it9SGvCkWTeF5i6+IceK6p7mtx+mkFwWB5qx+v3IhgG51iUEtLq9w==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "QBMaEmLIAzUUnH7+1Vbui+9Ui0dSsglkKxjW4sofOHycB3218UrnrUzq+Y75syrR4krUCtY7loWKaVBOeU3qKw==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8" + "Microsoft.EntityFrameworkCore": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging.Console": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "dFc0yDudyD1iIg6z9XT7ofsT3hVO7Y4ylrxGHIVRR0GaZ4CUk4ujOrMoy7wWEdNHZhvJooySg6hZpOxyS8zEVA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Configuration": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.NET.Test.Sdk": { @@ -128,40 +128,40 @@ }, "Microsoft.Data.Sqlite.Core": { "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "iZrONyMKPjxfVZnUktqO30QjzNwAGH+AxM61s8lKQnVhgbQ3bn0hiXI129ZmVicEbIcwljyy2OVsIYUR51ZHKQ==", + "resolved": "10.0.11", + "contentHash": "hubA20AGenQ4Sx0ElWaPpB8DISjXpdx463+1zOGRslsT0e/t/06ITv+pHsop8CcJ0d8PZLfgnT7juCDVD79Dkw==", "dependencies": { - "SQLitePCLRaw.core": "2.1.11" + "SQLitePCLRaw.core": "2.1.12" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "jbKDXWPZQhuPHygMnwzNOqxBADVcpRVytcKYZsA++QqhPkpF93Ta8o5mbJQGrARSjlkr9WtOaADV97EDMOZ7DA==" + "resolved": "10.0.11", + "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "M3BZ8JH8rB6BE7dO2g9iVbrHLnEz9wMXT6q+tDR6Nq3gyP3KmBj5OTiZGxyF3vesjOQNKanYoPGSNBR4kR2llg==" + "resolved": "10.0.11", + "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", + "resolved": "10.0.11", + "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "sYMYQjNprfqPTryuLNnr0/AOtnhlfuZ0ZxyOV0d3AXOEL8j9KV0EbelpZYyIatT2hJiaSGO9XGr5YDRsh22OfQ==", + "resolved": "10.0.11", + "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Compliance.Abstractions": { @@ -175,47 +175,47 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { @@ -228,40 +228,40 @@ }, "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "0j06qq5I1YGSIi4M86UaVITBprvn3bTWKmQiLDNEqnZ2d+UxaFb+tVqemxVzr5lkr6GTR83ExUfSIpCnBaTZFA==" + "resolved": "10.0.11", + "contentHash": "5DCBP95vpklAkEfIbT2yzunianCeRtjaLrmAwoIgTf+xOJGFMmkAut/p3R2YmZL7TM1dnV81adGIg4m7Kg6wUw==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.11", + "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", + "resolved": "10.0.11", + "contentHash": "S7LvLeVHKNPaY2NMyxW7c2TBGsLgxoSUBCV5Ev5iN8kgC7EPR2UB7eW7vHsElGMcIUDwRmoxLfvGDynCn3q6EA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.ObjectPool": { @@ -271,20 +271,20 @@ }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "Microsoft.Extensions.Telemetry.Abstractions": { "type": "Transitive", @@ -471,7 +471,7 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions": { @@ -480,12 +480,12 @@ "CsvHelper": "[33.1.0, )", "Dapper": "[2.1.79, )", "Mapster": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.8, )", - "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.8, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Http": "[10.0.8, )", - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Options": "[10.0.8, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.11, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Http": "[10.0.11, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Options": "[10.0.11, )", "Microsoft.Extensions.Resilience": "[10.6.0, )", "SmartPipe.Core": "[2.2.0, )", "SmartPipe.Extensions.Channels": "[2.2.0, )", @@ -513,16 +513,16 @@ "smartpipe.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "smartpipe.extensions.hosting": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.8, )", - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )", "SmartPipe.Extensions.DependencyInjection": "[2.2.0, )" } @@ -530,14 +530,14 @@ "smartpipe.extensions.json": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, "smartpipe.extensions.logging": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", "SmartPipe.Core": "[2.2.0, )" } }, @@ -570,78 +570,78 @@ }, "Microsoft.EntityFrameworkCore": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.8", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.DependencyInjection": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Diagnostics.HealthChecks": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "IlXZUZIA9b99yQURc8jOLmmS6GD42vj6t+LqWs6Dd+naggObSbEXDw8DU8DUu2tL8CnyG96F4pDLhjd7BmZPAQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "fzUGzOCLquuyIxVVvvcw9h8KaUrRZ/cGTai2gBDQ1YxbdH/8eZnSyoOu+PQJYoD+RUcgTTlVe6D4+FrcaUOR9w==", "dependencies": { - "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Http": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "/9LU/KWJOrtZJB9ymPjcARDyjp679BvBA/aSncv2Kt84WlSKz767HtxHg8EFsu8n21BMLZi+5XxlkKbLwfn4iA==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "ujx8RvcKzkxPFBguwgiygbwWVHVK0P7HFlNJ6I0JBRb28tzwe42jixBoA+dGmqG9IHepPVcm04vv2IDnU93ekA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Resilience": { diff --git a/tests/SmartPipe.Extensions.Transforms.Tests/packages.lock.json b/tests/SmartPipe.Extensions.Transforms.Tests/packages.lock.json index 0876ec2..aebac36 100644 --- a/tests/SmartPipe.Extensions.Transforms.Tests/packages.lock.json +++ b/tests/SmartPipe.Extensions.Transforms.Tests/packages.lock.json @@ -152,7 +152,7 @@ "smartpipe.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" } }, "smartpipe.extensions.transforms": { @@ -163,17 +163,17 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } } } diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs index 8edf644..3157c71 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs @@ -217,6 +217,19 @@ public async Task IntegrityMode_DoesNotReadCurrentRepositorySnapshots() request.FileName == "dotnet" && request.Arguments.Any(argument => argument.Contains("package", StringComparison.Ordinal))); } + [Fact] + public async Task IntegrityMode_AllowsCurrentSdkServicingDrift() + { + using var scenario = new BaselineScenario(); + await scenario.CaptureAsync(TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(scenario.GlobalJsonPath, "{\"sdk\":{\"version\":\"10.0.303\"}}", TestContext.Current.CancellationToken); + + var result = await scenario.VerifyAsync(mode: BaselineVerificationMode.Integrity); + + Assert.True(result.Success, result.Format()); + Assert.DoesNotContain(result.Diagnostics, item => item.Code == "SPB004"); + } + [Fact] public async Task MissingBaselineReport_FailsVerification() { diff --git a/tests/SmartPipe.RepositoryChecks.Tests/PackageGraph/PackageInfrastructureGapTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/PackageGraph/PackageInfrastructureGapTests.cs index f7e2b04..95568fd 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/PackageGraph/PackageInfrastructureGapTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/PackageGraph/PackageInfrastructureGapTests.cs @@ -67,7 +67,7 @@ public async Task FacadeOptionsAndLegacyFrameworkClosure_AreExplicitAndNonExpiri Assert.Contains(central.Descendants("PackageVersion"), element => (string?)element.Attribute("Include") == options - && (string?)element.Attribute("Version") == "10.0.8"); + && (string?)element.Attribute("Version") == "10.0.11"); Assert.Contains(facadeProject.Descendants("PackageReference"), element => (string?)element.Attribute("Include") == options && element.Attribute("Version") is null); From 8dce0ddfe1c30ab864eb0b89cf9bcfafdddc2f05 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Thu, 27 Aug 2026 21:36:32 +0500 Subject: [PATCH 16/22] ci: retire SmartPipe self-hosted runner --- .github/workflows/ci.yml | 61 +- .github/workflows/codeql.yml | 34 +- .github/workflows/dependency-review.yml | 48 +- .../workflows/reusable-release-validation.yml | 7 +- docs/contributing.md | 75 +- .../2.2.0-branch-and-review-policy.md | 2 +- .../Commands/BaselineCaptureService.cs | 4 +- .../Commands/BaselineVerificationService.cs | 18 +- eng/baselines/README.md | 6 +- eng/runner/install-runner.ps1 | 95 -- eng/runner/job-start-cleanup.ps1 | 82 -- eng/runner/monitor-pr.ps1 | 145 --- eng/runner/runner-safety.ps1 | 848 ------------------ eng/runner/uninstall-runner.ps1 | 55 -- eng/tests/runner-contract.Tests.ps1 | 373 -------- eng/tests/workflow-contract.Tests.ps1 | 6 - eng/tests/workflow_contract_tests.py | 477 ++++------ .../Commands/BaselineOrchestrationTests.cs | 43 +- 18 files changed, 252 insertions(+), 2127 deletions(-) delete mode 100644 eng/runner/install-runner.ps1 delete mode 100644 eng/runner/job-start-cleanup.ps1 delete mode 100644 eng/runner/monitor-pr.ps1 delete mode 100644 eng/runner/runner-safety.ps1 delete mode 100644 eng/runner/uninstall-runner.ps1 delete mode 100644 eng/tests/runner-contract.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19fe5fa..befa3cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ permissions: contents: read env: - NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages jobs: validation: @@ -36,16 +36,16 @@ jobs: permissions: contents: read with: - runner-labels: ${{ github.event_name == 'pull_request' && '["self-hosted","Windows","X64","smartpipe-cleanup-v1"]' || '["ubuntu-latest"]' }} + runner-labels: ${{ github.event_name == 'pull_request' && '["windows-latest"]' || '["ubuntu-latest"]' }} hosting-integration: - name: Hosting integration (${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}) + name: Hosting integration (${{ matrix.os == 'windows-latest' && 'Windows' || matrix.os }}) if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) - runs-on: ${{ matrix.os == 'self-hosted' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || matrix.os }} + runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: fail-fast: false - matrix: ${{ fromJSON(github.event_name == 'pull_request' && '{"os":["self-hosted"]}' || '{"os":["ubuntu-latest","windows-latest"]}') }} + matrix: ${{ fromJSON(github.event_name == 'pull_request' && '{"os":["windows-latest"]}' || '{"os":["ubuntu-latest","windows-latest"]}') }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -56,6 +56,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore Hosting integration tests run: dotnet restore tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --locked-mode @@ -68,7 +70,7 @@ jobs: json-file-windows: if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'windows-latest' }} + runs-on: windows-latest timeout-minutes: 20 steps: @@ -80,6 +82,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode @@ -124,7 +128,7 @@ jobs: baseline-contract-windows: name: Baseline contract (Windows) if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'windows-latest' }} + runs-on: windows-latest timeout-minutes: 20 steps: @@ -137,6 +141,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true @@ -156,7 +162,7 @@ jobs: diagnostic-consumer: name: Diagnostic consumer (${{ inputs.diagnostic-scenario }}) if: github.event_name == 'workflow_dispatch' && (inputs.diagnostic-sha != '' || inputs.diagnostic-scenario != '' || inputs.diagnostic-repeat != '') - runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] + runs-on: windows-latest timeout-minutes: 45 steps: - name: Validate diagnostic inputs @@ -190,6 +196,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode @@ -235,40 +243,3 @@ jobs: if ($summaryText.Length -gt 8192) { $summaryText = $summaryText.Substring(0, 8192) + [Environment]::NewLine + '... summary truncated ...' } Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value $summaryText if ($failed) { exit 1 } - - cleanup-self-hosted: - name: Cleanup self-hosted workspace - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - needs: [validation, hosting-integration, json-file-windows, baseline-contract-windows] - runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] - steps: - - name: Cleanup generated outputs - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is required.' } - $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) - if ((Get-Item -LiteralPath $workspace -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Workspace is a reparse point.' } - $prefix = "$workspace$([IO.Path]::DirectorySeparatorChar)" - $targets = [Collections.Generic.List[string]]::new() - $targets.Add((Join-Path $workspace 'artifacts')) - $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) - $targets.Add((Join-Path $workspace '.nuget')) - $pending = [Collections.Generic.Stack[string]]::new() - $pending.Push($workspace) - while ($pending.Count) { - foreach ($directory in Get-ChildItem -LiteralPath $pending.Pop() -Force -Directory) { - if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } - if ($directory.Name -in 'bin', 'obj') { $targets.Add($directory.FullName) } - else { $pending.Push($directory.FullName) } - } - } - foreach ($target in $targets | Sort-Object Length -Descending -Unique) { - $fullPath = [IO.Path]::GetFullPath($target) - if (!$fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Outside workspace: $fullPath" } - if (Test-Path -LiteralPath $fullPath -PathType Container) { - if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point: $fullPath" } - if (Get-ChildItem -LiteralPath $fullPath -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { throw "Reparse point: $fullPath" } - Remove-Item -LiteralPath $fullPath -Recurse -Force - } - } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5327eed..1889dcd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,4 @@ -name: Hosted .NET static analysis +name: CodeQL on: push: @@ -10,10 +10,14 @@ on: permissions: contents: read + security-events: write + +env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages jobs: analyze: - name: Hosted .NET static analysis + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -24,15 +28,19 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Initialize CodeQL + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: csharp - - name: Restore locked - shell: pwsh - run: | - dotnet restore SmartPipe.Core.slnx --locked-mode - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Build static analysis - shell: pwsh - run: | - dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build + run: dotnet build SmartPipe.Core.slnx -c Release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + ram: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '16384' || '' }} + threads: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '2' || '' }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0d77c8e..153a0a4 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,4 +1,4 @@ -name: Repository security audit +name: Dependency Review on: pull_request: @@ -6,53 +6,15 @@ on: permissions: contents: read + pull-requests: read jobs: - repository-security-audit: - name: Repository security audit - if: github.event.pull_request.head.repo.full_name == github.repository + dependency-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - global-json-file: global.json - - - name: Restore locked - shell: pwsh - run: | - dotnet restore SmartPipe.Core.slnx --locked-mode - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Build repository checks - shell: pwsh - run: | - dotnet build eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-restore -warnaserror - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Verify repository package contracts - shell: pwsh - run: | - dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build --no-restore -- verify --profile sp220-05 --format github --failures-only - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Vulnerable package scan - shell: pwsh - run: | - New-Item -ItemType Directory -Path artifacts/audit -Force | Out-Null - dotnet package list --project SmartPipe.Core.slnx --vulnerable --include-transitive --format json --output-version 1 --no-restore > artifacts/audit/vulnerable.json - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Verify direct production audit policy - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build --no-restore -- verify-nuget-audit --repo-root . --report artifacts/audit/vulnerable.json - - - name: Deprecated package scan - shell: pwsh - run: | - dotnet package list --project SmartPipe.Core.slnx --deprecated --include-transitive --format json --output-version 1 --no-restore > artifacts/audit/deprecated.json - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Dependency review + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index 1232a5f..2d19453 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -23,7 +23,7 @@ permissions: contents: read env: - NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages jobs: build-test-pack: @@ -40,6 +40,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode @@ -264,6 +266,7 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ inputs.artifact-name }} + retention-days: ${{ inputs.artifact-name == 'packages' && 7 || 90 }} path: | artifacts/packages artifacts/consumers/**/result.json @@ -284,6 +287,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode diff --git a/docs/contributing.md b/docs/contributing.md index 4e3bfc5..6174db4 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -80,68 +80,18 @@ unbounded-memory symptom in progress notes. README examples are intentionally minimal. CI consumer smoke is the executable check for the public quick-start scenarios. -## Dedicated Windows runner operations +## Hosted CI operations -The same-repository Windows jobs use the exact labels -`self-hosted`, `Windows`, `X64`, and `smartpipe-cleanup-v1`. The installation -root is deliberately fixed at `C:\SmartPipe-Runner`; do not point the hook at a -developer checkout, `_tool`, the runner binaries, or a shared temporary root. +GitHub-hosted runners provide the CI environment. Same-repository pull requests +run validation and Windows-specific lanes on `windows-latest`; push and manual +dispatch runs keep the Hosting integration Linux and Windows matrix. CodeQL and +Dependency Review use their official public-repository workflows. -Install or remove the repository-owned hook only while the runner is idle: - -```powershell -gh auth status -pwsh -NoProfile -File eng\runner\install-runner.ps1 -pwsh -NoProfile -File eng\runner\uninstall-runner.ps1 -``` - -The scripts resolve the exact runner name from `.runner` (`agentName`); an -optional `-RunnerName` is accepted only when it exactly matches that value. -They fail closed for missing or ambiguous configuration. The installer checks -the repository, queued/in-progress Actions runs, and remote runner state before -mutation. It writes only the hook's `.env` entry, copies the hook plus its -safety helper into the runner's `hooks` directory, registers exactly -`smartpipe-cleanup-v1` through the GitHub runner-label API while preserving -other labels, stops listeners tied to the exact root, launches one hidden -`run.cmd`, and waits for exactly one online, idle listener. Uninstall removes -only that custom label and the owned entry/copies, preserves unrelated labels -and `.env` lines, then performs the same bounded one-listener restart. A failed -operation reports recovery guidance; never convert the runner to a service as -part of this operation. The second owned `.env` entry points -`DOTNET_INSTALL_DIR` at `_work\_tool\dotnet`, giving `actions/setup-dotnet` a -writable persistent directory without granting access to -`C:\Program Files\dotnet`. -The hook entry is `ACTIONS_RUNNER_HOOK_JOB_STARTED`; upgrades remove the legacy -`ACTIONS_RUNNER_HOOK_JOB_COMPLETED` entry and hook copy before writing the new -owned state. Before any file, label, stop, or restart mutation, every -`Runner.Listener.exe` must be classifiable to this exact root. Missing or -unreadable process metadata and listeners belonging to another root fail closed -with their PIDs; ambiguous listeners are never stopped automatically. - -The job-start hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout -remote, and canonicalizes every target beneath the dedicated runner root. It -runs before the next job starts, after the runner has completed the previous -job's process cleanup, removes the exact prior checkout, and recreates its -empty workspace directory before the next checkout. It also removes the known -`SmartPipe.Core`, `SmartPipe-Core`, `CodeQL`, and `codeql` directories below -`RUNNER_TEMP`. Missing temp targets are successful. Any outside path, broad -root, reparse point, unsafe repository, non-empty recreation, or deletion error -fails closed before removal. An existing empty workspace is accepted -idempotently; any non-empty workspace must pass the exact repository/origin -authorization before removal. The existing workflow cleanup jobs remain as -defense in depth. - -For a compact, transition-only pull-request view: - -```powershell -pwsh -NoProfile -File eng\runner\monitor-pr.ps1 -PullRequest 123 -MaxPolls 120 -``` - -The monitor uses `gh pr view`, prints only a changed head/state/merge/check -summary, and stops at `MERGED`, `CLOSED`, or the poll bound. For each newly -failed head it retrieves one failed-run log, prints a bounded first-causal -slice, and removes its task-specific temporary log directory on exit. `-Once` -is useful for a single snapshot. It does not upload logs or alter GitHub state. +Restore-heavy jobs set `NUGET_PACKAGES` below `GITHUB_WORKSPACE` and use the +built-in `actions/setup-dotnet` cache keyed by `**/packages.lock.json`. Build +outputs, credentials, and secrets are never cached. Generic CI package +artifacts are retained for seven days; versioned release artifacts retain the +repository's normal release retention. The optional diagnostic dispatch runs one exact commit and one internal consumer scenario without changing normal push or pull-request behavior: @@ -158,8 +108,3 @@ lowercase letters, digits, and hyphens, and repeat must be `1` through `5`. The job restores, builds, and packs once, then reports bounded run snippets in the step summary without artifacts. Normal jobs run when all three inputs are empty. - -If rollout must be reverted, stop the idle listener, run the uninstaller, -restart the listener, and revert the workflow change with a normal commit. -Do not delete the runner root or use `git clean`; safe cleanup is intentionally -recoverable and scoped to the exact approved boundaries. diff --git a/docs/governance/2.2.0-branch-and-review-policy.md b/docs/governance/2.2.0-branch-and-review-policy.md index 4ff6d50..d81303c 100644 --- a/docs/governance/2.2.0-branch-and-review-policy.md +++ b/docs/governance/2.2.0-branch-and-review-policy.md @@ -42,7 +42,7 @@ The repository owner or administrator applies and verifies an active GitHub rule | Conversation resolution | Required | | Status checks | Required | | Branch currentness | Required, or enforced by merge queue | -| Checks | `CI / validation`, Windows JSON lane, Hosted .NET static analysis, Repository security audit, baseline contract | +| Checks | `CI / validation`, Windows JSON lane, CodeQL, Dependency Review, baseline contract | | Linear history | Disabled while merge commits are required for reviewed hotfix synchronization | | Bypass | Repository owner only; audited as described below | diff --git a/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs b/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs index d3b38f1..0b34c76 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs @@ -328,8 +328,8 @@ private static async Task> ReadWorkflowEvidenceA foreach (var requiredName in new[] { "CI", - "Hosted .NET static analysis", - "Repository security audit", + "CodeQL", + "Dependency Review", }) { var successful = runs.Where(run => diff --git a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs index ec85418..a5c50a1 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs @@ -45,25 +45,18 @@ internal sealed class BaselineVerificationService private const string TargetRelease = "2.2.0"; private const string SolutionPath = "SmartPipe.Core.slnx"; private static readonly TimeSpan ProcessTimeout = TimeSpan.FromMinutes(2); - private static readonly string[] HistoricalManifestWorkflowNames = + private static readonly string[] ManifestWorkflowNames = [ "CI", "CodeQL", "Dependency Review", ]; - private static readonly string[] CurrentManifestWorkflowNames = - [ - "CI", - "Hosted .NET static analysis", - "Repository security audit", - ]; - private static readonly (string Name, string Path, string[] Events)[] CurrentWorkflowPolicy = [ ("CI", ".github/workflows/ci.yml", ["push", "pull_request"]), - ("Hosted .NET static analysis", ".github/workflows/codeql.yml", ["push", "pull_request"]), - ("Repository security audit", ".github/workflows/dependency-review.yml", ["pull_request"]), + ("CodeQL", ".github/workflows/codeql.yml", ["push", "pull_request"]), + ("Dependency Review", ".github/workflows/dependency-review.yml", ["pull_request"]), ]; private readonly IProcessRunner _processRunner; @@ -132,11 +125,10 @@ internal async Task VerifyAsync( var workflowNames = manifest.Repository.RequiredWorkflows .Select(static workflow => workflow.Name) .ToHashSet(StringComparer.Ordinal); - if (!workflowNames.SetEquals(HistoricalManifestWorkflowNames) - && !workflowNames.SetEquals(CurrentManifestWorkflowNames)) + if (!workflowNames.SetEquals(ManifestWorkflowNames)) { throw new JsonException( - "Manifest workflow evidence must contain exactly either CI, CodeQL, and Dependency Review or CI, Hosted .NET static analysis, and Repository security audit."); + "Manifest workflow evidence must contain exactly CI, CodeQL, and Dependency Review."); } // Resolve and de-alias every referenced path before any package, process, or repository work. diff --git a/eng/baselines/README.md b/eng/baselines/README.md index 203ac38..91fd89f 100644 --- a/eng/baselines/README.md +++ b/eng/baselines/README.md @@ -26,9 +26,9 @@ The manifest rejects unknown properties and schema versions. `repository.capture - `SPB007`-`SPB010`: package hash, signature, identity/assets, or dependencies mismatch; - `SPB014`: public API snapshot mismatch; - `SPB015`: repository dependency snapshot mismatch; -- `SPB016`: required release branch missing from CI, Hosted .NET static analysis, or Repository security audit workflow policy. +- `SPB016`: required release branch missing from CI, CodeQL, or Dependency Review workflow policy. Offline verification never fetches packages. It requires the capture commit to exist and be an ancestor of current HEAD, failing closed for unrelated or missing/shallow history. It hashes package bytes before signature or archive inspection and ignores unreferenced files in the baseline directory. -Capture consumes the literal JSON array produced by `gh run list --json databaseId,workflowName,headSha,status,conclusion,url,event,createdAt`. Every returned run must target the requested capture commit; that `headSha` is retained in each workflow manifest entry, and exactly one completed successful run is required for each of CI, Hosted .NET static analysis, and Repository security audit. Workflow policy verification uses a bounded parser for the repository's current YAML shape and checks `release/2.2.0` in the actual `on.push.branches` and/or `on.pull_request.branches` lists; comments, environment values, and step text do not count. -Capture requires those current check names exactly and persists those literal names. Offline verification accepts only a complete historical manifest set (`CI`, `CodeQL`, `Dependency Review`) or a complete current set; mixed or extra workflow identities fail closed. +Capture consumes the literal JSON array produced by `gh run list --json databaseId,workflowName,headSha,status,conclusion,url,event,createdAt`. Every returned run must target the requested capture commit; that `headSha` is retained in each workflow manifest entry, and exactly one completed successful run is required for each of CI, CodeQL, and Dependency Review. Workflow policy verification uses a bounded parser for the repository's current YAML shape and checks `release/2.2.0` in the actual `on.push.branches` and/or `on.pull_request.branches` lists; comments, environment values, and step text do not count. +Capture requires those current check names exactly and persists those literal names. Offline verification accepts only the complete historical workflow set (`CI`, `CodeQL`, `Dependency Review`); mixed or extra workflow identities fail closed. diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 deleted file mode 100644 index 9931e1b..0000000 --- a/eng/runner/install-runner.ps1 +++ /dev/null @@ -1,95 +0,0 @@ -[CmdletBinding()] -param( - [string] $RunnerRoot = 'C:\SmartPipe-Runner', - [string] $Repository = 'MrFr3di/SmartPipe-Core', - [string] $RunnerName = '', - [string] $GhPath = 'gh', - [string] $ListenerFixturePath = '', - [int] $ListenerTimeoutSeconds = 60, - [switch] $SkipRemoteCheck, - [switch] $SkipListenerReady, - [switch] $AllowTestRoot, - [switch] $Uninstall -) - -$ErrorActionPreference = 'Stop' -. (Join-Path $PSScriptRoot 'runner-safety.ps1') - -try { - Assert-SmartPipeRepository -Repository $Repository - $runner = Get-SmartPipeFullPath -Path $RunnerRoot - if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { - throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." - } - - if (-not (Test-Path -LiteralPath $runner -PathType Container)) { - throw "Dedicated runner root is missing: $runner" - } - Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner - $resolvedRunnerName = Resolve-SmartPipeRunnerName -Root $runner -RequestedName $RunnerName - if ($SkipRemoteCheck) { - throw 'Remote idle checks cannot be skipped because runner label registration is required. Recovery: no runner files or labels were changed.' - } - - Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath - $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath - Assert-SmartPipeListenerSafety -Root $runner -FixturePath $ListenerFixturePath - - if ($Uninstall) { - $environmentPath = Join-Path $runner '.env' - Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath - $hookDirectory = Join-Path $runner 'hooks' - foreach ($name in @('smartpipe-job-start-cleanup.ps1', 'smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { - $path = Join-Path $hookDirectory $name - if (Test-Path -LiteralPath $path) { - Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner - Remove-Item -LiteralPath $path -Force -ErrorAction Stop - } - } - Remove-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath - if (-not $SkipListenerReady) { - Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds - } - Write-Output "Removed SmartPipe-owned hook, environment entry, and label from $runner and restored one listener." - exit 0 - } - - $hookSource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'job-start-cleanup.ps1') - $safetySource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'runner-safety.ps1') - if (-not (Test-Path -LiteralPath $hookSource -PathType Leaf) -or - -not (Test-Path -LiteralPath $safetySource -PathType Leaf)) { - throw 'Runner hook sources are missing.' - } - - $hookDirectory = Join-Path $runner 'hooks' - if (-not (Test-Path -LiteralPath $hookDirectory -PathType Container)) { - New-Item -ItemType Directory -Path $hookDirectory -Force | Out-Null - } - Assert-SmartPipeNoReparsePath -Path $hookDirectory -Boundary $runner - - $legacyHookDestination = Join-Path $hookDirectory 'smartpipe-post-job-cleanup.ps1' - if (Test-Path -LiteralPath $legacyHookDestination) { - Assert-SmartPipeNoReparsePath -Path $legacyHookDestination -Boundary $runner - Remove-Item -LiteralPath $legacyHookDestination -Force -ErrorAction Stop - } - - $hookDestination = Join-Path $hookDirectory 'smartpipe-job-start-cleanup.ps1' - $safetyDestination = Join-Path $hookDirectory 'runner-safety.ps1' - Copy-Item -LiteralPath $hookSource -Destination $hookDestination -Force - Copy-Item -LiteralPath $safetySource -Destination $safetyDestination -Force - - $environmentPath = Join-Path $runner '.env' - $dotnetInstallDirectory = Join-Path $runner '_work\_tool\dotnet' - Write-SmartPipeEnvironment -EnvironmentPath $environmentPath -HookPath $hookDestination -DotNetInstallDirectory $dotnetInstallDirectory - Add-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath - - if (-not $SkipListenerReady) { - Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds - } - Write-Output "Installed SmartPipe hook and label under $runner with one online idle listener." -} -catch { - $errorText = [string]$_.Exception.Message - Write-Error -Message "$errorText Recovery: confirm the runner and repository are idle, then inspect or rerun eng\runner\uninstall-runner.ps1; existing runner labels are never intentionally removed." - exit 1 -} diff --git a/eng/runner/job-start-cleanup.ps1 b/eng/runner/job-start-cleanup.ps1 deleted file mode 100644 index e451cba..0000000 --- a/eng/runner/job-start-cleanup.ps1 +++ /dev/null @@ -1,82 +0,0 @@ -[CmdletBinding()] -param( - [string] $RunnerRoot = 'C:\SmartPipe-Runner', - [string] $WorkspaceRoot = $env:GITHUB_WORKSPACE, - [string] $TempRoot = $env:RUNNER_TEMP, - [string] $Repository = $env:GITHUB_REPOSITORY, - [switch] $AllowTestRoot -) - -$ErrorActionPreference = 'Stop' -. (Join-Path $PSScriptRoot 'runner-safety.ps1') - -try { - Assert-SmartPipeRepository -Repository $Repository - $runner = Get-SmartPipeFullPath -Path $RunnerRoot - if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { - throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." - } - if (-not (Test-Path -LiteralPath $runner -PathType Container)) { - throw "Dedicated runner root is missing: $runner" - } - - Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner - Set-Location -LiteralPath $runner - [Environment]::CurrentDirectory = $runner - - if ([string]::IsNullOrWhiteSpace($WorkspaceRoot)) { - throw 'GITHUB_WORKSPACE is required.' - } - - $workspace = Get-SmartPipeFullPath -Path $WorkspaceRoot - if (-not (Test-SmartPipeContainedPath -Path $workspace -Boundary $runner)) { - throw "Workspace is outside the dedicated runner root: $workspace" - } - - if (Test-Path -LiteralPath $workspace -PathType Container) { - Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner - if (@(Get-ChildItem -LiteralPath $workspace -Force -ErrorAction Stop).Count -gt 0) { - Assert-SmartPipeWorkspaceRepository -Workspace $workspace - [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) - } - } - elseif (Test-Path -LiteralPath $workspace) { - throw "Workspace path is not a directory: $workspace" - } - else { - Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner - } - - if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { - New-Item -ItemType Directory -Path $workspace -ErrorAction Stop | Out-Null - } - Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner - if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { - throw "Workspace directory was not created: $workspace" - } - if (@(Get-ChildItem -LiteralPath $workspace -Force -ErrorAction Stop).Count -ne 0) { - throw "Workspace directory is not empty after cleanup: $workspace" - } - - if (-not [string]::IsNullOrWhiteSpace($TempRoot)) { - $temp = Get-SmartPipeFullPath -Path $TempRoot - if (-not (Test-SmartPipeContainedPath -Path $temp -Boundary $runner)) { - throw "Runner temp is outside the dedicated runner root: $temp" - } - - if (Test-Path -LiteralPath $temp -PathType Container) { - Assert-SmartPipeNoReparsePath -Path $temp -Boundary $runner - foreach ($name in @('SmartPipe.Core', 'SmartPipe-Core', 'CodeQL', 'codeql')) { - $target = Join-Path $temp $name - [void](Remove-SmartPipeCleanupTarget -Path $target -Boundary $temp) - } - } - } - - Write-Output 'SmartPipe job-start cleanup completed.' -} -catch { - $errorText = [string]$_.Exception.Message - Write-Error -Message $errorText - exit 1 -} diff --git a/eng/runner/monitor-pr.ps1 b/eng/runner/monitor-pr.ps1 deleted file mode 100644 index bad8378..0000000 --- a/eng/runner/monitor-pr.ps1 +++ /dev/null @@ -1,145 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [int] $PullRequest, - [string] $Repository = 'MrFr3di/SmartPipe-Core', - [string] $GhPath = 'gh', - [int] $PollSeconds = 60, - [int] $MaxPolls = 0, - [switch] $Once -) - -$ErrorActionPreference = 'Stop' -. (Join-Path $PSScriptRoot 'runner-safety.ps1') - -function Get-SmartPipeCheckSummary { - param( - [Parameter(Mandatory = $true)] - [object] $Checks - ) - - $parts = [Collections.Generic.List[string]]::new() - foreach ($check in @($Checks)) { - if ($null -eq $check) { - continue - } - $properties = @($check.PSObject.Properties.Name) - $name = if ('name' -in $properties -and $null -ne $check.name) { [string]$check.name } elseif ('context' -in $properties -and $null -ne $check.context) { [string]$check.context } else { 'check' } - $state = if ('conclusion' -in $properties -and [string]$check.conclusion) { [string]$check.conclusion } elseif ('status' -in $properties -and $null -ne $check.status) { [string]$check.status } else { 'pending' } - $parts.Add("$name=$state") - } - - $summary = $parts -join ',' - if ($summary.Length -gt 512) { - return $summary.Substring(0, 512) + '...' - } - - return $summary -} - -function Write-SmartPipeFirstFailure { - param( - [Parameter(Mandatory = $true)] [string] $Head, - [Parameter(Mandatory = $true)] [string] $TemporaryRoot - ) - - $global:LASTEXITCODE = 0 - $runJson = & $GhPath run list --repo $Repository --commit $Head --status failure --limit 1 --json databaseId 2>&1 - if ($global:LASTEXITCODE -ne 0) { - Write-Output 'PR diagnostic: unable to list the failed workflow run.' - return - } - - $runs = @(($runJson -join [Environment]::NewLine) | ConvertFrom-Json) - if ($runs.Count -eq 0) { - Write-Output 'PR diagnostic: no failed workflow run is available yet.' - return - } - - $runId = [string]$runs[0].databaseId - if ($runId -notmatch '^[0-9]+$') { - Write-Output 'PR diagnostic: failed workflow run id is invalid.' - return - } - - $global:LASTEXITCODE = 0 - $failedLog = @(& $GhPath run view $runId --repo $Repository --log-failed 2>&1 | ForEach-Object { [string]$_ }) - $logExitCode = $global:LASTEXITCODE - $logPath = Join-Path $TemporaryRoot "failed-$Head-$runId.log" - [IO.File]::WriteAllLines($logPath, $failedLog) - if ($logExitCode -ne 0) { - Write-Output 'PR diagnostic: failed-step log retrieval was incomplete.' - return - } - - $index = -1 - for ($line = 0; $line -lt $failedLog.Count; $line++) { - if ($failedLog[$line] -match '(?i)(error|exception|failed|NU[0-9]{4}|SP[A-Z]+[0-9]{3})') { - $index = $line - break - } - } - if ($index -lt 0) { $index = 0 } - $last = [Math]::Min($failedLog.Count - 1, $index + 4) - $slice = if ($failedLog.Count -eq 0) { 'no failed-step output' } else { ($failedLog[$index..$last] -join ' | ').Trim() } - if ($slice.Length -gt 1024) { $slice = $slice.Substring(0, 1024) + '...' } - Write-Output "PR diagnostic: first causal slice: $slice" -} - -$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-pr-monitor-$PID-$([Guid]::NewGuid().ToString('N'))" -try { - Assert-SmartPipeRepository -Repository $Repository - if ($PullRequest -lt 1) { - throw 'PullRequest must be positive.' - } - if ($PollSeconds -lt 1) { - throw 'PollSeconds must be positive.' - } - if ($MaxPolls -lt 0) { - throw 'MaxPolls cannot be negative.' - } - - New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null - $previous = $null - $diagnosedHead = '' - $poll = 0 - while ($true) { - $LASTEXITCODE = 0 - $json = & $GhPath pr view $PullRequest --repo $Repository --json state,mergeStateStatus,headRefOid,statusCheckRollup 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "gh pr view failed: $($json -join ' ')" - } - - $view = ($json -join [Environment]::NewLine) | ConvertFrom-Json - $state = [string]$view.state - $mergeState = [string]$view.mergeStateStatus - $head = [string]$view.headRefOid - $checks = Get-SmartPipeCheckSummary -Checks $view.statusCheckRollup - $signature = "$state|$mergeState|$head|$checks" - if ($signature -ne $previous) { - Write-Output "PR #$PullRequest transition: state=$state merge=$mergeState head=$head checks=$checks" - $previous = $signature - } - if ($head -ne $diagnosedHead -and $checks -match '(?i)=(FAILURE|CANCELLED|TIMED_OUT|ACTION_REQUIRED|STARTUP_FAILURE)') { - Write-SmartPipeFirstFailure -Head $head -TemporaryRoot $temporaryRoot - $diagnosedHead = $head - } - - $poll++ - if ($state -in @('MERGED', 'CLOSED') -or $Once -or ($MaxPolls -gt 0 -and $poll -ge $MaxPolls)) { - break - } - - Start-Sleep -Seconds $PollSeconds - } -} -catch { - $errorText = [string]$_.Exception.Message - Write-Error -Message $errorText - exit 1 -} -finally { - if (Test-Path -LiteralPath $temporaryRoot) { - Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue - } -} diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 deleted file mode 100644 index daf99a3..0000000 --- a/eng/runner/runner-safety.ps1 +++ /dev/null @@ -1,848 +0,0 @@ -Set-StrictMode -Version Latest - -$script:SmartPipeRunnerDefaultRoot = 'C:\SmartPipe-Runner' -$script:SmartPipeRunnerRepository = 'MrFr3di/SmartPipe-Core' -$script:SmartPipeRunnerLabel = 'smartpipe-cleanup-v1' - -function Get-SmartPipeFullPath { - param( - [Parameter(Mandatory = $true)] - [string] $Path - ) - - if ([string]::IsNullOrWhiteSpace($Path)) { - throw 'A path is required.' - } - - try { - $fullPath = [IO.Path]::GetFullPath($Path) - } - catch { - throw "Invalid path: $Path" - } - - if ($fullPath.Length -gt 3) { - return $fullPath.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) - } - - return $fullPath -} - -function Test-SmartPipeSamePath { - param( - [Parameter(Mandatory = $true)] - [string] $Left, - - [Parameter(Mandatory = $true)] - [string] $Right - ) - - return [string]::Equals( - (Get-SmartPipeFullPath -Path $Left), - (Get-SmartPipeFullPath -Path $Right), - [StringComparison]::OrdinalIgnoreCase) -} - -function Test-SmartPipeContainedPath { - param( - [Parameter(Mandatory = $true)] - [string] $Path, - - [Parameter(Mandatory = $true)] - [string] $Boundary, - - [switch] $AllowBoundary - ) - - $candidate = Get-SmartPipeFullPath -Path $Path - $boundaryPath = Get-SmartPipeFullPath -Path $Boundary - if ($AllowBoundary -and (Test-SmartPipeSamePath -Left $candidate -Right $boundaryPath)) { - return $true - } - - $prefix = "$boundaryPath$([IO.Path]::DirectorySeparatorChar)" - return $candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) -} - -function Assert-SmartPipeNoReparsePath { - param( - [Parameter(Mandatory = $true)] - [string] $Path, - - [Parameter(Mandatory = $true)] - [string] $Boundary - ) - - $candidate = Get-SmartPipeFullPath -Path $Path - $boundaryPath = Get-SmartPipeFullPath -Path $Boundary - if (-not (Test-SmartPipeContainedPath -Path $candidate -Boundary $boundaryPath -AllowBoundary)) { - throw "Path is outside the approved boundary: $candidate" - } - - $current = $candidate - while ($true) { - if (Test-Path -LiteralPath $current) { - $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop - if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Reparse point is not an approved cleanup target: $current" - } - } - - if (Test-SmartPipeSamePath -Left $current -Right $boundaryPath) { - break - } - - $parent = Split-Path -Path $current -Parent - if ([string]::IsNullOrWhiteSpace($parent) -or (Test-SmartPipeSamePath -Left $parent -Right $current)) { - throw "Could not prove path containment: $candidate" - } - - $current = Get-SmartPipeFullPath -Path $parent - if (-not (Test-SmartPipeContainedPath -Path $current -Boundary $boundaryPath -AllowBoundary)) { - throw "Path escaped the approved boundary: $candidate" - } - } - - if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { - return - } - - $pending = [Collections.Generic.Stack[string]]::new() - $pending.Push($candidate) - while ($pending.Count -gt 0) { - $directory = $pending.Pop() - foreach ($child in Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) { - if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Reparse point is not an approved cleanup target: $($child.FullName)" - } - - if ($child.PSIsContainer) { - $pending.Push($child.FullName) - } - } - } -} - -function Assert-SmartPipeCleanupTarget { - param( - [Parameter(Mandatory = $true)] - [string] $Path, - - [Parameter(Mandatory = $true)] - [string] $Boundary, - - [switch] $AllowBoundary - ) - - $candidate = Get-SmartPipeFullPath -Path $Path - $boundaryPath = Get-SmartPipeFullPath -Path $Boundary - if (Test-SmartPipeSamePath -Left $candidate -Right $boundaryPath) { - throw "Cleanup target is the approved boundary itself: $candidate" - } - if (-not (Test-SmartPipeContainedPath -Path $candidate -Boundary $boundaryPath -AllowBoundary:$AllowBoundary)) { - throw "Cleanup target is outside the approved boundary: $candidate" - } - - $runnerLeaf = Split-Path -Path $candidate -Leaf - if ($runnerLeaf -in @('_tool', '_work', 'bin', 'Runner', 'externals')) { - throw "Cleanup target is too broad or protected: $candidate" - } - - $runnerRoot = Get-SmartPipeFullPath -Path $script:SmartPipeRunnerDefaultRoot - if (Test-SmartPipeSamePath -Left $candidate -Right $runnerRoot) { - throw 'The dedicated runner root is never a cleanup target.' - } - - Assert-SmartPipeNoReparsePath -Path $candidate -Boundary $Boundary - return $candidate -} - -function Remove-SmartPipeCleanupTarget { - param( - [Parameter(Mandatory = $true)] - [string] $Path, - - [Parameter(Mandatory = $true)] - [string] $Boundary, - - [switch] $AllowBoundary - ) - - $candidate = Assert-SmartPipeCleanupTarget -Path $Path -Boundary $Boundary -AllowBoundary:$AllowBoundary - if (-not (Test-Path -LiteralPath $candidate)) { - return $false - } - - if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { - throw "Cleanup target is not a directory: $candidate" - } - - Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction Stop - return $true -} - -function Assert-SmartPipeRepository { - param( - [Parameter(Mandatory = $true)] - [AllowEmptyString()] - [string] $Repository - ) - - if (-not [string]::Equals($Repository, $script:SmartPipeRunnerRepository, [StringComparison]::OrdinalIgnoreCase)) { - throw "Unexpected repository '$Repository'." - } -} - -function Resolve-SmartPipeRunnerName { - param( - [Parameter(Mandatory = $true)] - [string] $Root, - - [string] $RequestedName = '' - ) - - $configPath = Join-Path $Root '.runner' - if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { - throw "Runner configuration is missing: $configPath" - } - - try { - $config = Get-Content -LiteralPath $configPath -Raw -ErrorAction Stop | ConvertFrom-Json - $agentNameProperty = @($config.PSObject.Properties | Where-Object { $_.Name -eq 'agentName' }) - if ($agentNameProperty.Count -ne 1 -or $null -eq $agentNameProperty[0].Value -or - $agentNameProperty[0].Value -is [Array]) { - throw 'agentName is missing or ambiguous.' - } - $configuredName = [string]$agentNameProperty[0].Value - } - catch { - throw "Runner configuration is invalid: $configPath" - } - - if ([string]::IsNullOrWhiteSpace($configuredName)) { - throw "Runner configuration has no unambiguous agentName: $configPath" - } - if (-not [string]::IsNullOrWhiteSpace($RequestedName) -and - -not [string]::Equals($RequestedName, $configuredName, [StringComparison]::Ordinal)) { - throw "Requested runner name '$RequestedName' does not match .runner agentName '$configuredName'." - } - - return $configuredName -} - -function Assert-SmartPipeWorkspaceRepository { - param( - [Parameter(Mandatory = $true)] - [string] $Workspace - ) - - $gitPath = Join-Path $Workspace '.git' - if (-not (Test-Path -LiteralPath $gitPath)) { - throw "Workspace repository metadata is missing: $Workspace" - } - - $configPath = if (Test-Path -LiteralPath $gitPath -PathType Container) { - Join-Path $gitPath 'config' - } - else { - $gitPath - } - - if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { - throw "Workspace repository configuration is missing: $Workspace" - } - - $global:LASTEXITCODE = 0 - $gitOutput = & git -C $Workspace remote get-url origin 2>&1 - $gitExitCode = $global:LASTEXITCODE - if ($gitExitCode -eq 0) { - $urls = @($gitOutput | ForEach-Object { ([string]$_).Trim() } | Where-Object { $_ -ne '' }) - if ($urls.Count -ne 1) { - throw "Workspace origin remote is ambiguous: $Workspace" - } - - Assert-SmartPipeCanonicalRemote -Url $urls[0] -Workspace $Workspace - return - } - - # Test fixtures and worktrees without a usable git executable use the - # strict INI fallback. Comments never participate in URL selection. - $section = '' - $originUrls = [Collections.Generic.List[string]]::new() - foreach ($line in (Get-Content -LiteralPath $configPath -ErrorAction Stop)) { - $text = ([string]$line).Trim() - if ($text -eq '' -or $text.StartsWith('#') -or $text.StartsWith(';')) { - continue - } - - if ($text -match '^\[remote\s+"([^"]+)"\]$') { - $section = $Matches[1] - continue - } - - if ($text -match '^(?[A-Za-z][A-Za-z0-9-]*)\s*=\s*(?\S+)$') { - if ($section -eq 'origin' -and $Matches.key -eq 'url') { - [void]$originUrls.Add($Matches.value) - } - elseif ($section -eq 'origin' -and $Matches.key -notin @('fetch', 'pushurl', 'mirror', 'tagopt')) { - throw "Unsupported origin configuration entry: $Workspace" - } - continue - } - - throw "Invalid git remote configuration: $Workspace" - } - - if ($originUrls.Count -ne 1) { - throw "Workspace origin remote is missing or ambiguous: $Workspace" - } - - Assert-SmartPipeCanonicalRemote -Url $originUrls[0] -Workspace $Workspace -} - -function Assert-SmartPipeCanonicalRemote { - param( - [Parameter(Mandatory = $true)] - [string] $Url, - - [Parameter(Mandatory = $true)] - [string] $Workspace - ) - - $normalized = $Url.Trim() - if ($normalized -match '^(?i:https://github\.com/MrFr3di/SmartPipe-Core(?:\.git)?|git@github\.com:MrFr3di/SmartPipe-Core(?:\.git)?|ssh://git@github\.com/MrFr3di/SmartPipe-Core(?:\.git)?)$') { - return - } - - throw "Workspace origin remote is not MrFr3di/SmartPipe-Core: $Workspace" -} - -function Get-SmartPipeListenerClassification { - param( - [Parameter(Mandatory = $true)] - [object] $Listener, - - [Parameter(Mandatory = $true)] - [string] $Root - ) - - $runnerRoot = Get-SmartPipeFullPath -Path $Root - $executablePath = '' - $executableReadable = $true - try { - $executablePath = [string]$Listener.ExecutablePath - } - catch { - $executableReadable = $false - } - - if (-not $executableReadable -or [string]::IsNullOrWhiteSpace($executablePath)) { - return 'unclassified' - } - try { - if (-not [IO.Path]::IsPathFullyQualified($executablePath)) { - return 'unclassified' - } - } - catch { - return 'unclassified' - } - - try { - if (Test-SmartPipeContainedPath -Path $executablePath -Boundary $runnerRoot) { - return 'exact' - } - return 'outside' - } - catch { - return 'unclassified' - } -} - -function Get-SmartPipeListenerProcesses { - param( - [Parameter(Mandatory = $true)] - [string] $Root, - - [string] $FixturePath = '' - ) - - $listenerRecords = @() - if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { - if (-not (Test-Path -LiteralPath $FixturePath -PathType Leaf)) { - return @() - } - - $text = (Get-Content -LiteralPath $FixturePath -Raw -ErrorAction Stop).Trim() - $runnerRoot = Get-SmartPipeFullPath -Path $Root - $fixtureExecutable = Join-Path $runnerRoot 'bin\Runner.Listener.exe' - if ($text -eq 'unclassified-duplicate') { - $listenerRecords = @( - [pscustomobject]@{ - ProcessId = 4101 - Name = 'Runner.Listener.exe' - ExecutablePath = $fixtureExecutable - CommandLine = $fixtureExecutable - }, - [pscustomobject]@{ - ProcessId = 4102 - Name = 'Runner.Listener.exe' - ExecutablePath = $null - CommandLine = "-RunnerRoot $runnerRoot" - } - ) - } - else { - $count = 0 - if (-not [int]::TryParse($text, [Globalization.NumberStyles]::Integer, [Globalization.CultureInfo]::InvariantCulture, [ref]$count) -or $count -lt 0) { - throw "Invalid listener fixture state: $FixturePath" - } - - $fixtureListeners = [Collections.Generic.List[object]]::new() - for ($index = 1; $index -le $count; $index++) { - [void]$fixtureListeners.Add([pscustomobject]@{ - ProcessId = 0 - Name = 'Runner.Listener.exe' - ExecutablePath = $fixtureExecutable - CommandLine = $fixtureExecutable - }) - } - $listenerRecords = @($fixtureListeners) - } - } - else { - try { - $listenerRecords = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Where-Object { - $_.Name -in @('Runner.Listener.exe', 'Runner.Listener') - }) - } - catch { - if ($IsWindows) { - throw "Unable to inspect listener processes for $Root." - } - return @() - } - } - - $exactListeners = [Collections.Generic.List[object]]::new() - $unclassifiedIds = [Collections.Generic.List[string]]::new() - $outsideIds = [Collections.Generic.List[string]]::new() - foreach ($listener in $listenerRecords) { - $processId = $null - try { - $processId = $listener.ProcessId - } - catch { - $processId = $null - } - $processIdText = if ($null -eq $processId -or [string]::IsNullOrWhiteSpace([string]$processId)) { 'unknown' } else { [string]$processId } - $classification = Get-SmartPipeListenerClassification -Listener $listener -Root $Root - if ($classification -eq 'exact') { - [void]$exactListeners.Add($listener) - } - elseif ($classification -eq 'outside') { - [void]$outsideIds.Add($processIdText) - } - else { - [void]$unclassifiedIds.Add($processIdText) - } - } - - if ($unclassifiedIds.Count -gt 0 -or $outsideIds.Count -gt 0) { - $details = [Collections.Generic.List[string]]::new() - if ($unclassifiedIds.Count -gt 0) { - [void]$details.Add("unclassified Runner.Listener PID(s): $($unclassifiedIds -join ', ')") - } - if ($outsideIds.Count -gt 0) { - [void]$details.Add("Runner.Listener outside '$Root' PID(s): $($outsideIds -join ', ')") - } - throw "Runner listener safety check failed for '$Root': $($details -join '; '). No listener was stopped." - } - - return @($exactListeners) -} - -function Assert-SmartPipeListenerSafety { - param( - [Parameter(Mandatory = $true)] - [string] $Root, - - [string] $FixturePath = '' - ) - - $null = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) -} - -function Stop-SmartPipeListenerProcesses { - param( - [Parameter(Mandatory = $true)] - [string] $Root, - - [string] $FixturePath = '', - - [int] $TimeoutSeconds = 20 - ) - - if ($TimeoutSeconds -lt 1) { - throw 'Listener stop timeout must be positive.' - } - - $listeners = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) - if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { - Set-Content -LiteralPath $FixturePath -Value '0' -NoNewline - return - } - - foreach ($listener in $listeners) { - if ([int]$listener.ProcessId -gt 0) { - Stop-Process -Id $listener.ProcessId -Force -ErrorAction Stop - } - } - - $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) - while (@(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath).Count -gt 0) { - if ([DateTime]::UtcNow -ge $deadline) { - throw "Runner listener did not stop within $TimeoutSeconds seconds: $Root" - } - Start-Sleep -Seconds 1 - } -} - -function Start-SmartPipeRunner { - param( - [Parameter(Mandatory = $true)] - [string] $Root, - - [string] $FixturePath = '' - ) - - $runCommand = Join-Path $Root 'run.cmd' - if (-not (Test-Path -LiteralPath $runCommand -PathType Leaf)) { - throw "Runner command is missing: $runCommand" - } - - Start-Process -FilePath $runCommand -WorkingDirectory $Root -WindowStyle Hidden | Out-Null - if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { - Set-Content -LiteralPath $FixturePath -Value '1' -NoNewline - } -} - -function Get-SmartPipeRemoteRunner { - param( - [Parameter(Mandatory = $true)] - [string] $Repository, - - [Parameter(Mandatory = $true)] - [string] $RunnerName, - - [string] $GhPath = 'gh' - ) - - $global:LASTEXITCODE = 0 - $json = & $GhPath api "repos/$Repository/actions/runners?per_page=100" 2>&1 - $ghExitCode = $global:LASTEXITCODE - if ($ghExitCode -ne 0) { - throw "Unable to query GitHub runner state: $($json -join ' ')" - } - - $response = ($json -join [Environment]::NewLine) | ConvertFrom-Json - $runners = @($response.runners | Where-Object { $_.name -eq $RunnerName }) - if ($runners.Count -ne 1) { - throw "Expected exactly one GitHub runner named '$RunnerName'." - } - - return ,$runners[0] -} - -function Get-SmartPipeRunnerLabelNames { - param( - [Parameter(Mandatory = $true)] - [object] $Runner - ) - - $names = [Collections.Generic.List[string]]::new() - foreach ($label in @($Runner.labels)) { - if ($label -is [string]) { - $name = [string]$label - } - else { - $nameProperty = $label.PSObject.Properties['name'] - $name = if ($null -ne $nameProperty) { [string]$nameProperty.Value } else { '' } - } - if (-not [string]::IsNullOrWhiteSpace($name)) { - [void]$names.Add($name) - } - } - return $names.ToArray() -} - -function Add-SmartPipeRunnerLabel { - param( - [Parameter(Mandatory = $true)] - [string] $Repository, - - [Parameter(Mandatory = $true)] - [object] $Runner, - - [string] $GhPath = 'gh' - ) - - $runnerId = [string]$Runner.id - if ([string]::IsNullOrWhiteSpace($runnerId) -or $runnerId -notmatch '^[0-9]+$') { - throw 'GitHub runner id is missing or invalid; refusing label mutation.' - } - - $before = @(Get-SmartPipeRunnerLabelNames -Runner $Runner) - $global:LASTEXITCODE = 0 - $json = & $GhPath api --method POST "repos/$Repository/actions/runners/$runnerId/labels" -f "labels[]=$script:SmartPipeRunnerLabel" 2>&1 - $ghExitCode = $global:LASTEXITCODE - if ($ghExitCode -ne 0) { - throw "Unable to add runner label '$script:SmartPipeRunnerLabel'. Existing labels were not intentionally removed." - } - - try { - $postResponse = ($json -join [Environment]::NewLine) | ConvertFrom-Json - $postLabels = @(Get-SmartPipeRunnerLabelNames -Runner $postResponse) - } - catch { - throw "GitHub runner label response was invalid: $($json -join ' '). Recovery: existing labels were not intentionally removed; inspect the runner before retrying." - } - if ($script:SmartPipeRunnerLabel -notin $postLabels) { - throw "GitHub did not confirm runner label '$script:SmartPipeRunnerLabel' in the mutation response." - } - - $afterRunner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName ([string]$Runner.name) -GhPath $GhPath - $after = @(Get-SmartPipeRunnerLabelNames -Runner $afterRunner) - if ($script:SmartPipeRunnerLabel -notin $after) { - throw "GitHub did not confirm runner label '$script:SmartPipeRunnerLabel'." - } - foreach ($label in $before) { - if ($label -notin $after) { - throw "Adding runner label removed existing label '$label'; refusing to continue." - } - } -} - -function Remove-SmartPipeRunnerLabel { - param( - [Parameter(Mandatory = $true)] - [string] $Repository, - - [Parameter(Mandatory = $true)] - [object] $Runner, - - [string] $GhPath = 'gh' - ) - - $runnerId = [string]$Runner.id - if ([string]::IsNullOrWhiteSpace($runnerId) -or $runnerId -notmatch '^[0-9]+$') { - throw 'GitHub runner id is missing or invalid; refusing label mutation.' - } - - $before = @(Get-SmartPipeRunnerLabelNames -Runner $Runner) - if ($script:SmartPipeRunnerLabel -in $before) { - $global:LASTEXITCODE = 0 - $null = & $GhPath api --method DELETE "repos/$Repository/actions/runners/$runnerId/labels/$script:SmartPipeRunnerLabel" 2>&1 - $ghExitCode = $global:LASTEXITCODE - if ($ghExitCode -ne 0) { - throw "Unable to remove runner label '$script:SmartPipeRunnerLabel'." - } - } - - $afterRunner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName ([string]$Runner.name) -GhPath $GhPath - $after = @(Get-SmartPipeRunnerLabelNames -Runner $afterRunner) - if ($script:SmartPipeRunnerLabel -in $after) { - throw "GitHub still reports runner label '$script:SmartPipeRunnerLabel' after removal." - } - foreach ($label in ($before | Where-Object { $_ -ne $script:SmartPipeRunnerLabel })) { - if ($label -notin $after) { - throw "Removing runner label removed unrelated label '$label'; refusing to continue." - } - } -} - -function Assert-SmartPipeActionsRunsIdle { - param( - [Parameter(Mandatory = $true)] - [string] $Repository, - - [string] $GhPath = 'gh' - ) - - foreach ($status in @('queued', 'in_progress')) { - $global:LASTEXITCODE = 0 - $json = & $GhPath api "repos/$Repository/actions/runs?status=$status&per_page=100" 2>&1 - $ghExitCode = $global:LASTEXITCODE - if ($ghExitCode -ne 0) { - throw "Unable to query $status GitHub Actions runs: $($json -join ' ')" - } - - $response = ($json -join [Environment]::NewLine) | ConvertFrom-Json - if (@($response.workflow_runs).Count -gt 0) { - throw "GitHub Actions has $status runs; refusing runner mutation." - } - } -} - -function Assert-SmartPipeRemoteRunnerIdle { - param( - [Parameter(Mandatory = $true)] - [string] $Repository, - - [Parameter(Mandatory = $true)] - [string] $RunnerName, - - [string] $GhPath = 'gh' - ) - - $runner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath - if ($runner.busy -eq $true) { - throw "Runner '$RunnerName' is busy." - } - return ,$runner -} - -function Wait-SmartPipeRunnerReady { - param( - [Parameter(Mandatory = $true)] - [string] $Root, - - [Parameter(Mandatory = $true)] - [string] $Repository, - - [Parameter(Mandatory = $true)] - [string] $RunnerName, - - [string] $GhPath = 'gh', - [string] $FixturePath = '', - [int] $TimeoutSeconds = 60 - ) - - if ($TimeoutSeconds -lt 1) { - throw 'Runner readiness timeout must be positive.' - } - - $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) - while ($true) { - $listeners = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) - if ($listeners.Count -gt 1) { - throw "More than one runner listener is tied to $Root." - } - - $runner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath - if ($listeners.Count -eq 1 -and [string]$runner.status -eq 'online' -and $runner.busy -eq $false) { - return - } - - if ([DateTime]::UtcNow -ge $deadline) { - throw "Runner '$RunnerName' did not become online and idle with one listener within $TimeoutSeconds seconds." - } - Start-Sleep -Seconds 1 - } -} - -function Restart-SmartPipeRunner { - param( - [Parameter(Mandatory = $true)] - [string] $Root, - - [Parameter(Mandatory = $true)] - [string] $Repository, - - [Parameter(Mandatory = $true)] - [string] $RunnerName, - - [string] $GhPath = 'gh', - [string] $FixturePath = '', - [int] $TimeoutSeconds = 60 - ) - - Stop-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath - Start-SmartPipeRunner -Root $Root -FixturePath $FixturePath - Wait-SmartPipeRunnerReady -Root $Root -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath -FixturePath $FixturePath -TimeoutSeconds $TimeoutSeconds -} - -function Get-SmartPipeOwnedEnvironment { - param( - [Parameter(Mandatory = $true)] - [string] $EnvironmentPath - ) - - if (Test-Path -LiteralPath $EnvironmentPath -PathType Leaf) { - $raw = Get-Content -LiteralPath $EnvironmentPath -Raw -ErrorAction Stop - if ([string]::IsNullOrEmpty($raw)) { - return ,([Collections.Generic.List[string]]::new()) - } - - $lines = [Collections.Generic.List[string]]::new() - $rawLines = @($raw -split '\r?\n') - if ($rawLines.Count -gt 0 -and $rawLines[$rawLines.Count - 1] -eq '') { - $rawLines = if ($rawLines.Count -eq 1) { @() } else { $rawLines[0..($rawLines.Count - 2)] } - } - foreach ($line in $rawLines) { - [void]$lines.Add([string]$line) - } - return ,$lines - } - - return ,([Collections.Generic.List[string]]::new()) -} - -function Write-SmartPipeEnvironment { - param( - [Parameter(Mandatory = $true)] - [string] $EnvironmentPath, - - [Parameter(Mandatory = $true)] - [string] $HookPath, - - [Parameter(Mandatory = $true)] - [string] $DotNetInstallDirectory - ) - - $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath - $owned = @{ - 'ACTIONS_RUNNER_HOOK_JOB_STARTED' = $HookPath - 'DOTNET_INSTALL_DIR' = $DotNetInstallDirectory - } - - foreach ($key in @('ACTIONS_RUNNER_HOOK_JOB_STARTED', 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR')) { - for ($index = $lines.Count - 1; $index -ge 0; $index--) { - if ($lines[$index] -match "^\s*${key}=") { - $lines.RemoveAt($index) - } - } - } - - foreach ($key in $owned.Keys) { - $lines.Add("$key=$($owned[$key])") - } - - $temporaryPath = "$EnvironmentPath.smartpipe.tmp" - [IO.File]::WriteAllText($temporaryPath, (($lines -join [Environment]::NewLine) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) - Move-Item -LiteralPath $temporaryPath -Destination $EnvironmentPath -Force -} - -function Remove-SmartPipeEnvironment { - param( - [Parameter(Mandatory = $true)] - [string] $EnvironmentPath - ) - - if (-not (Test-Path -LiteralPath $EnvironmentPath -PathType Leaf)) { - return - } - - $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath - $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_STARTED', 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR') - for ($index = $lines.Count - 1; $index -ge 0; $index--) { - foreach ($key in $ownedKeys) { - if ($lines[$index] -match "^\s*${key}=") { - $lines.RemoveAt($index) - break - } - } - } - - [IO.File]::WriteAllText($EnvironmentPath, (($lines -join [Environment]::NewLine) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) -} diff --git a/eng/runner/uninstall-runner.ps1 b/eng/runner/uninstall-runner.ps1 deleted file mode 100644 index 032485a..0000000 --- a/eng/runner/uninstall-runner.ps1 +++ /dev/null @@ -1,55 +0,0 @@ -[CmdletBinding()] -param( - [string] $RunnerRoot = 'C:\SmartPipe-Runner', - [string] $Repository = 'MrFr3di/SmartPipe-Core', - [string] $RunnerName = '', - [string] $GhPath = 'gh', - [string] $ListenerFixturePath = '', - [int] $ListenerTimeoutSeconds = 60, - [switch] $SkipListenerReady, - [switch] $AllowTestRoot -) - -$ErrorActionPreference = 'Stop' -. (Join-Path $PSScriptRoot 'runner-safety.ps1') - -try { - Assert-SmartPipeRepository -Repository $Repository - $runner = Get-SmartPipeFullPath -Path $RunnerRoot - if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { - throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." - } - - if (-not (Test-Path -LiteralPath $runner -PathType Container)) { - Write-Output "Runner root is already absent: $runner" - exit 0 - } - Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner - $resolvedRunnerName = Resolve-SmartPipeRunnerName -Root $runner -RequestedName $RunnerName - Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath - $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath - Assert-SmartPipeListenerSafety -Root $runner -FixturePath $ListenerFixturePath - - $environmentPath = Join-Path $runner '.env' - Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath - - $hookDirectory = Join-Path $runner 'hooks' - foreach ($name in @('smartpipe-job-start-cleanup.ps1', 'smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { - $path = Join-Path $hookDirectory $name - if (Test-Path -LiteralPath $path) { - Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner - Remove-Item -LiteralPath $path -Force -ErrorAction Stop - } - } - Remove-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath - - if (-not $SkipListenerReady) { - Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds - } - Write-Output "Removed SmartPipe-owned hook, environment entry, and label from $runner and restored one listener." -} -catch { - $errorText = [string]$_.Exception.Message - Write-Error -Message "$errorText Recovery: confirm the runner and repository are idle, then inspect or rerun eng\runner\uninstall-runner.ps1; unrelated runner labels are never removed." - exit 1 -} diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 deleted file mode 100644 index b1c16bb..0000000 --- a/eng/tests/runner-contract.Tests.ps1 +++ /dev/null @@ -1,373 +0,0 @@ -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -$runnerScriptRoot = Join-Path $PSScriptRoot '..\runner' -$jobStartScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'job-start-cleanup.ps1')) -$installScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'install-runner.ps1')) -$uninstallScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'uninstall-runner.ps1')) -$monitorScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'monitor-pr.ps1')) - -function Assert-RunnerEqual { - param( - [Parameter(Mandatory = $true)] $Actual, - [Parameter(Mandatory = $true)] $Expected, - [Parameter(Mandatory = $true)] [string] $Message - ) - - if ($Actual -ne $Expected) { - throw "$Message (actual: '$Actual'; expected: '$Expected')" - } -} - -function Assert-RunnerTrue { - param( - [Parameter(Mandatory = $true)] [bool] $Condition, - [Parameter(Mandatory = $true)] [string] $Message - ) - - if (-not $Condition) { - throw $Message - } -} - -function Invoke-RunnerScript { - param( - [Parameter(Mandatory = $true)] [string] $ScriptPath, - [Parameter(Mandatory = $true)] [string[]] $Arguments, - [string] $WorkingDirectory = '' - ) - - if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { - $output = & pwsh -NoProfile -File $ScriptPath @Arguments 2>&1 - $exitCode = $LASTEXITCODE - } - else { - $captureId = [Guid]::NewGuid().ToString('N') - $stdoutPath = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-$captureId.out" - $stderrPath = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-$captureId.err" - try { - $process = Start-Process -FilePath pwsh -ArgumentList (@('-NoProfile', '-File', $ScriptPath) + $Arguments) -WorkingDirectory $WorkingDirectory -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru - $output = @((Get-Content -LiteralPath $stdoutPath -ErrorAction SilentlyContinue), (Get-Content -LiteralPath $stderrPath -ErrorAction SilentlyContinue)) - $exitCode = $process.ExitCode - } - finally { - Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue - } - } - [pscustomobject]@{ - ExitCode = $exitCode - Output = ($output | Out-String).Trim() - } -} - -$fixture = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-contract-$([Guid]::NewGuid().ToString('N'))" -$runnerRoot = Join-Path $fixture 'SmartPipe-Runner' -$workspace = Join-Path $runnerRoot '_work\SmartPipe.Core\SmartPipe.Core' -$tempRoot = Join-Path $runnerRoot '_temp' -$toolRoot = Join-Path $runnerRoot '_tool' -$sibling = Join-Path $runnerRoot '_work\Other.Repo\Other.Repo' - -try { - New-Item -ItemType Directory -Path $workspace, $tempRoot, $toolRoot, $sibling -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $workspace '.git'), (Join-Path $tempRoot 'SmartPipe.Core'), (Join-Path $tempRoot 'CodeQL') -Force | Out-Null - @' -{"agentName":"SmartPipe-Runner"} -'@ | Set-Content -LiteralPath (Join-Path $runnerRoot '.runner') - @' -[remote "origin"] - url = https://github.com/MrFr3di/SmartPipe-Core.git -'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') - 'workspace output' | Set-Content -LiteralPath (Join-Path $workspace 'output.txt') - 'tool must survive' | Set-Content -LiteralPath (Join-Path $toolRoot 'preserve.txt') - 'sibling must survive' | Set-Content -LiteralPath (Join-Path $sibling 'preserve.txt') - 'known temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'SmartPipe.Core\cache.txt') - 'known codeql temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'CodeQL\cache.txt') - 'unrelated temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'unrelated.tmp') - - $cleanup = Invoke-RunnerScript -ScriptPath $jobStartScript -WorkingDirectory $workspace -Arguments @( - '-RunnerRoot', $runnerRoot, - '-WorkspaceRoot', $workspace, - '-TempRoot', $tempRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-AllowTestRoot' - ) - Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Job-start cleanup must succeed for a valid checkout. $($cleanup.Output)" - Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace -PathType Container) -Message 'The exact workspace directory must be recreated.' - Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $workspace -Force).Count -Expected 0 -Message 'The recreated workspace must be empty.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $workspace '.git'))) -Message 'The recreated workspace must not retain .git.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $workspace 'output.txt'))) -Message 'The recreated workspace must not retain stale files.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $toolRoot 'preserve.txt')) -Message '_tool must be preserved.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $sibling 'preserve.txt')) -Message 'Sibling repositories must be preserved.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $tempRoot 'unrelated.tmp')) -Message 'Unrelated temp files must be preserved.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'SmartPipe.Core'))) -Message 'Known SmartPipe temp must be removed.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'CodeQL'))) -Message 'Known CodeQL temp must be removed.' - - $emptyCleanup = Invoke-RunnerScript -ScriptPath $jobStartScript -WorkingDirectory $workspace -Arguments @( - '-RunnerRoot', $runnerRoot, - '-WorkspaceRoot', $workspace, - '-TempRoot', $tempRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-AllowTestRoot' - ) - Assert-RunnerEqual -Actual $emptyCleanup.ExitCode -Expected 0 -Message "An existing empty workspace must be idempotently clean. $($emptyCleanup.Output)" - Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $workspace -Force).Count -Expected 0 -Message 'An idempotent empty workspace must remain empty.' - - $absentWorkspace = Join-Path $runnerRoot '_work\SmartPipe.Core\absent' - $absent = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-WorkspaceRoot', $absentWorkspace, - '-TempRoot', $tempRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-AllowTestRoot' - ) - Assert-RunnerEqual -Actual $absent.ExitCode -Expected 0 -Message 'Absent cleanup targets must be successful.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath $absentWorkspace -PathType Container) -Message 'An absent workspace must be recreated.' - Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $absentWorkspace -Force).Count -Expected 0 -Message 'A recreated absent workspace must be empty.' - - New-Item -ItemType Directory -Path $workspace, (Join-Path $workspace '.git') -Force | Out-Null - @' -[remote "origin"] - url = https://github.com/example/other.git -# https://github.com/MrFr3di/SmartPipe-Core.git -[remote "upstream"] - url = https://github.com/MrFr3di/SmartPipe-Core.git -'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') - $wrongRepo = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-WorkspaceRoot', $workspace, - '-TempRoot', $tempRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-AllowTestRoot' - ) - Assert-RunnerTrue -Condition ($wrongRepo.ExitCode -ne 0) -Message 'A checkout with a commented or secondary canonical remote must fail closed.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace) -Message 'A rejected checkout must not be deleted.' - - $outside = Join-Path $fixture 'outside' - New-Item -ItemType Directory -Path $outside -Force | Out-Null - $outsideResult = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-WorkspaceRoot', $outside, - '-TempRoot', $tempRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-AllowTestRoot' - ) - Assert-RunnerTrue -Condition ($outsideResult.ExitCode -ne 0) -Message 'A workspace outside the runner root must fail closed.' - - Remove-Item -LiteralPath $workspace -Recurse -Force - New-Item -ItemType Directory -Path $workspace, (Join-Path $workspace '.git') -Force | Out-Null - @' -[remote "origin"] - url = https://github.com/MrFr3di/SmartPipe-Core.git -'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') - - $reparseCreated = $false - try { - New-Item -ItemType SymbolicLink -Path (Join-Path $workspace 'reparse') -Target $sibling -Force -ErrorAction Stop | Out-Null - $reparseCreated = $true - } - catch { - Write-Output 'Runner contract: symbolic-link fixture unavailable; reparse refusal remains covered by workflow cleanup contracts.' - } - if ($reparseCreated) { - $reparse = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-WorkspaceRoot', $workspace, - '-TempRoot', $tempRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-AllowTestRoot' - ) - Assert-RunnerTrue -Condition ($reparse.ExitCode -ne 0) -Message 'A reparse point must fail closed.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace) -Message 'A reparse rejection must preserve the checkout.' - } - - Remove-Item -LiteralPath $workspace -Recurse -Force - @' -@echo off -exit /b 0 -'@ | Set-Content -LiteralPath (Join-Path $runnerRoot 'run.cmd') - $listenerFixture = Join-Path $fixture 'listener.count' - '1' | Set-Content -LiteralPath $listenerFixture -NoNewline - $runnerGh = Join-Path $fixture 'runner-gh.ps1' -$queuedFlag = Join-Path $fixture 'queued.flag' -$inProgressFlag = Join-Path $fixture 'in-progress.flag' -$offlineFlag = Join-Path $fixture 'offline.flag' - $labelState = Join-Path $fixture 'runner-labels.json' - @('self-hosted', 'Windows', 'X64', 'existing-label') | ConvertTo-Json -Compress | Set-Content -LiteralPath $labelState - @' -param([Parameter(ValueFromRemainingArguments = $true)][string[]] $Arguments) -$joined = $Arguments -join ' ' -$labels = @((Get-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE -Raw | ConvertFrom-Json)) -if ($joined -like '*actions/runners/42/labels/smartpipe-cleanup-v1*') { - $labels = @($labels | Where-Object { $_ -ne 'smartpipe-cleanup-v1' }) - $labels | ConvertTo-Json -Compress | Set-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE - $response = @{ labels = @($labels | ForEach-Object { @{ name = $_ } }) } -} -elseif ($joined -like '*actions/runners/42/labels*') { - if ('smartpipe-cleanup-v1' -notin $labels) { $labels += 'smartpipe-cleanup-v1' } - Remove-Item -LiteralPath $env:SMARTPIPE_OFFLINE_FLAG -Force -ErrorAction SilentlyContinue - $labels | ConvertTo-Json -Compress | Set-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE - $response = @{ labels = @($labels | ForEach-Object { @{ name = $_ } }) } -} -elseif ($joined -like '*actions/runs?status=queued*') { - if (Test-Path -LiteralPath $env:SMARTPIPE_QUEUED_FLAG) { $response = @{ workflow_runs = @(@{ id = 1 }) } } else { $response = @{ workflow_runs = @() } } -} -elseif ($joined -like '*actions/runs?status=in_progress*') { - if (Test-Path -LiteralPath $env:SMARTPIPE_IN_PROGRESS_FLAG) { $response = @{ workflow_runs = @(@{ id = 2 }) } } else { $response = @{ workflow_runs = @() } } -} -elseif ($joined -like '*actions/runners?*') { - $labelObjects = @($labels | ForEach-Object { @{ name = $_ } }) - $runnerStatus = if (Test-Path -LiteralPath $env:SMARTPIPE_OFFLINE_FLAG) { 'offline' } else { 'online' } - $response = @{ runners = @(@{ id = 42; name = 'SmartPipe-Runner'; status = $runnerStatus; busy = $false; labels = $labelObjects }) } -} -elseif ($null -eq $response) { - throw "Unexpected fake gh request: $joined" -} - $response | ConvertTo-Json -Depth 5 -Compress -'@ | Set-Content -LiteralPath $runnerGh - $env:SMARTPIPE_QUEUED_FLAG = $queuedFlag - $env:SMARTPIPE_IN_PROGRESS_FLAG = $inProgressFlag - $env:SMARTPIPE_OFFLINE_FLAG = $offlineFlag - $env:SMARTPIPE_LABEL_STATE = $labelState - $environment = Join-Path $runnerRoot '.env' -@' -UNRELATED_ENV=preserve -ACTIONS_RUNNER_HOOK_JOB_COMPLETED=C:\legacy\smartpipe-post-job-cleanup.ps1 -'@ | Set-Content -LiteralPath $environment - New-Item -ItemType Directory -Path (Join-Path $runnerRoot 'hooks') -Force | Out-Null - 'legacy hook' | Set-Content -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1') - - New-Item -ItemType File -Path $queuedFlag -Force | Out-Null - $queuedInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-RunnerName', 'SmartPipe-Runner', - '-GhPath', $runnerGh, - '-ListenerFixturePath', $listenerFixture, - '-AllowTestRoot' - ) - Assert-RunnerTrue -Condition ($queuedInstall.ExitCode -ne 0) -Message "Installer must refuse queued Actions runs before mutation. $($queuedInstall.Output)" - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'Queued-run refusal must not copy the hook.' - Remove-Item -LiteralPath $queuedFlag -Force - - New-Item -ItemType File -Path $inProgressFlag -Force | Out-Null - $inProgressInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-RunnerName', 'SmartPipe-Runner', - '-GhPath', $runnerGh, - '-ListenerFixturePath', $listenerFixture, - '-AllowTestRoot' - ) - Assert-RunnerTrue -Condition ($inProgressInstall.ExitCode -ne 0) -Message "Installer must refuse in-progress Actions runs before mutation. $($inProgressInstall.Output)" - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'In-progress refusal must not copy the hook.' - Remove-Item -LiteralPath $inProgressFlag -Force - - New-Item -ItemType File -Path $offlineFlag -Force | Out-Null - $install = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-GhPath', $runnerGh, - '-ListenerFixturePath', $listenerFixture, - '-AllowTestRoot' - ) - Assert-RunnerEqual -Actual $install.ExitCode -Expected 0 -Message "Installer must accept an idle fixture root and restore one listener. $($install.Output)" - Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Successful installation must leave exactly one listener fixture.' - $labelsAfterInstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) - Assert-RunnerTrue -Condition ('smartpipe-cleanup-v1' -in $labelsAfterInstall) -Message 'Installer must register the cleanup label through GitHub.' - Assert-RunnerTrue -Condition ('existing-label' -in $labelsAfterInstall) -Message 'Installer must preserve unrelated runner labels.' - $installAgain = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-RunnerName', 'SmartPipe-Runner', - '-GhPath', $runnerGh, - '-ListenerFixturePath', $listenerFixture, - '-AllowTestRoot' - ) - Assert-RunnerEqual -Actual $installAgain.ExitCode -Expected 0 -Message 'Installer must be idempotent.' - $environmentLines = @(Get-Content -LiteralPath $environment) - Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_STARTED=' }).Count -Expected 1 -Message 'Job-start hook environment entry must be unique.' - Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 0 -Message 'Legacy job-completed hook environment entry must be removed.' - Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^DOTNET_INSTALL_DIR=' }).Count -Expected 1 -Message '.NET install directory entry must be unique.' - Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^SMARTPIPE_CLEANUP_LABEL=' }).Count -Expected 0 -Message 'Runner labels must not be represented by an environment marker.' - Assert-RunnerTrue -Condition (@($environmentLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Installer must preserve unrelated environment entries.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1')) -Message 'Installer must copy the job-start hook.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Installer must remove the legacy hook copy.' - - $environmentBeforeAmbiguous = Get-Content -LiteralPath $environment -Raw - $labelsBeforeAmbiguous = Get-Content -LiteralPath $labelState -Raw - 'unclassified-duplicate' | Set-Content -LiteralPath $listenerFixture -NoNewline - $ambiguousInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-RunnerName', 'SmartPipe-Runner', - '-GhPath', $runnerGh, - '-ListenerFixturePath', $listenerFixture, - '-AllowTestRoot' - ) - Assert-RunnerTrue -Condition ($ambiguousInstall.ExitCode -ne 0) -Message "Installer must refuse an unclassified duplicate before mutation. $($ambiguousInstall.Output)" - Assert-RunnerTrue -Condition ($ambiguousInstall.Output -match '4102') -Message "Unclassified listener diagnostics must report the exact PID. $($ambiguousInstall.Output)" - Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected 'unclassified-duplicate' -Message 'Unclassified duplicate refusal must not stop or rewrite the listener fixture.' - Assert-RunnerEqual -Actual (Get-Content -LiteralPath $environment -Raw) -Expected $environmentBeforeAmbiguous -Message 'Unclassified duplicate refusal must precede environment mutation.' - Assert-RunnerEqual -Actual (Get-Content -LiteralPath $labelState -Raw) -Expected $labelsBeforeAmbiguous -Message 'Unclassified duplicate refusal must precede label mutation.' - '1' | Set-Content -LiteralPath $listenerFixture -NoNewline - - $uninstall = Invoke-RunnerScript -ScriptPath $uninstallScript -Arguments @( - '-RunnerRoot', $runnerRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-GhPath', $runnerGh, - '-ListenerFixturePath', $listenerFixture, - '-AllowTestRoot' - ) - Assert-RunnerEqual -Actual $uninstall.ExitCode -Expected 0 -Message "Uninstaller must succeed and restore one listener. $($uninstall.Output)" - Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Uninstall must leave exactly one listener fixture.' - $uninstalledLines = @(Get-Content -LiteralPath $environment) - Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^(ACTIONS_RUNNER_HOOK_JOB_STARTED|ACTIONS_RUNNER_HOOK_JOB_COMPLETED|DOTNET_INSTALL_DIR)=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' - Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Uninstaller must preserve unrelated environment entries.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the legacy hook copy.' - $labelsAfterUninstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) - Assert-RunnerTrue -Condition ('smartpipe-cleanup-v1' -notin $labelsAfterUninstall) -Message 'Uninstaller must remove only the owned cleanup label.' - Assert-RunnerTrue -Condition ('existing-label' -in $labelsAfterUninstall) -Message 'Uninstaller must preserve unrelated runner labels.' - - $fakeGh = Join-Path $fixture 'fake-gh.ps1' - $fakeCount = Join-Path $fixture 'fake-gh.count' - @' -param([Parameter(ValueFromRemainingArguments = $true)][string[]] $Arguments) -$joined = $Arguments -join ' ' -if ($joined -like '*run list*') { - @(@{ databaseId = 99 }) | ConvertTo-Json -Compress - exit 0 -} -if ($joined -like '*run view*') { - "build error $([string]::new('x', 1400))" - exit 0 -} -$count = if (Test-Path -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT) { [int](Get-Content -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT) } else { 0 } -Set-Content -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT -Value ($count + 1) -@{ state = 'OPEN'; mergeStateStatus = 'DIRTY'; headRefOid = '0123456789abcdef0123456789abcdef01234567'; statusCheckRollup = @(@{ name = 'build'; status = 'COMPLETED'; conclusion = 'FAILURE' }) } | ConvertTo-Json -Compress -'@ | Set-Content -LiteralPath $fakeGh - $env:SMARTPIPE_FAKE_GH_COUNT = $fakeCount - $monitor = Invoke-RunnerScript -ScriptPath $monitorScript -Arguments @( - '-PullRequest', '42', - '-Repository', 'MrFr3di/SmartPipe-Core', - '-GhPath', $fakeGh, - '-PollSeconds', '1', - '-MaxPolls', '2' - ) - Remove-Item Env:\SMARTPIPE_FAKE_GH_COUNT -ErrorAction SilentlyContinue - Assert-RunnerEqual -Actual $monitor.ExitCode -Expected 0 -Message "PR monitor fixture must succeed. $($monitor.Output)" - Assert-RunnerEqual -Actual @($monitor.Output -split '\r?\n' | Where-Object { $_ -match '^PR #42 transition:' }).Count -Expected 1 -Message 'PR monitor must emit only state transitions.' - $diagnosticLines = @($monitor.Output -split '\r?\n' | Where-Object { $_ -match '^PR diagnostic: first causal slice:' }) - Assert-RunnerEqual -Actual $diagnosticLines.Count -Expected 1 -Message 'PR monitor must emit one first-causal slice per failed head.' - Assert-RunnerTrue -Condition ($diagnosticLines[0].Length -le 1070) -Message 'PR monitor causal output must remain bounded.' - - Write-Output 'Runner contract tests passed (cleanup containment, lifecycle idempotence, and transition-only monitoring).' -} -finally { - Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item Env:\SMARTPIPE_FAKE_GH_COUNT -ErrorAction SilentlyContinue - Remove-Item Env:\SMARTPIPE_QUEUED_FLAG -ErrorAction SilentlyContinue - Remove-Item Env:\SMARTPIPE_IN_PROGRESS_FLAG -ErrorAction SilentlyContinue - Remove-Item Env:\SMARTPIPE_OFFLINE_FLAG -ErrorAction SilentlyContinue -} diff --git a/eng/tests/workflow-contract.Tests.ps1 b/eng/tests/workflow-contract.Tests.ps1 index dcf05ae..c3920fd 100644 --- a/eng/tests/workflow-contract.Tests.ps1 +++ b/eng/tests/workflow-contract.Tests.ps1 @@ -7,9 +7,3 @@ python $testScript if ($LASTEXITCODE -ne 0) { throw "Workflow contract tests failed with exit code $LASTEXITCODE." } - -$runnerTestScript = Join-Path $PSScriptRoot 'runner-contract.Tests.ps1' -pwsh -NoProfile -File $runnerTestScript -if ($LASTEXITCODE -ne 0) { - throw "Runner contract tests failed with exit code $LASTEXITCODE." -} diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 4411a9d..4e3e65a 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -31,21 +31,22 @@ ) } SHA_REF = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") -SELF_HOSTED_WINDOWS = ["self-hosted", "Windows", "X64", "smartpipe-cleanup-v1"] -SELF_HOSTED_WINDOWS_JSON = '["self-hosted","Windows","X64","smartpipe-cleanup-v1"]' +HOSTED_WINDOWS = "windows-latest" +HOSTED_WINDOWS_JSON = '["windows-latest"]' +CODEQL_ACTION_REF = ( + "github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9" +) +CODEQL_ANALYZE_ACTION_REF = ( + "github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9" +) +DEPENDENCY_REVIEW_ACTION_REF = ( + "actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294" +) SAME_REPOSITORY_PR_GUARD = ( "github.event_name != 'pull_request' || " "github.event.pull_request.head.repo.full_name == github.repository" ) PULL_REQUEST_SAME_REPOSITORY_GUARD = "github.event.pull_request.head.repo.full_name == github.repository" -CLEANUP_SAME_REPOSITORY_GUARD = ( - "always() && (github.event_name != 'pull_request' || " - "github.event.pull_request.head.repo.full_name == github.repository)" -) -CLEANUP_PULL_REQUEST_GUARD = ( - "always() && github.event_name == 'pull_request' && " - "github.event.pull_request.head.repo.full_name == github.repository" -) DIAGNOSTIC_INPUTS_EMPTY_GUARD = ( "(github.event_name != 'workflow_dispatch' || " "(inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && " @@ -59,26 +60,16 @@ ) CI_VALIDATION_RUNNER_INPUT = ( "${{ github.event_name == 'pull_request' && " - "'[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]' || " + "'[\"windows-latest\"]' || " "'[\"ubuntu-latest\"]' }}" ) -CI_WINDOWS_RUNNER = ( - "${{ github.event_name == 'pull_request' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " - "'windows-latest' }}" -) -NUGET_PACKAGES_PR = ( - "${{ github.event_name == 'pull_request' && " - "format('{0}/.nuget/packages', github.workspace) || '' }}" -) -HOSTING_NAME = "${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}" -HOSTING_RUNNER = ( - "${{ matrix.os == 'self-hosted' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || matrix.os }}" -) +CI_WINDOWS_RUNNER = HOSTED_WINDOWS +NUGET_PACKAGES_PATH = "${{ github.workspace }}/.nuget/packages" +HOSTING_NAME = "${{ matrix.os == 'windows-latest' && 'Windows' || matrix.os }}" +HOSTING_RUNNER = "${{ matrix.os }}" HOSTING_MATRIX = ( "${{ fromJSON(github.event_name == 'pull_request' && " - "'{\"os\":[\"self-hosted\"]}' || " + "'{\"os\":[\"windows-latest\"]}' || " "'{\"os\":[\"ubuntu-latest\",\"windows-latest\"]}') }}" ) LYCHEE_URL = ( @@ -99,9 +90,9 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -def require_self_hosted_windows(job: dict, label: str) -> None: - require(job.get("runs-on") == SELF_HOSTED_WINDOWS, - f"{label} must target the self-hosted Windows X64 runner labels.") +def require_hosted_windows(job: dict, label: str) -> None: + require(job.get("runs-on") == HOSTED_WINDOWS, + f"{label} must target hosted Windows (`windows-latest`).") def require_parameterized_runner(job: dict, label: str) -> None: @@ -114,53 +105,66 @@ def require_runner_expression(job: dict, expected: str, label: str) -> None: f"{label} must use the event-aware runner expression.") -def assert_static_analysis_contract(workflow: dict) -> None: - require(workflow.get("name") == "Hosted .NET static analysis", - "Static-analysis workflow must identify the hosted .NET analyzer check honestly.") - require(workflow.get("permissions") == {"contents": "read"}, - "Static analysis must request only read access to repository contents.") - serialized = json.dumps(workflow).lower() - for forbidden in ("security-events", "codeql", "self-hosted", "cleanup-self-hosted"): - require(forbidden not in serialized, - f"Hosted static analysis must not retain {forbidden} configuration.") - +def assert_codeql_contract(workflow: dict) -> None: + require(workflow.get("name") == "CodeQL", + "CodeQL workflow must retain the official public check name.") + require(workflow.get("permissions") == { + "contents": "read", + "security-events": "write", + }, "CodeQL must request only contents read and security-events write permissions.") jobs = workflow.get("jobs", {}) require(set(jobs) == {"analyze"}, - "Hosted static analysis must define only the analyzer job.") + "CodeQL workflow must define only the analyze job.") job = jobs["analyze"] - require(job.get("name") == "Hosted .NET static analysis", - "Static analysis job must preserve its distinct check name.") + require(job.get("if") == SAME_REPOSITORY_PR_GUARD, + "CodeQL must skip untrusted fork pull requests.") require(job.get("runs-on") == "ubuntu-latest", - "Static analysis must use hosted Linux.") - static_steps = steps(job, "Hosted .NET static analysis") + "CodeQL must use hosted Linux.") + codeql_steps = steps(job, "CodeQL analyze") checkout = next( - step for step in static_steps + step for step in codeql_steps if str(step.get("uses", "")).startswith("actions/checkout") ) require(checkout.get("with", {}).get("persist-credentials") is False, - "Static analysis checkout must disable persisted credentials.") - setup = named_step(static_steps, "Setup .NET") + "CodeQL checkout must disable persisted credentials.") + setup = named_step(codeql_steps, "Setup .NET") require(setup.get("with", {}).get("global-json-file") == "global.json", - "Static analysis must use the pinned SDK from global.json.") - restore = named_step(static_steps, "Restore locked") - restore_run = str(restore.get("run", "")) - require(restore.get("shell") == "pwsh" - and "dotnet restore SmartPipe.Core.slnx --locked-mode" in restore_run - and NATIVE_FAIL_FAST_GUARD in restore_run, - "Static analysis must perform a fail-closed locked restore.") - build = named_step(static_steps, "Build static analysis") - build_run = str(build.get("run", "")) - require(build.get("shell") == "pwsh" - and "dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror" in build_run - and NATIVE_FAIL_FAST_GUARD in build_run, - "Static analysis must use the existing analyzers with a fail-closed warnings-as-errors build.") + "CodeQL must use the pinned SDK from global.json.") + require(setup.get("with", {}).get("cache") is True + and setup.get("with", {}).get("cache-dependency-path") == "**/packages.lock.json", + "CodeQL setup-dotnet must cache only the lock-file keyed NuGet packages.") + init = named_step(codeql_steps, "Initialize CodeQL") + require(init.get("uses") == CODEQL_ACTION_REF + and init.get("with", {}).get("languages") == "csharp", + "CodeQL must initialize the official pinned C# action.") + build = named_step(codeql_steps, "Build") + require(build.get("run") == "dotnet build SmartPipe.Core.slnx -c Release", + "CodeQL must build the solution before analysis.") + analyze = named_step(codeql_steps, "Perform CodeQL Analysis") + require(analyze.get("uses") == CODEQL_ANALYZE_ACTION_REF, + "CodeQL must run the official pinned analysis action.") def assert_nuget_isolation_contract(workflow: dict, workflow_name: str) -> None: environment = workflow.get("env") require(isinstance(environment, dict) - and environment.get("NUGET_PACKAGES") == NUGET_PACKAGES_PR, - f"{workflow_name} must isolate pull-request NuGet packages inside GITHUB_WORKSPACE.") + and environment.get("NUGET_PACKAGES") == NUGET_PACKAGES_PATH, + f"{workflow_name} must isolate NuGet packages inside GITHUB_WORKSPACE.") + + +def assert_setup_dotnet_cache_contract(workflow: dict, workflow_name: str) -> None: + setup_steps = [ + step + for job in workflow["jobs"].values() + for step in job.get("steps", []) + if str(step.get("uses", "")).startswith("actions/setup-dotnet") + ] + require(bool(setup_steps), f"{workflow_name} must contain setup-dotnet steps.") + for step in setup_steps: + with_block = step.get("with", {}) + require(with_block.get("cache") is True + and with_block.get("cache-dependency-path") == "**/packages.lock.json", + f"{workflow_name} restore-heavy setup-dotnet must use lock-file keyed caching.") def assert_diagnostic_contract(ci: dict) -> None: @@ -179,7 +183,7 @@ def assert_diagnostic_contract(ci: dict) -> None: require(isinstance(job, dict), "CI must define the optional diagnostic-consumer job.") require(job.get("if") == DIAGNOSTIC_GUARD, "Diagnostic consumer must run only for a workflow dispatch with diagnostic input.") - require_self_hosted_windows(job, "Diagnostic consumer") + require_hosted_windows(job, "Diagnostic consumer") diagnostic_steps = steps(job, "diagnostic-consumer") validation = named_step(diagnostic_steps, "Validate diagnostic inputs") validation_script = str(validation.get("run", "")) @@ -244,126 +248,30 @@ def require_ci_normal_job_guard(job: dict, label: str) -> None: f"{label} must retain the same-repository guard and skip only diagnostic dispatches.") -def assert_cleanup_job( - workflow: dict, - workflow_name: str, - expected_needs: list[str], - expected_guard: str, - cleanup_nuget: bool = False, -) -> None: - job = workflow["jobs"].get("cleanup-self-hosted") - require(isinstance(job, dict), - f"{workflow_name} must define cleanup-self-hosted.") - require(job.get("name") == "Cleanup self-hosted workspace", - f"{workflow_name} cleanup must preserve its check name.") - require(job.get("needs") == expected_needs, - f"{workflow_name} cleanup must wait for every workflow job.") - require_self_hosted_windows(job, f"{workflow_name} cleanup") - require(job.get("if") == expected_guard, - f"{workflow_name} cleanup must always run only for trusted repository work.") - cleanup_steps = steps(job, f"{workflow_name} cleanup") - require(len(cleanup_steps) == 1, - f"{workflow_name} cleanup must contain exactly one cleanup step.") - cleanup = named_step(cleanup_steps, "Cleanup generated outputs") - require(cleanup.get("shell") == "pwsh", - f"{workflow_name} cleanup must use PowerShell on Windows.") - script = str(cleanup.get("run", "")) - for token in ( - "$env:GITHUB_WORKSPACE", "[IO.Path]::GetFullPath", "StartsWith", - "[StringComparison]::OrdinalIgnoreCase", "[IO.FileAttributes]::ReparsePoint", - "Join-Path $workspace 'artifacts'", - "Join-Path $workspace 'BenchmarkDotNet.Artifacts'", - "$directory.Name -in 'bin', 'obj'", - "Remove-Item -LiteralPath $fullPath -Recurse -Force", - ): - require(token in script, - f"{workflow_name} cleanup must enforce safe workspace-bound deletion ({token}).") - if cleanup_nuget: - require("Join-Path $workspace '.nuget'" in script, - f"{workflow_name} cleanup must remove its workspace-local NuGet packages.") - require("git clean" not in script.lower(), - f"{workflow_name} cleanup must not use git clean.") - require(re.search(r"Remove-Item\s+-LiteralPath\s+\$workspace(?:\s|$)", script) is None, - f"{workflow_name} cleanup must not delete the workspace root.") - direct_reparse_guard = ( - "if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band " - "[IO.FileAttributes]::ReparsePoint)" - ) - require(direct_reparse_guard in script, - f"{workflow_name} cleanup must reject direct target reparse points before recursive deletion.") - require(script.index(direct_reparse_guard) < script.index( - "Get-ChildItem -LiteralPath $fullPath -Force -Recurse"), - f"{workflow_name} cleanup must check direct target reparse points before recursion.") - - -def assert_repository_security_audit_contract(workflow: dict) -> None: - require(workflow.get("name") == "Repository security audit", - "Dependency Review workflow must identify the repository-controlled security audit.") - require(workflow.get("permissions") == {"contents": "read"}, - "Repository security audit must request only read access to repository contents.") +def assert_dependency_review_contract(workflow: dict) -> None: + require(workflow.get("name") == "Dependency Review", + "Dependency Review workflow must retain the official public check name.") + require(workflow.get("permissions") == { + "contents": "read", + "pull-requests": "read", + }, "Dependency Review must request only contents and pull-requests read permissions.") jobs = workflow.get("jobs", {}) - require("cleanup-self-hosted" not in jobs, - "Hosted repository security audit must not depend on self-hosted cleanup.") - job = jobs.get("repository-security-audit") - require(isinstance(job, dict), - "Dependency Review workflow must define repository-security-audit.") - require(job.get("name") == "Repository security audit", - "Repository security audit must preserve its distinct check name.") - require(job.get("if") == PULL_REQUEST_SAME_REPOSITORY_GUARD, - "Repository security audit must run only for same-repository pull requests.") + require(set(jobs) == {"dependency-review"}, + "Dependency Review workflow must define only the dependency-review job.") + job = jobs["dependency-review"] require(job.get("runs-on") == "ubuntu-latest", - "Repository security audit must use hosted Linux.") - require("self-hosted" not in str(job.get("runs-on", "")), - "Repository security audit must not use a self-hosted runner.") - - job_steps = steps(job, "Repository security audit") + "Dependency Review must use hosted Linux.") + require("if" not in job, + "Dependency Review must run for public fork pull requests as well as same-repository requests.") + job_steps = steps(job, "Dependency Review") checkouts = [step for step in job_steps if str(step.get("uses", "")).startswith("actions/checkout")] require(len(checkouts) == 1 and checkouts[0].get("with", {}).get("persist-credentials") is False, - "Repository security audit checkout must be pinned and credential-free.") - setup = [step for step in job_steps - if str(step.get("uses", "")).startswith("actions/setup-dotnet")] - require(len(setup) == 1 - and setup[0].get("with", {}).get("global-json-file") == "global.json", - "Repository security audit setup-dotnet must use global.json as the SDK source.") - require(not any("actions/dependency-review-action" in str(step.get("uses", "")) - for step in job_steps), - "Repository security audit must not claim hosted Dependency Review execution.") - require(not any(step.get("continue-on-error") for step in job_steps), - "Repository security audit must fail closed without continue-on-error.") - - restore = named_step(job_steps, "Restore locked") - require("dotnet restore SmartPipe.Core.slnx --locked-mode" in str(restore.get("run", "")), - "Repository security audit must perform locked restore.") - build = named_step(job_steps, "Build repository checks") - require(build.get("shell") == "pwsh" - and "dotnet build eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj " - "--configuration Release --no-restore -warnaserror" in str(build.get("run", "")), - "Repository security audit must build RepositoryChecks with warnings as errors.") - profile = named_step(job_steps, "Verify repository package contracts") - require(profile.get("shell") == "pwsh" - and "dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj " - "--configuration Release --no-build --no-restore -- verify --profile sp220-05 " - "--format github --failures-only" in str(profile.get("run", "")), - "Repository security audit must run the strict repository package profile.") - vulnerable = named_step(job_steps, "Vulnerable package scan") - require(vulnerable.get("shell") == "pwsh" - and "dotnet package list --project SmartPipe.Core.slnx --vulnerable " - "--include-transitive --format json --output-version 1 --no-restore" in str(vulnerable.get("run", "")) - and "artifacts/audit/vulnerable.json" in str(vulnerable.get("run", "")), - "Repository security audit must produce a strict vulnerable package report.") - audit = named_step(job_steps, "Verify direct production audit policy") - require(audit.get("shell") == "pwsh" - and "verify-nuget-audit" in str(audit.get("run", "")) - and "--report artifacts/audit/vulnerable.json" in str(audit.get("run", "")), - "Repository security audit must enforce the repository NuGet audit policy.") - deprecated = named_step(job_steps, "Deprecated package scan") - require(deprecated.get("shell") == "pwsh" - and "dotnet package list --project SmartPipe.Core.slnx --deprecated " - "--include-transitive --format json --output-version 1 --no-restore" in str(deprecated.get("run", "")) - and "artifacts/audit/deprecated.json" in str(deprecated.get("run", "")), - "Repository security audit must report deprecated packages without suppressing failures.") + "Dependency Review checkout must be pinned and credential-free.") + review = named_step(job_steps, "Dependency review") + require(review.get("uses") == DEPENDENCY_REVIEW_ACTION_REF, + "Dependency Review must run the official pinned public action.") def assert_reusable_windows_shell_contract(reusable_steps: list[dict]) -> None: @@ -933,7 +841,7 @@ def validate(documents: dict[str, dict]) -> None: require_parameterized_runner(concurrency_job, "HealthChecks concurrency") require_same_repository_pr_guard(concurrency_job, "HealthChecks concurrency") require("strategy" not in concurrency_job, - "HealthChecks concurrency must use one self-hosted Windows lane while Linux hosted minutes are unavailable.") + "HealthChecks concurrency must use one hosted runner lane.") concurrency_steps = steps(concurrency_job, "reusable health-checks-concurrency") for step_name in ("Run bounded observation concurrency", "Run concurrent health evaluation"): command = str(named_step(concurrency_steps, step_name).get("run", "")) @@ -969,6 +877,9 @@ def validate(documents: dict[str, dict]) -> None: "Reusable validation artifact upload must skip only pull_request events and remain required for non-PR events.") require(upload.get("with", {}).get("name") == "${{ inputs.artifact-name }}", "Reusable validation must upload the caller-selected artifact name.") + require(upload.get("with", {}).get("retention-days") == + "${{ inputs.artifact-name == 'packages' && 7 || 90 }}", + "Reusable validation must retain generic CI packages for seven days and versioned artifacts for the existing policy.") upload_path = str(upload.get("with", {}).get("path", "")) require("artifacts/packages" in upload_path and "artifacts/consumers/**/result.json" in upload_path @@ -1053,7 +964,6 @@ def validate(documents: dict[str, dict]) -> None: explicit_names.append((str(job["name"]), file_name, job_id)) duplicates = {name for name, _, _ in explicit_names if sum(item[0] == name for item in explicit_names) > 1} - duplicates.discard("Cleanup self-hosted workspace") require(not duplicates, f"Required job/check names must be unique: {sorted(duplicates)}") windows_text = "\n".join(windows_runs) @@ -1064,16 +974,19 @@ def validate(documents: dict[str, dict]) -> None: "Windows lifecycle filter must not use the obsolete " "SmartPipe.Extensions.Tests.Sinks namespace.") - assert_cleanup_job( - ci, - "ci.yml", - ["validation", "hosting-integration", "json-file-windows", "baseline-contract-windows"], - CLEANUP_PULL_REQUEST_GUARD, - cleanup_nuget=True, - ) - assert_repository_security_audit_contract(dependency_review) + for workflow_name, document in documents.items(): + require("cleanup-self-hosted" not in document["jobs"], + f"{workflow_name} must not define the obsolete cleanup-self-hosted job.") + assert_dependency_review_contract(dependency_review) assert_nuget_isolation_contract(ci, "ci.yml") - assert_static_analysis_contract(static_analysis) + assert_nuget_isolation_contract(static_analysis, "codeql.yml") + assert_codeql_contract(static_analysis) + for workflow_name, document in ( + ("ci.yml", ci), + ("codeql.yml", static_analysis), + ("reusable-release-validation.yml", reusable), + ): + assert_setup_dotnet_cache_contract(document, workflow_name) all_runs = windows_runs + hosting_runs + reusable_runs filtered = [command for command in all_runs if "--filter-class" in command or "--filter-query" in command] @@ -1259,55 +1172,57 @@ def _add_consumer_logs_to_upload(documents: dict[str, dict]) -> None: upload["with"]["path"] += "\nartifacts/consumers/**/logs/**" -def _use_hosted_runner_for_required_lanes(documents: dict[str, dict]) -> None: - lanes = ( - ("ci.yml", "hosting-integration"), - ("ci.yml", "json-file-windows"), - ("ci.yml", "baseline-contract-windows"), - ("reusable-release-validation.yml", "build-test-pack"), - ("reusable-release-validation.yml", "health-checks-concurrency"), - ) - for workflow_name, job_name in lanes: - documents[workflow_name]["jobs"][job_name]["runs-on"] = "windows-latest" - - -def _make_repository_security_audit_self_hosted(documents: dict[str, dict]) -> None: - documents["dependency-review.yml"]["jobs"]["repository-security-audit"]["runs-on"] = SELF_HOSTED_WINDOWS - - -def _make_ci_validation_always_self_hosted(documents: dict[str, dict]) -> None: - documents["ci.yml"]["jobs"]["validation"]["with"]["runner-labels"] = SELF_HOSTED_WINDOWS_JSON - - -def _make_hosting_always_self_hosted(documents: dict[str, dict]) -> None: +def _make_hosting_matrix_hosted_only(documents: dict[str, dict]) -> None: job = documents["ci.yml"]["jobs"]["hosting-integration"] job["strategy"]["matrix"] = ( - "${{ fromJSON('{\"os\":[\"self-hosted\"]}') }}" + "${{ fromJSON('{\"os\":[\"ubuntu-latest\"]}') }}" ) def _make_hosting_static_runner(documents: dict[str, dict]) -> None: - documents["ci.yml"]["jobs"]["hosting-integration"]["runs-on"] = SELF_HOSTED_WINDOWS + documents["ci.yml"]["jobs"]["hosting-integration"]["runs-on"] = HOSTED_WINDOWS -def _make_ci_json_always_self_hosted(documents: dict[str, dict]) -> None: - documents["ci.yml"]["jobs"]["json-file-windows"]["runs-on"] = SELF_HOSTED_WINDOWS +def _make_codeql_substitute_name(documents: dict[str, dict]) -> None: + documents["codeql.yml"]["name"] = "Private static analysis" -def _make_ci_baseline_always_self_hosted(documents: dict[str, dict]) -> None: - documents["ci.yml"]["jobs"]["baseline-contract-windows"]["runs-on"] = SELF_HOSTED_WINDOWS +def _make_codeql_non_official_action(documents: dict[str, dict]) -> None: + init = named_step(documents["codeql.yml"]["jobs"]["analyze"]["steps"], "Initialize CodeQL") + init["uses"] = "github/codeql-action/init@0000000000000000000000000000000000000000" -def _make_static_analysis_always_self_hosted(documents: dict[str, dict]) -> None: - documents["codeql.yml"]["jobs"]["analyze"]["runs-on"] = SELF_HOSTED_WINDOWS +def _make_dependency_review_non_official_action(documents: dict[str, dict]) -> None: + review = named_step( + documents["dependency-review.yml"]["jobs"]["dependency-review"]["steps"], + "Dependency review", + ) + review["uses"] = "actions/dependency-review-action@0000000000000000000000000000000000000000" def _remove_nuget_isolation(documents: dict[str, dict], workflow_name: str) -> None: documents[workflow_name]["env"].pop("NUGET_PACKAGES", None) -def _make_cleanup_non_pr_capable(documents: dict[str, dict], workflow_name: str) -> None: - documents[workflow_name]["jobs"]["cleanup-self-hosted"]["if"] = CLEANUP_SAME_REPOSITORY_GUARD +def _remove_setup_dotnet_cache(documents: dict[str, dict], workflow_name: str) -> None: + for job in documents[workflow_name]["jobs"].values(): + for step in job.get("steps", []): + if str(step.get("uses", "")).startswith("actions/setup-dotnet"): + step["with"].pop("cache", None) + step["with"].pop("cache-dependency-path", None) + return + + +def _change_artifact_retention(documents: dict[str, dict]) -> None: + upload = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Upload immutable packages and reports", + ) + upload["with"]["retention-days"] = 7 + + +def _add_ci_cleanup_job(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["cleanup-self-hosted"] = {} def _remove_ci_runner_override(documents: dict[str, dict]) -> None: @@ -1319,7 +1234,7 @@ def _remove_diagnostic_input(documents: dict[str, dict]) -> None: def _make_diagnostic_hosted(documents: dict[str, dict]) -> None: - documents["ci.yml"]["jobs"]["diagnostic-consumer"]["runs-on"] = "windows-latest" + documents["ci.yml"]["jobs"]["diagnostic-consumer"]["runs-on"] = "ubuntu-latest" def _make_ci_normal_job_diagnostic_capable(documents: dict[str, dict]) -> None: @@ -1373,7 +1288,7 @@ def _change_runner_default(documents: dict[str, dict]) -> None: def _override_publish_runner(documents: dict[str, dict]) -> None: documents["publish-nuget.yml"]["jobs"]["validation"].setdefault("with", {})[ "runner-labels" - ] = SELF_HOSTED_WINDOWS_JSON + ] = HOSTED_WINDOWS_JSON def _remove_leaf_exit_guard(documents: dict[str, dict]) -> None: @@ -1444,44 +1359,6 @@ def _remove_reusable_pr_guard(documents: dict[str, dict]) -> None: documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"].pop("if", None) -def _remove_ci_cleanup_job(documents: dict[str, dict]) -> None: - del documents["ci.yml"]["jobs"]["cleanup-self-hosted"] - - -def _make_ci_cleanup_delete_workspace_root(documents: dict[str, dict]) -> None: - cleanup = named_step( - documents["ci.yml"]["jobs"]["cleanup-self-hosted"]["steps"], - "Cleanup generated outputs", - ) - cleanup["run"] = str(cleanup["run"]) + "\nRemove-Item -LiteralPath $workspace -Recurse -Force" - - -def _remove_cleanup_direct_target_guard(documents: dict[str, dict], workflow_name: str) -> None: - cleanup = named_step( - documents[workflow_name]["jobs"]["cleanup-self-hosted"]["steps"], - "Cleanup generated outputs", - ) - direct_reparse_guard = ( - "if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band " - "[IO.FileAttributes]::ReparsePoint)" - ) - cleanup["run"] = "\n".join( - line for line in str(cleanup.get("run", "")).splitlines() - if direct_reparse_guard not in line - ) - - -def _remove_cleanup_nuget_target(documents: dict[str, dict], workflow_name: str) -> None: - cleanup = named_step( - documents[workflow_name]["jobs"]["cleanup-self-hosted"]["steps"], - "Cleanup generated outputs", - ) - cleanup["run"] = "\n".join( - line for line in str(cleanup.get("run", "")).splitlines() - if "Join-Path $workspace '.nuget'" not in line - ) - - def _restore_lychee_action(documents: dict[str, dict]) -> None: cleanup = named_step( documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], @@ -1555,7 +1432,7 @@ def main() -> int: assert_mutation_rejected( documents, _make_diagnostic_hosted, - "must target the self-hosted Windows X64 runner labels", + "must target hosted Windows", ) assert_mutation_rejected( documents, @@ -1659,7 +1536,7 @@ def main() -> int: assert_mutation_rejected( documents, _restore_floating_sdk_selection, - "global.json as the SDK source", + "global.json", ) assert_mutation_rejected(documents, _remove_release_branch, "release/2.2.0") assert_mutation_rejected( @@ -1718,49 +1595,53 @@ def main() -> int: ) assert_mutation_rejected( documents, - _use_hosted_runner_for_required_lanes, - "runner-labels workflow input", + _make_hosting_matrix_hosted_only, + "original hosted non-PR matrix", ) assert_mutation_rejected( documents, - _make_repository_security_audit_self_hosted, - "must use hosted Linux", + _make_hosting_static_runner, + "event-aware runner expression", ) assert_mutation_rejected( documents, - _make_ci_validation_always_self_hosted, - "exact reusable workflow caller", + lambda docs: docs["ci.yml"]["jobs"]["json-file-windows"].__setitem__( + "runs-on", "ubuntu-latest"), + "Windows JSON lane must use the event-aware runner expression", ) assert_mutation_rejected( documents, - _make_hosting_always_self_hosted, - "original hosted non-PR matrix", + lambda docs: docs["ci.yml"]["jobs"]["baseline-contract-windows"].__setitem__( + "runs-on", "ubuntu-latest"), + "Windows baseline contract lane must use the event-aware runner expression", ) assert_mutation_rejected( documents, - _make_hosting_static_runner, - "event-aware runner expression", + _make_codeql_substitute_name, + "official public check name", ) assert_mutation_rejected( documents, - _make_ci_json_always_self_hosted, - "event-aware runner expression", + _make_codeql_non_official_action, + "official pinned C# action", ) assert_mutation_rejected( documents, - _make_ci_baseline_always_self_hosted, - "event-aware runner expression", + _make_dependency_review_non_official_action, + "official pinned public action", ) - assert_mutation_rejected( - documents, - _make_static_analysis_always_self_hosted, - "must not retain self-hosted", - ) - for workflow_name in ("ci.yml", "reusable-release-validation.yml"): + for workflow_name in ("ci.yml", "reusable-release-validation.yml", "codeql.yml"): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_nuget_isolation(docs, name), - f"{workflow_name} must isolate pull-request NuGet packages", + f"{workflow_name} must isolate NuGet packages", + ) + assert_mutation_rejected( + documents, + lambda docs, name=workflow_name: _remove_setup_dotnet_cache(docs, name), + "cache only the lock-file keyed" + if workflow_name == "codeql.yml" + else f"{workflow_name} restore-heavy setup-dotnet", ) assert_mutation_rejected( documents, @@ -1777,6 +1658,11 @@ def main() -> int: _override_publish_runner, "Publish validation must use reusable hosted Linux runner default", ) + assert_mutation_rejected( + documents, + _change_artifact_retention, + "retain generic CI packages for seven days", + ) assert_mutation_rejected( documents, _remove_leaf_exit_guard, @@ -1823,32 +1709,9 @@ def main() -> int: ) assert_mutation_rejected( documents, - _remove_ci_cleanup_job, - "must define cleanup-self-hosted", + _add_ci_cleanup_job, + "must not define the obsolete cleanup-self-hosted job", ) - for workflow_name in ("ci.yml",): - assert_mutation_rejected( - documents, - lambda docs, name=workflow_name: _make_cleanup_non_pr_capable(docs, name), - f"{workflow_name} cleanup must always run only for trusted repository work", - ) - assert_mutation_rejected( - documents, - _make_ci_cleanup_delete_workspace_root, - "must not delete the workspace root", - ) - for workflow_name in ("ci.yml",): - assert_mutation_rejected( - documents, - lambda docs, name=workflow_name: _remove_cleanup_direct_target_guard(docs, name), - f"{workflow_name} cleanup must reject direct target reparse points", - ) - for workflow_name in ("ci.yml",): - assert_mutation_rejected( - documents, - lambda docs, name=workflow_name: _remove_cleanup_nuget_target(docs, name), - f"{workflow_name} cleanup must remove its workspace-local NuGet packages", - ) assert_mutation_rejected( documents, _restore_lychee_action, diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs index 8edf644..5076327 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs @@ -121,7 +121,7 @@ public async Task Capture_AcceptsAndPersistsCurrentWorkflowNames() scenario.ManifestPath, TestContext.Current.CancellationToken))!.AsObject(); Assert.Equal( - ["CI", "Hosted .NET static analysis", "Repository security audit"], + ["CI", "CodeQL", "Dependency Review"], root["repository"]!["requiredWorkflows"]!.AsArray() .Select(workflow => workflow!["name"]!.GetValue()) .Order(StringComparer.Ordinal)); @@ -284,15 +284,15 @@ public async Task Capture_RejectsDuplicateSuccessfulWorkflowEvidenceAsAmbiguous( } [Fact] - public async Task Capture_RejectsHistoricalSecurityWorkflowName() + public async Task Capture_RejectsSubstituteSecurityWorkflowName() { using var scenario = new BaselineScenario(); - scenario.WriteWorkflowEvidence("historical-security-name"); + scenario.WriteWorkflowEvidence("substitute-security-name"); var exception = await Assert.ThrowsAsync( () => scenario.CaptureAsync(TestContext.Current.CancellationToken)); - Assert.Contains("Repository security audit", exception.Message, StringComparison.Ordinal); + Assert.Contains("Dependency Review", exception.Message, StringComparison.Ordinal); } [Fact] @@ -338,34 +338,17 @@ public async Task ManifestMutation_Fails() } [Fact] - public async Task HistoricalManifestWorkflowNamesReachNormalIntegrityDiagnostics() + public async Task CanonicalManifestWorkflowNamesReachNormalIntegrityDiagnostics() { using var scenario = new BaselineScenario(); await scenario.CaptureAsync(TestContext.Current.CancellationToken); var manifest = BaselineManifestSerializer.Deserialize( await File.ReadAllTextAsync(scenario.ManifestPath, TestContext.Current.CancellationToken)); - var historicalManifest = manifest with - { - Repository = manifest.Repository with - { - RequiredWorkflows = manifest.Repository.RequiredWorkflows - .Select(workflow => workflow with - { - Name = workflow.Name switch - { - "Hosted .NET static analysis" => "CodeQL", - "Repository security audit" => "Dependency Review", - _ => workflow.Name, - }, - }) - .ToArray(), - }, - }; await BaselineManifestSerializer.WriteAsync( - scenario.ManifestPath, historicalManifest, TestContext.Current.CancellationToken); + scenario.ManifestPath, manifest, TestContext.Current.CancellationToken); await File.WriteAllBytesAsync( Path.Combine(scenario.BaselinePath, "baseline-report.md"), - BaselineReport.Create(historicalManifest), TestContext.Current.CancellationToken); + BaselineReport.Create(manifest), TestContext.Current.CancellationToken); await File.AppendAllTextAsync(scenario.PublicApiPath, "\nHistorical.Api", TestContext.Current.CancellationToken); var result = await scenario.VerifyAsync(); @@ -375,7 +358,7 @@ await File.WriteAllBytesAsync( } [Theory] - [InlineData("CodeQL")] + [InlineData("Dependency Review")] [InlineData("Unexpected workflow")] public async Task NonCompleteManifestWorkflowNamesFailSchemaValidation(string replacementName) { @@ -384,7 +367,7 @@ public async Task NonCompleteManifestWorkflowNamesFailSchemaValidation(string re var root = JsonNode.Parse(await File.ReadAllTextAsync( scenario.ManifestPath, TestContext.Current.CancellationToken))!.AsObject(); var workflows = root["repository"]!["requiredWorkflows"]!.AsArray(); - workflows.Single(workflow => workflow!["name"]!.GetValue() == "Hosted .NET static analysis")!["name"] = replacementName; + workflows.Single(workflow => workflow!["name"]!.GetValue() == "CodeQL")!["name"] = replacementName; await File.WriteAllTextAsync( scenario.ManifestPath, root.ToJsonString(), TestContext.Current.CancellationToken); @@ -793,9 +776,9 @@ public void WriteWorkflowEvidence(string mutation) var ciSha = mutation == "mixed-sha" ? new string('a', 40) : Sha; var ciStatus = mutation == "pending" ? "in_progress" : "completed"; var ciConclusion = mutation == "pending" ? string.Empty : mutation == "failed" ? "failure" : "success"; - var securityWorkflowName = mutation == "historical-security-name" - ? "Dependency Review" - : "Repository security audit"; + var securityWorkflowName = mutation == "substitute-security-name" + ? "Repository security audit" + : "Dependency Review"; var extra = mutation switch { "extra-pending" => $$""" @@ -815,7 +798,7 @@ public void WriteWorkflowEvidence(string mutation) var evidence = $$""" [ {"databaseId":1,"workflowName":"CI","headSha":"{{ciSha}}","status":"{{ciStatus}}","conclusion":"{{ciConclusion}}","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/1","event":"push","createdAt":"2026-07-17T00:00:00Z"}, - {"databaseId":2,"workflowName":"Hosted .NET static analysis","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/2","event":"push","createdAt":"2026-07-17T00:01:00Z"}, + {"databaseId":2,"workflowName":"CodeQL","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/2","event":"push","createdAt":"2026-07-17T00:01:00Z"}, {"databaseId":3,"workflowName":"{{securityWorkflowName}}","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/3","event":"pull_request","createdAt":"2026-07-17T00:02:00Z"}{{extra}} ] """; From 4586d81f1c4be3ae6bde0cedf42e76f1db9894c7 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Thu, 27 Aug 2026 21:48:01 +0500 Subject: [PATCH 17/22] fix(ci): keep hosted restores source-stable --- .github/workflows/ci.yml | 6 ++--- .../workflows/reusable-release-validation.yml | 4 ++-- eng/tests/workflow_contract_tests.py | 22 ++++++++++++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index befa3cf..0bf9d4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore Hosting integration tests - run: dotnet restore tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --locked-mode + run: dotnet restore tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Build Hosting integration tests run: dotnet build tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --configuration Release --no-restore -warnaserror @@ -86,7 +86,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore locked - run: dotnet restore SmartPipe.Core.slnx --locked-mode + run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Build JSON test project run: dotnet build tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-restore -warnaserror @@ -200,7 +200,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore locked - run: dotnet restore SmartPipe.Core.slnx --locked-mode + run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Build run: dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index 2d19453..2e369a3 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -44,7 +44,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore locked - run: dotnet restore SmartPipe.Core.slnx --locked-mode + run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Test release version validation shell: pwsh @@ -291,7 +291,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore locked - run: dotnet restore SmartPipe.Core.slnx --locked-mode + run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Build concurrency projects shell: pwsh diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 4e3e65a..7ba0634 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -167,6 +167,16 @@ def assert_setup_dotnet_cache_contract(workflow: dict, workflow_name: str) -> No f"{workflow_name} restore-heavy setup-dotnet must use lock-file keyed caching.") +def assert_hosted_restore_source_contract(documents: dict[str, dict]) -> None: + for workflow_name in ("ci.yml", "reusable-release-validation.yml"): + workflow = documents[workflow_name] + for job_name, job in workflow["jobs"].items(): + for command in runs(job.get("steps", [])): + if "dotnet restore " in command: + require("-p:DisableImplicitLibraryPacksFolder=true" in command, + f"{workflow_name}:{job_name} hosted restore must disable the SDK library-packs source.") + + def assert_diagnostic_contract(ci: dict) -> None: dispatch = ci.get("on", {}).get("workflow_dispatch", {}) inputs = dispatch.get("inputs", {}) if isinstance(dispatch, dict) else {} @@ -202,7 +212,8 @@ def assert_diagnostic_contract(ci: dict) -> None: and "DIAGNOSTIC_SHA" in str(verify.get("run", "")), "Diagnostic consumer must verify the checked out commit SHA.") restore = named_step(diagnostic_steps, "Restore locked") - require(str(restore.get("run", "")).strip() == "dotnet restore SmartPipe.Core.slnx --locked-mode", + require(str(restore.get("run", "")).strip() == + "dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true", "Diagnostic consumer must perform one locked solution restore.") build = named_step(diagnostic_steps, "Build") require("--no-restore" in str(build.get("run", "")) @@ -716,6 +727,7 @@ def validate(documents: dict[str, dict]) -> None: for workflow_name, expected in expected_triggers.items(): require(documents[workflow_name].get("on") == expected, f"{workflow_name} trigger contract changed.") + assert_hosted_restore_source_contract(documents) workflow_call = reusable.get("on", {}).get("workflow_call") require(isinstance(workflow_call, dict), "Reusable validation must declare on.workflow_call.") @@ -743,7 +755,9 @@ def validate(documents: dict[str, dict]) -> None: require(any("ruamel.yaml==0.18.16" in command for command in reusable_runs), "Reusable validation must install the pinned YAML 1.2 parser.") restores = [command for command in reusable_runs if "dotnet restore SmartPipe.Core.slnx" in command] - require(restores == ["dotnet restore SmartPipe.Core.slnx --locked-mode"], + require(restores == [ + "dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true", + ], "Reusable validation must perform exactly one locked-mode solution restore.") build_step = named_step(reusable_steps, "Build") repository_test_step = named_step(reusable_steps, "Repository baseline contract tests") @@ -926,7 +940,9 @@ def validate(documents: dict[str, dict]) -> None: windows_steps = steps(windows, "json-file-windows") windows_runs = runs(windows_steps) windows_restores = [command for command in windows_runs if "dotnet restore SmartPipe.Core.slnx" in command] - require(windows_restores == ["dotnet restore SmartPipe.Core.slnx --locked-mode"], + require(windows_restores == [ + "dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true", + ], "Windows JSON lane must perform exactly one locked-mode solution restore.") require(not any("Category=Stress" in command for command in windows_runs), "Windows JSON lane must not execute the stress suite.") From e5370e674a58649ba7e9563ba61494fca0e84c28 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 07:59:59 +0500 Subject: [PATCH 18/22] feat(json): add reusable pipeline definitions --- CHANGELOG.md | 8 + .../JsonPipelineBenchmarks.cs | 343 +++++++++++++ benchmarks/SmartPipe.Benchmarks/Program.cs | 7 +- .../SmartPipe.Benchmarks.csproj | 1 + .../SmartPipe.Benchmarks/packages.lock.json | 7 + docs/aot-compatibility.md | 6 + .../architecture/pipeline-definition-model.md | 24 + docs/migration/2.2.0-core-definition-model.md | 10 + docs/package-ownership.md | 1 + eng/consumer-scenarios.json | 24 + eng/consumer-scenarios.schema.json | 4 +- eng/package-graph.json | 5 + eng/package-ownership.json | 11 + .../JsonFraming}/Utf8LineRecordReader.cs | 2 +- .../DeadLetterRecordReader.cs | 1 + .../JsonInputOptionsValidator.cs | 44 +- .../JsonMetadataSnapshot.cs | 87 ++++ .../JsonPipelineComponents.cs | 122 +++++ .../JsonPipelineDefinitionBuilder.cs | 111 +++++ .../PublicAPI.Unshipped.txt | 14 + src/SmartPipe.Extensions.Json/README.md | 14 +- .../Selectors/DeadLetterSource.cs | 1 + .../Selectors/JsonFileSource.cs | 35 +- .../Sinks/DeadLetterSink.cs | 9 +- .../Sinks/JsonFileSink.cs | 11 +- .../SmartPipe.Extensions.Json.csproj | 4 + .../Consumer.csproj | 16 + .../Program.cs | 63 +++ .../Scenarios/json-direct/Consumer.csproj | 2 +- .../Scenarios/json-direct/Program.cs | 50 +- .../Scenarios/json-nativeaot/Consumer.csproj | 2 +- .../Scenarios/json-nativeaot/Program.cs | 48 +- .../Scenarios/json-trim/Consumer.csproj | 14 + .../Consumers/Scenarios/json-trim/Program.cs | 50 ++ .../JsonPipelineDefinitionContractTests.cs | 463 ++++++++++++++++++ .../Utf8LineRecordReaderTests.cs | 18 +- 36 files changed, 1554 insertions(+), 78 deletions(-) create mode 100644 benchmarks/SmartPipe.Benchmarks/JsonPipelineBenchmarks.cs rename src/{SmartPipe.Extensions.Json => Shared/JsonFraming}/Utf8LineRecordReader.cs (99%) create mode 100644 src/SmartPipe.Extensions.Json/JsonMetadataSnapshot.cs create mode 100644 src/SmartPipe.Extensions.Json/JsonPipelineComponents.cs create mode 100644 src/SmartPipe.Extensions.Json/JsonPipelineDefinitionBuilder.cs create mode 100644 tests/Consumers/Scenarios/json-dependency-injection-direct/Consumer.csproj create mode 100644 tests/Consumers/Scenarios/json-dependency-injection-direct/Program.cs create mode 100644 tests/Consumers/Scenarios/json-trim/Consumer.csproj create mode 100644 tests/Consumers/Scenarios/json-trim/Program.cs create mode 100644 tests/SmartPipe.Extensions.Json.Tests/JsonPipelineDefinitionContractTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2385a..7971934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,14 @@ - Non-generic definition metadata now exposes defensive read-only collections and rejects duplicate stage IDs with the shared structural topology validator. +### JSON definition integration + +- Added source-generated-metadata JSON definition builders and runtime-owned + source, transform, and sink components with lazy per-run activation. +- Added direct, trimmed, NativeAOT, and DependencyInjection composition + consumers for the canonical JSON definitions while preserving the existing + facade-source and 2.1.2 binary compatibility scenarios. + ### Build and package infrastructure - Central package management, lock-file reconciliation, package graph and diff --git a/benchmarks/SmartPipe.Benchmarks/JsonPipelineBenchmarks.cs b/benchmarks/SmartPipe.Benchmarks/JsonPipelineBenchmarks.cs new file mode 100644 index 0000000..1294e7e --- /dev/null +++ b/benchmarks/SmartPipe.Benchmarks/JsonPipelineBenchmarks.cs @@ -0,0 +1,343 @@ +#nullable enable + +using System.Text.Json; +using System.Text.Json.Serialization; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging.Abstractions; +using SmartPipe.Core; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Json; +using SmartPipe.Extensions.Selectors; +using SmartPipe.Extensions.Sinks; + +namespace SmartPipe.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("JSON")] +public class JsonPipelineBenchmarks +{ + private const int BatchItemCount = 32; + private const int SinkItemCount = 32; + private const int OversizedLimit = 128; + private readonly Dictionary _rootPaths = []; + private readonly Dictionary _ndjsonPaths = []; + private readonly Dictionary _boundaryPaths = []; + private readonly Dictionary _boundaryLimits = []; + private readonly Dictionary _oversizedPaths = []; + private readonly List _definitionPaths = []; + private string _directory = null!; + private string _batchPath = null!; + private string _partialPath = null!; + private string _sinkPath = null!; + + [GlobalSetup] + public async Task Setup() + { + _directory = Path.Combine(Path.GetTempPath(), $"smartpipe-json-bench-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + _batchPath = Path.Combine(_directory, "batch.jsonl"); + _partialPath = Path.Combine(_directory, "partial.jsonl"); + _sinkPath = Path.Combine(_directory, "sink.jsonl"); + + foreach (var itemCount in new[] { 1_000, 100_000 }) + { + var path = Path.Combine(_directory, $"root-{itemCount}.json"); + var items = Enumerable.Range(0, itemCount) + .Select(static value => new JsonBenchmarkItem(value, "root")) + .ToList(); + await File.WriteAllTextAsync( + path, + JsonSerializer.Serialize(items, BenchmarkJsonContext.Default.ListJsonBenchmarkItem)); + _rootPaths.Add(itemCount, path); + } + + foreach (var size in new[] { 64, 1_024, 65_536 }) + { + var item = CreateItem(size, value: size); + var record = JsonSerializer.Serialize(item, BenchmarkJsonContext.Default.JsonBenchmarkItem); + var ndjsonPath = Path.Combine(_directory, $"ndjson-{size}.jsonl"); + await File.WriteAllTextAsync(ndjsonPath, string.Join('\n', Enumerable.Repeat(record, 4)) + "\n"); + _ndjsonPaths.Add(size, ndjsonPath); + + var boundaryPath = Path.Combine(_directory, $"boundary-{size}.jsonl"); + var boundaryBytes = JsonSerializer.SerializeToUtf8Bytes(item, BenchmarkJsonContext.Default.JsonBenchmarkItem); + await File.WriteAllBytesAsync(boundaryPath, [.. boundaryBytes, (byte)'\n']); + _boundaryPaths.Add(size, boundaryPath); + _boundaryLimits.Add(size, boundaryBytes.Length + 1); + + } + + foreach (var size in new[] { 256, 4_096, 65_536 }) + { + var oversizedPath = Path.Combine(_directory, $"oversized-{size}.jsonl"); + var validRecord = JsonSerializer.Serialize( + new JsonBenchmarkItem(size, "ok"), + BenchmarkJsonContext.Default.JsonBenchmarkItem); + var oversized = new string('x', size) + "\n" + validRecord + "\n"; + await File.WriteAllTextAsync(oversizedPath, oversized); + _oversizedPaths.Add(size, oversizedPath); + } + + var batchRecords = Enumerable.Range(0, BatchItemCount) + .Select(static value => new JsonBenchmarkItem(value, "batch")) + .ToList(); + var batchJson = JsonSerializer.Serialize(batchRecords, BenchmarkJsonContext.Default.ListJsonBenchmarkItem); + await File.WriteAllTextAsync(_batchPath, batchJson + "\n" + batchJson + "\n"); + await File.WriteAllTextAsync(_partialPath, string.Join('\n', Enumerable.Repeat( + JsonSerializer.Serialize(new JsonBenchmarkItem(1, "partial"), BenchmarkJsonContext.Default.JsonBenchmarkItem), + 256)) + "\n"); + + for (var index = 0; index < 32; index++) + { + var path = Path.Combine(_directory, $"definition-{index}.json"); + await File.WriteAllTextAsync( + path, + JsonSerializer.Serialize( + new[] { new JsonBenchmarkItem(index, "definition") }, + BenchmarkJsonContext.Default.JsonBenchmarkItemArray)); + _definitionPaths.Add(path); + } + + if (await ReadFileAsync(_rootPaths[1_000], new JsonFileSourceOptions { Format = JsonFileFormat.Array }) != 1_000) + throw new InvalidOperationException("JSON root-array benchmark setup failed."); + if (await ReadFileAsync(_batchPath, new JsonFileSourceOptions { Format = JsonFileFormat.BatchJsonLines }) != BatchItemCount * 2) + throw new InvalidOperationException("JSON batch benchmark setup failed."); + if (await ReadFileAsync( + _boundaryPaths[64], + new JsonFileSourceOptions + { + Format = JsonFileFormat.Ndjson, + MaxRecordSizeBytes = _boundaryLimits[64], + }) != 1) + throw new InvalidOperationException("JSON boundary benchmark setup failed."); + if (await ReadFileAsync( + _oversizedPaths[256], + new JsonFileSourceOptions + { + Format = JsonFileFormat.Ndjson, + InvalidRecordBehavior = InvalidJsonRecordBehavior.SkipAndLog, + MaxRecordSizeBytes = OversizedLimit, + }) != 1) + throw new InvalidOperationException("JSON oversized-discard benchmark setup failed."); + + var definition = JsonPipelineDefinitionBuilder + .FromJsonFile( + new PipelineKey("json-benchmark-setup"), + _rootPaths[1_000], + BenchmarkJsonContext.Default.JsonBenchmarkItem, + BenchmarkJsonContext.Default.ListJsonBenchmarkItem, + new JsonFileSourceOptions { Format = JsonFileFormat.Array }) + .Build(); + if (await RunDefinitionAsync(definition) != 1_000) + throw new InvalidOperationException("JSON definition benchmark setup failed."); + } + + [GlobalCleanup] + public void Cleanup() => Directory.Delete(_directory, recursive: true); + + [Benchmark] + [Arguments(1_000)] + [Arguments(100_000)] + public Task RootArray_Read(int itemCount) => ReadFileAsync( + _rootPaths[itemCount], + new JsonFileSourceOptions { Format = JsonFileFormat.Array }); + + [Benchmark] + [Arguments(64)] + [Arguments(1_024)] + [Arguments(65_536)] + public Task Ndjson_Read_RecordSize(int recordSize) => ReadFileAsync( + _ndjsonPaths[recordSize], + new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson }); + + [Benchmark] + public Task BatchJsonLines_Read() => ReadFileAsync( + _batchPath, + new JsonFileSourceOptions { Format = JsonFileFormat.BatchJsonLines }); + + [Benchmark] + [Arguments(64)] + [Arguments(1_024)] + [Arguments(65_536)] + public Task MaxRecord_Boundary(int recordSize) => ReadFileAsync( + _boundaryPaths[recordSize], + new JsonFileSourceOptions + { + Format = JsonFileFormat.Ndjson, + MaxRecordSizeBytes = _boundaryLimits[recordSize], + }); + + [Benchmark] + [Arguments(256)] + [Arguments(4_096)] + [Arguments(65_536)] + public Task OversizedDiscard_Scaling(int oversizedSize) => ReadFileAsync( + _oversizedPaths[oversizedSize], + new JsonFileSourceOptions + { + Format = JsonFileFormat.Ndjson, + InvalidRecordBehavior = InvalidJsonRecordBehavior.SkipAndLog, + MaxRecordSizeBytes = OversizedLimit, + }); + + [Benchmark] + public async Task ThirtyTwo_IndependentDefinitionsAndFiles() + { + var total = 0; + for (var index = 0; index < _definitionPaths.Count; index++) + { + var definition = JsonPipelineDefinitionBuilder + .FromJsonFile( + new PipelineKey($"json-benchmark-definition-{index}"), + _definitionPaths[index], + BenchmarkJsonContext.Default.JsonBenchmarkItem, + BenchmarkJsonContext.Default.ListJsonBenchmarkItem, + new JsonFileSourceOptions { Format = JsonFileFormat.Array }) + .Build(); + total += await RunDefinitionAsync(definition).ConfigureAwait(false); + } + + return total; + } + + [Benchmark] + public async Task PartialEnumeration_DisposesSource() + { + await using var source = new JsonFileSource( + _partialPath, + BenchmarkJsonContext.Default.JsonBenchmarkItem, + BenchmarkJsonContext.Default.ListJsonBenchmarkItem, + new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson }); + await source.InitializeAsync().ConfigureAwait(false); + var count = 0; + await foreach (var _ in source.ReadEnvelopesAsync().ConfigureAwait(false)) + { + count++; + break; + } + + return count; + } + + [Benchmark] + public async Task CancellationAndDisposal_Interaction() + { + using var cancellation = new CancellationTokenSource(); + var source = new JsonFileSource( + _partialPath, + BenchmarkJsonContext.Default.JsonBenchmarkItem, + BenchmarkJsonContext.Default.ListJsonBenchmarkItem, + new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson }); + try + { + await source.InitializeAsync().ConfigureAwait(false); + var readTask = ConsumeSourceAsync(source, cancellation.Token); + await Task.Yield(); + var disposalTask = source.DisposeAsync().AsTask(); + cancellation.Cancel(); + try + { + await Task.WhenAll(readTask, disposalTask).ConfigureAwait(false); + return 0; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + return 1; + } + } + finally + { + await source.DisposeAsync().ConfigureAwait(false); + } + } + + [Benchmark] + [Arguments(1, 64)] + [Arguments(1, 1_024)] + [Arguments(1, 65_536)] + [Arguments(1_000, 64)] + [Arguments(1_000, 1_024)] + [Arguments(1_000, 65_536)] + public async Task Sink_AllocationFlushPayload(int flushInterval, int payloadSize) + { + var payload = new string('p', payloadSize); + await using var sink = new JsonFileSink( + _sinkPath, + BenchmarkJsonContext.Default.JsonBenchmarkItem, + BenchmarkJsonContext.Default.ListJsonBenchmarkItem, + new JsonFileSinkOptions + { + Format = JsonFileFormat.Ndjson, + OpenMode = JsonFileOpenMode.Create, + FlushInterval = flushInterval, + }); + await sink.InitializeAsync().ConfigureAwait(false); + for (var index = 0; index < SinkItemCount; index++) + { + await sink.WriteAsync( + ProcessingEnvelope.Create(new JsonBenchmarkItem(index, payload))) + .ConfigureAwait(false); + } + + return SinkItemCount; + } + + private static JsonBenchmarkItem CreateItem(int targetSize, int value) + { + var padding = new string('x', Math.Max(0, targetSize - 32)); + return new JsonBenchmarkItem(value, padding); + } + + private static async Task ReadFileAsync( + string path, + JsonFileSourceOptions options, + CancellationToken cancellationToken = default) + { + var logger = options.InvalidRecordBehavior == InvalidJsonRecordBehavior.SkipAndLog + ? NullLogger>.Instance + : null; + await using var source = new JsonFileSource( + path, + BenchmarkJsonContext.Default.JsonBenchmarkItem, + BenchmarkJsonContext.Default.ListJsonBenchmarkItem, + options, + logger); + await source.InitializeAsync(cancellationToken).ConfigureAwait(false); + var count = 0; + await foreach (var _ in source.ReadEnvelopesAsync(cancellationToken).ConfigureAwait(false)) + count++; + return count; + } + + private static async Task ConsumeSourceAsync( + JsonFileSource source, + CancellationToken cancellationToken) + { + var count = 0; + await foreach (var _ in source.ReadEnvelopesAsync(cancellationToken).ConfigureAwait(false)) + count++; + return count; + } + + private static async Task RunDefinitionAsync( + PipelineDefinition definition) + { + await using var run = await definition.StartAsync().ConfigureAwait(false); + var count = 0; + await foreach (var output in run.Outputs.ReadAllAsync().ConfigureAwait(false)) + { + if (output.Result.IsSuccess) + count++; + } + + await run.Completion.ConfigureAwait(false); + return count; + } +} + +internal sealed record JsonBenchmarkItem(int Value, string Payload); + +[JsonSerializable(typeof(JsonBenchmarkItem))] +[JsonSerializable(typeof(JsonBenchmarkItem[]))] +[JsonSerializable(typeof(List))] +internal sealed partial class BenchmarkJsonContext : JsonSerializerContext; diff --git a/benchmarks/SmartPipe.Benchmarks/Program.cs b/benchmarks/SmartPipe.Benchmarks/Program.cs index 0d14f2a..c9a0467 100644 --- a/benchmarks/SmartPipe.Benchmarks/Program.cs +++ b/benchmarks/SmartPipe.Benchmarks/Program.cs @@ -1,8 +1,3 @@ -using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; -using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -var config = DefaultConfig.Instance.AddJob(Job.Default.WithToolchain(InProcessNoEmitToolchain.Instance)); - -BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj b/benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj index e5d661c..edae74a 100644 --- a/benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj +++ b/benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj @@ -5,6 +5,7 @@ + diff --git a/benchmarks/SmartPipe.Benchmarks/packages.lock.json b/benchmarks/SmartPipe.Benchmarks/packages.lock.json index 7da8b63..aaa086b 100644 --- a/benchmarks/SmartPipe.Benchmarks/packages.lock.json +++ b/benchmarks/SmartPipe.Benchmarks/packages.lock.json @@ -151,6 +151,13 @@ "SmartPipe.Core": "[2.2.0, )" } }, + "smartpipe.extensions.json": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", + "SmartPipe.Core": "[2.2.0, )" + } + }, "smartpipe.extensions.logging": { "type": "Project", "dependencies": { diff --git a/docs/aot-compatibility.md b/docs/aot-compatibility.md index bc20c23..f3e608f 100644 --- a/docs/aot-compatibility.md +++ b/docs/aot-compatibility.md @@ -55,6 +55,12 @@ These five integrations are implemented by `SmartPipe.Extensions.Json`. `JsonLinesDeadLetterSerializer` remains in `SmartPipe.Core` and also exposes a source-generated metadata constructor. +For canonical typed definitions, use `JsonPipelineDefinitionBuilder.FromJsonFile` +or `FromJsonDeadLetterFile`, then `TransformJson` and `ToJsonFile` from +`SmartPipe.Extensions.Json`. Those adapters require source-generated metadata, +snapshot it into private resolver-backed options, and keep component activation +and logger creation lazy and trimming-safe. + The legacy `JsonFileSink` batch-metadata constructors and the default `BatchJsonLines` format write one JSON array per flushed line. Explicit `Ndjson` writes one value per line, while `Array` writes one root array. The diff --git a/docs/architecture/pipeline-definition-model.md b/docs/architecture/pipeline-definition-model.md index 5d45bf2..79755a1 100644 --- a/docs/architecture/pipeline-definition-model.md +++ b/docs/architecture/pipeline-definition-model.md @@ -190,3 +190,27 @@ CPU, .NET SDK/runtime, BenchmarkDotNet job/configuration, raw artifact, and allocation shape from the same environment. No absolute timing or percentage threshold is a correctness gate; deterministic tests prove compile-once, resource-free compilation, activation order, and bounded cleanup counts. + +## JSON Line Framing + +`SmartPipe.Extensions.Json` compiles the internal +`src/Shared/JsonFraming/Utf8LineRecordReader.cs` as a linked source. This +transport-neutral helper is BCL-only and handles only bounded LF/CRLF framing, +UTF-8 bytes, BOM handling, cancellation, and discard-through-boundary for +oversized records. It is not a public API or a separate package. JSON stream +probing, unframed-input limits, record validation, path diagnostics, and +invalid-record policy remain owned by the JSON package. + +## JSON Definition Adapters + +`SmartPipe.Extensions.Json` exposes `JsonPipelineComponents` plus the typed +`JsonPipelineDefinitionBuilder` and `JsonPipelineDefinitionBuilderExtensions` +adapters. File sources, file sinks, transforms, and dead-letter components are +created through Core `RuntimeOwned` descriptors, so definition construction is +resource-free and each activation receives a fresh component. Source-generated +metadata is accepted only when its resolver can be re-run from a private, +read-only `JsonSerializerOptions` snapshot; item and batch metadata must share +one caller options instance, while transform input and output metadata are +snapshotted independently. Logger factories are borrowed and logger instances +are created at activation for policies that require logging; JSON never disposes +the factory. diff --git a/docs/migration/2.2.0-core-definition-model.md b/docs/migration/2.2.0-core-definition-model.md index 8bc7e82..32a7883 100644 --- a/docs/migration/2.2.0-core-definition-model.md +++ b/docs/migration/2.2.0-core-definition-model.md @@ -66,3 +66,13 @@ components; those paths bypass readiness, ownership, and shared disposal. The existing non-generic `PipelineDefinition` and `PipelineExecutionPlan` remain callable metadata compatibility types. Their collections are now defensive read-only copies, and duplicate stage IDs fail compilation before runtime work. + +The `SmartPipe.Extensions.Json` package also provides the typed +`JsonPipelineDefinitionBuilder` entry points and `JsonPipelineDefinitionBuilderExtensions` +for source-generated JSON metadata. `FromJsonFile` or +`FromJsonDeadLetterFile` creates a runtime-owned source; `TransformJson` appends +a runtime-owned transform, and `ToJsonFile` adds a runtime-owned sink. These +adapters snapshot serializer metadata and option records while building the +descriptor, then create fresh components on each activation. A logger factory +is borrowed and is used only during activation when a skip-or-log policy needs +it; the factory is never disposed by the pipeline. diff --git a/docs/package-ownership.md b/docs/package-ownership.md index 9d20294..ca7b533 100644 --- a/docs/package-ownership.md +++ b/docs/package-ownership.md @@ -11,6 +11,7 @@ The machine-readable authority is `eng/package-ownership.json`. | Composite, conditional, compression, and filter transforms | `SmartPipe.Extensions.Transforms` | `SmartPipe.Extensions` | type forwarding | | `LoggerSink` | `SmartPipe.Extensions.Logging` | `SmartPipe.Extensions` | type forwarding | | `ValidationTransform` and `ToFilter` | `SmartPipe.Extensions.DataAnnotations` | `SmartPipe.Extensions` | type forwarding | +| Canonical JSON pipeline definitions | `SmartPipe.Extensions.Json` | none | new 2.2 API | The HealthChecks leaf depends only on Core, DependencyInjection, DI abstractions, Diagnostics.HealthChecks, and Options. It does not depend on Hosting, ASP.NET Core, or the broad facade. diff --git a/eng/consumer-scenarios.json b/eng/consumer-scenarios.json index 35161e3..4d3005e 100644 --- a/eng/consumer-scenarios.json +++ b/eng/consumer-scenarios.json @@ -95,6 +95,30 @@ "timeout": "00:15:00", "runSecondLockedRestore": true }, + { + "id": "json-trim", + "set": "current", + "mode": "publish-trimmed", + "templatePath": "tests/Consumers/Scenarios/json-trim/Consumer.csproj", + "packageIds": ["SmartPipe.Core", "SmartPipe.Extensions.Json"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.Json"], + "forbiddenDependencies": ["SmartPipe.Extensions", "CsvHelper", "Dapper", "Microsoft.EntityFrameworkCore", "Mapster", "Polly"], + "baselineVersion": null, + "timeout": "00:10:00", + "runSecondLockedRestore": true + }, + { + "id": "json-dependency-injection-direct", + "set": "current", + "mode": "build-and-run", + "templatePath": "tests/Consumers/Scenarios/json-dependency-injection-direct/Consumer.csproj", + "packageIds": ["SmartPipe.Core", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Json"], + "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.DependencyInjection", "SmartPipe.Extensions.Json"], + "forbiddenDependencies": ["SmartPipe.Extensions", "SmartPipe.Extensions.Hosting", "SmartPipe.Extensions.HealthChecks", "Microsoft.Extensions.Hosting.Abstractions", "Microsoft.Extensions.Diagnostics.HealthChecks", "Microsoft.Extensions.Options"], + "baselineVersion": null, + "timeout": "00:05:00", + "runSecondLockedRestore": true + }, { "id": "dependency-injection-direct", "set": "current", diff --git a/eng/consumer-scenarios.schema.json b/eng/consumer-scenarios.schema.json index 88b75bb..4443ca8 100644 --- a/eng/consumer-scenarios.schema.json +++ b/eng/consumer-scenarios.schema.json @@ -13,8 +13,8 @@ }, "scenarios": { "type": "array", - "minItems": 33, - "maxItems": 33, + "minItems": 35, + "maxItems": 35, "items": { "$ref": "#/$defs/scenario" } } }, diff --git a/eng/package-graph.json b/eng/package-graph.json index 56f577b..b50d6fb 100644 --- a/eng/package-graph.json +++ b/eng/package-graph.json @@ -39,6 +39,8 @@ "core-trim", "core-nativeaot", "json-nativeaot", + "json-trim", + "json-dependency-injection-direct", "dependency-injection-direct", "dependency-injection-keyed", "dependency-injection-from-keyed-services", @@ -206,6 +208,8 @@ "json-direct", "legacy-binary-2.1.2", "json-nativeaot", + "json-trim", + "json-dependency-injection-direct", "dependency-injection-facade-binary-2.1.2", "hosting-facade-binary-2.1.2" ] @@ -548,6 +552,7 @@ "temporaryAllowances": [], "consumerScenarios": [ "dependency-injection-direct", + "json-dependency-injection-direct", "dependency-injection-keyed", "dependency-injection-from-keyed-services", "dependency-injection-facade-source", diff --git a/eng/package-ownership.json b/eng/package-ownership.json index e00a18b..d1bd810 100644 --- a/eng/package-ownership.json +++ b/eng/package-ownership.json @@ -89,6 +89,17 @@ "namespacePreserved": true, "evidence": "2.1.2 Json assembly implementation" }, + { + "typePattern": "SmartPipe.Extensions.Json.JsonPipeline*", + "baselineAssembly": "SmartPipe.Extensions.Json", + "currentImplementationAssembly": "SmartPipe.Extensions.Json", + "targetImplementationAssembly": "SmartPipe.Extensions.Json", + "compatibilityAssembly": null, + "strategy": "stay", + "migrationEpic": "SP220-08", + "namespacePreserved": true, + "evidence": "Canonical source-generated JSON definition adapters introduced by SP220-08" + }, { "typePattern": "SmartPipe.Extensions.Selectors.CsvFileSource*", "baselineAssembly": "SmartPipe.Extensions", diff --git a/src/SmartPipe.Extensions.Json/Utf8LineRecordReader.cs b/src/Shared/JsonFraming/Utf8LineRecordReader.cs similarity index 99% rename from src/SmartPipe.Extensions.Json/Utf8LineRecordReader.cs rename to src/Shared/JsonFraming/Utf8LineRecordReader.cs index 4b925fa..c8d2a4f 100644 --- a/src/SmartPipe.Extensions.Json/Utf8LineRecordReader.cs +++ b/src/Shared/JsonFraming/Utf8LineRecordReader.cs @@ -1,4 +1,4 @@ -namespace SmartPipe.Extensions; +namespace SmartPipe.Shared.JsonFraming; internal readonly record struct Utf8LineRecord(byte[] Bytes, bool TooLarge); diff --git a/src/SmartPipe.Extensions.Json/DeadLetterRecordReader.cs b/src/SmartPipe.Extensions.Json/DeadLetterRecordReader.cs index b553837..b488859 100644 --- a/src/SmartPipe.Extensions.Json/DeadLetterRecordReader.cs +++ b/src/SmartPipe.Extensions.Json/DeadLetterRecordReader.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using SmartPipe.Core; +using SmartPipe.Shared.JsonFraming; namespace SmartPipe.Extensions; diff --git a/src/SmartPipe.Extensions.Json/JsonInputOptionsValidator.cs b/src/SmartPipe.Extensions.Json/JsonInputOptionsValidator.cs index a679ba5..9ef0470 100644 --- a/src/SmartPipe.Extensions.Json/JsonInputOptionsValidator.cs +++ b/src/SmartPipe.Extensions.Json/JsonInputOptionsValidator.cs @@ -5,10 +5,13 @@ namespace SmartPipe.Extensions; internal static class JsonInputOptionsValidator { public static JsonFileSourceOptions Validate(JsonFileSourceOptions? options, ILogger? logger) + => Validate(options, logger is not null); + + internal static JsonFileSourceOptions Validate(JsonFileSourceOptions? options, bool loggerAvailable) { ArgumentNullException.ThrowIfNull(options); ValidateCommon(options.Format, options.InvalidRecordBehavior, options.MaxDepth, - options.MaxRecordSizeBytes, options.MaxUnframedInputSizeBytes, logger, nameof(options)); + options.MaxRecordSizeBytes, options.MaxUnframedInputSizeBytes, loggerAvailable, nameof(options)); if (options.InvalidRecordBehavior == InvalidJsonRecordBehavior.SkipAndLog && options.Format is not (JsonFileFormat.Ndjson or JsonFileFormat.BatchJsonLines)) throw new ArgumentException( @@ -18,10 +21,13 @@ public static JsonFileSourceOptions Validate(JsonFileSourceOptions? options, ILo } public static DeadLetterSourceOptions Validate(DeadLetterSourceOptions? options, ILogger? logger) + => Validate(options, logger is not null); + + internal static DeadLetterSourceOptions Validate(DeadLetterSourceOptions? options, bool loggerAvailable) { ArgumentNullException.ThrowIfNull(options); ValidateCommon(options.Format, options.InvalidRecordBehavior, options.MaxDepth, - options.MaxRecordSizeBytes, options.MaxUnframedInputSizeBytes, logger, nameof(options)); + options.MaxRecordSizeBytes, options.MaxUnframedInputSizeBytes, loggerAvailable, nameof(options)); if (options.Format == JsonFileFormat.BatchJsonLines) throw new ArgumentException("BatchJsonLines is not supported by DeadLetterSource.", nameof(options)); if (options.Format == JsonFileFormat.Array @@ -32,13 +38,43 @@ public static DeadLetterSourceOptions Validate(DeadLetterSourceOptions? options, return options with { }; } + internal static JsonFileSinkOptions Validate(JsonFileSinkOptions? options) + { + ArgumentNullException.ThrowIfNull(options); + if (!Enum.IsDefined(options.Format)) + throw new ArgumentOutOfRangeException(nameof(options), options.Format, "The JSON format is not defined."); + if (!Enum.IsDefined(options.OpenMode)) + throw new ArgumentOutOfRangeException(nameof(options), options.OpenMode, "The JSON open mode is not defined."); + if (options.Format == JsonFileFormat.Auto) + throw new ArgumentException("Auto format is valid only for JSON sources.", nameof(options)); + if (options.Format == JsonFileFormat.Array && options.OpenMode == JsonFileOpenMode.Append) + throw new ArgumentException("A root JSON array cannot be appended safely.", nameof(options)); + if (options.FlushInterval <= 0) + throw new ArgumentOutOfRangeException(nameof(options), options.FlushInterval, "Flush interval must be greater than zero."); + return options with { }; + } + + internal static DeadLetterSinkOptions Validate(DeadLetterSinkOptions? options, bool loggerAvailable) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(options.RetryDelays); + ArgumentNullException.ThrowIfNull(options.TimeProvider); + if (!Enum.IsDefined(options.FailureMode)) + throw new ArgumentOutOfRangeException(nameof(options), options.FailureMode, "The dead-letter failure mode is not defined."); + if (options.RetryDelays.Any(static delay => delay < TimeSpan.Zero)) + throw new ArgumentOutOfRangeException(nameof(options), "Retry delays cannot be negative."); + if (options.FailureMode == Sinks.DeadLetterWriteFailureMode.LogAndDrop && !loggerAvailable) + throw new ArgumentException("LogAndDrop requires a logger factory.", nameof(options)); + return options with { RetryDelays = options.RetryDelays.ToArray() }; + } + private static void ValidateCommon( JsonFileFormat format, InvalidJsonRecordBehavior invalidRecordBehavior, int maxDepth, int maxRecordSizeBytes, long maxUnframedInputSizeBytes, - ILogger? logger, + bool loggerAvailable, string parameterName) { if (!Enum.IsDefined(format)) @@ -51,7 +87,7 @@ private static void ValidateCommon( throw new ArgumentOutOfRangeException(parameterName, maxRecordSizeBytes, "MaxRecordSizeBytes must be greater than zero."); if (maxUnframedInputSizeBytes <= 0) throw new ArgumentOutOfRangeException(parameterName, maxUnframedInputSizeBytes, "MaxUnframedInputSizeBytes must be greater than zero."); - if (invalidRecordBehavior == InvalidJsonRecordBehavior.SkipAndLog && logger == null) + if (invalidRecordBehavior == InvalidJsonRecordBehavior.SkipAndLog && !loggerAvailable) throw new ArgumentException("SkipAndLog requires a logger.", parameterName); } } diff --git a/src/SmartPipe.Extensions.Json/JsonMetadataSnapshot.cs b/src/SmartPipe.Extensions.Json/JsonMetadataSnapshot.cs new file mode 100644 index 0000000..94ecbf9 --- /dev/null +++ b/src/SmartPipe.Extensions.Json/JsonMetadataSnapshot.cs @@ -0,0 +1,87 @@ +#nullable enable + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Json; + +internal static class JsonMetadataSnapshot +{ + public static (JsonTypeInfo Item, JsonTypeInfo> Batch) ForFile( + JsonTypeInfo? itemTypeInfo, + JsonTypeInfo>? batchTypeInfo, + int? maxDepth = null) + { + ArgumentNullException.ThrowIfNull(itemTypeInfo); + ArgumentNullException.ThrowIfNull(batchTypeInfo); + if (itemTypeInfo.Type != typeof(T) || batchTypeInfo.Type != typeof(List)) + throw new ArgumentException("JSON type metadata does not match the source item and batch types."); + if (!ReferenceEquals(itemTypeInfo.Options, batchTypeInfo.Options)) + throw new ArgumentException("Item and batch JSON type metadata must come from the same serializer context."); + + var options = CloneOptions(itemTypeInfo.Options, maxDepth); + return (Resolve(options), Resolve>(options)); + } + + public static JsonTypeInfo ForValue(JsonTypeInfo? typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + if (typeInfo.Type != typeof(T)) + throw new ArgumentException("JSON type metadata does not match the requested value type."); + + return Resolve(CloneOptions(typeInfo.Options, maxDepth: null)); + } + + public static JsonTypeInfo> ForDeadLetterEnvelope( + JsonTypeInfo>? typeInfo, + int? maxDepth = null) + { + ArgumentNullException.ThrowIfNull(typeInfo); + if (typeInfo.Type != typeof(DeadLetterEnvelope)) + throw new ArgumentException("JSON type metadata does not match the dead-letter envelope type."); + + return Resolve>(CloneOptions(typeInfo.Options, maxDepth)); + } + + private static JsonSerializerOptions CloneOptions(JsonSerializerOptions source, int? maxDepth) + { + ArgumentNullException.ThrowIfNull(source); + if (source.TypeInfoResolver is null) + throw new ArgumentException("Source-generated JSON metadata must provide a type-info resolver."); + + var clone = new JsonSerializerOptions(source) + { + TypeInfoResolver = source.TypeInfoResolver, + }; + if (maxDepth.HasValue) + clone.MaxDepth = maxDepth.Value; + + clone.MakeReadOnly(); + return clone; + } + + private static JsonTypeInfo Resolve(JsonSerializerOptions options) + { + try + { + if (options.GetTypeInfo(typeof(T)) is JsonTypeInfo typeInfo) + return typeInfo; + } + catch (NotSupportedException exception) + { + throw new ArgumentException( + $"The JSON metadata resolver cannot resolve '{typeof(T)}'.", + exception); + } + catch (InvalidOperationException exception) + { + throw new ArgumentException( + $"The JSON metadata resolver cannot resolve '{typeof(T)}'.", + exception); + } + + throw new ArgumentException( + $"The JSON metadata resolver returned incompatible metadata for '{typeof(T)}'."); + } +} diff --git a/src/SmartPipe.Extensions.Json/JsonPipelineComponents.cs b/src/SmartPipe.Extensions.Json/JsonPipelineComponents.cs new file mode 100644 index 0000000..9cb0f3d --- /dev/null +++ b/src/SmartPipe.Extensions.Json/JsonPipelineComponents.cs @@ -0,0 +1,122 @@ +#nullable enable + +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.Logging; +using SmartPipe.Core; +using SmartPipe.Extensions.Selectors; +using SmartPipe.Extensions.Sinks; +using SmartPipe.Extensions.Transforms; + +namespace SmartPipe.Extensions.Json; + +/// Creates runtime-owned JSON pipeline components. +public static class JsonPipelineComponents +{ + /// Creates a lazy, per-run JSON file source component. + public static PipelineComponent> FileSource( + string path, + JsonTypeInfo itemTypeInfo, + JsonTypeInfo> batchTypeInfo, + JsonFileSourceOptions options, + ILoggerFactory? loggerFactory = null) + { + var validatedPath = ValidatePath(path); + var validatedOptions = JsonInputOptionsValidator.Validate(options, loggerFactory is not null); + var metadata = JsonMetadataSnapshot.ForFile(itemTypeInfo, batchTypeInfo, validatedOptions.MaxDepth); + + return PipelineComponent.RuntimeOwned>( + (_, _) => ValueTask.FromResult>( + new JsonFileSource( + validatedPath, + metadata.Item, + metadata.Batch, + validatedOptions, + loggerFactory?.CreateLogger>()))); + } + + /// Creates a lazy, per-run JSON file sink component. + public static PipelineComponent> FileSink( + string path, + JsonTypeInfo itemTypeInfo, + JsonTypeInfo> batchTypeInfo, + JsonFileSinkOptions options) + { + var validatedPath = ValidatePath(path); + var validatedOptions = JsonInputOptionsValidator.Validate(options); + var metadata = JsonMetadataSnapshot.ForFile(itemTypeInfo, batchTypeInfo); + + return PipelineComponent.RuntimeOwned>( + (_, _) => ValueTask.FromResult>( + new JsonFileSink( + validatedPath, + metadata.Item, + metadata.Batch, + validatedOptions))); + } + + /// Creates a lazy, per-run JSON transform component. + public static PipelineComponent> Transform( + JsonTypeInfo inputTypeInfo, + JsonTypeInfo outputTypeInfo) + { + var inputMetadata = JsonMetadataSnapshot.ForValue(inputTypeInfo); + var outputMetadata = JsonMetadataSnapshot.ForValue(outputTypeInfo); + + return PipelineComponent.RuntimeOwned>( + (_, _) => ValueTask.FromResult>( + new JsonTransform(inputMetadata, outputMetadata))); + } + + /// Creates a lazy, per-run dead-letter JSON source component. + public static PipelineComponent> DeadLetterSource( + string path, + JsonTypeInfo> envelopeTypeInfo, + DeadLetterSourceOptions options, + ILoggerFactory? loggerFactory = null) + { + var validatedPath = ValidatePath(path); + var validatedOptions = JsonInputOptionsValidator.Validate(options, loggerFactory is not null); + var metadata = JsonMetadataSnapshot.ForDeadLetterEnvelope( + envelopeTypeInfo, + validatedOptions.MaxDepth); + + return PipelineComponent.RuntimeOwned>( + (_, _) => ValueTask.FromResult>( + loggerFactory is null + ? new DeadLetterSource(validatedPath, metadata, validatedOptions) + : new DeadLetterSource( + validatedPath, + metadata, + validatedOptions, + loggerFactory.CreateLogger>()))); + } + + /// Creates a lazy, per-run dead-letter JSON sink component. + public static PipelineComponent>> DeadLetterSink( + string path, + JsonTypeInfo> envelopeTypeInfo, + DeadLetterSinkOptions options, + ILoggerFactory? loggerFactory = null) + { + var validatedPath = ValidatePath(path); + var validatedOptions = JsonInputOptionsValidator.Validate(options, loggerFactory is not null); + var metadata = JsonMetadataSnapshot.ForDeadLetterEnvelope(envelopeTypeInfo); + + return PipelineComponent.RuntimeOwned>>( + (_, _) => ValueTask.FromResult>>( + new DeadLetterSink( + validatedPath, + new JsonLinesDeadLetterSerializer(metadata), + validatedOptions, + loggerFactory?.CreateLogger>(), + stream: null))); + } + + private static string ValidatePath(string? path) + { + ArgumentNullException.ThrowIfNull(path); + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path cannot be empty or whitespace.", nameof(path)); + return path; + } +} diff --git a/src/SmartPipe.Extensions.Json/JsonPipelineDefinitionBuilder.cs b/src/SmartPipe.Extensions.Json/JsonPipelineDefinitionBuilder.cs new file mode 100644 index 0000000..e4ffe07 --- /dev/null +++ b/src/SmartPipe.Extensions.Json/JsonPipelineDefinitionBuilder.cs @@ -0,0 +1,111 @@ +#nullable enable + +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.Logging; +using SmartPipe.Core; + +namespace SmartPipe.Extensions.Json; + +/// Starts typed pipeline definitions backed by JSON file components. +public static class JsonPipelineDefinitionBuilder +{ + /// Starts a typed definition with a JSON file source. + public static PipelineDefinitionBuilder FromJsonFile( + PipelineKey pipelineKey, + string path, + JsonTypeInfo itemTypeInfo, + JsonTypeInfo> batchTypeInfo, + JsonFileSourceOptions options, + ILoggerFactory? loggerFactory = null) => + SmartPipe.Core.PipelineDefinitionBuilder.From( + pipelineKey, + JsonPipelineComponents.FileSource( + path, + itemTypeInfo, + batchTypeInfo, + options, + loggerFactory)); + + /// Starts a typed definition with a dead-letter JSON source. + public static PipelineDefinitionBuilder FromJsonDeadLetterFile( + PipelineKey pipelineKey, + string path, + JsonTypeInfo> envelopeTypeInfo, + DeadLetterSourceOptions options, + ILoggerFactory? loggerFactory = null) => + SmartPipe.Core.PipelineDefinitionBuilder.From( + pipelineKey, + JsonPipelineComponents.DeadLetterSource( + path, + envelopeTypeInfo, + options, + loggerFactory)); +} + +/// Adds JSON transforms and file sinks to typed definitions. +public static class JsonPipelineDefinitionBuilderExtensions +{ +#pragma warning disable RS0026 // The canonical JSON builder intentionally mirrors Core's typed overload families. + /// Appends a JSON transform to a source-only definition. + public static PipelineDefinitionBuilder TransformJson( + this PipelineDefinitionBuilder builder, + PipelineStageKey stageKey, + JsonTypeInfo inputTypeInfo, + JsonTypeInfo outputTypeInfo, + StageFailureOptions? failureOptions = null, + StageDeadLetterOptions? deadLetterOptions = null, + string? stageName = null) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Transform( + stageKey, + JsonPipelineComponents.Transform(inputTypeInfo, outputTypeInfo), + failureOptions, + deadLetterOptions, + stageName); + } + + /// Appends a JSON transform to a multi-stage definition. + public static PipelineDefinitionBuilder TransformJson( + this PipelineDefinitionBuilder builder, + PipelineStageKey stageKey, + JsonTypeInfo inputTypeInfo, + JsonTypeInfo outputTypeInfo, + StageFailureOptions? failureOptions = null, + StageDeadLetterOptions? deadLetterOptions = null, + string? stageName = null) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Transform( + stageKey, + JsonPipelineComponents.Transform(inputTypeInfo, outputTypeInfo), + failureOptions, + deadLetterOptions, + stageName); + } + + /// Completes a source-only definition with a JSON file sink. + public static PipelineDefinition ToJsonFile( + this PipelineDefinitionBuilder builder, + string path, + JsonTypeInfo itemTypeInfo, + JsonTypeInfo> batchTypeInfo, + JsonFileSinkOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.To(JsonPipelineComponents.FileSink(path, itemTypeInfo, batchTypeInfo, options)); + } + + /// Completes a multi-stage definition with a JSON file sink. + public static PipelineDefinition ToJsonFile( + this PipelineDefinitionBuilder builder, + string path, + JsonTypeInfo itemTypeInfo, + JsonTypeInfo> batchTypeInfo, + JsonFileSinkOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.To(JsonPipelineComponents.FileSink(path, itemTypeInfo, batchTypeInfo, options)); + } +#pragma warning restore RS0026 +} diff --git a/src/SmartPipe.Extensions.Json/PublicAPI.Unshipped.txt b/src/SmartPipe.Extensions.Json/PublicAPI.Unshipped.txt index 3182dee..c50cb74 100644 --- a/src/SmartPipe.Extensions.Json/PublicAPI.Unshipped.txt +++ b/src/SmartPipe.Extensions.Json/PublicAPI.Unshipped.txt @@ -1,4 +1,18 @@ #nullable enable +SmartPipe.Extensions.Json.JsonPipelineComponents +static SmartPipe.Extensions.Json.JsonPipelineComponents.DeadLetterSink(string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo!>! envelopeTypeInfo, SmartPipe.Extensions.DeadLetterSinkOptions! options, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) -> SmartPipe.Core.PipelineComponent!>!>! +static SmartPipe.Extensions.Json.JsonPipelineComponents.DeadLetterSource(string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo!>! envelopeTypeInfo, SmartPipe.Extensions.DeadLetterSourceOptions! options, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) -> SmartPipe.Core.PipelineComponent!>! +static SmartPipe.Extensions.Json.JsonPipelineComponents.FileSink(string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! itemTypeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo!>! batchTypeInfo, SmartPipe.Extensions.JsonFileSinkOptions! options) -> SmartPipe.Core.PipelineComponent!>! +static SmartPipe.Extensions.Json.JsonPipelineComponents.FileSource(string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! itemTypeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo!>! batchTypeInfo, SmartPipe.Extensions.JsonFileSourceOptions! options, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) -> SmartPipe.Core.PipelineComponent!>! +static SmartPipe.Extensions.Json.JsonPipelineComponents.Transform(System.Text.Json.Serialization.Metadata.JsonTypeInfo! inputTypeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo! outputTypeInfo) -> SmartPipe.Core.PipelineComponent!>! +SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilder +static SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilder.FromJsonDeadLetterFile(SmartPipe.Core.PipelineKey pipelineKey, string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo!>! envelopeTypeInfo, SmartPipe.Extensions.DeadLetterSourceOptions! options, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) -> SmartPipe.Core.PipelineDefinitionBuilder! +static SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilder.FromJsonFile(SmartPipe.Core.PipelineKey pipelineKey, string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! itemTypeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo!>! batchTypeInfo, SmartPipe.Extensions.JsonFileSourceOptions! options, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) -> SmartPipe.Core.PipelineDefinitionBuilder! +SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilderExtensions +static SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilderExtensions.ToJsonFile(this SmartPipe.Core.PipelineDefinitionBuilder! builder, string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! itemTypeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo!>! batchTypeInfo, SmartPipe.Extensions.JsonFileSinkOptions! options) -> SmartPipe.Core.PipelineDefinition! +static SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilderExtensions.ToJsonFile(this SmartPipe.Core.PipelineDefinitionBuilder! builder, string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! itemTypeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo!>! batchTypeInfo, SmartPipe.Extensions.JsonFileSinkOptions! options) -> SmartPipe.Core.PipelineDefinition! +static SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilderExtensions.TransformJson(this SmartPipe.Core.PipelineDefinitionBuilder! builder, SmartPipe.Core.PipelineStageKey stageKey, System.Text.Json.Serialization.Metadata.JsonTypeInfo! inputTypeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo! outputTypeInfo, SmartPipe.Core.StageFailureOptions? failureOptions = null, SmartPipe.Core.StageDeadLetterOptions? deadLetterOptions = null, string? stageName = null) -> SmartPipe.Core.PipelineDefinitionBuilder! +static SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilderExtensions.TransformJson(this SmartPipe.Core.PipelineDefinitionBuilder! builder, SmartPipe.Core.PipelineStageKey stageKey, System.Text.Json.Serialization.Metadata.JsonTypeInfo! inputTypeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo! outputTypeInfo, SmartPipe.Core.StageFailureOptions? failureOptions = null, SmartPipe.Core.StageDeadLetterOptions? deadLetterOptions = null, string? stageName = null) -> SmartPipe.Core.PipelineDefinitionBuilder! SmartPipe.Extensions.Selectors.DeadLetterSource SmartPipe.Extensions.Selectors.DeadLetterSource.DeadLetterSource(string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! valueTypeInfo) -> void SmartPipe.Extensions.Selectors.DeadLetterSource.DeadLetterSource(string! path, System.Text.Json.Serialization.Metadata.JsonTypeInfo! valueTypeInfo, SmartPipe.Extensions.DeadLetterSourceOptions! options) -> void diff --git a/src/SmartPipe.Extensions.Json/README.md b/src/SmartPipe.Extensions.Json/README.md index e7c2d4a..500ff1e 100644 --- a/src/SmartPipe.Extensions.Json/README.md +++ b/src/SmartPipe.Extensions.Json/README.md @@ -23,6 +23,8 @@ health-check, or Newtonsoft.Json dependencies. - `JsonTransform` - `DeadLetterSource` - `DeadLetterSink` +- `JsonPipelineComponents`, `JsonPipelineDefinitionBuilder`, and + `JsonPipelineDefinitionBuilderExtensions` The related `JsonLinesDeadLetterSerializer` remains part of `SmartPipe.Core`. @@ -33,6 +35,12 @@ The package uses `System.Text.Json` from the .NET 10 shared framework, so an additional `System.Text.Json` NuGet dependency is neither required nor pinned. Newtonsoft.Json is not a dependency and is not selected at runtime. +Line-framed input uses the internal, bounded UTF-8 reader linked from +`src/Shared/JsonFraming/Utf8LineRecordReader.cs`. The reader is BCL-only and +knows only about LF/CRLF boundaries, BOM bytes, and the configured record-size +limit. JSON validation, path diagnostics, and invalid-record policy remain in +this package; the framer is not a public API or a separate package. + ## Trimming and NativeAOT Reflection-based constructors are annotated for trimming and NativeAOT risk. @@ -53,9 +61,9 @@ source-generated paths. Explicitly line-framed records (`Ndjson`, (`MaxRecordSizeBytes`); root arrays and auto-detected legacy top-level value sequences use a 256 MiB unframed input limit (`MaxUnframedInputSizeBytes`). `SkipAndLog` requires a logger and is supported only when the source is reading -independently line-framed records. `JsonFileSource` requires explicit -`Ndjson` or `BatchJsonLines`; dead-letter `Auto` recovery depends on whether it -detects a framed stream rather than a root array. +independently line-framed records. `JsonFileSource` defaults to `Auto` and +also accepts explicit `Ndjson` or `BatchJsonLines`; dead-letter `Auto` recovery +depends on whether it detects a framed stream rather than a root array. Append mode preserves existing bytes. If a non-empty destination has no final LF, the sink inserts one before the next record; an existing partial row is not diff --git a/src/SmartPipe.Extensions.Json/Selectors/DeadLetterSource.cs b/src/SmartPipe.Extensions.Json/Selectors/DeadLetterSource.cs index a536f63..a6fbe63 100644 --- a/src/SmartPipe.Extensions.Json/Selectors/DeadLetterSource.cs +++ b/src/SmartPipe.Extensions.Json/Selectors/DeadLetterSource.cs @@ -5,6 +5,7 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.Logging; using SmartPipe.Core; +using SmartPipe.Shared.JsonFraming; namespace SmartPipe.Extensions.Selectors; diff --git a/src/SmartPipe.Extensions.Json/Selectors/JsonFileSource.cs b/src/SmartPipe.Extensions.Json/Selectors/JsonFileSource.cs index 91528b8..496179a 100644 --- a/src/SmartPipe.Extensions.Json/Selectors/JsonFileSource.cs +++ b/src/SmartPipe.Extensions.Json/Selectors/JsonFileSource.cs @@ -5,6 +5,8 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.Logging; using SmartPipe.Core; +using SmartPipe.Extensions.Json; +using SmartPipe.Shared.JsonFraming; namespace SmartPipe.Extensions.Selectors; @@ -111,7 +113,10 @@ public JsonFileSource( ArgumentNullException.ThrowIfNull(listTypeInfo); _options = JsonInputOptionsValidator.Validate(options, logger); _logger = logger; - var frozenTypeInfo = FreezeSourceGeneratedOptions(itemTypeInfo, listTypeInfo, _options.MaxDepth); + var frozenTypeInfo = JsonMetadataSnapshot.ForFile( + itemTypeInfo, + listTypeInfo, + _options.MaxDepth); _deserializeItems = (stream, topLevelValues, token) => WrapJsonErrors( JsonSerializer.DeserializeAsyncEnumerable(stream, frozenTypeInfo.Item, topLevelValues, token), @@ -119,11 +124,11 @@ public JsonFileSource( "document"); _deserializeBatches = (stream, token) => WrapJsonErrors( - JsonSerializer.DeserializeAsyncEnumerable(stream, frozenTypeInfo.List, topLevelValues: true, token), + JsonSerializer.DeserializeAsyncEnumerable(stream, frozenTypeInfo.Batch, topLevelValues: true, token), _path, "document"); _deserializeItemRecord = bytes => JsonSerializer.Deserialize(bytes, frozenTypeInfo.Item); - _deserializeBatchRecord = bytes => JsonSerializer.Deserialize(bytes, frozenTypeInfo.List); + _deserializeBatchRecord = bytes => JsonSerializer.Deserialize(bytes, frozenTypeInfo.Batch); } [RequiresUnreferencedCode("Reflection-based JSON file reading is not trimming-safe.")] @@ -353,30 +358,6 @@ private static JsonSerializerOptions FreezeOptions(JsonSerializerOptions? option return clone; } - private static (JsonTypeInfo Item, JsonTypeInfo> List) FreezeSourceGeneratedOptions( - JsonTypeInfo itemTypeInfo, - JsonTypeInfo> listTypeInfo, - int maxDepth) - { - if (itemTypeInfo.Type != typeof(T) || listTypeInfo.Type != typeof(List)) - throw new ArgumentException("JSON type metadata does not match the source item and batch types."); - if (!ReferenceEquals(itemTypeInfo.Options, listTypeInfo.Options)) - throw new ArgumentException("Item and list JSON type metadata must come from the same serializer context."); - if (itemTypeInfo.Options.TypeInfoResolver == null) - throw new ArgumentException("Source-generated JSON metadata must provide a type-info resolver."); - - var clone = new JsonSerializerOptions(itemTypeInfo.Options) - { - MaxDepth = maxDepth, - TypeInfoResolver = itemTypeInfo.Options.TypeInfoResolver, - }; - if (clone.GetTypeInfo(typeof(T)) is not JsonTypeInfo frozenItem - || clone.GetTypeInfo(typeof(List)) is not JsonTypeInfo> frozenList) - throw new ArgumentException("The JSON metadata resolver cannot resolve both item and batch types."); - clone.MakeReadOnly(); - return (frozenItem, frozenList); - } - private static async IAsyncEnumerable WrapJsonErrors( IAsyncEnumerable values, string path, diff --git a/src/SmartPipe.Extensions.Json/Sinks/DeadLetterSink.cs b/src/SmartPipe.Extensions.Json/Sinks/DeadLetterSink.cs index 003f15d..6de1725 100644 --- a/src/SmartPipe.Extensions.Json/Sinks/DeadLetterSink.cs +++ b/src/SmartPipe.Extensions.Json/Sinks/DeadLetterSink.cs @@ -324,14 +324,7 @@ private static string ValidatePath(string? path) } private static DeadLetterSinkOptions ValidateOptions(DeadLetterSinkOptions? options) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(options.RetryDelays); - ArgumentNullException.ThrowIfNull(options.TimeProvider); - if (options.RetryDelays.Any(static delay => delay < TimeSpan.Zero)) - throw new ArgumentOutOfRangeException(nameof(options), "Retry delays cannot be negative."); - return options with { RetryDelays = options.RetryDelays.ToArray() }; - } + => JsonInputOptionsValidator.Validate(options, loggerAvailable: true); [LoggerMessage(1, LogLevel.Warning, "IOException on attempt {Attempt}/{MaxAttempts} writing to dead letter file {Path}. Retrying in {DelayMilliseconds}ms.")] private static partial void LogRetry( diff --git a/src/SmartPipe.Extensions.Json/Sinks/JsonFileSink.cs b/src/SmartPipe.Extensions.Json/Sinks/JsonFileSink.cs index edc4769..26fb76d 100644 --- a/src/SmartPipe.Extensions.Json/Sinks/JsonFileSink.cs +++ b/src/SmartPipe.Extensions.Json/Sinks/JsonFileSink.cs @@ -356,16 +356,7 @@ private static async Task WriteTransactionalAsync(Stream stream, byte[] bytes, b } private static JsonFileSinkOptions ValidateOptions(JsonFileSinkOptions? options) - { - ArgumentNullException.ThrowIfNull(options); - if (options.Format == JsonFileFormat.Auto) - throw new ArgumentException("Auto format is valid only for JSON sources.", nameof(options)); - if (options.Format == JsonFileFormat.Array && options.OpenMode == JsonFileOpenMode.Append) - throw new ArgumentException("A root JSON array cannot be appended safely.", nameof(options)); - if (options.FlushInterval <= 0) - throw new ArgumentOutOfRangeException(nameof(options), options.FlushInterval, "Flush interval must be greater than zero."); - return options with { }; - } + => JsonInputOptionsValidator.Validate(options); private static string ValidatePath(string? path) { diff --git a/src/SmartPipe.Extensions.Json/SmartPipe.Extensions.Json.csproj b/src/SmartPipe.Extensions.Json/SmartPipe.Extensions.Json.csproj index e67fc7a..bf537e9 100644 --- a/src/SmartPipe.Extensions.Json/SmartPipe.Extensions.Json.csproj +++ b/src/SmartPipe.Extensions.Json/SmartPipe.Extensions.Json.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/tests/Consumers/Scenarios/json-dependency-injection-direct/Consumer.csproj b/tests/Consumers/Scenarios/json-dependency-injection-direct/Consumer.csproj new file mode 100644 index 0000000..b1cb74a --- /dev/null +++ b/tests/Consumers/Scenarios/json-dependency-injection-direct/Consumer.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + enable + enable + true + false + + + + + + + + diff --git a/tests/Consumers/Scenarios/json-dependency-injection-direct/Program.cs b/tests/Consumers/Scenarios/json-dependency-injection-direct/Program.cs new file mode 100644 index 0000000..caff157 --- /dev/null +++ b/tests/Consumers/Scenarios/json-dependency-injection-direct/Program.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using SmartPipe.Core; +using SmartPipe.Extensions; +using SmartPipe.Extensions.DependencyInjection; +using SmartPipe.Extensions.Json; + +var inputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-di-{Guid.NewGuid():N}-input.json"); +var outputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-di-{Guid.NewGuid():N}-output.jsonl"); +try +{ + await File.WriteAllTextAsync(inputPath, "[{\"Value\":13}]\n"); + var key = new PipelineKey("json-dependency-injection-direct"); + var definition = JsonPipelineDefinitionBuilder + .FromJsonFile( + key, + inputPath, + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ListConsumerModel, + new JsonFileSourceOptions { Format = JsonFileFormat.Array }) + .TransformJson( + new PipelineStageKey("json-round-trip"), + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ConsumerModel) + .ToJsonFile( + outputPath, + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ListConsumerModel, + new JsonFileSinkOptions + { + Format = JsonFileFormat.BatchJsonLines, + OpenMode = JsonFileOpenMode.Create, + FlushInterval = 1, + }); + + var services = new ServiceCollection(); + services.AddSmartPipe().AddPipeline(definition); + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true, + ValidateOnBuild = true, + }); + var factory = provider + .GetRequiredService() + .GetFactory(key); + await using var run = await factory.StartAsync(); + await run.Completion; + var output = await File.ReadAllTextAsync(outputPath); + if (!output.Contains("13", StringComparison.Ordinal)) return 1; +} +finally +{ + File.Delete(inputPath); + File.Delete(outputPath); +} + +Console.WriteLine("CONSUMER_OK json-dependency-injection-direct"); +return 0; + +internal sealed record ConsumerModel(int Value); +[JsonSerializable(typeof(ConsumerModel))] +[JsonSerializable(typeof(List))] +internal sealed partial class ConsumerJsonContext : JsonSerializerContext; diff --git a/tests/Consumers/Scenarios/json-direct/Consumer.csproj b/tests/Consumers/Scenarios/json-direct/Consumer.csproj index 8ebf482..594d4b2 100644 --- a/tests/Consumers/Scenarios/json-direct/Consumer.csproj +++ b/tests/Consumers/Scenarios/json-direct/Consumer.csproj @@ -1,4 +1,4 @@ - Exenet10.0enableenabletrue + Exenet10.0enableenabletruefalse diff --git a/tests/Consumers/Scenarios/json-direct/Program.cs b/tests/Consumers/Scenarios/json-direct/Program.cs index e88264f..447d99e 100644 --- a/tests/Consumers/Scenarios/json-direct/Program.cs +++ b/tests/Consumers/Scenarios/json-direct/Program.cs @@ -1,17 +1,51 @@ -using System.Text.Json; using System.Text.Json.Serialization; using SmartPipe.Core; -using SmartPipe.Extensions.Transforms; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Json; + +var inputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-direct-{Guid.NewGuid():N}-input.json"); +var outputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-direct-{Guid.NewGuid():N}-output.jsonl"); +try +{ + await File.WriteAllTextAsync(inputPath, "[{\"Value\":42}]\n"); + var key = new PipelineKey("json-direct"); + var definition = JsonPipelineDefinitionBuilder + .FromJsonFile( + key, + inputPath, + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ListConsumerModel, + new JsonFileSourceOptions { Format = JsonFileFormat.Array }) + .TransformJson( + new PipelineStageKey("json-round-trip"), + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ConsumerModel) + .ToJsonFile( + outputPath, + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ListConsumerModel, + new JsonFileSinkOptions + { + Format = JsonFileFormat.BatchJsonLines, + OpenMode = JsonFileOpenMode.Create, + FlushInterval = 1, + }); + + await using var run = await definition.StartAsync(); + await run.Completion; + var output = await File.ReadAllTextAsync(outputPath); + if (!output.Contains("42", StringComparison.Ordinal)) return 1; +} +finally +{ + File.Delete(inputPath); + File.Delete(outputPath); +} -var model = new ConsumerModel(42); -var json = JsonSerializer.Serialize(model, ConsumerJsonContext.Default.ConsumerModel); -var transformed = await new JsonTransform(ConsumerJsonContext.Default.ConsumerModel, ConsumerJsonContext.Default.ConsumerModel) - .TransformAsync(ProcessingEnvelope.Create(model)); -var roundTrip = JsonSerializer.Deserialize(json, ConsumerJsonContext.Default.ConsumerModel); -if (roundTrip?.Value != 42 || transformed.IsSuccess is false || transformed.Value?.Value != 42) return 1; Console.WriteLine("CONSUMER_OK json-direct"); return 0; internal sealed record ConsumerModel(int Value); [JsonSerializable(typeof(ConsumerModel))] +[JsonSerializable(typeof(List))] internal sealed partial class ConsumerJsonContext : JsonSerializerContext; diff --git a/tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj b/tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj index 8ebf482..594d4b2 100644 --- a/tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj +++ b/tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj @@ -1,4 +1,4 @@ - Exenet10.0enableenabletrue + Exenet10.0enableenabletruefalse diff --git a/tests/Consumers/Scenarios/json-nativeaot/Program.cs b/tests/Consumers/Scenarios/json-nativeaot/Program.cs index 268ec80..ab2b1c7 100644 --- a/tests/Consumers/Scenarios/json-nativeaot/Program.cs +++ b/tests/Consumers/Scenarios/json-nativeaot/Program.cs @@ -1,14 +1,50 @@ -using System.Text.Json; using System.Text.Json.Serialization; -using SmartPipe.Extensions.Transforms; +using SmartPipe.Core; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Json; + +var inputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-nativeaot-{Guid.NewGuid():N}-input.json"); +var outputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-nativeaot-{Guid.NewGuid():N}-output.jsonl"); +try +{ + await File.WriteAllTextAsync(inputPath, "[{\"Value\":7}]\n"); + var definition = JsonPipelineDefinitionBuilder + .FromJsonFile( + new PipelineKey("json-nativeaot"), + inputPath, + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ListConsumerModel, + new JsonFileSourceOptions { Format = JsonFileFormat.Array }) + .TransformJson( + new PipelineStageKey("json-round-trip"), + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ConsumerModel) + .ToJsonFile( + outputPath, + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ListConsumerModel, + new JsonFileSinkOptions + { + Format = JsonFileFormat.BatchJsonLines, + OpenMode = JsonFileOpenMode.Create, + FlushInterval = 1, + }); + + await using var run = await definition.StartAsync(); + await run.Completion; + var output = await File.ReadAllTextAsync(outputPath); + if (!output.Contains("7", StringComparison.Ordinal)) return 1; +} +finally +{ + File.Delete(inputPath); + File.Delete(outputPath); +} -var info = ConsumerJsonContext.Default.ConsumerModel; -var transform = new JsonTransform(info, info); -var json = JsonSerializer.Serialize(new ConsumerModel(7), info); -if (json.Length == 0 || transform is null) return 1; Console.WriteLine("CONSUMER_OK json-nativeaot"); return 0; internal sealed record ConsumerModel(int Value); [JsonSerializable(typeof(ConsumerModel))] +[JsonSerializable(typeof(List))] internal sealed partial class ConsumerJsonContext : JsonSerializerContext; diff --git a/tests/Consumers/Scenarios/json-trim/Consumer.csproj b/tests/Consumers/Scenarios/json-trim/Consumer.csproj new file mode 100644 index 0000000..601e7a5 --- /dev/null +++ b/tests/Consumers/Scenarios/json-trim/Consumer.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + enable + enable + true + false + + + + + + diff --git a/tests/Consumers/Scenarios/json-trim/Program.cs b/tests/Consumers/Scenarios/json-trim/Program.cs new file mode 100644 index 0000000..7e882d9 --- /dev/null +++ b/tests/Consumers/Scenarios/json-trim/Program.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; +using SmartPipe.Core; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Json; + +var inputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-trim-{Guid.NewGuid():N}-input.json"); +var outputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-trim-{Guid.NewGuid():N}-output.jsonl"); +try +{ + await File.WriteAllTextAsync(inputPath, "[{\"Value\":11}]\n"); + var definition = JsonPipelineDefinitionBuilder + .FromJsonFile( + new PipelineKey("json-trim"), + inputPath, + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ListConsumerModel, + new JsonFileSourceOptions { Format = JsonFileFormat.Array }) + .TransformJson( + new PipelineStageKey("json-round-trip"), + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ConsumerModel) + .ToJsonFile( + outputPath, + ConsumerJsonContext.Default.ConsumerModel, + ConsumerJsonContext.Default.ListConsumerModel, + new JsonFileSinkOptions + { + Format = JsonFileFormat.BatchJsonLines, + OpenMode = JsonFileOpenMode.Create, + FlushInterval = 1, + }); + + await using var run = await definition.StartAsync(); + await run.Completion; + var output = await File.ReadAllTextAsync(outputPath); + if (!output.Contains("11", StringComparison.Ordinal)) return 1; +} +finally +{ + File.Delete(inputPath); + File.Delete(outputPath); +} + +Console.WriteLine("CONSUMER_OK json-trim"); +return 0; + +internal sealed record ConsumerModel(int Value); +[JsonSerializable(typeof(ConsumerModel))] +[JsonSerializable(typeof(List))] +internal sealed partial class ConsumerJsonContext : JsonSerializerContext; diff --git a/tests/SmartPipe.Extensions.Json.Tests/JsonPipelineDefinitionContractTests.cs b/tests/SmartPipe.Extensions.Json.Tests/JsonPipelineDefinitionContractTests.cs new file mode 100644 index 0000000..75f24e2 --- /dev/null +++ b/tests/SmartPipe.Extensions.Json.Tests/JsonPipelineDefinitionContractTests.cs @@ -0,0 +1,463 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using SmartPipe.Core; +using SmartPipe.Extensions; +using SmartPipe.Extensions.Sinks; + +namespace SmartPipe.Extensions.Tests; + +public sealed class JsonPipelineDefinitionContractTests +{ + [Fact] + public void CanonicalComponentFactories_ExposeThePlannedPublicSurface() + { + var type = typeof(JsonFileSourceOptions).Assembly.GetType( + "SmartPipe.Extensions.Json.JsonPipelineComponents"); + + Assert.NotNull(type); + Assert.True(type!.IsAbstract && type.IsSealed); + + AssertFactory(type, "FileSource", typeof(IPipelineSource<>), 5); + AssertFactory(type, "FileSink", typeof(IPipelineSink<>), 4); + AssertFactory(type, "Transform", typeof(IPipelineTransformer<,>), 2); + AssertFactory(type, "DeadLetterSource", typeof(IPipelineSource<>), 4); + AssertFactory(type, "DeadLetterSink", typeof(IPipelineSink<>), 4); + } + + [Fact] + public void CanonicalBuilders_ExposeThePlannedPublicSurface() + { + var assembly = typeof(JsonFileSourceOptions).Assembly; + var builder = assembly.GetType("SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilder"); + var extensions = assembly.GetType("SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilderExtensions"); + + Assert.NotNull(builder); + Assert.NotNull(extensions); + Assert.True(builder!.IsAbstract && builder.IsSealed); + Assert.True(extensions!.IsAbstract && extensions.IsSealed); + + AssertMethod(builder, "FromJsonFile", parameterCount: 6); + AssertMethod(builder, "FromJsonDeadLetterFile", parameterCount: 5); + Assert.Equal( + 2, + extensions.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Count(method => method.Name == "TransformJson" && method.IsGenericMethodDefinition)); + Assert.Equal( + 2, + extensions.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Count(method => method.Name == "ToJsonFile" && method.IsGenericMethodDefinition)); + } + + [Fact] + public async Task CanonicalFileSource_IsLazyRuntimeOwnedAndFreshPerActivation() + { + var components = RequireComponentsType(); + var loggerFactory = new TrackingLoggerFactory(); + var options = new JsonFileSourceOptions + { + Format = JsonFileFormat.Ndjson, + MaxDepth = 8, + }; + var descriptor = InvokeFactory( + components, + "FileSource", + typeof(DefinitionItem), + "this-file-does-not-exist.json", + DefinitionJsonContext.Default.DefinitionItem, + DefinitionJsonContext.Default.ListDefinitionItem, + options, + loggerFactory); + + Assert.Equal(PipelineComponentOwnership.RuntimeOwned, GetProperty(descriptor, "Ownership")); + Assert.True((bool)GetProperty(descriptor, "Initialize")!); + Assert.True((bool)GetProperty(descriptor, "IsPerRun")!); + Assert.Equal(0, loggerFactory.CreateLoggerCalls); + Assert.Equal(JsonFileFormat.Ndjson, options.Format); + Assert.Equal(8, options.MaxDepth); + + var firstContext = new PipelineActivationContext(new PipelineKey("json"), Guid.NewGuid()); + var secondContext = new PipelineActivationContext(new PipelineKey("json"), Guid.NewGuid()); + using var firstCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var secondCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var first = await InvokeActivatorAsync(descriptor, firstContext, firstCts.Token); + var second = await InvokeActivatorAsync(descriptor, secondContext, secondCts.Token); + + Assert.NotSame(first, second); + Assert.Equal(2, loggerFactory.CreateLoggerCalls); + + await ((IAsyncDisposable)first).DisposeAsync(); + await ((IAsyncDisposable)second).DisposeAsync(); + Assert.Equal(0, loggerFactory.DisposeCalls); + } + + [Fact] + public void CanonicalSkipPolicies_RequireAnExplicitLoggerFactory() + { + var components = RequireComponentsType(); + var sourceException = Assert.ThrowsAny(() => InvokeFactory( + components, + "FileSource", + typeof(DefinitionItem), + "input.json", + DefinitionJsonContext.Default.DefinitionItem, + DefinitionJsonContext.Default.ListDefinitionItem, + new JsonFileSourceOptions + { + Format = JsonFileFormat.Ndjson, + InvalidRecordBehavior = InvalidJsonRecordBehavior.SkipAndLog, + }, + null)); + AssertPolicyException(sourceException); + + var deadLetterSinkException = Assert.ThrowsAny(() => InvokeFactory( + components, + "DeadLetterSink", + typeof(DefinitionItem), + "dead-letter.json", + DefinitionJsonContext.Default.DeadLetterEnvelopeDefinitionItem, + new DeadLetterSinkOptions { FailureMode = DeadLetterWriteFailureMode.LogAndDrop }, + null)); + AssertPolicyException(deadLetterSinkException); + } + + [Fact] + public void CanonicalDeadLetterSourceSkipPolicy_RequiresAnExplicitLoggerFactory() + { + var exception = Assert.ThrowsAny(() => InvokeFactory( + RequireComponentsType(), + "DeadLetterSource", + typeof(DefinitionItem), + "dead-letter.json", + DefinitionJsonContext.Default.DeadLetterEnvelopeDefinitionItem, + new DeadLetterSourceOptions + { + Format = JsonFileFormat.Ndjson, + InvalidRecordBehavior = InvalidJsonRecordBehavior.SkipAndLog, + }, + null)); + + AssertPolicyException(exception); + } + + [Fact] + public void CanonicalMetadataFactories_RejectMissingOrUnresolvableResolvers() + { + var noResolverOptions = new JsonSerializerOptions(); + var noResolverItem = JsonTypeInfo.CreateJsonTypeInfo(noResolverOptions); + var noResolverBatch = JsonTypeInfo.CreateJsonTypeInfo>(noResolverOptions); + var noResolverException = Assert.ThrowsAny(() => InvokeFactory( + RequireComponentsType(), + "FileSource", + typeof(DefinitionItem), + "input.json", + noResolverItem, + noResolverBatch, + new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson }, + null)); + AssertArgumentException(noResolverException); + + var unresolvableOptions = new JsonSerializerOptions + { + TypeInfoResolver = new NullTypeInfoResolver(), + }; + var unresolvableItem = JsonTypeInfo.CreateJsonTypeInfo(unresolvableOptions); + var unresolvableBatch = JsonTypeInfo.CreateJsonTypeInfo>(unresolvableOptions); + var unresolvableException = Assert.ThrowsAny(() => InvokeFactory( + RequireComponentsType(), + "FileSource", + typeof(DefinitionItem), + "input.json", + unresolvableItem, + unresolvableBatch, + new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson }, + null)); + AssertArgumentException(unresolvableException); + } + + [Fact] + public void CanonicalMetadataFactories_RejectMismatchedContexts() + { + var first = new DefinitionJsonContext(new JsonSerializerOptions()); + var second = new DefinitionJsonContext(new JsonSerializerOptions()); + var exception = Assert.ThrowsAny(() => InvokeFactory( + RequireComponentsType(), + "FileSource", + typeof(DefinitionItem), + "input.json", + first.DefinitionItem, + second.ListDefinitionItem, + new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson }, + null)); + + AssertArgumentException(exception); + } + + [Fact] + public void CanonicalFileSource_DoesNotMutateCallerSerializerOptions() + { + var serializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + var context = new DefinitionJsonContext(serializerOptions); + var originalMaxDepth = serializerOptions.MaxDepth; + var originalReadOnly = serializerOptions.IsReadOnly; + var originalResolver = serializerOptions.TypeInfoResolver; + var originalItemTypeInfoReadOnly = context.DefinitionItem.IsReadOnly; + var originalBatchTypeInfoReadOnly = context.ListDefinitionItem.IsReadOnly; + + _ = InvokeFactory( + RequireComponentsType(), + "FileSource", + typeof(DefinitionItem), + "input.json", + context.DefinitionItem, + context.ListDefinitionItem, + new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson, MaxDepth = 13 }, + null); + + Assert.Equal(originalMaxDepth, serializerOptions.MaxDepth); + Assert.Equal(originalReadOnly, serializerOptions.IsReadOnly); + Assert.Same(originalResolver, serializerOptions.TypeInfoResolver); + Assert.Same(serializerOptions, context.DefinitionItem.Options); + Assert.Equal(originalItemTypeInfoReadOnly, context.DefinitionItem.IsReadOnly); + Assert.Equal(originalBatchTypeInfoReadOnly, context.ListDefinitionItem.IsReadOnly); + } + + [Fact] + public async Task CanonicalDeadLetterSink_CopiesRetryDelaysAndCreatesLoggerAtActivation() + { + var loggerFactory = new TrackingLoggerFactory(); + var retryDelays = new List + { + TimeSpan.FromMilliseconds(11), + TimeSpan.FromMilliseconds(22), + }; + var descriptor = InvokeFactory( + RequireComponentsType(), + "DeadLetterSink", + typeof(DefinitionItem), + Path.Combine(Path.GetTempPath(), $"smartpipe-dl-{Guid.NewGuid():N}.json"), + DefinitionJsonContext.Default.DeadLetterEnvelopeDefinitionItem, + new DeadLetterSinkOptions + { + FailureMode = DeadLetterWriteFailureMode.LogAndDrop, + RetryDelays = retryDelays, + }, + loggerFactory); + + Assert.Equal(0, loggerFactory.CreateLoggerCalls); + retryDelays[0] = TimeSpan.FromHours(1); + var sink = await InvokeActivatorAsync( + descriptor, + new PipelineActivationContext(new PipelineKey("json-dead-letter"), Guid.NewGuid()), + TestContext.Current.CancellationToken); + + Assert.Equal(1, loggerFactory.CreateLoggerCalls); + var delaysField = sink.GetType().GetField("_retryDelays", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(delaysField); + var capturedDelays = Assert.IsType(delaysField!.GetValue(sink)); + Assert.Equal(TimeSpan.FromMilliseconds(11), capturedDelays[0]); + Assert.Equal(TimeSpan.FromMilliseconds(22), capturedDelays[1]); + + await ((IAsyncDisposable)sink).DisposeAsync(); + Assert.Equal(0, loggerFactory.DisposeCalls); + } + + [Fact] + public void CanonicalBuilderExtensions_ChainTypedDefinitionWithoutActivation() + { + var assembly = typeof(JsonFileSourceOptions).Assembly; + var builderType = assembly.GetType("SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilder"); + var extensionsType = assembly.GetType("SmartPipe.Extensions.Json.JsonPipelineDefinitionBuilderExtensions"); + Assert.NotNull(builderType); + Assert.NotNull(extensionsType); + + var itemTypeInfo = DefinitionJsonContext.Default.DefinitionItem; + var listTypeInfo = DefinitionJsonContext.Default.ListDefinitionItem; + var inputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-definition-input-{Guid.NewGuid():N}.json"); + var outputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-definition-output-{Guid.NewGuid():N}.json"); + Assert.False(File.Exists(inputPath)); + Assert.False(File.Exists(outputPath)); + var sourceFactory = builderType!.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(method => method.Name == "FromJsonFile" && method.IsGenericMethodDefinition); + var builder = sourceFactory.MakeGenericMethod(typeof(DefinitionItem)).Invoke(null, + [ + new PipelineKey("json-builder"), + inputPath, + itemTypeInfo, + listTypeInfo, + new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson }, + null, + ]); + Assert.NotNull(builder); + + var transform = extensionsType!.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(method => method.Name == "TransformJson" + && method.IsGenericMethodDefinition + && method.GetGenericArguments().Length == 2); + var transformed = transform.MakeGenericMethod(typeof(DefinitionItem), typeof(DefinitionItem)).Invoke(null, + [ + builder, + new PipelineStageKey("json-transform"), + itemTypeInfo, + itemTypeInfo, + null, + null, + null, + ]); + Assert.NotNull(transformed); + + var sinkFactory = extensionsType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(method => method.Name == "ToJsonFile" + && method.IsGenericMethodDefinition + && method.GetGenericArguments().Length == 2); + var definition = sinkFactory.MakeGenericMethod(typeof(DefinitionItem), typeof(DefinitionItem)).Invoke(null, + [ + transformed, + outputPath, + itemTypeInfo, + listTypeInfo, + new JsonFileSinkOptions { Format = JsonFileFormat.BatchJsonLines }, + ]); + + var typedDefinition = Assert.IsType>(definition); + Assert.True(typedDefinition.HasSink); + Assert.Single(typedDefinition.Stages); + Assert.Equal("json-transform", typedDefinition.Stages[0].Key.Value); + Assert.False(File.Exists(inputPath)); + Assert.False(File.Exists(outputPath)); + } + + [Fact] + public void JsonPackage_DoesNotReferenceDependencyInjectionOrFacade() + { + var references = typeof(JsonFileSourceOptions).Assembly + .GetReferencedAssemblies() + .Select(static assembly => assembly.Name) + .Where(static name => name is not null) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert.DoesNotContain("SmartPipe.Extensions", references); + Assert.DoesNotContain("SmartPipe.Extensions.DependencyInjection", references); + } + + private static Type RequireComponentsType() + { + var type = typeof(JsonFileSourceOptions).Assembly.GetType( + "SmartPipe.Extensions.Json.JsonPipelineComponents"); + Assert.NotNull(type); + return type!; + } + + private static object InvokeFactory( + Type components, + string name, + Type genericType, + params object?[] arguments) + { + var method = components.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(candidate => candidate.Name == name && candidate.IsGenericMethodDefinition); + return method.MakeGenericMethod(genericType).Invoke(null, arguments)!; + } + + private static object? GetProperty(object value, string name) + { + var property = value.GetType().GetProperty( + name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(property); + return property!.GetValue(value); + } + + private static async Task InvokeActivatorAsync( + object descriptor, + PipelineActivationContext context, + CancellationToken cancellationToken) + { + var property = descriptor.GetType().GetProperty( + "Activator", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(property); + var activator = Assert.IsAssignableFrom(property!.GetValue(descriptor)); + var valueTask = activator.DynamicInvoke(context, cancellationToken); + Assert.NotNull(valueTask); + var asTask = valueTask!.GetType().GetMethod("AsTask", Type.EmptyTypes); + Assert.NotNull(asTask); + var task = Assert.IsAssignableFrom(asTask!.Invoke(valueTask, null)); + await task; + return task.GetType().GetProperty("Result")!.GetValue(task)!; + } + + private static void AssertPolicyException(Exception exception) + { + var actual = UnwrapInvocationException(exception); + Assert.True( + actual is ArgumentException or InvalidOperationException, + $"Expected a policy validation exception, got {actual.GetType().FullName}: {actual.Message}"); + } + + private static void AssertArgumentException(Exception exception) + { + var actual = UnwrapInvocationException(exception); + Assert.IsType(actual); + } + + private static Exception UnwrapInvocationException(Exception exception) => + exception is TargetInvocationException { InnerException: not null } invocation + ? invocation.InnerException! + : exception; + + private static void AssertFactory(Type type, string name, Type resultDefinition, int parameterCount) + { + var method = type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .SingleOrDefault(candidate => candidate.Name == name); + + Assert.NotNull(method); + Assert.True(method!.IsGenericMethodDefinition); + Assert.Equal(resultDefinition, method.ReturnType.GetGenericArguments()[0].GetGenericTypeDefinition()); + Assert.Equal(parameterCount, method.GetParameters().Length); + } + + private static void AssertMethod(Type type, string name, int parameterCount) + { + var method = type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .SingleOrDefault(candidate => candidate.Name == name); + + Assert.NotNull(method); + Assert.True(method!.IsGenericMethodDefinition); + Assert.Equal(parameterCount, method.GetParameters().Length); + } + + private sealed class NullTypeInfoResolver : IJsonTypeInfoResolver + { + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) => null; + } + + private sealed class TrackingLoggerFactory : ILoggerFactory + { + public int CreateLoggerCalls { get; private set; } + public int DisposeCalls { get; private set; } + + public ILogger CreateLogger(string categoryName) + { + CreateLoggerCalls++; + return NullLogger.Instance; + } + + public void AddProvider(ILoggerProvider provider) { } + + public void Dispose() => DisposeCalls++; + } +} + +public sealed record DefinitionItem(int Id); + +[JsonSerializable(typeof(DefinitionItem))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(DeadLetterEnvelope))] +internal sealed partial class DefinitionJsonContext : JsonSerializerContext; diff --git a/tests/SmartPipe.Extensions.Json.Tests/Utf8LineRecordReaderTests.cs b/tests/SmartPipe.Extensions.Json.Tests/Utf8LineRecordReaderTests.cs index c7d7dcc..6d1c6c7 100644 --- a/tests/SmartPipe.Extensions.Json.Tests/Utf8LineRecordReaderTests.cs +++ b/tests/SmartPipe.Extensions.Json.Tests/Utf8LineRecordReaderTests.cs @@ -1,5 +1,5 @@ using System.Text; -using SmartPipe.Extensions; +using SmartPipe.Shared.JsonFraming; namespace SmartPipe.Extensions.Tests; @@ -117,6 +117,22 @@ public async Task PartialMultibyteUtf8_IsPreservedWithoutDecoding() Assert.Equal(expected, record.Bytes); } + [Fact] + public async Task BomBlankLineAndCrLfSplitAcrossReads_AreFramedCorrectly() + { + var bytes = Encoding.UTF8.GetPreamble() + .Concat(" \r\n\"first\"\r\n\"second\"\n"u8.ToArray()) + .ToArray(); + await using var stream = new OneByteReadStream(bytes); + + var records = await ReadAllAsync(stream, maxRecordSizeBytes: 16); + + Assert.Equal(2, records.Count); + Assert.Equal("\"first\""u8.ToArray(), records[0].Bytes); + Assert.Equal("\"second\""u8.ToArray(), records[1].Bytes); + Assert.All(records, static record => Assert.False(record.TooLarge)); + } + private static async Task> ReadAllAsync(Stream stream, int maxRecordSizeBytes) { var records = new List(); From f0a6fce508141c0d8d567e41bc4c02ab13c2b3b1 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:30:39 +0500 Subject: [PATCH 19/22] fix(build): normalize benchmark lock file --- benchmarks/SmartPipe.Benchmarks/packages.lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/SmartPipe.Benchmarks/packages.lock.json b/benchmarks/SmartPipe.Benchmarks/packages.lock.json index aaa086b..3199933 100644 --- a/benchmarks/SmartPipe.Benchmarks/packages.lock.json +++ b/benchmarks/SmartPipe.Benchmarks/packages.lock.json @@ -207,4 +207,4 @@ } } } -} \ No newline at end of file +} From b094aacde5ef697d9cfc81da55d193d256e932c5 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:36:45 +0500 Subject: [PATCH 20/22] test(repo): update JSON consumer inventory --- .../Consumers/ConsumerScenarioSchemaTests.cs | 6 ++++-- .../Consumers/LocalNuGetConfigWriterTests.cs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioSchemaTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioSchemaTests.cs index 6be6542..91ad360 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioSchemaTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioSchemaTests.cs @@ -9,12 +9,12 @@ namespace SmartPipe.RepositoryChecks.Tests.Consumers; public sealed class ConsumerScenarioSchemaTests { [Fact] - public async Task CurrentManifest_HasExactlyThirtyThreeStrictScenarios() + public async Task CurrentManifest_HasExactlyThirtyFiveStrictScenarios() { var root = RepositoryRoot(); var graph = await new PackageGraphLoader().LoadAsync(root, "eng/package-graph.json", TestContext.Current.CancellationToken); var document = await new ConsumerScenarioLoader().LoadAsync(root, "eng/consumer-scenarios.json", graph, TestContext.Current.CancellationToken); - Assert.Equal(33, document.Scenarios.Count); + Assert.Equal(35, document.Scenarios.Count); Assert.Equal( [ "core-direct", @@ -24,6 +24,8 @@ public async Task CurrentManifest_HasExactlyThirtyThreeStrictScenarios() "core-trim", "core-nativeaot", "json-nativeaot", + "json-trim", + "json-dependency-injection-direct", "dependency-injection-direct", "dependency-injection-keyed", "dependency-injection-from-keyed-services", diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs index 6d75437..b31af8b 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs @@ -33,7 +33,7 @@ public void TrackedScenarioProjects_AreVersionlessCpmConsumers() { var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); var projects = Directory.EnumerateFiles(Path.Combine(root, "tests", "Consumers", "Scenarios"), "*.csproj", SearchOption.AllDirectories).ToArray(); - Assert.Equal(26, projects.Length); + Assert.Equal(28, projects.Length); Assert.All(projects, project => Assert.DoesNotContain(" Version=", File.ReadAllText(project), StringComparison.Ordinal)); } From e9d52964aa2f90ee1e585f7a543f1b1a2c45b5a7 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:55:02 +0500 Subject: [PATCH 21/22] test(ci): update JSON consumer contract --- eng/tests/workflow_contract_tests.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 7ba0634..528d2f6 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -619,7 +619,8 @@ def assert_consumer_contract() -> None: current = [scenario for scenario in document["scenarios"] if scenario["set"] == "current"] expected = { "core-direct", "json-direct", "extensions-meta", "legacy-binary-2.1.2", - "core-trim", "core-nativeaot", "json-nativeaot", + "core-trim", "core-nativeaot", "json-nativeaot", "json-trim", + "json-dependency-injection-direct", "dependency-injection-direct", "dependency-injection-keyed", "dependency-injection-from-keyed-services", "dependency-injection-facade-source", "dependency-injection-facade-binary-2.1.2", "dependency-injection-trim", @@ -633,8 +634,8 @@ def assert_consumer_contract() -> None: "channels-direct", "transforms-direct", "logging-direct", "data-annotations-direct", "data-annotations-runtime", } - require(len(current) == 33 and {scenario["id"] for scenario in current} == expected, - "Current consumer set must contain the exact thirty-three scenarios.") + require(len(current) == 35 and {scenario["id"] for scenario in current} == expected, + "Current consumer set must contain the exact thirty-five scenarios.") hosting = [scenario for scenario in current if scenario.get("category") == "hosting"] require({scenario["id"] for scenario in hosting} == { "hosting-direct", "hosting-facade-source", "hosting-facade-binary-2.1.2", From 1b5210ab4c0bb6cb15458a1735edd9d1d1983984 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:14:17 +0500 Subject: [PATCH 22/22] fix(json): reuse consumer templates --- .../JsonPipelineBenchmarks.cs | 12 ++--- eng/consumer-scenarios.json | 4 +- .../Program.cs | 34 +++++-------- .../Scenarios/json-direct/Program.cs | 13 +++-- .../Scenarios/json-nativeaot/Consumer.csproj | 4 -- .../Scenarios/json-nativeaot/Program.cs | 50 ------------------- .../Scenarios/json-trim/Consumer.csproj | 14 ------ .../Consumers/Scenarios/json-trim/Program.cs | 50 ------------------- .../Consumers/LocalNuGetConfigWriterTests.cs | 2 +- 9 files changed, 27 insertions(+), 156 deletions(-) delete mode 100644 tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj delete mode 100644 tests/Consumers/Scenarios/json-nativeaot/Program.cs delete mode 100644 tests/Consumers/Scenarios/json-trim/Consumer.csproj delete mode 100644 tests/Consumers/Scenarios/json-trim/Program.cs diff --git a/benchmarks/SmartPipe.Benchmarks/JsonPipelineBenchmarks.cs b/benchmarks/SmartPipe.Benchmarks/JsonPipelineBenchmarks.cs index 1294e7e..58dfb82 100644 --- a/benchmarks/SmartPipe.Benchmarks/JsonPipelineBenchmarks.cs +++ b/benchmarks/SmartPipe.Benchmarks/JsonPipelineBenchmarks.cs @@ -209,14 +209,8 @@ public async Task PartialEnumeration_DisposesSource() BenchmarkJsonContext.Default.ListJsonBenchmarkItem, new JsonFileSourceOptions { Format = JsonFileFormat.Ndjson }); await source.InitializeAsync().ConfigureAwait(false); - var count = 0; - await foreach (var _ in source.ReadEnvelopesAsync().ConfigureAwait(false)) - { - count++; - break; - } - - return count; + await using var enumerator = source.ReadEnvelopesAsync().GetAsyncEnumerator(); + return await enumerator.MoveNextAsync().ConfigureAwait(false) ? 1 : 0; } [Benchmark] @@ -234,7 +228,7 @@ public async Task CancellationAndDisposal_Interaction() var readTask = ConsumeSourceAsync(source, cancellation.Token); await Task.Yield(); var disposalTask = source.DisposeAsync().AsTask(); - cancellation.Cancel(); + await cancellation.CancelAsync().ConfigureAwait(false); try { await Task.WhenAll(readTask, disposalTask).ConfigureAwait(false); diff --git a/eng/consumer-scenarios.json b/eng/consumer-scenarios.json index 4d3005e..d5bb34f 100644 --- a/eng/consumer-scenarios.json +++ b/eng/consumer-scenarios.json @@ -87,7 +87,7 @@ "id": "json-nativeaot", "set": "current", "mode": "publish-native-aot", - "templatePath": "tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj", + "templatePath": "tests/Consumers/Scenarios/json-direct/Consumer.csproj", "packageIds": ["SmartPipe.Core", "SmartPipe.Extensions.Json"], "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.Json"], "forbiddenDependencies": ["SmartPipe.Extensions", "CsvHelper", "Dapper", "Microsoft.EntityFrameworkCore", "Mapster", "Polly"], @@ -99,7 +99,7 @@ "id": "json-trim", "set": "current", "mode": "publish-trimmed", - "templatePath": "tests/Consumers/Scenarios/json-trim/Consumer.csproj", + "templatePath": "tests/Consumers/Scenarios/json-direct/Consumer.csproj", "packageIds": ["SmartPipe.Core", "SmartPipe.Extensions.Json"], "expectedSmartPipeDependencies": ["SmartPipe.Core", "SmartPipe.Extensions.Json"], "forbiddenDependencies": ["SmartPipe.Extensions", "CsvHelper", "Dapper", "Microsoft.EntityFrameworkCore", "Mapster", "Polly"], diff --git a/tests/Consumers/Scenarios/json-dependency-injection-direct/Program.cs b/tests/Consumers/Scenarios/json-dependency-injection-direct/Program.cs index caff157..0e9f3bc 100644 --- a/tests/Consumers/Scenarios/json-dependency-injection-direct/Program.cs +++ b/tests/Consumers/Scenarios/json-dependency-injection-direct/Program.cs @@ -1,3 +1,4 @@ +using JsonDependencyInjectionConsumer; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using SmartPipe.Core; @@ -6,7 +7,6 @@ using SmartPipe.Extensions.Json; var inputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-di-{Guid.NewGuid():N}-input.json"); -var outputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-di-{Guid.NewGuid():N}-output.jsonl"); try { await File.WriteAllTextAsync(inputPath, "[{\"Value\":13}]\n"); @@ -18,20 +18,7 @@ ConsumerJsonContext.Default.ConsumerModel, ConsumerJsonContext.Default.ListConsumerModel, new JsonFileSourceOptions { Format = JsonFileFormat.Array }) - .TransformJson( - new PipelineStageKey("json-round-trip"), - ConsumerJsonContext.Default.ConsumerModel, - ConsumerJsonContext.Default.ConsumerModel) - .ToJsonFile( - outputPath, - ConsumerJsonContext.Default.ConsumerModel, - ConsumerJsonContext.Default.ListConsumerModel, - new JsonFileSinkOptions - { - Format = JsonFileFormat.BatchJsonLines, - OpenMode = JsonFileOpenMode.Create, - FlushInterval = 1, - }); + .Build(); var services = new ServiceCollection(); services.AddSmartPipe().AddPipeline(definition); @@ -44,20 +31,23 @@ .GetRequiredService() .GetFactory(key); await using var run = await factory.StartAsync(); + var output = await run.Outputs.ReadAsync(); await run.Completion; - var output = await File.ReadAllTextAsync(outputPath); - if (!output.Contains("13", StringComparison.Ordinal)) return 1; + if (!output.Result.IsSuccess || output.Result.Value?.Value != 13 || run.Outputs.TryRead(out _)) return 1; } finally { File.Delete(inputPath); - File.Delete(outputPath); } Console.WriteLine("CONSUMER_OK json-dependency-injection-direct"); return 0; -internal sealed record ConsumerModel(int Value); -[JsonSerializable(typeof(ConsumerModel))] -[JsonSerializable(typeof(List))] -internal sealed partial class ConsumerJsonContext : JsonSerializerContext; +namespace JsonDependencyInjectionConsumer +{ + internal sealed record ConsumerModel(int Value); + + [JsonSerializable(typeof(ConsumerModel))] + [JsonSerializable(typeof(List))] + internal sealed partial class ConsumerJsonContext : JsonSerializerContext; +} diff --git a/tests/Consumers/Scenarios/json-direct/Program.cs b/tests/Consumers/Scenarios/json-direct/Program.cs index 447d99e..92f0c45 100644 --- a/tests/Consumers/Scenarios/json-direct/Program.cs +++ b/tests/Consumers/Scenarios/json-direct/Program.cs @@ -1,3 +1,4 @@ +using JsonDirectConsumer; using System.Text.Json.Serialization; using SmartPipe.Core; using SmartPipe.Extensions; @@ -45,7 +46,11 @@ Console.WriteLine("CONSUMER_OK json-direct"); return 0; -internal sealed record ConsumerModel(int Value); -[JsonSerializable(typeof(ConsumerModel))] -[JsonSerializable(typeof(List))] -internal sealed partial class ConsumerJsonContext : JsonSerializerContext; +namespace JsonDirectConsumer +{ + internal sealed record ConsumerModel(int Value); + + [JsonSerializable(typeof(ConsumerModel))] + [JsonSerializable(typeof(List))] + internal sealed partial class ConsumerJsonContext : JsonSerializerContext; +} diff --git a/tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj b/tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj deleted file mode 100644 index 594d4b2..0000000 --- a/tests/Consumers/Scenarios/json-nativeaot/Consumer.csproj +++ /dev/null @@ -1,4 +0,0 @@ - - Exenet10.0enableenabletruefalse - - diff --git a/tests/Consumers/Scenarios/json-nativeaot/Program.cs b/tests/Consumers/Scenarios/json-nativeaot/Program.cs deleted file mode 100644 index ab2b1c7..0000000 --- a/tests/Consumers/Scenarios/json-nativeaot/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Text.Json.Serialization; -using SmartPipe.Core; -using SmartPipe.Extensions; -using SmartPipe.Extensions.Json; - -var inputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-nativeaot-{Guid.NewGuid():N}-input.json"); -var outputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-nativeaot-{Guid.NewGuid():N}-output.jsonl"); -try -{ - await File.WriteAllTextAsync(inputPath, "[{\"Value\":7}]\n"); - var definition = JsonPipelineDefinitionBuilder - .FromJsonFile( - new PipelineKey("json-nativeaot"), - inputPath, - ConsumerJsonContext.Default.ConsumerModel, - ConsumerJsonContext.Default.ListConsumerModel, - new JsonFileSourceOptions { Format = JsonFileFormat.Array }) - .TransformJson( - new PipelineStageKey("json-round-trip"), - ConsumerJsonContext.Default.ConsumerModel, - ConsumerJsonContext.Default.ConsumerModel) - .ToJsonFile( - outputPath, - ConsumerJsonContext.Default.ConsumerModel, - ConsumerJsonContext.Default.ListConsumerModel, - new JsonFileSinkOptions - { - Format = JsonFileFormat.BatchJsonLines, - OpenMode = JsonFileOpenMode.Create, - FlushInterval = 1, - }); - - await using var run = await definition.StartAsync(); - await run.Completion; - var output = await File.ReadAllTextAsync(outputPath); - if (!output.Contains("7", StringComparison.Ordinal)) return 1; -} -finally -{ - File.Delete(inputPath); - File.Delete(outputPath); -} - -Console.WriteLine("CONSUMER_OK json-nativeaot"); -return 0; - -internal sealed record ConsumerModel(int Value); -[JsonSerializable(typeof(ConsumerModel))] -[JsonSerializable(typeof(List))] -internal sealed partial class ConsumerJsonContext : JsonSerializerContext; diff --git a/tests/Consumers/Scenarios/json-trim/Consumer.csproj b/tests/Consumers/Scenarios/json-trim/Consumer.csproj deleted file mode 100644 index 601e7a5..0000000 --- a/tests/Consumers/Scenarios/json-trim/Consumer.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - Exe - net10.0 - enable - enable - true - false - - - - - - diff --git a/tests/Consumers/Scenarios/json-trim/Program.cs b/tests/Consumers/Scenarios/json-trim/Program.cs deleted file mode 100644 index 7e882d9..0000000 --- a/tests/Consumers/Scenarios/json-trim/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Text.Json.Serialization; -using SmartPipe.Core; -using SmartPipe.Extensions; -using SmartPipe.Extensions.Json; - -var inputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-trim-{Guid.NewGuid():N}-input.json"); -var outputPath = Path.Combine(Path.GetTempPath(), $"smartpipe-json-trim-{Guid.NewGuid():N}-output.jsonl"); -try -{ - await File.WriteAllTextAsync(inputPath, "[{\"Value\":11}]\n"); - var definition = JsonPipelineDefinitionBuilder - .FromJsonFile( - new PipelineKey("json-trim"), - inputPath, - ConsumerJsonContext.Default.ConsumerModel, - ConsumerJsonContext.Default.ListConsumerModel, - new JsonFileSourceOptions { Format = JsonFileFormat.Array }) - .TransformJson( - new PipelineStageKey("json-round-trip"), - ConsumerJsonContext.Default.ConsumerModel, - ConsumerJsonContext.Default.ConsumerModel) - .ToJsonFile( - outputPath, - ConsumerJsonContext.Default.ConsumerModel, - ConsumerJsonContext.Default.ListConsumerModel, - new JsonFileSinkOptions - { - Format = JsonFileFormat.BatchJsonLines, - OpenMode = JsonFileOpenMode.Create, - FlushInterval = 1, - }); - - await using var run = await definition.StartAsync(); - await run.Completion; - var output = await File.ReadAllTextAsync(outputPath); - if (!output.Contains("11", StringComparison.Ordinal)) return 1; -} -finally -{ - File.Delete(inputPath); - File.Delete(outputPath); -} - -Console.WriteLine("CONSUMER_OK json-trim"); -return 0; - -internal sealed record ConsumerModel(int Value); -[JsonSerializable(typeof(ConsumerModel))] -[JsonSerializable(typeof(List))] -internal sealed partial class ConsumerJsonContext : JsonSerializerContext; diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs index b31af8b..6d75437 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/LocalNuGetConfigWriterTests.cs @@ -33,7 +33,7 @@ public void TrackedScenarioProjects_AreVersionlessCpmConsumers() { var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); var projects = Directory.EnumerateFiles(Path.Combine(root, "tests", "Consumers", "Scenarios"), "*.csproj", SearchOption.AllDirectories).ToArray(); - Assert.Equal(28, projects.Length); + Assert.Equal(26, projects.Length); Assert.All(projects, project => Assert.DoesNotContain(" Version=", File.ReadAllText(project), StringComparison.Ordinal)); }