diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aeeae06..1f7e9f5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,25 +5,31 @@ 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
+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
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 +51,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 +72,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 +136,40 @@ 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'))
+ $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 58ff0b5..2e44a0e 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'
@@ -12,9 +12,13 @@ permissions:
contents: read
security-events: write
+env:
+ NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }}
+
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 +39,43 @@ 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
+ 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'))
+ $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/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..fc358d6 100644
--- a/.github/workflows/reusable-release-validation.yml
+++ b/.github/workflows/reusable-release-validation.yml
@@ -13,13 +13,22 @@ 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
+env:
+ NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }}
+
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 +45,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 +83,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 +127,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 +157,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 +208,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 +283,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 +300,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..12c2b29
--- /dev/null
+++ b/docs/channels.md
@@ -0,0 +1,16 @@
+# 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.
+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
+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..dd11197
--- /dev/null
+++ b/docs/data-annotations.md
@@ -0,0 +1,19 @@
+# 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. 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
+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/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/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..87e8a25 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;
@@ -22,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)
@@ -47,19 +49,20 @@ 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)
{
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);
@@ -93,6 +96,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 +106,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 +124,47 @@ 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);
+ 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);
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 +209,114 @@ 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,
+ ExpectedDiagnosticRegexTimeout);
+ var errors = Regex.Matches(
+ output,
+ @"\berror\s+(?[A-Z][A-Z0-9]*[0-9]{4}):",
+ RegexOptions.CultureInvariant,
+ ExpectedDiagnosticRegexTimeout);
+ 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);
@@ -175,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)
@@ -267,6 +429,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();
@@ -281,6 +508,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,
@@ -334,8 +589,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/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(
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..66f45b9 100644
--- a/eng/tests/workflow_contract_tests.py
+++ b/eng/tests/workflow_contract_tests.py
@@ -31,6 +31,66 @@
)
}
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' }}"
+)
+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) || '' }}"
+)
+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 +103,214 @@ 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 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_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,
+ 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,
+ 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_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 +527,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 +574,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 +587,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 +605,27 @@ 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")
+ 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") ==
"./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 +662,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 +678,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 +716,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 +768,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 +793,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 +813,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 +837,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 +848,40 @@ 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_cleanup_job(
+ codeql,
+ "codeql.yml",
+ ["analyze"],
+ CLEANUP_PULL_REQUEST_GUARD,
+ cleanup_nuget=True,
+ )
+ 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.")
+ 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]
@@ -575,6 +904,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 +984,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 +1017,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 +1066,209 @@ 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 _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)
+
+
+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 _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"],
+ "Docs link check",
+ )
+ cleanup["uses"] = "lycheeverse/lychee-action@v2"
def _move_graph_before_integrity(documents: dict[str, dict]) -> None:
@@ -848,16 +1397,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 +1429,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 +1453,161 @@ 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_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",
+ )
+ 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,
+ "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",
+ )
+ 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,
+ "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.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
new file mode 100644
index 0000000..e664257
--- /dev/null
+++ b/src/SmartPipe.Extensions.Channels/ChannelMerge.cs
@@ -0,0 +1,253 @@
+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);
+
+ 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++)
+ {
+ readerSnapshot[index] = readers[index]
+ ?? throw new ArgumentNullException(nameof(readers));
+ }
+
+ var output = CreateOutput(options);
+ _ = 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..6ebae6d
--- /dev/null
+++ b/src/SmartPipe.Extensions.DataAnnotations/ValidationTransform.cs
@@ -0,0 +1,88 @@
+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!;
+ object payloadInstance = payload!;
+
+ var validationResults = new List();
+ var validationContext = new ValidationContext(payloadInstance);
+ if (!Validator.TryValidateObject(payloadInstance, 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