From a4fcc452ee0a879a07efae4fa9e501bd8f0ef55a Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:07:41 +0700 Subject: [PATCH 01/30] Add pinned coverage collector --- Directory.Packages.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 180cba41..5a986cbf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,5 +8,6 @@ + From 25abab623fea2600a5bbe1c691d55d7fe15727e3 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:07:50 +0700 Subject: [PATCH 02/30] Enable cross-platform test coverage collection --- tests/ARSVIN.Tests/ARSVIN.Tests.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj index f78111eb..ccd53316 100644 --- a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj +++ b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj @@ -15,6 +15,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From 67f6a4f1bd933f3bb130ea95371b23ddfaa77aae Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:08:05 +0700 Subject: [PATCH 03/30] Add deterministic coverage quality gate --- scripts/test-with-coverage.ps1 | 85 ++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 scripts/test-with-coverage.ps1 diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 new file mode 100644 index 00000000..6aca725c --- /dev/null +++ b/scripts/test-with-coverage.ps1 @@ -0,0 +1,85 @@ +[CmdletBinding()] +param( + [ValidateRange(0, 100)] + [double] $MinimumLineCoverage = 20, + + [switch] $NoRestore +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' +$resultsRoot = Join-Path $root 'artifacts\test-results' + +if (Test-Path $resultsRoot) { + Remove-Item $resultsRoot -Recurse -Force +} +New-Item -ItemType Directory -Path $resultsRoot -Force | Out-Null + +$arguments = @( + 'test', + $testProject, + '-c', 'Release', + '--logger', 'trx;LogFileName=ARSVIN.Tests.trx', + '--results-directory', $resultsRoot, + '--collect', 'XPlat Code Coverage', + '--', + 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura', + 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile=**/*.g.cs,**/*.g.i.cs,**/obj/**' +) + +if ($NoRestore) { + $arguments = @('test', $testProject, '-c', 'Release', '--no-restore') + $arguments[5..($arguments.Count - 1)] +} + +& dotnet @arguments +if ($LASTEXITCODE -ne 0) { + throw "dotnet test with coverage failed with exit code $LASTEXITCODE." +} + +$coverageFile = Get-ChildItem $resultsRoot -Recurse -Filter 'coverage.cobertura.xml' -File | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + +if (-not $coverageFile) { + throw "Coverage collector did not produce coverage.cobertura.xml under $resultsRoot." +} + +$normalizedCoverage = Join-Path $resultsRoot 'coverage.cobertura.xml' +if ($coverageFile.FullName -ne $normalizedCoverage) { + Copy-Item $coverageFile.FullName $normalizedCoverage -Force +} + +[xml] $coverage = Get-Content $normalizedCoverage -Raw +$lineRateText = [string] $coverage.coverage.'line-rate' +if ([string]::IsNullOrWhiteSpace($lineRateText)) { + throw 'Cobertura report does not contain a root line-rate value.' +} + +$lineRate = [double]::Parse( + $lineRateText, + [System.Globalization.CultureInfo]::InvariantCulture +) +$lineCoverage = [Math]::Round($lineRate * 100, 2) + +Write-Host "Line coverage: $lineCoverage%" +Write-Host "Minimum required: $MinimumLineCoverage%" +Write-Host "Coverage report: $normalizedCoverage" + +if ($env:GITHUB_STEP_SUMMARY) { + @" +## Test coverage + +| Metric | Result | +|---|---:| +| Line coverage | **$lineCoverage%** | +| Required minimum | **$MinimumLineCoverage%** | +| Report | `artifacts/test-results/coverage.cobertura.xml` | +"@ | Add-Content $env:GITHUB_STEP_SUMMARY +} + +if ($lineCoverage -lt $MinimumLineCoverage) { + throw "Line coverage $lineCoverage% is below the required $MinimumLineCoverage%." +} From a53970b8c313930379cc66216d16657d983ff9ff Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:08:22 +0700 Subject: [PATCH 04/30] Fix coverage test argument assembly --- scripts/test-with-coverage.ps1 | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index 6aca725c..d53d3c69 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -21,7 +21,14 @@ New-Item -ItemType Directory -Path $resultsRoot -Force | Out-Null $arguments = @( 'test', $testProject, - '-c', 'Release', + '-c', 'Release' +) + +if ($NoRestore) { + $arguments += '--no-restore' +} + +$arguments += @( '--logger', 'trx;LogFileName=ARSVIN.Tests.trx', '--results-directory', $resultsRoot, '--collect', 'XPlat Code Coverage', @@ -30,10 +37,6 @@ $arguments = @( 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile=**/*.g.cs,**/*.g.i.cs,**/obj/**' ) -if ($NoRestore) { - $arguments = @('test', $testProject, '-c', 'Release', '--no-restore') + $arguments[5..($arguments.Count - 1)] -} - & dotnet @arguments if ($LASTEXITCODE -ne 0) { throw "dotnet test with coverage failed with exit code $LASTEXITCODE." From 89528e196c0b94e70d808a6d1c6a10575de7c6d2 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:08:43 +0700 Subject: [PATCH 05/30] Add deterministic CycloneDX SBOM generator --- scripts/generate-sbom.ps1 | 151 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 scripts/generate-sbom.ps1 diff --git a/scripts/generate-sbom.ps1 b/scripts/generate-sbom.ps1 new file mode 100644 index 00000000..f40e3553 --- /dev/null +++ b/scripts/generate-sbom.ps1 @@ -0,0 +1,151 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$')] + [string] $Version, + + [string] $OutputPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$solution = Join-Path $root 'ARSVIN.sln' + +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $root 'artifacts\release\ARSVIN-SBOM.cdx.json' +} +elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) { + $OutputPath = Join-Path $root $OutputPath +} + +$outputDirectory = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null + +Write-Host '==> Resolving complete NuGet dependency graph' +$commandOutput = & dotnet list $solution package --include-transitive --format json --output-version 1 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "dotnet list package failed with exit code $LASTEXITCODE.`n$($commandOutput -join [Environment]::NewLine)" +} + +$jsonText = $commandOutput -join [Environment]::NewLine +$jsonStart = $jsonText.IndexOf('{') +$jsonEnd = $jsonText.LastIndexOf('}') +if ($jsonStart -lt 0 -or $jsonEnd -le $jsonStart) { + throw 'Could not locate the JSON dependency graph in dotnet list package output.' +} + +$dependencyGraph = $jsonText.Substring($jsonStart, $jsonEnd - $jsonStart + 1) | ConvertFrom-Json +$packages = @{} + +foreach ($project in @($dependencyGraph.projects)) { + foreach ($framework in @($project.frameworks)) { + foreach ($scope in @('topLevelPackages', 'transitivePackages')) { + $property = $framework.PSObject.Properties[$scope] + if (-not $property) { + continue + } + + foreach ($package in @($property.Value)) { + $id = [string] $package.id + $resolvedVersion = [string] $package.resolvedVersion + if ([string]::IsNullOrWhiteSpace($resolvedVersion)) { + $resolvedVersion = [string] $package.version + } + + if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($resolvedVersion)) { + continue + } + + $key = "$($id.ToLowerInvariant())|$resolvedVersion" + $isTopLevel = $scope -eq 'topLevelPackages' + if (-not $packages.ContainsKey($key) -or $isTopLevel) { + $packages[$key] = [ordered]@{ + Id = $id + Version = $resolvedVersion + Scope = if ($isTopLevel) { 'direct' } else { 'transitive' } + } + } + } + } + } +} + +if ($packages.Count -eq 0) { + throw 'The dependency graph did not contain any resolved NuGet packages.' +} + +$components = foreach ($package in $packages.Values | Sort-Object Id, Version) { + $escapedId = [Uri]::EscapeDataString([string] $package.Id) + $escapedVersion = [Uri]::EscapeDataString([string] $package.Version) + $purl = "pkg:nuget/$escapedId@$escapedVersion" + + [ordered]@{ + type = 'library' + 'bom-ref' = $purl + name = [string] $package.Id + version = [string] $package.Version + purl = $purl + properties = @( + [ordered]@{ + name = 'arsvin:dependency-scope' + value = [string] $package.Scope + } + ) + } +} + +$metadataProperties = @() +if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_SHA)) { + $metadataProperties += [ordered]@{ + name = 'arsvin:source-commit' + value = $env:GITHUB_SHA + } +} + +$sbom = [ordered]@{ + bomFormat = 'CycloneDX' + specVersion = '1.5' + version = 1 + metadata = [ordered]@{ + timestamp = [DateTimeOffset]::UtcNow.ToString('o') + tools = [ordered]@{ + components = @( + [ordered]@{ + type = 'application' + author = 'ARSVIN project' + name = 'generate-sbom.ps1' + version = '1.0.0' + } + ) + } + component = [ordered]@{ + type = 'application' + 'bom-ref' = "pkg:generic/arsvin@$Version" + name = 'ARSVIN Windows Suite' + version = $Version + licenses = @( + [ordered]@{ + license = [ordered]@{ + id = 'Apache-2.0' + } + } + ) + properties = $metadataProperties + } + } + components = @($components) +} + +$json = $sbom | ConvertTo-Json -Depth 20 +[System.IO.File]::WriteAllText( + $OutputPath, + $json + [Environment]::NewLine, + [System.Text.UTF8Encoding]::new($false) +) + +$written = Get-Item $OutputPath +Write-Host "==> CycloneDX SBOM written: $($written.FullName)" +Write-Host " Components: $($components.Count)" +Write-Host " Size: $($written.Length) bytes" From 8cb189b83e08ce9aeb7fa0816c55562543ec9b30 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:08:51 +0700 Subject: [PATCH 06/30] Fail CI and release builds on compiler warnings --- build.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.ps1 b/build.ps1 index 9a020bad..eea6880c 100644 --- a/build.ps1 +++ b/build.ps1 @@ -24,12 +24,12 @@ Invoke-DotNet -Arguments @('restore', $subscriberProject) Invoke-DotNet -Arguments @('restore', $testProject) Write-Host '==> Building ARSVIN Publisher' -Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore') +Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore', '-warnaserror') Write-Host '==> Building ArSubsv Subscriber' -Invoke-DotNet -Arguments @('build', $subscriberProject, '-c', 'Release', '--no-restore') +Invoke-DotNet -Arguments @('build', $subscriberProject, '-c', 'Release', '--no-restore', '-warnaserror') Write-Host '==> Running tests' -Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore') +Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore', '/p:TreatWarningsAsErrors=true') Write-Host '==> Build completed successfully' From 3d273376e0c802ae66ce59091e25883be4ec0f82 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:09:02 +0700 Subject: [PATCH 07/30] Add coverage evidence and warning gates to CI --- .github/workflows/ci.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6e5233e..348ae12b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,14 +36,24 @@ jobs: - name: Restore tests run: dotnet restore tests/ARSVIN.Tests/ARSVIN.Tests.csproj - - name: Build Publisher - run: dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore + - name: Build Publisher with warnings as errors + run: dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore -warnaserror - - name: Build Subscriber - run: dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore + - name: Build Subscriber with warnings as errors + run: dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore -warnaserror - - name: Test - run: dotnet test tests/ARSVIN.Tests/ARSVIN.Tests.csproj -c Release --no-restore --logger trx + - name: Test with coverage gate + shell: pwsh + run: .\scripts\test-with-coverage.ps1 -MinimumLineCoverage 20 -NoRestore + + - name: Upload test and coverage evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: arsvin-test-evidence + path: artifacts/test-results + if-no-files-found: warn + retention-days: 14 - name: Publisher dependency vulnerability report run: dotnet list src/ARSVIN/ARSVIN.csproj package --vulnerable --include-transitive From 9d3e1906e0fcf5d2ac04304c912b7806abc37b6d Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:09:13 +0700 Subject: [PATCH 08/30] Enforce warning-free test compilation --- scripts/test-with-coverage.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index d53d3c69..97ea5975 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -29,6 +29,7 @@ if ($NoRestore) { } $arguments += @( + '/p:TreatWarningsAsErrors=true', '--logger', 'trx;LogFileName=ARSVIN.Tests.trx', '--results-directory', $resultsRoot, '--collect', 'XPlat Code Coverage', From 06b4a2b6b1194d32307e55c5bd75265d6eca926f Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:09:26 +0700 Subject: [PATCH 09/30] Require warning-free CodeQL build --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ad814542..13692c5a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,8 +34,8 @@ jobs: - name: Restore solution run: dotnet restore ARSVIN.sln - - name: Build solution - run: dotnet build ARSVIN.sln -c Release --no-restore + - name: Build solution with warnings as errors + run: dotnet build ARSVIN.sln -c Release --no-restore -warnaserror - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 From 7cdb630995e9e302443770c1fc9106035fc2ab2e Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:10:03 +0700 Subject: [PATCH 10/30] Add SBOM, pinned installer tool, and release attestations --- .github/workflows/release.yml | 67 +++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e5e9a62..52c65bda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ on: - '.github/workflows/release.yml' - 'installer/**' - 'scripts/publish-release.ps1' + - 'scripts/generate-sbom.ps1' - 'publish-win-x64.ps1' - 'build.ps1' - 'src/**' @@ -35,6 +36,7 @@ concurrency: env: DOTNET_CLI_TELEMETRY_OPTOUT: '1' DOTNET_NOLOGO: '1' + INNO_SETUP_VERSION: '6.7.1' jobs: package: @@ -106,7 +108,7 @@ jobs: throw "Tag ${{ github.ref_name }} does not point to a commit contained in main. Merge the release commit before tagging." } - - name: Build and test + - name: Build and test with warnings as errors shell: pwsh run: .\build.ps1 @@ -114,9 +116,22 @@ jobs: shell: pwsh run: .\scripts\publish-release.ps1 -Version '${{ steps.version.outputs.value }}' -SkipTests - - name: Install Inno Setup + - name: Install pinned Inno Setup shell: pwsh - run: choco install innosetup --no-progress -y + run: | + choco install innosetup --version=$env:INNO_SETUP_VERSION --allow-downgrade --no-progress -y + if ($LASTEXITCODE -ne 0) { + throw "Chocolatey could not install Inno Setup $env:INNO_SETUP_VERSION." + } + + $installed = choco list --local-only --exact innosetup --limit-output + if ($LASTEXITCODE -ne 0) { + throw 'Could not query the installed Inno Setup package.' + } + + if ($installed -notcontains "innosetup|$env:INNO_SETUP_VERSION") { + throw "Expected Inno Setup $env:INNO_SETUP_VERSION, found: $($installed -join ', ')" + } - name: Build Windows installer shell: pwsh @@ -130,6 +145,11 @@ jobs: throw 'Inno Setup compiler (ISCC.exe) was not found.' } + $compilerVersion = (Get-Item $iscc).VersionInfo.ProductVersion + if (-not $compilerVersion.StartsWith($env:INNO_SETUP_VERSION, [StringComparison]::OrdinalIgnoreCase)) { + throw "Expected ISCC $env:INNO_SETUP_VERSION, found $compilerVersion." + } + $sourceDir = (Resolve-Path '.\artifacts\installer-input').Path $outputDir = (Resolve-Path '.\artifacts\release').Path $version = '${{ steps.version.outputs.value }}' @@ -144,6 +164,10 @@ jobs: throw "Inno Setup failed with exit code $LASTEXITCODE." } + - name: Generate CycloneDX SBOM + shell: pwsh + run: .\scripts\generate-sbom.ps1 -Version '${{ steps.version.outputs.value }}' + - name: Validate release artifacts shell: pwsh run: | @@ -152,7 +176,8 @@ jobs: 'ARSVIN-Publisher-win-x64.exe', 'ArSubsv-Subscriber-win-x64.exe', 'ARSVIN-Suite-Setup-win-x64.exe', - 'ARSVIN-win-x64-portable.zip' + 'ARSVIN-win-x64-portable.zip', + 'ARSVIN-SBOM.cdx.json' ) foreach ($name in $expected) { @@ -166,6 +191,14 @@ jobs: } } + $sbom = Get-Content (Join-Path $releaseRoot 'ARSVIN-SBOM.cdx.json') -Raw | ConvertFrom-Json + if ($sbom.bomFormat -ne 'CycloneDX' -or $sbom.specVersion -ne '1.5') { + throw 'Generated SBOM is not CycloneDX 1.5.' + } + if (@($sbom.components).Count -lt 1) { + throw 'Generated SBOM contains no dependency components.' + } + Get-ChildItem $releaseRoot -File | Select-Object Name, Length | Sort-Object Name | @@ -259,8 +292,8 @@ jobs: [System.Text.UTF8Encoding]::new($false) ) - if ($lines.Count -ne 4) { - throw "Expected four checksummed release artifacts, found $($lines.Count)." + if ($lines.Count -ne 5) { + throw "Expected five checksummed release artifacts, found $($lines.Count)." } Get-Content (Join-Path $releaseRoot 'SHA256SUMS.txt') @@ -272,6 +305,7 @@ jobs: path: | artifacts/release/*.exe artifacts/release/*.zip + artifacts/release/*.json artifacts/release/SHA256SUMS.txt if-no-files-found: error retention-days: 30 @@ -283,6 +317,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write + artifact-metadata: write steps: - name: Download validated release artifact @@ -291,6 +328,23 @@ jobs: name: ${{ needs.package.outputs.artifact_name }} path: artifacts/release + - name: Attest release build provenance + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4 + with: + subject-path: | + artifacts/release/*.exe + artifacts/release/*.zip + artifacts/release/*.json + artifacts/release/SHA256SUMS.txt + + - name: Attest release SBOM + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4 + with: + subject-path: | + artifacts/release/*.exe + artifacts/release/*.zip + sbom-path: artifacts/release/ARSVIN-SBOM.cdx.json + - name: Create GitHub Release uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: @@ -306,4 +360,5 @@ jobs: artifacts/release/ArSubsv-Subscriber-win-x64.exe artifacts/release/ARSVIN-Suite-Setup-win-x64.exe artifacts/release/ARSVIN-win-x64-portable.zip + artifacts/release/ARSVIN-SBOM.cdx.json artifacts/release/SHA256SUMS.txt From 47578853424ea861642dc16f2e324470ef56c0a8 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:10:38 +0700 Subject: [PATCH 11/30] Document SBOM, provenance, and coverage gates --- README.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1bc17b4e..a1e818d9 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,15 @@ Every tagged release builds validated Windows x64 artifacts with stable filename | `ArSubsv-Subscriber-win-x64.exe` | Self-contained, single-file portable Subscriber. | | `ARSVIN-Suite-Setup-win-x64.exe` | Installer containing both applications, Start Menu shortcuts, documentation, and uninstaller. | | `ARSVIN-win-x64-portable.zip` | Portable suite folder containing both applications and essential documentation. | +| `ARSVIN-SBOM.cdx.json` | CycloneDX 1.5 software bill of materials for resolved NuGet dependencies. | | `SHA256SUMS.txt` | SHA-256 checksums for release verification. | +Stable and prerelease tag builds publish signed GitHub artifact attestations for provenance. Verify a downloaded file with GitHub CLI: + +```powershell +gh attestation verify .\ARSVIN-Suite-Setup-win-x64.exe --repo masarray/arsvin +``` + The binaries are currently **unsigned**. Windows SmartScreen may show an unknown-publisher warning. Releases do not silently install Npcap; download Npcap from its official website when live capture or transmission is required. ## Quick start @@ -121,7 +128,7 @@ For developers: - .NET 8 SDK. - PowerShell 7+ recommended. - Visual Studio 2022, JetBrains Rider, or VS Code with C# tooling. -- Inno Setup 6 only when building the installer locally. +- Inno Setup 6.7.1 when reproducing the automated installer build locally. ## Build from source @@ -134,19 +141,25 @@ cd arsvin Build all release artifacts except the installer: ```powershell -.\scripts\publish-release.ps1 -Version 0.1.0 +.\scripts\publish-release.ps1 -Version 0.3.0 ``` Compatibility wrapper: ```powershell -.\publish-win-x64.ps1 -Version 0.1.0 +.\publish-win-x64.ps1 -Version 0.3.0 +``` + +Run tests with the repository coverage gate and retain TRX/Cobertura evidence: + +```powershell +.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 20 ``` -Run tests directly: +Generate a CycloneDX SBOM after restoring the solution: ```powershell -dotnet test .\tests\ARSVIN.Tests\ARSVIN.Tests.csproj -c Release +.\scripts\generate-sbom.ps1 -Version 0.3.0 ``` ## Repository structure @@ -156,7 +169,7 @@ src/ARSVIN/ Publisher application and SV generation engine src/ARSVIN.Subscriber/ ArSubsv subscriber and visualization companion tests/ARSVIN.Tests/ Protocol and publisher helper tests installer/ Inno Setup definition for the Windows suite -scripts/ Repeatable release packaging scripts +scripts/ Repeatable release packaging and validation scripts docs/ Engineering, safety, and contributor documentation samples/ SCL, COMTRADE, scenario, and evidence samples site/ Static, SEO-ready GitHub Pages product site From d0e077ce4d8a2611aad34bc28fa9eed1ce0bc592 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:11:06 +0700 Subject: [PATCH 12/30] Document coverage, SBOM, and release provenance --- docs/build-and-release.md | 72 ++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/docs/build-and-release.md b/docs/build-and-release.md index ffc65385..28c9768a 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -1,6 +1,6 @@ # Build and Release -ARSVIN uses a tag-driven GitHub Actions workflow to build two self-contained portable applications, one suite installer, one portable ZIP, and SHA-256 checksums. +ARSVIN uses a tag-driven GitHub Actions workflow to build two self-contained portable applications, one suite installer, one portable ZIP, a CycloneDX software bill of materials, SHA-256 checksums, and signed GitHub artifact attestations. ## Prerequisites @@ -13,7 +13,7 @@ For local source builds: For local installer builds: - All requirements above -- Inno Setup 6 +- Inno Setup 6.7.1 Npcap is required only for live capture/transmission testing. It is not silently bundled or installed by the ARSVIN release process. @@ -29,7 +29,23 @@ The script restores, builds, and tests: - `src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj` - `tests/ARSVIN.Tests/ARSVIN.Tests.csproj` -External command exit codes are checked, so CI and local builds stop immediately when a `dotnet` command fails. +External command exit codes are checked, and compiler warnings are treated as errors for the validated build path. + +### Coverage evidence + +```powershell +.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 20 +``` + +The script: + +1. runs the xUnit suite with the pinned Coverlet collector, +2. writes TRX evidence under `artifacts/test-results`, +3. normalizes the Cobertura report to `artifacts/test-results/coverage.cobertura.xml`, +4. reads the root line-rate value, +5. fails when line coverage is below the configured threshold. + +The initial gate is 20%. It is a regression floor, not a claim that the complete WPF application surface is covered. Raise the threshold as engine tests are expanded. ## Validate the public site @@ -70,6 +86,22 @@ artifacts/installer-input/ The two direct `.exe` files are self-contained .NET 8 single-file applications. The ZIP includes both applications, essential documentation, license notices, and sample files. +## Generate the CycloneDX SBOM + +After restoring the solution, run: + +```powershell +.\scripts\generate-sbom.ps1 -Version 0.3.0 +``` + +Output: + +```text +artifacts/release/ARSVIN-SBOM.cdx.json +``` + +The generator reads the complete resolved NuGet graph for `ARSVIN.sln`, deduplicates direct and transitive packages, and writes CycloneDX 1.5 JSON. The SBOM covers managed application dependencies. It does not claim to inventory Windows, Npcap, GitHub-hosted runner contents, or every tool used by the build service. + ## Build the installer locally After running the publish script: @@ -93,6 +125,8 @@ Output: artifacts/release/ARSVIN-Suite-Setup-win-x64.exe ``` +The automated workflow installs and verifies the exact Chocolatey package version declared in `INNO_SETUP_VERSION`. It also checks the `ISCC.exe` product version before compiling. + The installer: - installs per-user under `%LOCALAPPDATA%\Programs\ARSVIN`, @@ -112,17 +146,19 @@ Workflow: `.github/workflows/release.yml` When release tooling, installer definitions, project files, or build configuration change, the workflow runs on the pull request and: -1. restores, builds, and tests the solution, +1. restores, builds, and tests the solution with warnings treated as errors, 2. publishes both portable single-file applications, 3. creates the portable suite ZIP, -4. compiles the Inno Setup installer, -5. checks all expected artifact names and non-empty files, -6. silently installs the suite into a temporary directory, -7. verifies Publisher, Subscriber, documentation, version file, and uninstaller, -8. silently uninstalls the temporary installation, -9. generates checksums and uploads a private workflow artifact. +4. installs the pinned Inno Setup version, +5. compiles the installer, +6. generates and structurally validates the CycloneDX SBOM, +7. checks all expected artifact names and non-empty files, +8. silently installs the suite into a temporary directory, +9. verifies Publisher, Subscriber, documentation, version file, and uninstaller, +10. silently uninstalls the temporary installation, +11. generates checksums and uploads a private workflow artifact. -A pull-request run never creates a public GitHub Release. +A pull-request run never creates a public GitHub Release or public attestation. ### Stable tagged release @@ -135,7 +171,7 @@ git tag -a v0.4.0 -m "ARSVIN v0.4.0" git push origin v0.4.0 ``` -The workflow repeats the validated packaging path, downloads the validated workflow artifact in a separate least-privilege release job, and publishes all public files to GitHub Releases. Stable tags are eligible to become the repository's latest release. +The workflow repeats the validated packaging path, downloads the validated workflow artifact in a separate least-privilege release job, creates signed provenance and SBOM attestations, and publishes all public files to GitHub Releases. Stable tags are eligible to become the repository's latest release. ### Prerelease tag @@ -164,16 +200,27 @@ Manual runs upload private workflow artifacts only. They never create or replace | `ArSubsv-Subscriber-win-x64.exe` | Portable Subscriber/analysis companion. | | `ARSVIN-Suite-Setup-win-x64.exe` | Installer for both applications. | | `ARSVIN-win-x64-portable.zip` | Portable suite package. | +| `ARSVIN-SBOM.cdx.json` | CycloneDX 1.5 managed-dependency SBOM. | | `SHA256SUMS.txt` | Integrity hashes for all release assets. | ## Verify a download +Verify the local hash: + ```powershell Get-FileHash .\ARSVIN-Suite-Setup-win-x64.exe -Algorithm SHA256 ``` Compare the result with `SHA256SUMS.txt` from the same GitHub Release. +Verify signed GitHub build provenance: + +```powershell +gh attestation verify .\ARSVIN-Suite-Setup-win-x64.exe --repo masarray/arsvin +``` + +GitHub artifact attestations use a short-lived signing identity issued during the tagged workflow. This validates repository/workflow provenance; it is separate from Windows Authenticode signing. + ## Code signing status Release binaries are currently unsigned. Windows SmartScreen may display an unknown-publisher warning. The workflow intentionally does not contain placeholder signing steps or require paid signing secrets. @@ -187,6 +234,7 @@ Before tagging a public release: - Confirm the intended release commit is already on `main`. - Confirm `main` CI, release validation, and CodeQL are green. - Update `VersionPrefix` and `CHANGELOG.md`. +- Review the generated SBOM and checksums. - Test Publisher dry run. - Test live publishing only on an isolated lab link. - Test Subscriber live capture and PCAP import. From 413e69ed99d8c634d539ad54172f2869312fc864 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:11:25 +0700 Subject: [PATCH 13/30] Record coverage, SBOM, and provenance hardening --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf43120f..41b9c6da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,25 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change ## Unreleased +### Added + +- Added a pinned Coverlet collector, TRX/Cobertura evidence upload, and a 20% line-coverage regression floor for the currently testable engine surface. +- Added a repository-owned CycloneDX 1.5 SBOM generator for direct and transitive NuGet dependencies. +- Added `ARSVIN-SBOM.cdx.json` to validated workflow artifacts, release downloads, and SHA-256 checksums. +- Added signed GitHub build-provenance and SBOM attestations for tagged release artifacts. + ### Security - Pinned all GitHub Actions used by CI, CodeQL, Pages, and release workflows to immutable commit SHAs while retaining version comments for maintainability and Dependabot updates. +- Pinned automated installer compilation to Inno Setup 6.7.1 and verify both the Chocolatey package and `ISCC.exe` product version. +- Treat compiler warnings as errors in validated Publisher, Subscriber, test, release, and CodeQL build paths. ### Planned -- Exact-version and integrity pinning for installer tooling. - Shared `ARSVIN.Engine` class library extraction. -- Code coverage and analyzer quality gates. +- Higher coverage thresholds and expanded protocol regression tests. - Search-indexable HTML engineering documentation. -- SBOM and release provenance/attestation. +- Windows Authenticode signing when a trusted certificate becomes available. ## 0.3.0 — 2026-07-11 From 0dc264172703eb420d222aa1dd48625a6f121a28 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:13:45 +0700 Subject: [PATCH 14/30] Expand public release verification checklist --- docs/public-release-checklist.md | 33 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/public-release-checklist.md b/docs/public-release-checklist.md index 25ac69ae..54b52fd2 100644 --- a/docs/public-release-checklist.md +++ b/docs/public-release-checklist.md @@ -15,23 +15,34 @@ Use this checklist before making a public GitHub release. ## Engineering quality - [ ] `dotnet restore` succeeds. -- [ ] `dotnet build -c Release` succeeds on Windows. -- [ ] `dotnet test -c Release` succeeds. +- [ ] warning-free `dotnet build -c Release -warnaserror` succeeds on Windows. +- [ ] unit tests succeed. +- [ ] the configured line-coverage regression floor succeeds. +- [ ] TRX and Cobertura evidence are retained by CI. - [ ] CodeQL workflow passes. +- [ ] dependency vulnerability reports pass. - [ ] Dependabot is enabled for NuGet and GitHub Actions. -- [ ] No secrets, real station files, private captures, or confidential SCL files are committed. +- [ ] no secrets, real station files, private captures, or confidential SCL files are committed. ## Safety - [ ] README warns that ARSVIN can transmit raw Ethernet frames. -- [ ] Live mode safety docs are up to date. -- [ ] Known limitations are honest and visible. -- [ ] Release notes state that the app is not a certified protection test set. -- [ ] Testers use isolated lab networks or point-to-point links. +- [ ] live mode safety docs are up to date. +- [ ] known limitations are honest and visible. +- [ ] release notes state that the app is not a certified protection test set. +- [ ] testers use isolated lab networks or point-to-point links. ## Release package -- [ ] Tag format is `vX.Y.Z`. -- [ ] Portable ZIP includes executable, README, LICENSE, NOTICE, and relevant docs. -- [ ] Release notes include highlights, fixes, known limitations, and safety reminders. -- [ ] Downloaded ZIP runs on a clean Windows machine with Npcap installed. +- [ ] tag format is `vX.Y.Z` or a valid semantic-version prerelease. +- [ ] tagged commit is already contained in protected `main`. +- [ ] Publisher portable EXE exists and is non-empty. +- [ ] Subscriber portable EXE exists and is non-empty. +- [ ] suite installer exists and passes silent install/uninstall smoke testing. +- [ ] portable ZIP includes executables, README, LICENSE, notices, and relevant docs. +- [ ] CycloneDX SBOM exists, parses, and contains resolved dependency components. +- [ ] SHA-256 checksum file covers all distributed release assets. +- [ ] GitHub build-provenance and SBOM attestations are created for tagged assets. +- [ ] `gh attestation verify` succeeds for a downloaded release file. +- [ ] release notes include highlights, fixes, known limitations, and safety reminders. +- [ ] downloaded artifacts run on a clean Windows machine with Npcap installed for live-network testing. From 705fe20d8b436891c61c6946fafbdb84193b22bd Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:16:24 +0700 Subject: [PATCH 15/30] Measure linked engine source in test assembly --- scripts/test-with-coverage.ps1 | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index 97ea5975..2af44d2a 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -35,7 +35,8 @@ $arguments += @( '--collect', 'XPlat Code Coverage', '--', 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura', - 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile=**/*.g.cs,**/*.g.i.cs,**/obj/**' + 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.IncludeTestAssembly=true', + 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile=**/tests/**,**/*Tests.cs,**/*.g.cs,**/*.g.i.cs,**/obj/**' ) & dotnet @arguments @@ -58,16 +59,23 @@ if ($coverageFile.FullName -ne $normalizedCoverage) { [xml] $coverage = Get-Content $normalizedCoverage -Raw $lineRateText = [string] $coverage.coverage.'line-rate' +$linesValidText = [string] $coverage.coverage.'lines-valid' if ([string]::IsNullOrWhiteSpace($lineRateText)) { throw 'Cobertura report does not contain a root line-rate value.' } +$linesValid = 0 +if (-not [int]::TryParse($linesValidText, [ref] $linesValid) -or $linesValid -le 0) { + throw 'Coverage report contains no instrumented source lines. Check Coverlet assembly and file filters.' +} + $lineRate = [double]::Parse( $lineRateText, [System.Globalization.CultureInfo]::InvariantCulture ) $lineCoverage = [Math]::Round($lineRate * 100, 2) +Write-Host "Instrumented lines: $linesValid" Write-Host "Line coverage: $lineCoverage%" Write-Host "Minimum required: $MinimumLineCoverage%" Write-Host "Coverage report: $normalizedCoverage" @@ -78,6 +86,7 @@ if ($env:GITHUB_STEP_SUMMARY) { | Metric | Result | |---|---:| +| Instrumented lines | **$linesValid** | | Line coverage | **$lineCoverage%** | | Required minimum | **$MinimumLineCoverage%** | | Report | `artifacts/test-results/coverage.cobertura.xml` | From a3e0da937396592bcf6956fcee15bf07a543f381 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:18:19 +0700 Subject: [PATCH 16/30] Add explicit Coverlet runsettings --- tests/coverage.runsettings | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/coverage.runsettings diff --git a/tests/coverage.runsettings b/tests/coverage.runsettings new file mode 100644 index 00000000..6247e2ad --- /dev/null +++ b/tests/coverage.runsettings @@ -0,0 +1,16 @@ + + + + + + + cobertura + true + **/tests/**,**/*Tests.cs,**/*.g.cs,**/*.g.i.cs,**/obj/** + true + false + + + + + From b0eb7946fd4d47fe9a01fca9d74fb8edcb06f6bf Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:18:31 +0700 Subject: [PATCH 17/30] Use explicit coverage runsettings --- scripts/test-with-coverage.ps1 | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index 2af44d2a..523f9b3c 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -11,6 +11,7 @@ Set-StrictMode -Version Latest $root = Split-Path -Parent $PSScriptRoot $testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' +$runSettings = Join-Path $root 'tests\coverage.runsettings' $resultsRoot = Join-Path $root 'artifacts\test-results' if (Test-Path $resultsRoot) { @@ -30,13 +31,10 @@ if ($NoRestore) { $arguments += @( '/p:TreatWarningsAsErrors=true', + '--settings', $runSettings, '--logger', 'trx;LogFileName=ARSVIN.Tests.trx', '--results-directory', $resultsRoot, - '--collect', 'XPlat Code Coverage', - '--', - 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura', - 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.IncludeTestAssembly=true', - 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile=**/tests/**,**/*Tests.cs,**/*.g.cs,**/*.g.i.cs,**/obj/**' + '--collect', 'XPlat Code Coverage' ) & dotnet @arguments From 52925cbc823bc322d671fb4b174d66ea8fcb31dd Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:21:16 +0700 Subject: [PATCH 18/30] Use Coverlet MSBuild instrumentation --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5a986cbf..181a38b8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,6 @@ - + From 59f3a3babd5e953b8343500f5f898149e9463ef3 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:21:27 +0700 Subject: [PATCH 19/30] Use Coverlet MSBuild instrumentation --- tests/ARSVIN.Tests/ARSVIN.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj index ccd53316..2161e4f7 100644 --- a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj +++ b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj @@ -15,7 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 3f0a05cc1dd4f6de5b61defd4a364f8bd208aee4 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:21:46 +0700 Subject: [PATCH 20/30] Collect coverage through MSBuild instrumentation --- scripts/test-with-coverage.ps1 | 37 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index 523f9b3c..735923fb 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -11,8 +11,8 @@ Set-StrictMode -Version Latest $root = Split-Path -Parent $PSScriptRoot $testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' -$runSettings = Join-Path $root 'tests\coverage.runsettings' $resultsRoot = Join-Path $root 'artifacts\test-results' +$coveragePrefix = Join-Path $resultsRoot 'coverage' if (Test-Path $resultsRoot) { Remove-Item $resultsRoot -Recurse -Force @@ -29,12 +29,19 @@ if ($NoRestore) { $arguments += '--no-restore' } +# Engine source is currently linked into the test assembly. Coverlet's MSBuild +# integration can instrument that assembly explicitly; test source files are +# excluded so the metric reflects the linked production engine surface. $arguments += @( '/p:TreatWarningsAsErrors=true', - '--settings', $runSettings, + '/p:CollectCoverage=true', + '/p:IncludeTestAssembly=true', + '/p:CoverletOutputFormat=cobertura', + "/p:CoverletOutput=$coveragePrefix", + '/p:DeterministicReport=true', + '/p:ExcludeByFile=**/tests/**%2c**/*Tests.cs%2c**/*.g.cs%2c**/*.g.i.cs%2c**/obj/**', '--logger', 'trx;LogFileName=ARSVIN.Tests.trx', - '--results-directory', $resultsRoot, - '--collect', 'XPlat Code Coverage' + '--results-directory', $resultsRoot ) & dotnet @arguments @@ -42,20 +49,12 @@ if ($LASTEXITCODE -ne 0) { throw "dotnet test with coverage failed with exit code $LASTEXITCODE." } -$coverageFile = Get-ChildItem $resultsRoot -Recurse -Filter 'coverage.cobertura.xml' -File | - Sort-Object LastWriteTimeUtc -Descending | - Select-Object -First 1 - -if (-not $coverageFile) { - throw "Coverage collector did not produce coverage.cobertura.xml under $resultsRoot." -} - -$normalizedCoverage = Join-Path $resultsRoot 'coverage.cobertura.xml' -if ($coverageFile.FullName -ne $normalizedCoverage) { - Copy-Item $coverageFile.FullName $normalizedCoverage -Force +$coverageFile = Join-Path $resultsRoot 'coverage.cobertura.xml' +if (-not (Test-Path $coverageFile -PathType Leaf)) { + throw "Coverlet MSBuild integration did not produce $coverageFile." } -[xml] $coverage = Get-Content $normalizedCoverage -Raw +[xml] $coverage = Get-Content $coverageFile -Raw $lineRateText = [string] $coverage.coverage.'line-rate' $linesValidText = [string] $coverage.coverage.'lines-valid' if ([string]::IsNullOrWhiteSpace($lineRateText)) { @@ -64,7 +63,7 @@ if ([string]::IsNullOrWhiteSpace($lineRateText)) { $linesValid = 0 if (-not [int]::TryParse($linesValidText, [ref] $linesValid) -or $linesValid -le 0) { - throw 'Coverage report contains no instrumented source lines. Check Coverlet assembly and file filters.' + throw 'Coverage report contains no instrumented production source lines.' } $lineRate = [double]::Parse( @@ -76,7 +75,7 @@ $lineCoverage = [Math]::Round($lineRate * 100, 2) Write-Host "Instrumented lines: $linesValid" Write-Host "Line coverage: $lineCoverage%" Write-Host "Minimum required: $MinimumLineCoverage%" -Write-Host "Coverage report: $normalizedCoverage" +Write-Host "Coverage report: $coverageFile" if ($env:GITHUB_STEP_SUMMARY) { @" @@ -84,7 +83,7 @@ if ($env:GITHUB_STEP_SUMMARY) { | Metric | Result | |---|---:| -| Instrumented lines | **$linesValid** | +| Instrumented production lines | **$linesValid** | | Line coverage | **$lineCoverage%** | | Required minimum | **$MinimumLineCoverage%** | | Report | `artifacts/test-results/coverage.cobertura.xml` | From 15eccaa7e3feab115bad62a1824f8c4e9b44626e Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:21:56 +0700 Subject: [PATCH 21/30] Remove unused collector runsettings --- tests/coverage.runsettings | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 tests/coverage.runsettings diff --git a/tests/coverage.runsettings b/tests/coverage.runsettings deleted file mode 100644 index 6247e2ad..00000000 --- a/tests/coverage.runsettings +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - cobertura - true - **/tests/**,**/*Tests.cs,**/*.g.cs,**/*.g.i.cs,**/obj/** - true - false - - - - - From 5a70ac169cf47a95bca3adfa601b26de99ffdee4 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:22:37 +0700 Subject: [PATCH 22/30] Keep exact Inno package pin without brittle file metadata gate --- .github/workflows/release.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52c65bda..447c0211 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,10 +145,11 @@ jobs: throw 'Inno Setup compiler (ISCC.exe) was not found.' } - $compilerVersion = (Get-Item $iscc).VersionInfo.ProductVersion - if (-not $compilerVersion.StartsWith($env:INNO_SETUP_VERSION, [StringComparison]::OrdinalIgnoreCase)) { - throw "Expected ISCC $env:INNO_SETUP_VERSION, found $compilerVersion." - } + $versionInfo = (Get-Item $iscc).VersionInfo + Write-Host "Validated Chocolatey package: innosetup|$env:INNO_SETUP_VERSION" + Write-Host "ISCC path: $iscc" + Write-Host "ISCC product version metadata: $($versionInfo.ProductVersion)" + Write-Host "ISCC file version metadata: $($versionInfo.FileVersion)" $sourceDir = (Resolve-Path '.\artifacts\installer-input').Path $outputDir = (Resolve-Path '.\artifacts\release').Path From cd86d5826a781c8a4695ff6f9482bc5b2344c160 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:25:41 +0700 Subject: [PATCH 23/30] Force linked engine test assembly instrumentation --- scripts/test-with-coverage.ps1 | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index 735923fb..612600c9 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -13,6 +13,7 @@ $root = Split-Path -Parent $PSScriptRoot $testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' $resultsRoot = Join-Path $root 'artifacts\test-results' $coveragePrefix = Join-Path $resultsRoot 'coverage' +$testLog = Join-Path $resultsRoot 'dotnet-test.log' if (Test-Path $resultsRoot) { Remove-Item $resultsRoot -Recurse -Force @@ -29,13 +30,15 @@ if ($NoRestore) { $arguments += '--no-restore' } -# Engine source is currently linked into the test assembly. Coverlet's MSBuild -# integration can instrument that assembly explicitly; test source files are -# excluded so the metric reflects the linked production engine surface. +# Production engine source is currently linked into ARSVIN.Tests. Force that +# assembly into the instrumentation set, keep source matching permissive for +# linked documents, and exclude test files from the resulting metric. $arguments += @( '/p:TreatWarningsAsErrors=true', '/p:CollectCoverage=true', '/p:IncludeTestAssembly=true', + '/p:Include=[ARSVIN.Tests]*', + '/p:ExcludeAssembliesWithoutSources=None', '/p:CoverletOutputFormat=cobertura', "/p:CoverletOutput=$coveragePrefix", '/p:DeterministicReport=true', @@ -44,9 +47,11 @@ $arguments += @( '--results-directory', $resultsRoot ) -& dotnet @arguments -if ($LASTEXITCODE -ne 0) { - throw "dotnet test with coverage failed with exit code $LASTEXITCODE." +Write-Host "dotnet $($arguments -join ' ')" +& dotnet @arguments 2>&1 | Tee-Object -FilePath $testLog +$testExitCode = $LASTEXITCODE +if ($testExitCode -ne 0) { + throw "dotnet test with coverage failed with exit code $testExitCode." } $coverageFile = Join-Path $resultsRoot 'coverage.cobertura.xml' From e0e2e698d72ced73cd18d9c8d7659ca57de9a7f2 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:27:51 +0700 Subject: [PATCH 24/30] Raise verified coverage regression floor to 50 percent --- scripts/test-with-coverage.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index 612600c9..450d49cd 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [ValidateRange(0, 100)] - [double] $MinimumLineCoverage = 20, + [double] $MinimumLineCoverage = 50, [switch] $NoRestore ) From 44d6ce945cb1c84ba1fc13d37f88f20a1485a5a9 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:28:09 +0700 Subject: [PATCH 25/30] Enforce 50 percent line coverage floor --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 348ae12b..07c3c22d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: - name: Test with coverage gate shell: pwsh - run: .\scripts\test-with-coverage.ps1 -MinimumLineCoverage 20 -NoRestore + run: .\scripts\test-with-coverage.ps1 -MinimumLineCoverage 50 -NoRestore - name: Upload test and coverage evidence if: always() From 62d4e859b0697a7af9bde34244f357ff39ae7691 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:28:51 +0700 Subject: [PATCH 26/30] Document verified 50 percent coverage floor --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a1e818d9..b07a3751 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,11 @@ Compatibility wrapper: Run tests with the repository coverage gate and retain TRX/Cobertura evidence: ```powershell -.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 20 +.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 50 ``` +The current linked engine surface measures 57.85% line coverage across 1,535 instrumented production lines. CI enforces a 50% regression floor while the shared engine project and broader protocol tests are developed. + Generate a CycloneDX SBOM after restoring the solution: ```powershell From 3e52fa9f7ffa7df700d16b3bbe8ab6a75cfa3527 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:33:20 +0700 Subject: [PATCH 27/30] Limit SBOM to shipped applications and stabilize output --- scripts/generate-sbom.ps1 | 139 ++++++++++++++++++++++++++------------ 1 file changed, 95 insertions(+), 44 deletions(-) diff --git a/scripts/generate-sbom.ps1 b/scripts/generate-sbom.ps1 index f40e3553..78011c52 100644 --- a/scripts/generate-sbom.ps1 +++ b/scripts/generate-sbom.ps1 @@ -11,7 +11,16 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $root = Split-Path -Parent $PSScriptRoot -$solution = Join-Path $root 'ARSVIN.sln' +$applicationProjects = @( + [ordered]@{ + Name = 'ARSVIN Publisher' + Path = Join-Path $root 'src\ARSVIN\ARSVIN.csproj' + }, + [ordered]@{ + Name = 'ArSubsv Subscriber' + Path = Join-Path $root 'src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj' + } +) if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $root 'artifacts\release\ARSVIN-SBOM.cdx.json' @@ -23,49 +32,77 @@ elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) { $outputDirectory = Split-Path -Parent $OutputPath New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null -Write-Host '==> Resolving complete NuGet dependency graph' -$commandOutput = & dotnet list $solution package --include-transitive --format json --output-version 1 2>&1 +$sourceCommitOutput = & git -C $root rev-parse HEAD 2>&1 if ($LASTEXITCODE -ne 0) { - throw "dotnet list package failed with exit code $LASTEXITCODE.`n$($commandOutput -join [Environment]::NewLine)" + throw "Could not resolve the source commit.`n$($sourceCommitOutput -join [Environment]::NewLine)" } +$sourceCommit = ($sourceCommitOutput -join '').Trim() -$jsonText = $commandOutput -join [Environment]::NewLine -$jsonStart = $jsonText.IndexOf('{') -$jsonEnd = $jsonText.LastIndexOf('}') -if ($jsonStart -lt 0 -or $jsonEnd -le $jsonStart) { - throw 'Could not locate the JSON dependency graph in dotnet list package output.' +$sourceTimestampOutput = & git -C $root show -s --format=%cI HEAD 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the source commit timestamp.`n$($sourceTimestampOutput -join [Environment]::NewLine)" } +$sourceTimestamp = [DateTimeOffset]::Parse( + ($sourceTimestampOutput -join '').Trim(), + [System.Globalization.CultureInfo]::InvariantCulture +).ToUniversalTime().ToString('o') -$dependencyGraph = $jsonText.Substring($jsonStart, $jsonEnd - $jsonStart + 1) | ConvertFrom-Json $packages = @{} -foreach ($project in @($dependencyGraph.projects)) { - foreach ($framework in @($project.frameworks)) { - foreach ($scope in @('topLevelPackages', 'transitivePackages')) { - $property = $framework.PSObject.Properties[$scope] - if (-not $property) { - continue - } +foreach ($applicationProject in $applicationProjects) { + $projectName = [string] $applicationProject.Name + $projectPath = [string] $applicationProject.Path - foreach ($package in @($property.Value)) { - $id = [string] $package.id - $resolvedVersion = [string] $package.resolvedVersion - if ([string]::IsNullOrWhiteSpace($resolvedVersion)) { - $resolvedVersion = [string] $package.version - } + Write-Host "==> Resolving NuGet dependencies: $projectName" + $commandOutput = & dotnet list $projectPath package --include-transitive --format json --output-version 1 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet list package failed for $projectName with exit code $LASTEXITCODE.`n$($commandOutput -join [Environment]::NewLine)" + } + + $jsonText = $commandOutput -join [Environment]::NewLine + $jsonStart = $jsonText.IndexOf('{') + $jsonEnd = $jsonText.LastIndexOf('}') + if ($jsonStart -lt 0 -or $jsonEnd -le $jsonStart) { + throw "Could not locate the JSON dependency graph for $projectName." + } + + $dependencyGraph = $jsonText.Substring($jsonStart, $jsonEnd - $jsonStart + 1) | ConvertFrom-Json - if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($resolvedVersion)) { + foreach ($project in @($dependencyGraph.projects)) { + foreach ($framework in @($project.frameworks)) { + foreach ($scopeName in @('topLevelPackages', 'transitivePackages')) { + $property = $framework.PSObject.Properties[$scopeName] + if (-not $property) { continue } - $key = "$($id.ToLowerInvariant())|$resolvedVersion" - $isTopLevel = $scope -eq 'topLevelPackages' - if (-not $packages.ContainsKey($key) -or $isTopLevel) { - $packages[$key] = [ordered]@{ - Id = $id - Version = $resolvedVersion - Scope = if ($isTopLevel) { 'direct' } else { 'transitive' } + foreach ($package in @($property.Value)) { + $id = [string] $package.id + $resolvedVersion = [string] $package.resolvedVersion + if ([string]::IsNullOrWhiteSpace($resolvedVersion)) { + $resolvedVersion = [string] $package.version + } + + if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($resolvedVersion)) { + continue + } + + $key = "$($id.ToLowerInvariant())|$resolvedVersion" + $isTopLevel = $scopeName -eq 'topLevelPackages' + + if (-not $packages.ContainsKey($key)) { + $packages[$key] = [pscustomobject]@{ + Id = $id + Version = $resolvedVersion + Scope = if ($isTopLevel) { 'direct' } else { 'transitive' } + UsedBy = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + } } + elseif ($isTopLevel) { + $packages[$key].Scope = 'direct' + } + + $null = $packages[$key].UsedBy.Add($projectName) } } } @@ -73,13 +110,21 @@ foreach ($project in @($dependencyGraph.projects)) { } if ($packages.Count -eq 0) { - throw 'The dependency graph did not contain any resolved NuGet packages.' + throw 'The application dependency graphs did not contain any resolved NuGet packages.' } -$components = foreach ($package in $packages.Values | Sort-Object Id, Version) { +$sortedPackages = @( + $packages.Values | + Sort-Object ` + @{ Expression = { ([string] $_.Id).ToLowerInvariant() } }, ` + @{ Expression = { [string] $_.Version } } +) + +$components = foreach ($package in $sortedPackages) { $escapedId = [Uri]::EscapeDataString([string] $package.Id) $escapedVersion = [Uri]::EscapeDataString([string] $package.Version) $purl = "pkg:nuget/$escapedId@$escapedVersion" + $usedBy = @($package.UsedBy | Sort-Object) -join ', ' [ordered]@{ type = 'library' @@ -91,32 +136,28 @@ $components = foreach ($package in $packages.Values | Sort-Object Id, Version) { [ordered]@{ name = 'arsvin:dependency-scope' value = [string] $package.Scope + }, + [ordered]@{ + name = 'arsvin:used-by' + value = $usedBy } ) } } -$metadataProperties = @() -if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_SHA)) { - $metadataProperties += [ordered]@{ - name = 'arsvin:source-commit' - value = $env:GITHUB_SHA - } -} - $sbom = [ordered]@{ bomFormat = 'CycloneDX' specVersion = '1.5' version = 1 metadata = [ordered]@{ - timestamp = [DateTimeOffset]::UtcNow.ToString('o') + timestamp = $sourceTimestamp tools = [ordered]@{ components = @( [ordered]@{ type = 'application' author = 'ARSVIN project' name = 'generate-sbom.ps1' - version = '1.0.0' + version = '1.1.0' } ) } @@ -132,7 +173,16 @@ $sbom = [ordered]@{ } } ) - properties = $metadataProperties + properties = @( + [ordered]@{ + name = 'arsvin:source-commit' + value = $sourceCommit + }, + [ordered]@{ + name = 'arsvin:included-applications' + value = ($applicationProjects.Name -join ', ') + } + ) } } components = @($components) @@ -147,5 +197,6 @@ $json = $sbom | ConvertTo-Json -Depth 20 $written = Get-Item $OutputPath Write-Host "==> CycloneDX SBOM written: $($written.FullName)" +Write-Host " Source commit: $sourceCommit" Write-Host " Components: $($components.Count)" Write-Host " Size: $($written.Length) bytes" From ceb5f3c763ce3a9162fa854951dfd3f332534f34 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:34:05 +0700 Subject: [PATCH 28/30] Document verified coverage and application-only SBOM --- docs/build-and-release.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/build-and-release.md b/docs/build-and-release.md index 28c9768a..6c5a720d 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -34,18 +34,18 @@ External command exit codes are checked, and compiler warnings are treated as er ### Coverage evidence ```powershell -.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 20 +.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 50 ``` The script: -1. runs the xUnit suite with the pinned Coverlet collector, -2. writes TRX evidence under `artifacts/test-results`, -3. normalizes the Cobertura report to `artifacts/test-results/coverage.cobertura.xml`, -4. reads the root line-rate value, -5. fails when line coverage is below the configured threshold. +1. runs the xUnit suite using pinned Coverlet MSBuild instrumentation, +2. explicitly instruments the linked production engine source in `ARSVIN.Tests`, +3. excludes test and generated source files from the reported metric, +4. writes TRX, the complete `dotnet test` log, and Cobertura evidence under `artifacts/test-results`, +5. fails when no production lines are instrumented or line coverage is below the configured threshold. -The initial gate is 20%. It is a regression floor, not a claim that the complete WPF application surface is covered. Raise the threshold as engine tests are expanded. +The verified baseline is 57.85% line coverage over 1,535 instrumented production lines, with 888 lines covered. CI enforces a 50% regression floor. This is not a claim that the complete WPF UI or every live-network path is covered; raise the threshold as the shared engine project and protocol tests expand. ## Validate the public site @@ -88,7 +88,7 @@ The two direct `.exe` files are self-contained .NET 8 single-file applications. ## Generate the CycloneDX SBOM -After restoring the solution, run: +After restoring the application projects, run: ```powershell .\scripts\generate-sbom.ps1 -Version 0.3.0 @@ -100,7 +100,9 @@ Output: artifacts/release/ARSVIN-SBOM.cdx.json ``` -The generator reads the complete resolved NuGet graph for `ARSVIN.sln`, deduplicates direct and transitive packages, and writes CycloneDX 1.5 JSON. The SBOM covers managed application dependencies. It does not claim to inventory Windows, Npcap, GitHub-hosted runner contents, or every tool used by the build service. +The generator reads the resolved NuGet graphs for the Publisher and Subscriber projects, deduplicates direct and transitive application packages, records which application uses each package, and writes CycloneDX 1.5 JSON. Test-only packages such as xUnit and Coverlet are intentionally excluded from the release SBOM. + +Component order and metadata timestamp are stabilized from the source commit so repeated generation from the same commit and version is reviewable. The SBOM covers managed application dependencies; it does not claim to inventory Windows, Npcap, GitHub-hosted runner contents, or every tool used by the build service. ## Build the installer locally @@ -125,7 +127,7 @@ Output: artifacts/release/ARSVIN-Suite-Setup-win-x64.exe ``` -The automated workflow installs and verifies the exact Chocolatey package version declared in `INNO_SETUP_VERSION`. It also checks the `ISCC.exe` product version before compiling. +The automated workflow installs and verifies the exact Chocolatey package version declared in `INNO_SETUP_VERSION`. It records the resolved `ISCC.exe` path and file metadata for evidence, while the exact package version remains the authoritative toolchain pin. The installer: @@ -149,9 +151,9 @@ When release tooling, installer definitions, project files, or build configurati 1. restores, builds, and tests the solution with warnings treated as errors, 2. publishes both portable single-file applications, 3. creates the portable suite ZIP, -4. installs the pinned Inno Setup version, +4. installs and verifies the pinned Inno Setup package, 5. compiles the installer, -6. generates and structurally validates the CycloneDX SBOM, +6. generates and structurally validates the application-only CycloneDX SBOM, 7. checks all expected artifact names and non-empty files, 8. silently installs the suite into a temporary directory, 9. verifies Publisher, Subscriber, documentation, version file, and uninstaller, @@ -200,7 +202,7 @@ Manual runs upload private workflow artifacts only. They never create or replace | `ArSubsv-Subscriber-win-x64.exe` | Portable Subscriber/analysis companion. | | `ARSVIN-Suite-Setup-win-x64.exe` | Installer for both applications. | | `ARSVIN-win-x64-portable.zip` | Portable suite package. | -| `ARSVIN-SBOM.cdx.json` | CycloneDX 1.5 managed-dependency SBOM. | +| `ARSVIN-SBOM.cdx.json` | CycloneDX 1.5 managed application-dependency SBOM. | | `SHA256SUMS.txt` | Integrity hashes for all release assets. | ## Verify a download @@ -234,7 +236,7 @@ Before tagging a public release: - Confirm the intended release commit is already on `main`. - Confirm `main` CI, release validation, and CodeQL are green. - Update `VersionPrefix` and `CHANGELOG.md`. -- Review the generated SBOM and checksums. +- Review the generated application SBOM and checksums. - Test Publisher dry run. - Test live publishing only on an isolated lab link. - Test Subscriber live capture and PCAP import. From f3dbe9f0a548d987153f978c66be44076b1c52fa Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:34:27 +0700 Subject: [PATCH 29/30] Record verified coverage and application-only SBOM --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b9c6da..2258f8f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,18 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change ### Added -- Added a pinned Coverlet collector, TRX/Cobertura evidence upload, and a 20% line-coverage regression floor for the currently testable engine surface. -- Added a repository-owned CycloneDX 1.5 SBOM generator for direct and transitive NuGet dependencies. +- Added pinned Coverlet MSBuild instrumentation, TRX/Cobertura/log evidence upload, and a verified 50% line-coverage regression floor for the linked production engine surface. +- Established a measured baseline of 57.85% line coverage across 1,535 instrumented production lines, with 888 lines covered and all 26 tests passing. +- Added a repository-owned CycloneDX 1.5 SBOM generator for direct and transitive Publisher/Subscriber NuGet dependencies while excluding test-only packages. - Added `ARSVIN-SBOM.cdx.json` to validated workflow artifacts, release downloads, and SHA-256 checksums. - Added signed GitHub build-provenance and SBOM attestations for tagged release artifacts. ### Security - Pinned all GitHub Actions used by CI, CodeQL, Pages, and release workflows to immutable commit SHAs while retaining version comments for maintainability and Dependabot updates. -- Pinned automated installer compilation to Inno Setup 6.7.1 and verify both the Chocolatey package and `ISCC.exe` product version. +- Pinned automated installer compilation to the exact Inno Setup 6.7.1 Chocolatey package and retain resolved compiler metadata in workflow evidence. - Treat compiler warnings as errors in validated Publisher, Subscriber, test, release, and CodeQL build paths. +- Stabilized SBOM component ordering, source commit, and metadata timestamp for repeatable review from the same commit. ### Planned From 18f945517722a28bb0b5c0b30c20987abca90098 Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 12 Jul 2026 07:34:58 +0700 Subject: [PATCH 30/30] Reject test-only packages from release SBOM --- scripts/generate-sbom.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/generate-sbom.ps1 b/scripts/generate-sbom.ps1 index 78011c52..cc75f5e4 100644 --- a/scripts/generate-sbom.ps1 +++ b/scripts/generate-sbom.ps1 @@ -120,6 +120,22 @@ $sortedPackages = @( @{ Expression = { [string] $_.Version } } ) +$testOnlyPackagePatterns = @( + '^coverlet(?:\.|$)', + '^xunit(?:\.|$)', + '^Microsoft\.NET\.Test\.Sdk$' +) +$testOnlyPackages = @( + $sortedPackages | Where-Object { + $packageId = [string] $_.Id + $testOnlyPackagePatterns | Where-Object { $packageId -match $_ } + } +) +if ($testOnlyPackages.Count -gt 0) { + $names = $testOnlyPackages.Id -join ', ' + throw "Release SBOM unexpectedly contains test-only packages: $names" +} + $components = foreach ($package in $sortedPackages) { $escapedId = [Uri]::EscapeDataString([string] $package.Id) $escapedVersion = [Uri]::EscapeDataString([string] $package.Version)