From d4741b89243ac995d848fbe154adea9706bff17d Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Thu, 27 Aug 2026 19:57:31 +0300 Subject: [PATCH 01/13] First round of changes for the readiness assessment --- .config/sbom-tool/dotnet-tools.json | 11 + .github/scripts/Assert-NuspecRepository.ps1 | 132 ++++ .github/scripts/verify-strong-name.ps1 | 137 +++++ .../igniteui-blazor-lite-release.yml | 569 +++++++++++++++++- .gitignore | 3 + CHANGELOG.md | 17 + README.md | 13 + docs/accessibility-conformance.md | 62 ++ docs/nullable-migration-plan.md | 45 ++ docs/performance.md | 65 ++ eng/Check-BundleBudget.ps1 | 228 +++++++ eng/IG.authenticode-certificates.sha256 | 3 + eng/IG.publickey.hex | 8 + eng/bundle-budgets.json | 86 +++ src/IgniteUI.Blazor.Lite.csproj | 9 +- 15 files changed, 1364 insertions(+), 24 deletions(-) create mode 100644 .config/sbom-tool/dotnet-tools.json create mode 100644 .github/scripts/Assert-NuspecRepository.ps1 create mode 100644 .github/scripts/verify-strong-name.ps1 create mode 100644 docs/accessibility-conformance.md create mode 100644 docs/nullable-migration-plan.md create mode 100644 docs/performance.md create mode 100644 eng/Check-BundleBudget.ps1 create mode 100644 eng/IG.authenticode-certificates.sha256 create mode 100644 eng/IG.publickey.hex create mode 100644 eng/bundle-budgets.json diff --git a/.config/sbom-tool/dotnet-tools.json b/.config/sbom-tool/dotnet-tools.json new file mode 100644 index 00000000..28595c7a --- /dev/null +++ b/.config/sbom-tool/dotnet-tools.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "microsoft.sbom.dotnettool": { + "version": "4.1.5", + "commands": ["sbom-tool"], + "rollForward": true + } + } +} diff --git a/.github/scripts/Assert-NuspecRepository.ps1 b/.github/scripts/Assert-NuspecRepository.ps1 new file mode 100644 index 00000000..d56f2057 --- /dev/null +++ b/.github/scripts/Assert-NuspecRepository.ps1 @@ -0,0 +1,132 @@ +<# +.SYNOPSIS + Asserts that a packed NuGet package carries the provenance metadata consumers rely on. + +.DESCRIPTION + The nuspec is generated at pack time from MSBuild properties, so a property that is unset, + misspelled, or silently dropped by a '--no-build' pack produces a package that restores fine + but cannot be traced back to source. IgniteUI.Blazor.Lite 0.1.1 shipped exactly that way: the + element carried a commit but no url. This script turns that class of omission into + a release failure instead of a post-release finding. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackagePath, + + [Parameter(Mandatory)] + [string]$ExpectedRepositoryUrl, + + [Parameter(Mandatory)] + [string]$ExpectedCommit, + + [string]$ExpectedPackageId, + + [string]$ExpectedVersion +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "NuGet package not found: $PackagePath" +} + +if ($ExpectedCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw "ExpectedCommit must be a full 40-character git SHA, but was '$ExpectedCommit'." +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath).ProviderPath) +try { + $entry = $archive.Entries | + Where-Object { $_.FullName -notlike '*/*' -and $_.FullName -like '*.nuspec' } | + Select-Object -First 1 + + if ($null -eq $entry) { + throw "No .nuspec found at the root of $PackagePath." + } + + $reader = New-Object System.IO.StreamReader($entry.Open()) + try { + $nuspecXml = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } +} +finally { + $archive.Dispose() +} + +$document = New-Object System.Xml.XmlDocument +$document.PreserveWhitespace = $false +$document.LoadXml($nuspecXml) + +# The nuspec default namespace changes with the schema version, so match on local names only. +$metadata = $document.SelectSingleNode('/*[local-name()="package"]/*[local-name()="metadata"]') +if ($null -eq $metadata) { + throw "The nuspec in $PackagePath has no element." +} + +function Get-MetadataValue([string]$Name) { + $node = $metadata.SelectSingleNode("*[local-name()=`"$Name`"]") + if ($null -eq $node) { return $null } + return $node.InnerText.Trim() +} + +$problems = @() + +function Assert-Value([string]$Label, [string]$Actual, [string]$Expected) { + if ([string]::IsNullOrWhiteSpace($Actual)) { + $script:problems += "$Label is missing from the nuspec." + } + elseif ($Expected -and $Actual -ne $Expected) { + $script:problems += "$Label is '$Actual', expected '$Expected'." + } +} + +$repository = $metadata.SelectSingleNode('*[local-name()="repository"]') +if ($null -eq $repository) { + $problems += ' is missing from the nuspec.' +} +else { + Assert-Value 'repository/@type' $repository.GetAttribute('type') 'git' + Assert-Value 'repository/@url' $repository.GetAttribute('url') $ExpectedRepositoryUrl + Assert-Value 'repository/@commit' $repository.GetAttribute('commit') $ExpectedCommit +} + +Assert-Value 'authors' (Get-MetadataValue 'authors') $null +Assert-Value 'projectUrl' (Get-MetadataValue 'projectUrl') $null +Assert-Value 'description' (Get-MetadataValue 'description') $null + +if ($ExpectedPackageId) { + Assert-Value 'id' (Get-MetadataValue 'id') $ExpectedPackageId +} + +if ($ExpectedVersion) { + Assert-Value 'version' (Get-MetadataValue 'version') $ExpectedVersion +} + +$license = $metadata.SelectSingleNode('*[local-name()="license"]') +if ($null -eq $license) { + $problems += ' is missing from the nuspec.' +} +elseif ($license.GetAttribute('type') -ne 'expression') { + $problems += "license/@type is '$($license.GetAttribute('type'))', expected 'expression'." +} + +# 'authors' defaults to the assembly name when is unset, which is not an author. +$authors = Get-MetadataValue 'authors' +if ($authors -and $ExpectedPackageId -and $authors -eq $ExpectedPackageId) { + $problems += "authors is '$authors', which is the package id rather than a real author. Set in the project file." +} + +if ($problems.Count -gt 0) { + throw "Package provenance metadata validation failed for $([System.IO.Path]::GetFileName($PackagePath)):`n- $($problems -join "`n- ")" +} + +Write-Host "Verified nuspec provenance for $([System.IO.Path]::GetFileName($PackagePath)):" +Write-Host " repository url : $($repository.GetAttribute('url'))" +Write-Host " repository commit : $($repository.GetAttribute('commit'))" +Write-Host " authors : $authors" diff --git a/.github/scripts/verify-strong-name.ps1 b/.github/scripts/verify-strong-name.ps1 new file mode 100644 index 00000000..0be52873 --- /dev/null +++ b/.github/scripts/verify-strong-name.ps1 @@ -0,0 +1,137 @@ +<# +.SYNOPSIS + Verifies that assemblies are strong-name signed with the approved Infragistics key. + +.DESCRIPTION + 'sn.exe -vf' proves only that an assembly's strong name is internally consistent, so any valid + private key passes it. This script additionally compares each assembly's public key against a + value pinned in the repository and established out of band from the signing key. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string[]]$Path, + + [Parameter(Mandatory)] + [string]$ExpectedPublicKeyPath, + + [string]$SnPath +) + +$ErrorActionPreference = 'Stop' +# Failures are aggregated per assembly, so sn.exe exit codes must not throw on their own. +$PSNativeCommandUseErrorActionPreference = $false + +function ConvertTo-HexString([byte[]]$Bytes) { + return (-join ($Bytes | ForEach-Object { $_.ToString('x2') })) +} + +if (-not (Test-Path -LiteralPath $ExpectedPublicKeyPath)) { + throw "Pinned public key file not found: $ExpectedPublicKeyPath" +} + +$hexLines = @( + Get-Content -LiteralPath $ExpectedPublicKeyPath | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and -not $_.StartsWith('#') } +) + +# A blank or malformed pin must fail loudly; otherwise the whole check silently becomes a no-op. +if ($hexLines.Count -ne 1) { + throw "$ExpectedPublicKeyPath must contain exactly one non-comment line, but contains $($hexLines.Count)." +} + +$expectedPublicKeyHex = $hexLines[0].ToLowerInvariant() +if ($expectedPublicKeyHex -notmatch '^[0-9a-f]{320,}$' -or $expectedPublicKeyHex.Length % 2 -ne 0) { + throw "$ExpectedPublicKeyPath does not hold a public key blob (expected an even number of at least 320 hex characters)." +} + +$expectedPublicKey = [byte[]]::new($expectedPublicKeyHex.Length / 2) +for ($index = 0; $index -lt $expectedPublicKey.Length; $index++) { + $expectedPublicKey[$index] = [Convert]::ToByte($expectedPublicKeyHex.Substring($index * 2, 2), 16) +} + +# SHA-1 is not a security choice here; it is the algorithm that defines a strong-name token. +$digest = [System.Security.Cryptography.SHA1]::Create().ComputeHash($expectedPublicKey) +$tokenBytes = $digest[-8..-1] +[array]::Reverse($tokenBytes) +$expectedToken = ConvertTo-HexString $tokenBytes + +if ($SnPath) { + if (-not (Test-Path -LiteralPath $SnPath -PathType Leaf)) { + throw "The specified sn.exe path does not exist: $SnPath" + } + + $strongNameTool = Get-Item -LiteralPath $SnPath +} +else { + $strongNameCommand = Get-Command sn.exe -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($null -ne $strongNameCommand) { + $strongNameTool = Get-Item -LiteralPath $strongNameCommand.Path + } + else { + $windowsSdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Microsoft SDKs\Windows' + $strongNameTool = Get-ChildItem -Path $windowsSdkRoot -Filter 'sn.exe' -Recurse -ErrorAction SilentlyContinue | + Sort-Object -Property @{ + Expression = { + $match = [regex]::Match($_.FullName, '\\v(?\d+(?:\.\d+)*)A?\\', 'IgnoreCase') + if ($match.Success) { [version]$match.Groups['version'].Value } else { [version]'0.0' } + } + Descending = $true + }, @{ + Expression = { $_.FullName } + Descending = $true + } | + Select-Object -First 1 + } +} + +if ($null -eq $strongNameTool) { + throw 'Could not find sn.exe on PATH or under the Windows SDK directory. Pass -SnPath explicitly.' +} + +Write-Verbose "Using sn.exe from '$($strongNameTool.FullName)'." + +$assemblies = @(Get-ChildItem -Path $Path -Filter '*.dll' -Recurse -File) +if ($assemblies.Count -eq 0) { + throw "No assemblies were found under '$($Path -join ', ')'. Refusing to report success." +} + +$problems = @() +foreach ($assembly in $assemblies) { + $output = & $strongNameTool.FullName -vf $assembly.FullName + if ($LASTEXITCODE -ne 0) { + $problems += "$($assembly.FullName): strong-name verification failed. $(($output | Where-Object { $_ }) -join ' ')" + continue + } + + $assemblyName = [System.Reflection.AssemblyName]::GetAssemblyName($assembly.FullName) + $token = $assemblyName.GetPublicKeyToken() + if ($null -eq $token -or $token.Length -eq 0) { + $problems += "$($assembly.FullName): not strong named." + continue + } + + $actualToken = ConvertTo-HexString $token + if ($actualToken -ne $expectedToken) { + $problems += "$($assembly.FullName): public key token is $actualToken, expected $expectedToken." + continue + } + + # Best effort: the token is a truncated hash, so compare the whole key when it is available. + $publicKey = $assemblyName.GetPublicKey() + if ($null -ne $publicKey -and $publicKey.Length -gt 0) { + $actualPublicKey = ConvertTo-HexString $publicKey + if ($actualPublicKey -ne $expectedPublicKeyHex) { + $problems += "$($assembly.FullName): public key does not match $ExpectedPublicKeyPath despite a matching token." + } + } +} + +if ($problems.Count -gt 0) { + throw "Strong-name validation failed:`n$($problems -join "`n")" +} + +Write-Host "Verified $($assemblies.Count) assemblies against public key token $expectedToken." diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index b9c2b7c5..81e8a8bf 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -4,34 +4,47 @@ on: release: types: [published] -permissions: - contents: read +permissions: {} + +concurrency: + group: release-${{ github.ref_name }} env: + BUILD_CONFIGURATION: Release + DOTNET_VERSION: "10.0.x" + NODE_VERSION: "22" VERSION: ${{ github.ref_name }} + PACKAGE_ID: IgniteUI.Blazor.Lite + REPOSITORY_URL: https://github.com/IgniteUI/igniteui-blazor + # Public, deliberately pinned identity. The strong-name counterpart lives in eng/IG.publickey.hex. + EXPECTED_CERT_SHA256_PATH: "eng/IG.authenticode-certificates.sha256" + # Bound sbom-tool's external license lookup. + SBOM_LICENSE_TIMEOUT_SECONDS: "180" jobs: - release: - name: Build & Pack NuGet + # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. + build: + name: Build runs-on: windows-latest - environment: nuget-org-publish + timeout-minutes: 30 permissions: - id-token: write # enable Azure OIDC token issuance for this job contents: read steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x + dotnet-version: ${{ env.DOTNET_VERSION }} - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "24" - registry-url: "https://registry.npmjs.org" + node-version: ${{ env.NODE_VERSION }} package-manager-cache: false # never use caching in release builds - name: Install npm dependencies @@ -46,12 +59,81 @@ jobs: - name: Restore .NET dependencies run: dotnet restore ./src/IgniteUI.Blazor.Lite.csproj + - name: Restore strong-name key + shell: pwsh + env: + STRONG_NAME_KEY_BASE64: ${{ secrets.IG_STRONG_NAME_KEY }} + run: | + if ([string]::IsNullOrWhiteSpace($env:STRONG_NAME_KEY_BASE64)) { + throw "The IG_STRONG_NAME_KEY organization secret is empty or unavailable to this repository." + } + + $keyBytes = [Convert]::FromBase64String($env:STRONG_NAME_KEY_BASE64) + [System.IO.File]::WriteAllBytes("${{ runner.temp }}\IG.StrongName.snk", $keyBytes) + + - name: Build strong-named assemblies + shell: pwsh + run: | + dotnet build ./src/IgniteUI.Blazor.Lite.csproj ` + --configuration ${{ env.BUILD_CONFIGURATION }} ` + --no-restore ` + -p:Version=${{ env.VERSION }} ` + -p:ContinuousIntegrationBuild=true ` + -p:GeneratePackageOnBuild=false ` + -p:TreatWarningsAsErrors=false ` + -p:ExposeInternalsToTests=false ` + -p:SignAssembly=true ` + -p:AssemblyOriginatorKeyFile="${{ runner.temp }}\IG.StrongName.snk" + + - name: Delete strong-name key + if: always() + shell: pwsh + run: Remove-Item "${{ runner.temp }}\IG.StrongName.snk" -Force -ErrorAction SilentlyContinue + + # wwwroot is gitignored and produced by webpack, so this artifact is its only carrier. + - name: Upload build output + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: build-output + path: | + src/bin/** + src/obj/** + src/wwwroot/** + include-hidden-files: true + retention-days: 1 + if-no-files-found: error + + sign-assemblies: + name: Sign assemblies + needs: build + runs-on: windows-latest + timeout-minutes: 20 + environment: nuget-org-publish + permissions: + contents: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Download build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-output + path: src + digest-mismatch: error + - name: Restore .NET local tools run: dotnet tool restore - - name: Build .NET library - run: dotnet build ./src/IgniteUI.Blazor.Lite.csproj --no-restore --configuration Release /p:Version=${{ env.VERSION }} /p:TreatWarningsAsErrors=false /p:ExposeInternalsToTests=false - - name: Authenticate to Azure uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 with: @@ -72,28 +154,157 @@ jobs: - name: Validate DLL signatures shell: pwsh run: | - $dlls = Get-ChildItem -Path "${{ github.workspace }}\src\bin\${{ env.BUILD_CONFIGURATION }}" -Filter "*.dll" -Recurse + $dlls = @(Get-ChildItem -Path "${{ github.workspace }}\src\bin\${{ env.BUILD_CONFIGURATION }}" -Filter '*.dll' -Recurse -File) + if ($dlls.Count -eq 0) { + throw "No DLLs found to validate." + } + + $allowedFingerprints = @( + Get-Content -LiteralPath $env:EXPECTED_CERT_SHA256_PATH | + ForEach-Object { $_.Trim().ToUpperInvariant() } | + Where-Object { $_ -and -not $_.StartsWith('#') } + ) + if ($allowedFingerprints.Count -eq 0 -or @($allowedFingerprints | Where-Object { $_ -notmatch '^[0-9A-F]{64}$' }).Count -gt 0) { + throw "$($env:EXPECTED_CERT_SHA256_PATH) must contain at least one valid SHA-256 certificate fingerprint." + } + $failed = @() + $fingerprints = @{} + $signerNames = @{} foreach ($dll in $dlls) { $sig = Get-AuthenticodeSignature $dll.FullName if ($sig.Status -ne 'Valid') { - $failed += $dll.FullName + $failed += "$($dll.FullName): signature status $($sig.Status)." + continue } + + $cn = $sig.SignerCertificate.GetNameInfo('SimpleName', $false) + $fingerprint = [Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($sig.SignerCertificate.RawData) + ) + if ($fingerprint -notin $allowedFingerprints) { + $failed += "$($dll.FullName): certificate SHA-256 fingerprint $fingerprint is not approved by '$($env:EXPECTED_CERT_SHA256_PATH)'." + continue + } + + $fingerprints[$fingerprint] = $true + $signerNames[$cn] = $true } if ($failed.Count -gt 0) { - Write-Error "Unsigned DLLs found:`n$($failed -join "`n")" - exit 1 + throw "Authenticode validation failed:`n$($failed -join "`n")" } - Write-Host "All DLLs signed successfully." + Write-Host "All $($dlls.Count) DLLs signed by an approved certificate." + @( + "### Authenticode signer", + "- Subject CN: $($signerNames.Keys -join ', ')", + "- SHA-256 fingerprint: $($fingerprints.Keys -join ', ')" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + - name: Upload signed assemblies + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signed-assemblies + path: | + src/bin/** + src/obj/** + src/wwwroot/** + include-hidden-files: true + retention-days: 1 + if-no-files-found: error + + pack: + name: Pack and sign package + needs: sign-assemblies + runs-on: windows-latest + timeout-minutes: 20 + environment: nuget-org-publish + permissions: + contents: read + id-token: write + outputs: + nupkg-sha256: ${{ steps.digest.outputs.nupkg-sha256 }} + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Download signed assemblies + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: signed-assemblies + path: src + digest-mismatch: error + + # Repository url and commit are passed explicitly: 0.1.1 shipped a nuspec carrying a commit + # but no url, which left consumers unable to reach the source for the version they restored. - name: Pack NuGet package - run: dotnet pack ./src/IgniteUI.Blazor.Lite.csproj --no-build --configuration Release --output ./nupkg /p:Version=${{ env.VERSION }} + run: > + dotnet pack ./src/IgniteUI.Blazor.Lite.csproj + --configuration ${{ env.BUILD_CONFIGURATION }} + --no-build + --no-restore + -p:PackageVersion=${{ env.VERSION }} + -p:RepositoryUrl=${{ env.REPOSITORY_URL }} + -p:RepositoryType=git + -p:RepositoryCommit=${{ github.sha }} + -o "${{ github.workspace }}/artifacts" + + - name: Validate packaged assembly strong names + shell: pwsh + run: | + $packagePath = "${{ github.workspace }}\artifacts\${env:PACKAGE_ID}.${env:VERSION}.nupkg" + $validationRoot = "${{ runner.temp }}\strong-name-validation" + $archivePath = "$validationRoot\package.zip" + $extractPath = "$validationRoot\package" + + try { + if (-not (Test-Path $packagePath)) { + throw "NuGet package not found: $packagePath" + } + + New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null + Copy-Item $packagePath $archivePath + Expand-Archive -Path $archivePath -DestinationPath $extractPath -Force + + .github/scripts/verify-strong-name.ps1 -Path $extractPath -ExpectedPublicKeyPath eng/IG.publickey.hex + } + finally { + Remove-Item $validationRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + - name: Validate package provenance metadata + shell: pwsh + run: > + .github/scripts/Assert-NuspecRepository.ps1 + -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -ExpectedRepositoryUrl "${{ env.REPOSITORY_URL }}" + -ExpectedCommit "${{ github.sha }}" + -ExpectedPackageId "${{ env.PACKAGE_ID }}" + -ExpectedVersion "${{ env.VERSION }}" + + - name: Restore .NET local tools + run: dotnet tool restore + + - name: Authenticate to Azure + uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Sign NuGet package shell: pwsh run: > dotnet tool run sign code azure-key-vault "*.nupkg" - --base-directory "${{ github.workspace }}/nupkg" + --base-directory "${{ github.workspace }}/artifacts" --azure-key-vault-url "${{ secrets.AZURE_KEYVAULT_URL }}" --azure-key-vault-certificate "${{ secrets.AZURE_KEYVAULT_CERTIFICATE }}" --azure-credential-type azure-cli @@ -101,7 +312,274 @@ jobs: --verbosity Warning - name: Validate NuGet package signature - run: dotnet nuget verify "${{ github.workspace }}/nupkg/IgniteUI.Blazor.Lite.${{ env.VERSION }}.nupkg" -v q + run: dotnet nuget verify "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" --verbosity quiet + + - name: Record package digest + id: digest + shell: pwsh + run: | + $name = "${env:PACKAGE_ID}.${env:VERSION}.nupkg" + $package = "${{ github.workspace }}\artifacts\$name" + $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() + + "$digest $name" | Set-Content -LiteralPath "$package.sha256" -Encoding ascii + "nupkg-sha256=$digest" | Add-Content -LiteralPath $env:GITHUB_OUTPUT + @("### Signed package digest", '```', "$digest $name", '```') | + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + - name: Upload signed package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nupkg-signed + path: artifacts/* + retention-days: 30 + if-no-files-found: error + + # Measures the assets that were actually built, rather than a rebuild of them. + evidence: + name: Collect release evidence + needs: build + runs-on: windows-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-output + path: src + digest-mismatch: error + + - name: Check bundle size budget + shell: pwsh + run: ./eng/Check-BundleBudget.ps1 + + - name: Upload release evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-evidence + path: artifacts/perf/* + retention-days: 30 + if-no-files-found: error + + sbom: + name: Generate SBOM and attest + needs: pack + runs-on: windows-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + attestations: write + outputs: + attested-sha256: ${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + package-manager-cache: false + + - name: Download signed package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nupkg-signed + path: artifacts + + - name: Verify signed package digest + shell: pwsh + env: + EXPECTED_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} + run: | + $package = "${{ github.workspace }}\artifacts\${env:PACKAGE_ID}.${env:VERSION}.nupkg" + $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() + + if ($digest -ne $env:EXPECTED_NUPKG_SHA256) { + throw "Package digest changed between jobs. Expected $($env:EXPECTED_NUPKG_SHA256) but found $digest." + } + + Write-Host "Verified package digest $digest." + + - name: Restore .NET dependencies + run: dotnet restore ./src/IgniteUI.Blazor.Lite.csproj + + # sbom-tool's component detector needs node_modules to see the JavaScript dependency graph too. + - name: Restore JavaScript dependencies + run: npm ci --ignore-scripts + + # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore' used by the sign steps. + - name: Restore sbom-tool (pinned) + run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json + + # -b is the signed package folder, so the shipped nupkg and its hash land in the SBOM's files + # section. -bc is the repository root because the npm graph lives there alongside the .NET + # project; -li/-pm resolve license and supplier metadata. + - name: Generate SBOMs + working-directory: .config/sbom-tool + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + $formats = @( + @{ Version = 'SPDX:2.2'; Output = 'spdx-2.2' }, + @{ Version = 'SPDX:3.0'; Output = 'spdx-3.0' } + ) + + foreach ($format in $formats) { + $manifestDirectory = Join-Path $env:GITHUB_WORKSPACE "sbom\$($format.Output)" + # sbom-tool requires the manifest directory to exist before generation. + New-Item -ItemType Directory -Path $manifestDirectory -Force | Out-Null + + Write-Host "Generating $($format.Version) SBOM..." + dotnet tool run sbom-tool -- generate ` + -b "$env:GITHUB_WORKSPACE\artifacts" ` + -bc "$env:GITHUB_WORKSPACE" ` + -m $manifestDirectory ` + -pn $env:PACKAGE_ID ` + -pv $env:VERSION ` + -ps Infragistics ` + -nsb http://spdx.org/spdxdocs/IgniteUI.Blazor.Lite ` + -mi $format.Version ` + -li true ` + -lto $env:SBOM_LICENSE_TIMEOUT_SECONDS ` + -pm true ` + -V Information + } + + - name: Verify SBOM output + shell: pwsh + run: | + $name = "${env:PACKAGE_ID}.${env:VERSION}.nupkg" + $spdx22 = "${{ github.workspace }}\sbom\spdx-2.2\_manifest\spdx_2.2\manifest.spdx.json" + $spdx30 = "${{ github.workspace }}\sbom\spdx-3.0\_manifest\spdx_3.0\manifest.spdx.json" + + foreach ($manifestPath in @($spdx22, $spdx30)) { + if (-not (Test-Path -LiteralPath $manifestPath) -or (Get-Item -LiteralPath $manifestPath).Length -eq 0) { + throw "SBOM manifest missing or empty: $manifestPath" + } + } + + $spdx = Get-Content -LiteralPath $spdx22 -Raw | ConvertFrom-Json + if (-not ($spdx.files | Where-Object { $_.fileName -like "*$name" })) { + throw "The SPDX 2.2 document does not reference $name." + } + + Write-Host "SBOM covers $($spdx.packages.Count) packages and $($spdx.files.Count) files." + + - name: Reverify package before attestation + id: verify-before-attestation + shell: pwsh + env: + EXPECTED_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} + run: | + $package = "${{ github.workspace }}\artifacts\${env:PACKAGE_ID}.${env:VERSION}.nupkg" + $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() + + if ($digest -ne $env:EXPECTED_NUPKG_SHA256) { + throw "Package changed before attestation. Expected $($env:EXPECTED_NUPKG_SHA256) but found $digest." + } + + "nupkg-sha256=$digest" | Add-Content -LiteralPath $env:GITHUB_OUTPUT + Write-Host "Verified package digest $digest immediately before attestation." + + - name: Attest build provenance + id: attest-provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg + subject-digest: sha256:${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} + + # actions/attest derives the predicate from an SPDX 2.x or CycloneDX document; SPDX 3.0 ships as evidence only. + - name: Attest SBOM + id: attest-sbom + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg + subject-digest: sha256:${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} + sbom-path: sbom/spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json + + - name: Collect attestation bundles + shell: pwsh + run: | + $target = "${{ github.workspace }}\sbom\attestations" + New-Item -ItemType Directory -Path $target -Force | Out-Null + Copy-Item "${{ steps.attest-provenance.outputs.bundle-path }}" (Join-Path $target 'provenance.sigstore.json') + Copy-Item "${{ steps.attest-sbom.outputs.bundle-path }}" (Join-Path $target 'sbom.sigstore.json') + + @( + "### Attestations", + "- Provenance: ${{ steps.attest-provenance.outputs.attestation-url }}", + "- SBOM: ${{ steps.attest-sbom.outputs.attestation-url }}" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + - name: Upload SBOM and attestations + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom + path: sbom/** + retention-days: 30 + if-no-files-found: error + + # The only job that can publish. It compiles nothing and never checks out the repository. + publish: + name: Publish to NuGet.org + needs: [pack, evidence, sbom] + runs-on: windows-latest + timeout-minutes: 15 + environment: nuget-org-publish + permissions: + id-token: write + + steps: + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Download signed package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nupkg-signed + path: artifacts + + - name: Verify the package that was packed, signed, and attested + shell: pwsh + env: + PACK_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} + ATTESTED_NUPKG_SHA256: ${{ needs.sbom.outputs.attested-sha256 }} + run: | + $package = "${{ github.workspace }}\artifacts\${env:PACKAGE_ID}.${env:VERSION}.nupkg" + $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() + + foreach ($expected in @($env:PACK_NUPKG_SHA256, $env:ATTESTED_NUPKG_SHA256)) { + if ($digest -ne $expected) { + throw "Refusing to publish: expected digest $expected but found $digest." + } + } + + Write-Host "Publishing package with digest $digest." + + - name: Validate NuGet package signature + run: dotnet nuget verify "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" --verbosity quiet - name: NuGet login (OIDC Trusted Publishing) uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 @@ -110,4 +588,51 @@ jobs: user: ${{ secrets.INFRAGISTICS_NUGET_ORG_USER }} - name: Publish to NuGet.org - run: dotnet nuget push "${{ github.workspace }}/nupkg/IgniteUI.Blazor.Lite.${{ env.VERSION }}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" + run: dotnet nuget push "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" + + attach-to-release: + name: Attach release evidence + needs: [pack, evidence, sbom, publish] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + + steps: + - name: Download signed package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nupkg-signed + path: artifacts + + - name: Download SBOM and attestations + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sbom + path: sbom + + - name: Download release evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-evidence + path: evidence + + - name: Attach evidence to the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + (cd sbom/spdx-2.2/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-2.2.zip" .) + (cd sbom/spdx-3.0/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-3.0.zip" .) + + gh release upload "$TAG" --clobber -R "${{ github.repository }}" \ + "artifacts/${PACKAGE_ID}.${TAG}.nupkg" \ + "artifacts/${PACKAGE_ID}.${TAG}.nupkg.sha256" \ + "${PACKAGE_ID}.${TAG}.spdx-2.2.zip" \ + "${PACKAGE_ID}.${TAG}.spdx-3.0.zip" \ + "sbom/attestations/provenance.sigstore.json" \ + "sbom/attestations/sbom.sigstore.json" \ + "evidence/performance-report.md" \ + "evidence/performance-report.json" diff --git a/.gitignore b/.gitignore index 38227051..b9090be5 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,9 @@ src/wwwroot/ bin obj +# Release evidence produced by eng/Check-BundleBudget.ps1 and the release workflow +artifacts/ + .vs .tfignore *.csproj.user diff --git a/CHANGELOG.md b/CHANGELOG.md index de7eb98a..34ba30c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Every release now publishes an SPDX 2.2 and SPDX 3.0 SBOM covering the resolved NuGet and npm dependency graphs, plus Sigstore build-provenance and SBOM attestations bound to the SHA-256 digest of the signed package. All of it is attached to the GitHub release next to the package and its checksum. Verify with `gh attestation verify .nupkg -R IgniteUI/igniteui-blazor`. +- Published [accessibility conformance](docs/accessibility-conformance.md), [performance](docs/performance.md), and [nullable migration](docs/nullable-migration-plan.md) documents. +- Bundle size budgets in `eng/bundle-budgets.json`, enforced during the release. An asset that grows past its budget, or a new asset nobody budgeted for, fails the release. + +### Changed + +- **Breaking:** shipped assemblies are now strong-name signed. This changes the assembly identity, so `PublicKeyToken` moves from null to `7dd5c3163f2cd0cb`. Projects with binding redirects or an explicit fully-qualified assembly reference to `IgniteUI.Blazor.Lite` need updating. +- The release workflow is split into isolated build, signing, packaging, evidence, SBOM, publish, and release-attachment jobs with least-privilege permissions and digest-verified handoffs between them. The strong-name key, the Key Vault credential, and the publish credential are no longer available to the same job. +- Authenticode signatures are now validated against a repository-pinned certificate fingerprint allowlist (`eng/IG.authenticode-certificates.sha256`) rather than only checking that a signature is valid. + +### Fixed + +- The package's `.nuspec` now carries the repository URL alongside the commit, and both are asserted against the released tag before the package is signed. `0.1.1` shipped a `` element with a commit but no URL, which left consumers unable to reach the source for the version they restored. +- `` is now set explicitly, so the package no longer reports its own package id as its author. + ## 0.1.0 - 2026-07-14 This release updates the Ignite UI for Blazor to the latest [igniteui-webcomponents@7.2.4 release](https://github.com/IgniteUI/igniteui-webcomponents/releases/tag/7.2.4) and matching related changes from `IgniteUI.Blazor` [25.2.77 (March 2026)](https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/general-changelog-dv-blazor#25277-march-2026), [25.2.102 (May 2026)](https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/general-changelog-dv-blazor#252102-may-2026) and [26.1.51 (June 2026)](https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/general-changelog-dv-blazor#26151-june-2026) with highlights noted below: diff --git a/README.md b/README.md index 19fa9ae8..2d2fc105 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,19 @@ After the above steps, open the solution in Visual Studio or run the stories pro dotnet run --project stories/IgniteUI.Blazor.Stories.csproj ``` +## Supply chain, accessibility and performance + +Every release publishes an SPDX 2.2 and SPDX 3.0 SBOM, a Sigstore build-provenance attestation, and an SBOM attestation, all bound to the SHA-256 digest of the signed package that was pushed to NuGet.org. They are attached to the corresponding [GitHub release](https://github.com/IgniteUI/igniteui-blazor/releases) alongside the package and its checksum. To verify a package you downloaded: + +```bash +gh attestation verify IgniteUI.Blazor.Lite..nupkg -R IgniteUI/igniteui-blazor +dotnet nuget verify IgniteUI.Blazor.Lite..nupkg +``` + +- [Accessibility conformance](docs/accessibility-conformance.md) — the WCAG 2.2 AA claim, its scope, how it is verified, and the known unfixed failures. +- [Performance targets and measurements](docs/performance.md) — the enforced bundle size budgets and the runtime targets. +- [Nullable migration plan](docs/nullable-migration-plan.md) — why the library ships without nullability annotations and the staged plan to change that. + [Dock Manager]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/layouts/dock-manager [Commercial]: https://www.infragistics.com/legal/license [MIT]: https://github.com/IgniteUI/igniteui-blazor/blob/master/LICENSE diff --git a/docs/accessibility-conformance.md b/docs/accessibility-conformance.md new file mode 100644 index 00000000..d4e49ffb --- /dev/null +++ b/docs/accessibility-conformance.md @@ -0,0 +1,62 @@ +# Accessibility conformance + +This document records the accessibility conformance claim for `IgniteUI.Blazor.Lite`, the scope that claim covers, how it is verified, and which failures are known and unfixed at the time of writing. + +It is deliberately specific about what has and has not been verified. A conformance document that implies more testing than was performed is worse than no document, because a consumer who needs the claim cannot tell which parts to trust. + +## Conformance claim + +`IgniteUI.Blazor.Lite` targets **WCAG 2.2 Level AA**. + +The components in this package are thin .NET wrappers around the custom elements published by [`igniteui-webcomponents`](https://github.com/IgniteUI/igniteui-webcomponents). Roles, names, states, keyboard interaction, and focus management are implemented in the underlying web component; the wrapper's accessibility responsibility is to pass parameters through faithfully and to avoid breaking the element's own contract. Accessibility defects therefore usually belong to one of two places, and this document distinguishes them. + +## Scope + +| Dimension | Covered | +| --- | --- | +| Components | Every `Igb*` component exported from `IgniteUI.Blazor.Controls` | +| Render modes | Interactive Server and Interactive WebAssembly | +| Browsers | Chromium, Firefox, and WebKit current stable | +| Assistive technology | NVDA, JAWS, and VoiceOver — see the smoke matrix below | + +Static server rendering is explicitly **out of scope for the conformance claim**. Components in that mode render as unupgraded custom elements with no interactive behaviour and no ARIA semantics, because the custom element definitions are never executed. See [render mode support](https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/general-getting-started-blazor-web-app#add-ignite-ui-for-blazor-component) for the supported configurations. + +## Contributor requirements + +[`CONTRIBUTING.md`](../.github/CONTRIBUTING.md) requires every contributor to implement and test against Section 508, WCAG, WAI-ARIA, and full keyboard navigation, and the pull request template carries an explicit accessibility verification checkbox. That is the standing bar for new work in this repository. + +## Verification method + +Three layers, with different cadences: + +1. **Automated scanning.** An axe-core scan runs over every component in the Playwright integration suite, asserting the `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, and `wcag22aa` rule sets. It gates pull requests and the release, and the resulting report is attached to the GitHub release as evidence. +2. **Keyboard operation.** Covered by the same suite: tab order, roving tab stops, arrow-key navigation, activation, and focus restoration. +3. **Screen reader smoke testing.** Manual, once per major release, against the matrix below. + +Automated scanning catches roughly the subset of WCAG that is machine-checkable. It is a regression net, not a conformance proof — the once-per-major manual assessment is what substantiates the AA claim. + +### Status of the automation + +**The axe-core scan and the keyboard suite described above are not yet in place.** They are being implemented on a separate branch against the existing Playwright integration suite in [`tests/IgniteUI.Blazor.Lite.IntegrationTests`](../tests/IgniteUI.Blazor.Lite.IntegrationTests). Until that lands, layers 1 and 2 are a documented commitment rather than an enforced gate, and no per-release scan artefact exists. This section is written in the present tense for the process that is being built; the gap is stated here rather than papered over. + +### Screen reader smoke matrix + +| Screen reader | Browser | Platform | Last recorded run | +| --- | --- | --- | --- | +| NVDA | Chrome | Windows 11 | not yet recorded | +| JAWS | Chrome | Windows 11 | not yet recorded | +| VoiceOver | Safari | macOS | not yet recorded | + +The first recorded run lands with the next release. Rows are filled in with the release version and date the run was performed against; a row that says "not yet recorded" means exactly that. + +## Known failures + +Accepted, unfixed accessibility defects at Level AA are listed here for as long as they remain unfixed. + +| Issue | Component | Summary | Origin | +| --- | --- | --- | --- | +| [#336](https://github.com/IgniteUI/igniteui-blazor/issues/336) | `IgbRadioGroup` / `IgbRadio` | A radio rendered after the group has upgraded is not adopted into the group: it receives no group name, leaves a second tabbable element in the group, and is excluded from arrow-key reconciliation. Keyboard operation, focus order, and the group relationship exposed to assistive technology all degrade together. Binding a value repairs the duplicate checked state but not membership, name, or arrow navigation. | Upstream `igniteui-webcomponents` | + +## Reporting an accessibility problem + +Open an issue at [github.com/IgniteUI/igniteui-blazor/issues](https://github.com/IgniteUI/igniteui-blazor/issues) describing the component, the render mode, the assistive technology and browser, and the expected versus observed behaviour. If the defect is in the underlying custom element it will be reproduced against [`igniteui-webcomponents`](https://github.com/IgniteUI/igniteui-webcomponents) and tracked there, with the tracking issue linked back into the table above. diff --git a/docs/nullable-migration-plan.md b/docs/nullable-migration-plan.md new file mode 100644 index 00000000..201b54bc --- /dev/null +++ b/docs/nullable-migration-plan.md @@ -0,0 +1,45 @@ +# Nullable reference type migration plan + +`src/IgniteUI.Blazor.Lite.csproj` sets `disable`, overriding the repository-wide `enable` in [`Directory.Build.props`](../Directory.Build.props). This document is the accepted plan for removing that override, and the record of why it exists in the meantime. + +Tracked in [#347](https://github.com/IgniteUI/igniteui-blazor/issues/347). + +## Current state + +Everything else in the repository — tests, the stories host, the test bed — compiles with nullable analysis enabled. Only the shipped library opts out. + +The component wrappers under `src/components/Blazor` were carried forward from the pre-open-source `IgniteUI.Blazor` codebase, which predates nullable reference types. They are hand-maintained rather than generated, so there is no generator to teach and no regeneration that fixes them: annotating them means editing them. There are over a hundred such files, and turning the flag on today produces thousands of warnings across a public API surface. + +## Consequence for consumers + +The package's public API ships without nullability annotations. A consumer compiling with nullable enabled sees the library's reference types as *oblivious* — neither nullable nor non-nullable — so the compiler will not warn them about passing `null` to a parameter that does not accept it, nor about dereferencing a return value that may be `null`. + +This is a real gap in the API contract and the reason the flag is worth turning on, not merely a build-log annoyance. + +## Why not simply enable it + +Two reasons, and only the second is about effort. + +Enabling nullable analysis on a public API is an API change. Annotating a parameter as non-nullable makes previously-accepted `null` a warning at every call site, and annotating a return as nullable makes previously-clean consumer code warn. Done in one commit across the whole surface, that is a large and untestable diff arriving in a single release. Done wrongly — annotating for what makes the warnings go away rather than for what the code actually permits — it bakes an incorrect contract into the package that is then itself a breaking change to correct. + +The second reason is that the warnings are not uniformly interesting. A large fraction come from a small number of patterns in the shared base classes, and fixing those first shrinks the remainder substantially. Ordering the work matters. + +## Plan + +The migration is staged per folder, enabling `#nullable enable` at file scope so that each stage is independently reviewable and independently revertable. The project-level `disable` stays until the last stage lands. + +1. **Interop and serialization core** — `src/componentsBase/*.cs`. The base classes, the renderer, the serializer, and the data adapters. This is where the nullability contract actually lives; most component-level warnings are downstream of decisions made here. +2. **Input infrastructure** — `src/componentsBase/WebInputs/*.cs`. +3. **Component wrappers** — `src/components/Blazor`, in alphabetical batches sized to a reviewable pull request. Public parameters and event callbacks are annotated to match the behaviour of the underlying custom element, not to silence the compiler. +4. **Flip the project** — remove the `disable` override and, in the same change, add `Nullable` so the state cannot regress. + +## Acceptance criteria + +- Every stage builds clean on `net8.0`, `net9.0` and `net10.0` with no new suppressions beyond a documented, justified `!` at a genuine interop boundary. +- No `#pragma warning disable` for nullable warnings survives into the shipped source. +- The public API surface is annotated to reflect what the component actually accepts and returns. +- After stage 4, `enable` is inherited from `Directory.Build.props` and the override is gone. + +## Interim commitment + +Until stage 4 lands, new files added to the library carry `#nullable enable` at file scope so the annotated surface only grows. This is the practical half of the plan: it prevents the backlog from getting larger while the existing backlog is worked through. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 00000000..c87c6ef9 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,65 @@ +# Performance targets and measurements + +This document is the published performance budget for `IgniteUI.Blazor.Lite`. It exists so that a size or latency regression is a decision somebody makes on the record, rather than something a consumer discovers after upgrading. + +Budgets are enforced, not aspirational: [`eng/bundle-budgets.json`](../eng/bundle-budgets.json) holds the numbers and [`eng/Check-BundleBudget.ps1`](../eng/Check-BundleBudget.ps1) fails the release when an asset exceeds one. The `evidence` job of the release workflow runs that check against the assets that were actually built and attaches `performance-report.md` and `performance-report.json` to the GitHub release. + +## Scope + +These budgets cover the static web assets the package ships under `_content/IgniteUI.Blazor`. They do not cover the consuming application's own bundle, the Blazor framework payload, or the .NET runtime download in a WebAssembly host — those are outside anything this package controls. + +## Asset size budgets + +Measured on 2026-08-27 from a production webpack build (`npm run build` followed by `npm run copythemes`) on Node 22, against `igniteui-webcomponents` 7.2.4. + +| Group | Measured raw KiB | Raw budget | Measured gzip KiB | Gzip budget | +| --- | ---: | ---: | ---: | ---: | +| `loader` | 2.5 | 8 | 1.0 | 4 | +| `app` | 408.8 | 460 | 103.8 | 118 | +| `web-components-core` | 272.2 | 310 | 34.5 | 40 | +| `web-components` | 1912.4 | 2100 | 255.6 | 285 | +| `lazy-chunks` | 88.2 | 120 | 27.8 | 40 | +| `license-notices` | 0.7 | 8 | n/a | n/a | +| `source-maps` | 6119.1 | 6900 | n/a | n/a | +| `themes` | 312.8 | 345 | 30.9 | 36 | + +| Total | Measured raw KiB | Raw budget | Measured gzip KiB | Gzip budget | +| --- | ---: | ---: | ---: | ---: | +| `served-javascript` | 2684.2 | 2950 | 422.6 | 470 | +| `served-assets` | 2997.0 | 3300 | 453.5 | 510 | +| `package-static-web-assets` | 9116.8 | 10240 | n/a | n/a | + +Bundle filenames are content-hashed, so budgets are expressed as patterns rather than filenames. Every produced file must match exactly one group — an asset that matches none fails the check, so a new bundle cannot enter the package without someone budgeting for it. + +Two things worth knowing about these numbers: + +- Source maps are two thirds of the package on disk but are never requested unless a developer opens devtools, so they carry a raw budget and no gzip budget. `served-assets` is the number that describes what a user actually downloads. +- Themes ship as eight prebuilt stylesheets and a consumer references one of them, so the `themes` figure is the whole set, not the per-page cost. + +## Runtime targets + +The following targets apply to the reference scenario — the Interactive Server test bed in [`tests/IgniteUI.Blazor.Lite.TestBed`](../tests/IgniteUI.Blazor.Lite.TestBed) rendering a single component on a warm server over a local connection, measured on the CI runner class. + +| Metric | Target | +| --- | --- | +| Time from `blazor.web.js` start to the package's JS initializer resolving | < 250 ms | +| First component upgrade (custom element defined and rendered) after initializer | < 150 ms | +| Property write from .NET to reflected DOM state | < 50 ms | +| User interaction to `EventCallback` invocation on the server | < 100 ms plus circuit round-trip | + +**These runtime targets are published but not yet measured per release.** The bundle-size half of this budget is enforced today; the timing harness that produces the runtime half is tracked separately and lands with the accessibility automation. Until it does, treat the table above as the committed target and the absence of a recorded measurement as a known gap rather than a passing result. + +## Changing a budget + +Raising a budget is allowed and sometimes correct — a new component or an upstream `igniteui-webcomponents` release legitimately adds bytes. What is not allowed is raising it silently. Update the number in `eng/bundle-budgets.json`, update the measured column in this table, and say why in the changelog entry for the release that carries the increase. + +## Reproducing locally + +```pwsh +npm ci +npm run build +npm run copythemes +./eng/Check-BundleBudget.ps1 +``` + +The report is written to `artifacts/perf/`. Pass `-ReportOnly` to measure without failing, which is what you want when reseeding budgets after an intentional increase. diff --git a/eng/Check-BundleBudget.ps1 b/eng/Check-BundleBudget.ps1 new file mode 100644 index 00000000..0321ac5b --- /dev/null +++ b/eng/Check-BundleBudget.ps1 @@ -0,0 +1,228 @@ +<# +.SYNOPSIS + Measures the shipped static web assets and enforces the budgets in eng/bundle-budgets.json. + +.DESCRIPTION + Produces the performance evidence the release attaches (PERF-09, PERF-10) and fails the build + when an asset grows past its recorded budget. Bundle filenames are content-hashed, so budgets + are expressed as patterns and every produced file must match exactly one group: an asset nobody + budgeted for is a failure, not a silent addition. + +.PARAMETER ReportOnly + Measure and write the report without failing on a breach. Use when reseeding budgets locally. +#> +[CmdletBinding()] +param( + [string]$BudgetPath = "$PSScriptRoot/bundle-budgets.json", + + [string]$Root, + + [string]$OutputDirectory, + + [switch]$ReportOnly +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $BudgetPath -PathType Leaf)) { + throw "Budget file not found: $BudgetPath" +} + +$repositoryRoot = (Resolve-Path -LiteralPath "$PSScriptRoot/..").ProviderPath +$budget = Get-Content -LiteralPath $BudgetPath -Raw | ConvertFrom-Json + +if (-not $Root) { $Root = Join-Path $repositoryRoot $budget.root } +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $repositoryRoot 'artifacts/perf' } + +if (-not (Test-Path -LiteralPath $Root -PathType Container)) { + throw "Asset root '$Root' does not exist. Run 'npm run build' and 'npm run copythemes' first." +} + +function Measure-GzipLength([string]$FilePath) { + $bytes = [System.IO.File]::ReadAllBytes($FilePath) + $buffer = New-Object System.IO.MemoryStream + try { + $gzip = New-Object System.IO.Compression.GZipStream($buffer, [System.IO.Compression.CompressionLevel]::Optimal, $true) + try { + $gzip.Write($bytes, 0, $bytes.Length) + } + finally { + $gzip.Dispose() + } + + return $buffer.Length + } + finally { + $buffer.Dispose() + } +} + +$rootPath = (Resolve-Path -LiteralPath $Root).ProviderPath +$files = @(Get-ChildItem -LiteralPath $rootPath -Recurse -File) +if ($files.Count -eq 0) { + throw "No files found under '$rootPath'. Refusing to report a passing budget for an empty build." +} + +$measurements = @() +$unmatched = @() +foreach ($file in $files) { + $relativePath = $file.FullName.Substring($rootPath.Length).TrimStart('\', '/').Replace('\', '/') + + # First match wins, so eng/bundle-budgets.json orders specific patterns before catch-alls. + $group = $budget.groups | Where-Object { + $pattern = $_.include | Where-Object { $relativePath -like $_ } + $null -ne $pattern + } | Select-Object -First 1 + + if ($null -eq $group) { + $unmatched += $relativePath + continue + } + + $measurements += [pscustomobject]@{ + Path = $relativePath + Group = $group.id + RawBytes = $file.Length + GzipBytes = Measure-GzipLength $file.FullName + } +} + +function ConvertTo-KiB([long]$Bytes) { + return [math]::Round($Bytes / 1KB, 1) +} + +$problems = @() +if ($unmatched.Count -gt 0) { + $problems += "These assets match no budget group in $([System.IO.Path]::GetFileName($BudgetPath)); add a group for them: $($unmatched -join ', ')" +} + +$groupResults = @() +foreach ($group in $budget.groups) { + $groupFiles = @($measurements | Where-Object { $_.Group -eq $group.id }) + $raw = ($groupFiles | Measure-Object -Property RawBytes -Sum).Sum + $gzip = ($groupFiles | Measure-Object -Property GzipBytes -Sum).Sum + if ($null -eq $raw) { $raw = 0 } + if ($null -eq $gzip) { $gzip = 0 } + + $result = [pscustomobject]@{ + Id = $group.id + Description = $group.description + FileCount = $groupFiles.Count + RawKiB = ConvertTo-KiB $raw + MaxRawKiB = $group.maxRawKiB + GzipKiB = ConvertTo-KiB $gzip + MaxGzipKiB = $group.maxGzipKiB + Files = @($groupFiles | ForEach-Object { $_.Path }) + } + $groupResults += $result + + if ($result.RawKiB -gt $group.maxRawKiB) { + $problems += "Group '$($group.id)' is $($result.RawKiB) KiB raw, over its $($group.maxRawKiB) KiB budget." + } + if ($null -ne $group.maxGzipKiB -and $result.GzipKiB -gt $group.maxGzipKiB) { + $problems += "Group '$($group.id)' is $($result.GzipKiB) KiB gzipped, over its $($group.maxGzipKiB) KiB budget." + } +} + +$totalResults = @() +foreach ($total in $budget.totals) { + $included = if ($total.groups -contains '*') { $measurements } else { $measurements | Where-Object { $total.groups -contains $_.Group } } + $included = @($included) + $raw = ($included | Measure-Object -Property RawBytes -Sum).Sum + $gzip = ($included | Measure-Object -Property GzipBytes -Sum).Sum + if ($null -eq $raw) { $raw = 0 } + if ($null -eq $gzip) { $gzip = 0 } + + $result = [pscustomobject]@{ + Id = $total.id + Description = $total.description + FileCount = $included.Count + RawKiB = ConvertTo-KiB $raw + MaxRawKiB = $total.maxRawKiB + GzipKiB = ConvertTo-KiB $gzip + MaxGzipKiB = $total.maxGzipKiB + } + $totalResults += $result + + if ($result.RawKiB -gt $total.maxRawKiB) { + $problems += "Total '$($total.id)' is $($result.RawKiB) KiB raw, over its $($total.maxRawKiB) KiB budget." + } + if ($null -ne $total.maxGzipKiB -and $result.GzipKiB -gt $total.maxGzipKiB) { + $problems += "Total '$($total.id)' is $($result.GzipKiB) KiB gzipped, over its $($total.maxGzipKiB) KiB budget." + } +} + +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$reportJsonPath = Join-Path $OutputDirectory 'performance-report.json' +$reportMarkdownPath = Join-Path $OutputDirectory 'performance-report.md' + +[pscustomobject]@{ + measuredAtUtc = (Get-Date).ToUniversalTime().ToString('o') + assetRoot = $budget.root + budgetFile = (Split-Path -Leaf $BudgetPath) + passed = ($problems.Count -eq 0) + problems = $problems + groups = $groupResults + totals = $totalResults + files = @($measurements | Sort-Object -Property RawBytes -Descending | ForEach-Object { + [pscustomobject]@{ + path = $_.Path + group = $_.Group + rawKiB = ConvertTo-KiB $_.RawBytes + gzipKiB = ConvertTo-KiB $_.GzipBytes + } + }) +} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $reportJsonPath -Encoding utf8 + +$markdown = New-Object System.Collections.Generic.List[string] +$markdown.Add('## Bundle size budget') +$markdown.Add('') +$markdown.Add("Measured `$($budget.root)` on $((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')) UTC.") +$markdown.Add('') +$markdown.Add('| Total | Raw KiB | Budget | Gzip KiB | Budget |') +$markdown.Add('| --- | ---: | ---: | ---: | ---: |') +foreach ($total in $totalResults) { + $gzipBudget = if ($null -ne $total.MaxGzipKiB) { $total.MaxGzipKiB } else { 'n/a' } + $gzipValue = if ($null -ne $total.MaxGzipKiB) { $total.GzipKiB } else { 'n/a' } + $markdown.Add("| $($total.Id) | $($total.RawKiB) | $($total.MaxRawKiB) | $gzipValue | $gzipBudget |") +} +$markdown.Add('') +$markdown.Add('| Group | Files | Raw KiB | Budget | Gzip KiB | Budget |') +$markdown.Add('| --- | ---: | ---: | ---: | ---: | ---: |') +foreach ($group in $groupResults) { + $gzipBudget = if ($null -ne $group.MaxGzipKiB) { $group.MaxGzipKiB } else { 'n/a' } + $gzipValue = if ($null -ne $group.MaxGzipKiB) { $group.GzipKiB } else { 'n/a' } + $markdown.Add("| $($group.Id) | $($group.FileCount) | $($group.RawKiB) | $($group.MaxRawKiB) | $gzipValue | $gzipBudget |") +} +$markdown.Add('') +if ($problems.Count -gt 0) { + $markdown.Add('### Budget breaches') + $markdown.Add('') + foreach ($problem in $problems) { $markdown.Add("- $problem") } +} +else { + $markdown.Add('All assets are within budget.') +} + +$markdown | Set-Content -LiteralPath $reportMarkdownPath -Encoding utf8 + +if ($env:GITHUB_STEP_SUMMARY) { + $markdown | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 +} + +Write-Host "Wrote $reportJsonPath" +Write-Host "Wrote $reportMarkdownPath" +$totalResults | Format-Table -Property Id, RawKiB, MaxRawKiB, GzipKiB, MaxGzipKiB -AutoSize | Out-String | Write-Host + +if ($problems.Count -gt 0) { + $message = "Bundle size budget failed:`n- $($problems -join "`n- ")" + if ($ReportOnly) { + Write-Warning $message + } + else { + throw $message + } +} +else { + Write-Host 'All assets are within budget.' +} diff --git a/eng/IG.authenticode-certificates.sha256 b/eng/IG.authenticode-certificates.sha256 new file mode 100644 index 00000000..0d66212c --- /dev/null +++ b/eng/IG.authenticode-certificates.sha256 @@ -0,0 +1,3 @@ +# Approved Authenticode signing certificates, one SHA-256 fingerprint per line. +# Fingerprints are computed over the certificate's DER-encoded RawData. +7F0D4484D1D3C797FDC85801CACE18DB5249D61AFFB17DA5C1644D8BEA24630D diff --git a/eng/IG.publickey.hex b/eng/IG.publickey.hex new file mode 100644 index 00000000..41e48dcd --- /dev/null +++ b/eng/IG.publickey.hex @@ -0,0 +1,8 @@ +# Infragistics strong-name public key, as the raw public key blob in hex. +# +# This is public data embedded in every assembly. It is pinned here so signing with a different key +# fails the release instead of silently establishing a new binary identity. +# +# Public key token: 7dd5c3163f2cd0cb +# Re-derive with: sn -Tp +002400000480000094000000060200000024000052534131000400000100010001afa6285b0af5cdd03aa2b6fdaf33fc4759cf9cd9bcf8b778ae60b9fcf71fc8126b78dbf930519614013b7999297907dd9c00bcc487a14f4c6733fe9adb96c053f005d7148f1666fcb882a0f9ba4307c85694b3322889dab357ad5cefd72ccc45e1b6973bdd2f15b2a300077b8d9de30739200887c5407c8a68c90345cbc4f1 diff --git a/eng/bundle-budgets.json b/eng/bundle-budgets.json new file mode 100644 index 00000000..e976ff76 --- /dev/null +++ b/eng/bundle-budgets.json @@ -0,0 +1,86 @@ +{ + "root": "src/wwwroot", + "baseline": { + "measuredOn": "2026-08-27", + "toolchain": "node 22 / webpack 5 production build (npm run build + npm run copythemes)", + "note": "Budgets are the measured size plus roughly 10-15% headroom. Raise one only with a recorded reason in docs/performance.md; a bundle that grows past its budget is a product decision, not a build detail." + }, + "groups": [ + { + "id": "loader", + "description": "Blazor JS initializer and the bootstrap shim that pulls in the entry bundle.", + "include": ["app.bootstrap.js", "app.bundle.js", "IgniteUI.Blazor.Lite.lib.module.js"], + "maxRawKiB": 8, + "maxGzipKiB": 4 + }, + { + "id": "app", + "description": "Interop entry bundle. Loaded on every page that uses a component.", + "include": ["app.*.bundle.js"], + "maxRawKiB": 460, + "maxGzipKiB": 118 + }, + { + "id": "web-components-core", + "description": "igniteui-webcomponents-core chunk.", + "include": ["igniteui-webcomponents-core.*.bundle.js"], + "maxRawKiB": 310, + "maxGzipKiB": 40 + }, + { + "id": "web-components", + "description": "igniteui-webcomponents chunk. The single largest shipped asset.", + "include": ["igniteui-webcomponents.*.bundle.js"], + "maxRawKiB": 2100, + "maxGzipKiB": 285 + }, + { + "id": "lazy-chunks", + "description": "Split chunks fetched on demand.", + "include": ["*.bundle.js"], + "maxRawKiB": 120, + "maxGzipKiB": 40 + }, + { + "id": "license-notices", + "description": "Third-party license banners emitted alongside the bundles.", + "include": ["*.LICENSE.txt"], + "maxRawKiB": 8 + }, + { + "id": "source-maps", + "description": "Shipped for debuggability. Never requested unless devtools are open, so no gzip budget.", + "include": ["*.js.map"], + "maxRawKiB": 6900 + }, + { + "id": "themes", + "description": "Prebuilt component themes copied from igniteui-webcomponents. Consumers reference one.", + "include": ["themes/*"], + "maxRawKiB": 345, + "maxGzipKiB": 36 + } + ], + "totals": [ + { + "id": "served-javascript", + "description": "Everything the browser can execute.", + "groups": ["loader", "app", "web-components-core", "web-components", "lazy-chunks"], + "maxRawKiB": 2950, + "maxGzipKiB": 470 + }, + { + "id": "served-assets", + "description": "Worst case over the wire for a page using a themed component.", + "groups": ["loader", "app", "web-components-core", "web-components", "lazy-chunks", "themes"], + "maxRawKiB": 3300, + "maxGzipKiB": 510 + }, + { + "id": "package-static-web-assets", + "description": "Everything shipped under _content/IgniteUI.Blazor, including source maps.", + "groups": ["*"], + "maxRawKiB": 10240 + } + ] +} diff --git a/src/IgniteUI.Blazor.Lite.csproj b/src/IgniteUI.Blazor.Lite.csproj index aa0d7a56..13ff3e94 100644 --- a/src/IgniteUI.Blazor.Lite.csproj +++ b/src/IgniteUI.Blazor.Lite.csproj @@ -2,8 +2,8 @@ .Lite - + disable @@ -25,6 +25,7 @@ true IgniteUI.Blazor$(PackageIdSuffix) 1.0.0 + Infragistics Infragistics;IgniteUI;Blazor Infragistics https://www.infragistics.com/products/ignite-ui-blazor @@ -33,6 +34,10 @@ MIT https://github.com/IgniteUI/igniteui-blazor git + + true + true From a03082bea78eb4bcc26005fbaaae8615732da76c Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Thu, 27 Aug 2026 20:32:02 +0300 Subject: [PATCH 02/13] Further refinement of the SBOM generation --- .github/workflows/igniteui-blazor-lite-release.yml | 8 ++++++-- .gitignore | 2 +- README.md | 12 ++++++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 81e8a8bf..60c139f3 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -20,6 +20,9 @@ env: EXPECTED_CERT_SHA256_PATH: "eng/IG.authenticode-certificates.sha256" # Bound sbom-tool's external license lookup. SBOM_LICENSE_TIMEOUT_SECONDS: "180" + # A production-only component graph should be almost entirely license-resolvable. 0.1.2-alpha.0 + # shipped an SBOM at 7.5% because it was generated from a dev install against a failing API. + SBOM_MIN_DECLARED_LICENSE_PERCENT: "50" jobs: # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. @@ -421,9 +424,10 @@ jobs: - name: Restore .NET dependencies run: dotnet restore ./src/IgniteUI.Blazor.Lite.csproj - # sbom-tool's component detector needs node_modules to see the JavaScript dependency graph too. + # Production-only: the SBOM describes what the package ships, and a full install would put + # webpack, typescript and tslint in it, letting consumers attribute build-tooling CVEs to us. - name: Restore JavaScript dependencies - run: npm ci --ignore-scripts + run: npm ci --omit=dev --ignore-scripts # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore' used by the sign steps. - name: Restore sbom-tool (pinned) diff --git a/.gitignore b/.gitignore index b9090be5..edaa9482 100644 --- a/.gitignore +++ b/.gitignore @@ -148,4 +148,4 @@ artifacts/ **/tmp/* -.idea/* \ No newline at end of file +.idea/* diff --git a/README.md b/README.md index 2d2fc105..b8f2c67d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ ![Ignite UI for Blazor](https://raw.githubusercontent.com/IgniteUI/igniteui-blazor/master/images/general/Ignite-UI-for-Blazor.png) -# Ignite UI for Blazor - from Infragistics +# Ignite UI for Blazor - from Infragistics [![CI](https://github.com/IgniteUI/igniteui-blazor/actions/workflows/ci.yml/badge.svg)](https://github.com/IgniteUI/igniteui-blazor/actions/workflows/ci.yml) [![NuGet version](https://badge.fury.io/nu/IgniteUI.Blazor.Lite.svg)](https://www.nuget.org/packages/IgniteUI.Blazor.Lite) [![Discord](https://img.shields.io/discord/836634487483269200?logo=discord&logoColor=ffffff)](https://discord.com/channels/836634487483269200/836636796229386241) -[Ignite UI for Blazor] is a complete library of UI components, giving you the ability to build modern web applications using encapsulation and the concept of reusable components in a dependency-free approach. +[Ignite UI for Blazor] is a complete library of UI components, giving you the ability to build modern web applications using encapsulation and the concept of reusable components in a dependency-free approach. All components are based on the [Indigo.Design Design System](https://www.infragistics.com/products/appbuilder/ui-toolkit) and are backed by ready-to-use UI kits for Figma. ## Browser Support @@ -101,7 +101,7 @@ Provide a complete windowing experience, splitting complex layouts into smaller, ### [Ignite UI for Blazor WebAssembly](https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/general-getting-started-blazor-client) -In order to use the Ignite UI Blazor in your application you should install +In order to use the Ignite UI Blazor in your application you should install [NuGet packages](https://www.nuget.org/packages?q=IgniteUI.Blazor). There are three ways to install Ignite UI for Blazor using NuGet: @@ -221,7 +221,7 @@ dotnet nuget verify IgniteUI.Blazor.Lite..nupkg [Data Grid Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/grids/data-grid [Tree Grid Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/grids/tree-grid/overview [Hierarchical Grid Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/grids/hierarchical-grid/overview -[Grid Lite]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/grid-lite/overview +[Grid Lite]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/grid-lite/overview [Switch Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/inputs/switch [Ripple Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/inputs/ripple [Radio Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/inputs/radio @@ -268,7 +268,7 @@ dotnet nuget verify IgniteUI.Blazor.Lite..nupkg [blazor Charts & Graphs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/charts/chart-overview [Bubble charts]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/charts/types/bubble-chart [Financial/Stock charts]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/charts/types/stock-chart -[Donut charts]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/charts/types/donut-chart +[Donut charts]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/charts/types/donut-chart [Spreadsheet Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/spreadsheet-overview [Dock Manager Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/layouts/dock-manager [Toolbar Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/menus/toolbar @@ -308,7 +308,7 @@ dotnet nuget verify IgniteUI.Blazor.Lite..nupkg [Geographic Polygon Map Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/geo-map-type-shape-polygon-series [Geographic Polyline Map Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/geo-map-type-shape-polyline-series [Dashboard Tile Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/dashboard-tile -[Ignite UI for Blazor]: https://www.infragistics.com/products/ignite-ui-blazor +[Ignite UI for Blazor]: https://www.infragistics.com/products/ignite-ui-blazor [Chat Docs]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/interactivity/chat [Tile Manager]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/layouts/tile-manager [Tooltip]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/inputs/tooltip From d5b25ca7a8e78fba66278c9683ad93ad6aaf0abb Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Thu, 27 Aug 2026 20:42:28 +0300 Subject: [PATCH 03/13] Ensure we are not SBOM-ing build-time packages --- .../igniteui-blazor-lite-release.yml | 61 ++++++++++++++++++- CHANGELOG.md | 2 +- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 60c139f3..9efc022b 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -23,6 +23,9 @@ env: # A production-only component graph should be almost entirely license-resolvable. 0.1.2-alpha.0 # shipped an SBOM at 7.5% because it was generated from a dev install against a failing API. SBOM_MIN_DECLARED_LICENSE_PERCENT: "50" + # ~59 components ship today (45 NuGet, 14 npm). The ceiling catches a whole class of + # over-reporting at once; 0.1.2-alpha.0 shipped 589. Raise it only for real dependency growth. + SBOM_MAX_COMPONENTS: "150" jobs: # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. @@ -421,6 +424,9 @@ jobs: Write-Host "Verified package digest $digest." + # Restores the library alone on purpose. A bare 'dotnet restore' would write a + # project.assets.json for the test, stories and template projects too, and the NuGet detector + # reads those, putting bunit/NUnit/Playwright into an SBOM that describes a shipped package. - name: Restore .NET dependencies run: dotnet restore ./src/IgniteUI.Blazor.Lite.csproj @@ -483,11 +489,62 @@ jobs: } $spdx = Get-Content -LiteralPath $spdx22 -Raw | ConvertFrom-Json + $packages = @($spdx.packages) + $problems = @() + if (-not ($spdx.files | Where-Object { $_.fileName -like "*$name" })) { - throw "The SPDX 2.2 document does not reference $name." + $problems += "The SPDX 2.2 document does not reference $name." + } + + $buildOnly = @( + 'webpack', 'typescript', 'prettier', 'tslint', 'ts-loader', 'lint-staged', 'cross-env', 'html-webpack-plugin', + 'bunit', 'xunit', 'NUnit', 'Moq', 'coverlet.collector', 'Microsoft.NET.Test.Sdk', + 'Microsoft.Playwright.NUnit', 'Microsoft.AspNetCore.Mvc.Testing', 'BlazingStory' + ) + $leaked = @($packages | Where-Object { $buildOnly -contains $_.name } | ForEach-Object { $_.name } | Sort-Object -Unique) + if ($leaked.Count -gt 0) { + $problems += "Build-only packages are in the SBOM: $($leaked -join ', '). It must be generated from a production-only restore and install." + } + + # The blocklist only catches names someone thought of; the ceiling catches the rest. + $ceiling = [int]$env:SBOM_MAX_COMPONENTS + if ($packages.Count -gt $ceiling) { + $problems += "The SBOM lists $($packages.Count) components, over the $ceiling ceiling. Something is contributing dependencies the package does not ship." + } + + $shipped = @('igniteui-webcomponents', 'lit-html', 'Microsoft.AspNetCore.Components.Web') + $missing = @($shipped | Where-Object { $packages.name -notcontains $_ }) + if ($missing.Count -gt 0) { + $problems += "Shipped dependencies are absent from the SBOM: $($missing -join ', ')." + } + + # licenseDeclared comes from local package metadata; licenseConcluded comes from the + # external license API, so only the former is gated. An API outage is reported, not fatal. + $declared = @($packages | Where-Object { $_.licenseDeclared -and $_.licenseDeclared -ne 'NOASSERTION' }).Count + $concluded = @($packages | Where-Object { $_.licenseConcluded -and $_.licenseConcluded -ne 'NOASSERTION' }).Count + $declaredPercent = if ($packages.Count -gt 0) { [math]::Round(100 * $declared / $packages.Count, 1) } else { 0 } + $floor = [double]$env:SBOM_MIN_DECLARED_LICENSE_PERCENT + + if ($declaredPercent -lt $floor) { + $problems += "Only $declaredPercent% of $($packages.Count) components declare a license, below the $floor% floor." + } + + @( + "### SBOM", + "- Components: $($packages.Count) (ceiling $ceiling), files: $($spdx.files.Count)", + "- Declared licenses: $declared ($declaredPercent%), floor $floor%", + "- Licenses resolved from the external API: $concluded" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + if ($concluded -eq 0) { + Write-Warning "The external license API resolved nothing. Check the sbom-tool warnings above for an outage." + } + + if ($problems.Count -gt 0) { + throw "SBOM validation failed:`n- $($problems -join "`n- ")" } - Write-Host "SBOM covers $($spdx.packages.Count) packages and $($spdx.files.Count) files." + Write-Host "SBOM covers $($packages.Count) packages and $($spdx.files.Count) files; $declaredPercent% declare a license." - name: Reverify package before attestation id: verify-before-attestation diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ba30c4..7053c6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Every release now publishes an SPDX 2.2 and SPDX 3.0 SBOM covering the resolved NuGet and npm dependency graphs, plus Sigstore build-provenance and SBOM attestations bound to the SHA-256 digest of the signed package. All of it is attached to the GitHub release next to the package and its checksum. Verify with `gh attestation verify .nupkg -R IgniteUI/igniteui-blazor`. +- Every release now publishes an SPDX 2.2 and SPDX 3.0 SBOM covering the NuGet and npm dependencies the package actually ships, plus Sigstore build-provenance and SBOM attestations bound to the SHA-256 digest of the signed package. All of it is attached to the GitHub release next to the package and its checksum. Verify with `gh attestation verify .nupkg -R IgniteUI/igniteui-blazor`. - Published [accessibility conformance](docs/accessibility-conformance.md), [performance](docs/performance.md), and [nullable migration](docs/nullable-migration-plan.md) documents. - Bundle size budgets in `eng/bundle-budgets.json`, enforced during the release. An asset that grows past its budget, or a new asset nobody budgeted for, fails the release. From 69358e51fa9386d18a0dc1ad8eb95be2dde793ab Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Thu, 27 Aug 2026 22:27:19 +0300 Subject: [PATCH 04/13] Fix the sbom verification error ... I don't like it, but let's hope it works --- .../igniteui-blazor-lite-release.yml | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 9efc022b..89033916 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -20,11 +20,12 @@ env: EXPECTED_CERT_SHA256_PATH: "eng/IG.authenticode-certificates.sha256" # Bound sbom-tool's external license lookup. SBOM_LICENSE_TIMEOUT_SECONDS: "180" - # A production-only component graph should be almost entirely license-resolvable. 0.1.2-alpha.0 - # shipped an SBOM at 7.5% because it was generated from a dev install against a failing API. + # Measured on a production-only graph: 61 components, 44 of them declaring a license (72.1%). + # The floor leaves room for the npm half, which declares nothing locally and depends on the + # external API. 0.1.2-alpha.0 shipped 7.5%. SBOM_MIN_DECLARED_LICENSE_PERCENT: "50" - # ~59 components ship today (45 NuGet, 14 npm). The ceiling catches a whole class of - # over-reporting at once; 0.1.2-alpha.0 shipped 589. Raise it only for real dependency growth. + # 61 components ship today (46 NuGet, 14 npm, 1 root). The ceiling catches a whole class of + # over-reporting at once; 0.1.2-alpha.1 reached 583. Raise it only for real dependency growth. SBOM_MAX_COMPONENTS: "150" jobs: @@ -430,10 +431,19 @@ jobs: - name: Restore .NET dependencies run: dotnet restore ./src/IgniteUI.Blazor.Lite.csproj - # Production-only: the SBOM describes what the package ships, and a full install would put - # webpack, typescript and tslint in it, letting consumers attribute build-tooling CVEs to us. + # Production-only, then the lockfile goes. npm ci needs it, but sbom-tool's NpmWithRoots + # detector reads it directly, and an npm lockfile records the whole dev graph regardless of + # what is installed -- that is how 537 build-time components reached the 0.1.2-alpha.1 SBOM + # even though only 14 packages were on disk. Without it the node_modules walk is the only + # npm source, which is exactly the shipped set. - name: Restore JavaScript dependencies - run: npm ci --omit=dev --ignore-scripts + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + npm ci --omit=dev --ignore-scripts + Remove-Item package-lock.json -Force # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore' used by the sign steps. - name: Restore sbom-tool (pinned) From 1acd0b191ad2e0ca1436d15ccc44dd651cd7892e Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Thu, 27 Aug 2026 23:03:57 +0300 Subject: [PATCH 05/13] Disable license lookup (not efficient and mandatory at the moment) and refactor comments --- .github/scripts/Assert-NuspecRepository.ps1 | 14 +-- .github/scripts/verify-strong-name.ps1 | 12 +-- .../igniteui-blazor-lite-release.yml | 100 +++++++----------- eng/Check-BundleBudget.ps1 | 17 +-- 4 files changed, 46 insertions(+), 97 deletions(-) diff --git a/.github/scripts/Assert-NuspecRepository.ps1 b/.github/scripts/Assert-NuspecRepository.ps1 index d56f2057..bab3b7e3 100644 --- a/.github/scripts/Assert-NuspecRepository.ps1 +++ b/.github/scripts/Assert-NuspecRepository.ps1 @@ -1,14 +1,6 @@ -<# -.SYNOPSIS - Asserts that a packed NuGet package carries the provenance metadata consumers rely on. - -.DESCRIPTION - The nuspec is generated at pack time from MSBuild properties, so a property that is unset, - misspelled, or silently dropped by a '--no-build' pack produces a package that restores fine - but cannot be traced back to source. IgniteUI.Blazor.Lite 0.1.1 shipped exactly that way: the - element carried a commit but no url. This script turns that class of omission into - a release failure instead of a post-release finding. -#> +# Asserts a packed NuGet package carries the provenance metadata consumers rely on. +# The nuspec is generated from MSBuild properties at pack time, so an unset one yields a package that +# restores fine but cannot be traced to source -- 0.1.1 shipped a commit with no repository url. [CmdletBinding()] param( [Parameter(Mandatory)] diff --git a/.github/scripts/verify-strong-name.ps1 b/.github/scripts/verify-strong-name.ps1 index 0be52873..f60d902a 100644 --- a/.github/scripts/verify-strong-name.ps1 +++ b/.github/scripts/verify-strong-name.ps1 @@ -1,12 +1,6 @@ -<# -.SYNOPSIS - Verifies that assemblies are strong-name signed with the approved Infragistics key. - -.DESCRIPTION - 'sn.exe -vf' proves only that an assembly's strong name is internally consistent, so any valid - private key passes it. This script additionally compares each assembly's public key against a - value pinned in the repository and established out of band from the signing key. -#> +# Verifies assemblies are strong-name signed with the approved Infragistics key. +# 'sn.exe -vf' only proves a strong name is internally consistent, so any valid private key passes; +# this also compares each assembly's public key against the value pinned in the repository. [CmdletBinding()] param( [Parameter(Mandatory)] diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 89033916..08d55342 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -18,14 +18,9 @@ env: REPOSITORY_URL: https://github.com/IgniteUI/igniteui-blazor # Public, deliberately pinned identity. The strong-name counterpart lives in eng/IG.publickey.hex. EXPECTED_CERT_SHA256_PATH: "eng/IG.authenticode-certificates.sha256" - # Bound sbom-tool's external license lookup. - SBOM_LICENSE_TIMEOUT_SECONDS: "180" - # Measured on a production-only graph: 61 components, 44 of them declaring a license (72.1%). - # The floor leaves room for the npm half, which declares nothing locally and depends on the - # external API. 0.1.2-alpha.0 shipped 7.5%. + # Only NuGet components declare a license locally, so the floor sits below the measured 73.3%. SBOM_MIN_DECLARED_LICENSE_PERCENT: "50" - # 61 components ship today (46 NuGet, 14 npm, 1 root). The ceiling catches a whole class of - # over-reporting at once; 0.1.2-alpha.1 reached 583. Raise it only for real dependency growth. + # 60 components ship today. Catches over-reporting the blocklist misses; alpha.1 hit 583. SBOM_MAX_COMPONENTS: "150" jobs: @@ -425,17 +420,14 @@ jobs: Write-Host "Verified package digest $digest." - # Restores the library alone on purpose. A bare 'dotnet restore' would write a - # project.assets.json for the test, stories and template projects too, and the NuGet detector - # reads those, putting bunit/NUnit/Playwright into an SBOM that describes a shipped package. + # Restores the library alone on purpose: a bare 'dotnet restore' writes project.assets.json for + # the test, stories and template projects too, and the NuGet detector reads those. - name: Restore .NET dependencies run: dotnet restore ./src/IgniteUI.Blazor.Lite.csproj - # Production-only, then the lockfile goes. npm ci needs it, but sbom-tool's NpmWithRoots - # detector reads it directly, and an npm lockfile records the whole dev graph regardless of - # what is installed -- that is how 537 build-time components reached the 0.1.2-alpha.1 SBOM - # even though only 14 packages were on disk. Without it the node_modules walk is the only - # npm source, which is exactly the shipped set. + # Production-only, then the lockfile goes: sbom-tool's NpmWithRoots detector reads it directly + # and an npm lockfile records the whole dev graph regardless of what is installed. Without it + # the node_modules walk is the only npm source, which is exactly the shipped set. - name: Restore JavaScript dependencies shell: pwsh run: | @@ -449,9 +441,9 @@ jobs: - name: Restore sbom-tool (pinned) run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json - # -b is the signed package folder, so the shipped nupkg and its hash land in the SBOM's files - # section. -bc is the repository root because the npm graph lives there alongside the .NET - # project; -li/-pm resolve license and supplier metadata. + # -b is the signed package folder, so the shipped nupkg and its hash land in the files section. + # -bc is the repository root because the npm graph lives there alongside the .NET project. + # External license lookup (-li) is off: it cost ~4 min a run and resolved nothing. - name: Generate SBOMs working-directory: .config/sbom-tool shell: pwsh @@ -479,8 +471,6 @@ jobs: -ps Infragistics ` -nsb http://spdx.org/spdxdocs/IgniteUI.Blazor.Lite ` -mi $format.Version ` - -li true ` - -lto $env:SBOM_LICENSE_TIMEOUT_SECONDS ` -pm true ` -V Information } @@ -489,72 +479,54 @@ jobs: shell: pwsh run: | $name = "${env:PACKAGE_ID}.${env:VERSION}.nupkg" - $spdx22 = "${{ github.workspace }}\sbom\spdx-2.2\_manifest\spdx_2.2\manifest.spdx.json" - $spdx30 = "${{ github.workspace }}\sbom\spdx-3.0\_manifest\spdx_3.0\manifest.spdx.json" + $manifests = @('spdx-2.2\_manifest\spdx_2.2', 'spdx-3.0\_manifest\spdx_3.0') | + ForEach-Object { "${{ github.workspace }}\sbom\$_\manifest.spdx.json" } - foreach ($manifestPath in @($spdx22, $spdx30)) { - if (-not (Test-Path -LiteralPath $manifestPath) -or (Get-Item -LiteralPath $manifestPath).Length -eq 0) { - throw "SBOM manifest missing or empty: $manifestPath" + foreach ($path in $manifests) { + if (-not (Test-Path -LiteralPath $path) -or (Get-Item -LiteralPath $path).Length -eq 0) { + throw "SBOM manifest missing or empty: $path" } } - $spdx = Get-Content -LiteralPath $spdx22 -Raw | ConvertFrom-Json + $spdx = Get-Content -LiteralPath $manifests[0] -Raw | ConvertFrom-Json $packages = @($spdx.packages) - $problems = @() + $names = $packages.name + $ceiling = [int]$env:SBOM_MAX_COMPONENTS + $floor = [double]$env:SBOM_MIN_DECLARED_LICENSE_PERCENT - if (-not ($spdx.files | Where-Object { $_.fileName -like "*$name" })) { - $problems += "The SPDX 2.2 document does not reference $name." - } + # licenseDeclared comes from local package metadata; nothing is fetched over the network. + $declared = @($packages | Where-Object { $_.licenseDeclared -and $_.licenseDeclared -ne 'NOASSERTION' }).Count + $percent = [math]::Round(100 * $declared / [math]::Max($packages.Count, 1), 1) $buildOnly = @( 'webpack', 'typescript', 'prettier', 'tslint', 'ts-loader', 'lint-staged', 'cross-env', 'html-webpack-plugin', 'bunit', 'xunit', 'NUnit', 'Moq', 'coverlet.collector', 'Microsoft.NET.Test.Sdk', 'Microsoft.Playwright.NUnit', 'Microsoft.AspNetCore.Mvc.Testing', 'BlazingStory' ) - $leaked = @($packages | Where-Object { $buildOnly -contains $_.name } | ForEach-Object { $_.name } | Sort-Object -Unique) - if ($leaked.Count -gt 0) { - $problems += "Build-only packages are in the SBOM: $($leaked -join ', '). It must be generated from a production-only restore and install." - } - - # The blocklist only catches names someone thought of; the ceiling catches the rest. - $ceiling = [int]$env:SBOM_MAX_COMPONENTS - if ($packages.Count -gt $ceiling) { - $problems += "The SBOM lists $($packages.Count) components, over the $ceiling ceiling. Something is contributing dependencies the package does not ship." - } - + $leaked = @($buildOnly | Where-Object { $names -contains $_ }) $shipped = @('igniteui-webcomponents', 'lit-html', 'Microsoft.AspNetCore.Components.Web') - $missing = @($shipped | Where-Object { $packages.name -notcontains $_ }) - if ($missing.Count -gt 0) { - $problems += "Shipped dependencies are absent from the SBOM: $($missing -join ', ')." - } + $missing = @($shipped | Where-Object { $names -notcontains $_ }) - # licenseDeclared comes from local package metadata; licenseConcluded comes from the - # external license API, so only the former is gated. An API outage is reported, not fatal. - $declared = @($packages | Where-Object { $_.licenseDeclared -and $_.licenseDeclared -ne 'NOASSERTION' }).Count - $concluded = @($packages | Where-Object { $_.licenseConcluded -and $_.licenseConcluded -ne 'NOASSERTION' }).Count - $declaredPercent = if ($packages.Count -gt 0) { [math]::Round(100 * $declared / $packages.Count, 1) } else { 0 } - $floor = [double]$env:SBOM_MIN_DECLARED_LICENSE_PERCENT - - if ($declaredPercent -lt $floor) { - $problems += "Only $declaredPercent% of $($packages.Count) components declare a license, below the $floor% floor." - } + # The blocklist only catches names someone thought of; the ceiling catches the rest. + $problems = @( + if (-not ($spdx.files.fileName -like "*$name")) { "does not reference $name" } + if ($leaked.Count) { "contains build-only packages ($($leaked -join ', ')); generate it from a production-only restore and install" } + if ($missing.Count) { "omits shipped dependencies ($($missing -join ', '))" } + if ($packages.Count -gt $ceiling) { "lists $($packages.Count) components, over the $ceiling ceiling" } + if ($percent -lt $floor) { "declares a license for only $percent% of $($packages.Count) components, under the $floor% floor" } + ) @( "### SBOM", "- Components: $($packages.Count) (ceiling $ceiling), files: $($spdx.files.Count)", - "- Declared licenses: $declared ($declaredPercent%), floor $floor%", - "- Licenses resolved from the external API: $concluded" + "- Declared licenses: $declared ($percent%, floor $floor%)" ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY - if ($concluded -eq 0) { - Write-Warning "The external license API resolved nothing. Check the sbom-tool warnings above for an outage." - } - - if ($problems.Count -gt 0) { - throw "SBOM validation failed:`n- $($problems -join "`n- ")" + if ($problems.Count) { + throw "SBOM validation failed. The SPDX 2.2 document:`n- $($problems -join "`n- ")" } - Write-Host "SBOM covers $($packages.Count) packages and $($spdx.files.Count) files; $declaredPercent% declare a license." + Write-Host "SBOM covers $($packages.Count) components and $($spdx.files.Count) files; $percent% declare a license." - name: Reverify package before attestation id: verify-before-attestation diff --git a/eng/Check-BundleBudget.ps1 b/eng/Check-BundleBudget.ps1 index 0321ac5b..199e6a5e 100644 --- a/eng/Check-BundleBudget.ps1 +++ b/eng/Check-BundleBudget.ps1 @@ -1,16 +1,6 @@ -<# -.SYNOPSIS - Measures the shipped static web assets and enforces the budgets in eng/bundle-budgets.json. - -.DESCRIPTION - Produces the performance evidence the release attaches (PERF-09, PERF-10) and fails the build - when an asset grows past its recorded budget. Bundle filenames are content-hashed, so budgets - are expressed as patterns and every produced file must match exactly one group: an asset nobody - budgeted for is a failure, not a silent addition. - -.PARAMETER ReportOnly - Measure and write the report without failing on a breach. Use when reseeding budgets locally. -#> +# Measures the shipped static web assets against eng/bundle-budgets.json and writes the release's +# performance evidence. Bundle filenames are content-hashed, so budgets are patterns and every file +# must match exactly one group -- an asset nobody budgeted for fails rather than passing silently. [CmdletBinding()] param( [string]$BudgetPath = "$PSScriptRoot/bundle-budgets.json", @@ -19,6 +9,7 @@ param( [string]$OutputDirectory, + # Measure without failing. Use when reseeding budgets after an intentional increase. [switch]$ReportOnly ) From 260736277522eab836e3ba1bd19ee9d745c02648 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Fri, 28 Aug 2026 17:33:39 +0300 Subject: [PATCH 06/13] ci: add dependency scanning; drop nullable migration plan superseded by #365 Pull requests are gated by dependency-review (fails on High and above). The release scans what it ships and records the report as a release asset, but stays advisory so a finding never holds up a publish. PR #365 enables nullable analysis outright and makes the staged migration plan moot, so the doc and its references are removed and the csproj nullable block is left exactly as master has it to keep that PR merging cleanly. --- .github/workflows/ci.yml | 18 ++++ .../igniteui-blazor-lite-release.yml | 96 ++++++++++++++++++- CHANGELOG.md | 3 +- README.md | 1 - docs/accessibility-conformance.md | 10 +- docs/nullable-migration-plan.md | 45 --------- docs/performance.md | 2 +- src/IgniteUI.Blazor.Lite.csproj | 15 +-- 8 files changed, 129 insertions(+), 61 deletions(-) delete mode 100644 docs/nullable-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bc9becb..9b226091 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,24 @@ permissions: contents: read jobs: + # Blocks a pull request that would introduce a High or Critical advisory, or a + # dependency under a license the package cannot ship. Pushes to master skip it — + # the action needs the two-commit range a pull request gives it. + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout repository + uses: actions/checkout@v7.0.1 + + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + build: name: Build & Validate runs-on: ubuntu-latest diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 08d55342..99a7a844 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -371,6 +371,90 @@ jobs: retention-days: 30 if-no-files-found: error + # Advisory by design. A finding is annotated and attached to the release as evidence, + # but never holds up the publish — the blocking gate for new vulnerable dependencies is + # the dependency-review job on pull requests, which stops them entering master. + dependency-scan: + name: Scan dependencies + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + package-manager-cache: false + + # Only what the package actually ships: the library project, and npm without devDependencies. + - name: Scan NuGet and npm dependencies + shell: bash + run: | + set -uo pipefail + mkdir -p artifacts/dependency-scan + + dotnet restore ./src/IgniteUI.Blazor.Lite.csproj > /dev/null + dotnet list ./src/IgniteUI.Blazor.Lite.csproj package --vulnerable --include-transitive \ + > artifacts/dependency-scan/nuget-vulnerable.txt 2>&1 || true + + # `npm audit` exits non-zero when it reports anything at or above the audit level. + npm audit --omit=dev > artifacts/dependency-scan/npm-audit.txt 2>&1 && npm_status=0 || npm_status=$? + npm audit --omit=dev --json > artifacts/dependency-scan/npm-audit.json 2>&1 || true + + nuget_status=0 + if grep -q 'has the following vulnerable packages' artifacts/dependency-scan/nuget-vulnerable.txt; then + nuget_status=1 + fi + + { + echo "### Dependency vulnerability scan" + echo + echo "Advisory only — findings are recorded but do not block this release." + echo + echo '
dotnet list package --vulnerable --include-transitive' + echo + echo '```' + cat artifacts/dependency-scan/nuget-vulnerable.txt + echo '```' + echo + echo '
' + echo + echo '
npm audit --omit=dev' + echo + echo '```' + cat artifacts/dependency-scan/npm-audit.txt + echo '```' + echo + echo '
' + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$nuget_status" -ne 0 ] || [ "$npm_status" -ne 0 ]; then + echo "::warning title=Vulnerable dependencies reported::${PACKAGE_ID} ${VERSION} was released with dependency advisories outstanding. See the run summary and the dependency-scan release asset." + echo "> ⚠️ **Vulnerable dependencies were reported for this release.** Review the scan output above and open a servicing issue if a fix is required." >> "$GITHUB_STEP_SUMMARY" + else + echo "> ✅ No vulnerable shipped dependencies reported." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload dependency scan + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dependency-scan + path: artifacts/dependency-scan/* + retention-days: 30 + if-no-files-found: error + sbom: name: Generate SBOM and attest needs: pack @@ -585,7 +669,7 @@ jobs: # The only job that can publish. It compiles nothing and never checks out the repository. publish: name: Publish to NuGet.org - needs: [pack, evidence, sbom] + needs: [pack, evidence, sbom, dependency-scan] runs-on: windows-latest timeout-minutes: 15 environment: nuget-org-publish @@ -635,7 +719,7 @@ jobs: attach-to-release: name: Attach release evidence - needs: [pack, evidence, sbom, publish] + needs: [pack, evidence, sbom, dependency-scan, publish] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -660,6 +744,12 @@ jobs: name: release-evidence path: evidence + - name: Download dependency scan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dependency-scan + path: evidence/dependency-scan + - name: Attach evidence to the release env: GH_TOKEN: ${{ github.token }} @@ -669,12 +759,14 @@ jobs: (cd sbom/spdx-2.2/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-2.2.zip" .) (cd sbom/spdx-3.0/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-3.0.zip" .) + (cd evidence/dependency-scan && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.dependency-scan.zip" .) gh release upload "$TAG" --clobber -R "${{ github.repository }}" \ "artifacts/${PACKAGE_ID}.${TAG}.nupkg" \ "artifacts/${PACKAGE_ID}.${TAG}.nupkg.sha256" \ "${PACKAGE_ID}.${TAG}.spdx-2.2.zip" \ "${PACKAGE_ID}.${TAG}.spdx-3.0.zip" \ + "${PACKAGE_ID}.${TAG}.dependency-scan.zip" \ "sbom/attestations/provenance.sigstore.json" \ "sbom/attestations/sbom.sigstore.json" \ "evidence/performance-report.md" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7053c6fc..1b6fafc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Every release now publishes an SPDX 2.2 and SPDX 3.0 SBOM covering the NuGet and npm dependencies the package actually ships, plus Sigstore build-provenance and SBOM attestations bound to the SHA-256 digest of the signed package. All of it is attached to the GitHub release next to the package and its checksum. Verify with `gh attestation verify .nupkg -R IgniteUI/igniteui-blazor`. -- Published [accessibility conformance](docs/accessibility-conformance.md), [performance](docs/performance.md), and [nullable migration](docs/nullable-migration-plan.md) documents. +- Published [accessibility conformance](docs/accessibility-conformance.md) and [performance](docs/performance.md) documents. - Bundle size budgets in `eng/bundle-budgets.json`, enforced during the release. An asset that grows past its budget, or a new asset nobody budgeted for, fails the release. +- Dependency vulnerability scanning. Pull requests are gated by a dependency review that fails on a new High or Critical advisory; every release additionally scans the dependencies it actually ships and attaches the report to the GitHub release. ### Changed diff --git a/README.md b/README.md index b8f2c67d..fbf3bf5c 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,6 @@ dotnet nuget verify IgniteUI.Blazor.Lite..nupkg - [Accessibility conformance](docs/accessibility-conformance.md) — the WCAG 2.2 AA claim, its scope, how it is verified, and the known unfixed failures. - [Performance targets and measurements](docs/performance.md) — the enforced bundle size budgets and the runtime targets. -- [Nullable migration plan](docs/nullable-migration-plan.md) — why the library ships without nullability annotations and the staged plan to change that. [Dock Manager]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/layouts/dock-manager [Commercial]: https://www.infragistics.com/legal/license diff --git a/docs/accessibility-conformance.md b/docs/accessibility-conformance.md index d4e49ffb..b7f50302 100644 --- a/docs/accessibility-conformance.md +++ b/docs/accessibility-conformance.md @@ -27,17 +27,17 @@ Static server rendering is explicitly **out of scope for the conformance claim** ## Verification method -Three layers, with different cadences: +Three layers, once in place, with different cadences: -1. **Automated scanning.** An axe-core scan runs over every component in the Playwright integration suite, asserting the `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, and `wcag22aa` rule sets. It gates pull requests and the release, and the resulting report is attached to the GitHub release as evidence. -2. **Keyboard operation.** Covered by the same suite: tab order, roving tab stops, arrow-key navigation, activation, and focus restoration. +1. **Automated scanning.** An axe-core scan will run over every component in the Playwright integration suite, asserting the `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, and `wcag22aa` rule sets. It will gate pull requests and the release, and the resulting report will be attached to the GitHub release as evidence. +2. **Keyboard operation.** Will be covered by the same suite: tab order, roving tab stops, arrow-key navigation, activation, and focus restoration. 3. **Screen reader smoke testing.** Manual, once per major release, against the matrix below. -Automated scanning catches roughly the subset of WCAG that is machine-checkable. It is a regression net, not a conformance proof — the once-per-major manual assessment is what substantiates the AA claim. +Automated scanning will catch roughly the subset of WCAG that is machine-checkable. It is a regression net, not a conformance proof — the once-per-major manual assessment is what substantiates the AA claim. ### Status of the automation -**The axe-core scan and the keyboard suite described above are not yet in place.** They are being implemented on a separate branch against the existing Playwright integration suite in [`tests/IgniteUI.Blazor.Lite.IntegrationTests`](../tests/IgniteUI.Blazor.Lite.IntegrationTests). Until that lands, layers 1 and 2 are a documented commitment rather than an enforced gate, and no per-release scan artefact exists. This section is written in the present tense for the process that is being built; the gap is stated here rather than papered over. +**The axe-core scan and the keyboard suite described above are not yet in place.** They are being implemented on a separate branch against the existing Playwright integration suite in [`tests/IgniteUI.Blazor.Lite.IntegrationTests`](../tests/IgniteUI.Blazor.Lite.IntegrationTests). Until that lands, layers 1 and 2 above are a documented commitment rather than an enforced gate, and no per-release scan artefact exists. ### Screen reader smoke matrix diff --git a/docs/nullable-migration-plan.md b/docs/nullable-migration-plan.md deleted file mode 100644 index 201b54bc..00000000 --- a/docs/nullable-migration-plan.md +++ /dev/null @@ -1,45 +0,0 @@ -# Nullable reference type migration plan - -`src/IgniteUI.Blazor.Lite.csproj` sets `disable`, overriding the repository-wide `enable` in [`Directory.Build.props`](../Directory.Build.props). This document is the accepted plan for removing that override, and the record of why it exists in the meantime. - -Tracked in [#347](https://github.com/IgniteUI/igniteui-blazor/issues/347). - -## Current state - -Everything else in the repository — tests, the stories host, the test bed — compiles with nullable analysis enabled. Only the shipped library opts out. - -The component wrappers under `src/components/Blazor` were carried forward from the pre-open-source `IgniteUI.Blazor` codebase, which predates nullable reference types. They are hand-maintained rather than generated, so there is no generator to teach and no regeneration that fixes them: annotating them means editing them. There are over a hundred such files, and turning the flag on today produces thousands of warnings across a public API surface. - -## Consequence for consumers - -The package's public API ships without nullability annotations. A consumer compiling with nullable enabled sees the library's reference types as *oblivious* — neither nullable nor non-nullable — so the compiler will not warn them about passing `null` to a parameter that does not accept it, nor about dereferencing a return value that may be `null`. - -This is a real gap in the API contract and the reason the flag is worth turning on, not merely a build-log annoyance. - -## Why not simply enable it - -Two reasons, and only the second is about effort. - -Enabling nullable analysis on a public API is an API change. Annotating a parameter as non-nullable makes previously-accepted `null` a warning at every call site, and annotating a return as nullable makes previously-clean consumer code warn. Done in one commit across the whole surface, that is a large and untestable diff arriving in a single release. Done wrongly — annotating for what makes the warnings go away rather than for what the code actually permits — it bakes an incorrect contract into the package that is then itself a breaking change to correct. - -The second reason is that the warnings are not uniformly interesting. A large fraction come from a small number of patterns in the shared base classes, and fixing those first shrinks the remainder substantially. Ordering the work matters. - -## Plan - -The migration is staged per folder, enabling `#nullable enable` at file scope so that each stage is independently reviewable and independently revertable. The project-level `disable` stays until the last stage lands. - -1. **Interop and serialization core** — `src/componentsBase/*.cs`. The base classes, the renderer, the serializer, and the data adapters. This is where the nullability contract actually lives; most component-level warnings are downstream of decisions made here. -2. **Input infrastructure** — `src/componentsBase/WebInputs/*.cs`. -3. **Component wrappers** — `src/components/Blazor`, in alphabetical batches sized to a reviewable pull request. Public parameters and event callbacks are annotated to match the behaviour of the underlying custom element, not to silence the compiler. -4. **Flip the project** — remove the `disable` override and, in the same change, add `Nullable` so the state cannot regress. - -## Acceptance criteria - -- Every stage builds clean on `net8.0`, `net9.0` and `net10.0` with no new suppressions beyond a documented, justified `!` at a genuine interop boundary. -- No `#pragma warning disable` for nullable warnings survives into the shipped source. -- The public API surface is annotated to reflect what the component actually accepts and returns. -- After stage 4, `enable` is inherited from `Directory.Build.props` and the override is gone. - -## Interim commitment - -Until stage 4 lands, new files added to the library carry `#nullable enable` at file scope so the annotated surface only grows. This is the practical half of the plan: it prevents the backlog from getting larger while the existing backlog is worked through. diff --git a/docs/performance.md b/docs/performance.md index c87c6ef9..d20a98a5 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -29,7 +29,7 @@ Measured on 2026-08-27 from a production webpack build (`npm run build` followed | `served-assets` | 2997.0 | 3300 | 453.5 | 510 | | `package-static-web-assets` | 9116.8 | 10240 | n/a | n/a | -Bundle filenames are content-hashed, so budgets are expressed as patterns rather than filenames. Every produced file must match exactly one group — an asset that matches none fails the check, so a new bundle cannot enter the package without someone budgeting for it. +Bundle filenames are content-hashed, so budgets are expressed as patterns rather than filenames, and a file's group is whichever pattern matches it first. `eng/bundle-budgets.json` lists specific patterns before catch-alls for exactly this reason — for example `app.*.bundle.js` is listed ahead of the generic `*.bundle.js`, so an app bundle is budgeted as `app`, not folded into `lazy-chunks`. An asset that matches no pattern at all fails the check, so a new bundle cannot enter the package without someone budgeting for it. Two things worth knowing about these numbers: diff --git a/src/IgniteUI.Blazor.Lite.csproj b/src/IgniteUI.Blazor.Lite.csproj index 13ff3e94..a6db0b0f 100644 --- a/src/IgniteUI.Blazor.Lite.csproj +++ b/src/IgniteUI.Blazor.Lite.csproj @@ -2,8 +2,8 @@ .Lite - + disable @@ -16,6 +16,13 @@ _content/IgniteUI.Blazor
+ + + true + true + + net8.0;net9.0;net10.0 @@ -34,10 +41,6 @@ MIT https://github.com/IgniteUI/igniteui-blazor git - - true - true From 65e8af57a54edc3a098dd16dea7ce5d287a3c5d2 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 31 Aug 2026 12:14:48 +0300 Subject: [PATCH 07/13] Harden workflow and implement nuget.org push checking before publishing the release evidence --- .../igniteui-blazor-lite-release.yml | 96 +++++++++++++++++-- 1 file changed, 87 insertions(+), 9 deletions(-) diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 99a7a844..21587a25 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -24,9 +24,10 @@ env: SBOM_MAX_COMPONENTS: "150" jobs: - # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. - build: - name: Build + # Runs npm lifecycle scripts and webpack, so it is deliberately kept free of every credential: + # no strong-name key, no Key Vault access, no publishing rights. Its only output is src/wwwroot. + build-assets: + name: Build web assets runs-on: windows-latest timeout-minutes: 30 permissions: @@ -38,11 +39,6 @@ jobs: with: persist-credentials: false - - name: Setup .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -58,6 +54,46 @@ jobs: - name: Copy component themes to wwwroot run: npm run copythemes + - name: Upload web assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: web-assets + path: src/wwwroot + include-hidden-files: true + retention-days: 1 + if-no-files-found: error + + # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. + # It installs no npm packages and runs no bundler, so JavaScript dependency code is never + # resident on the runner the private key is written to. + build: + name: Build library + needs: build-assets + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + # The Razor SDK reads wwwroot when it builds the static web asset manifest, so the assets + # have to be in place before restore rather than merged in at pack time. + - name: Download web assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: web-assets + path: src/wwwroot + digest-mismatch: error + - name: Restore .NET dependencies run: dotnet restore ./src/IgniteUI.Blazor.Lite.csproj @@ -714,8 +750,50 @@ jobs: with: user: ${{ secrets.INFRAGISTICS_NUGET_ORG_USER }} + # Refuses to publish over an existing version instead of using --skip-duplicate: a full rerun produces newly signed bytes, so skipping + # the duplicate would attach this run's SBOM, attestations and checksum to a release whose published package is a different build. - name: Publish to NuGet.org - run: dotnet nuget push "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: | + $ErrorActionPreference = 'Stop' + # Pinned: a failed push is inspected through $LASTEXITCODE below rather than terminating the step. + $PSNativeCommandUseErrorActionPreference = $false + + $package = "${{ github.workspace }}\artifacts\${env:PACKAGE_ID}.${env:VERSION}.nupkg" + $id = ($env:PACKAGE_ID).ToLowerInvariant() + $version = ($env:VERSION).ToLowerInvariant() + $feedUrl = "https://api.nuget.org/v3-flatcontainer/$id/$version/$id.$version.nupkg" + + # The flat container lags a push by seconds to minutes, so the retries stop a duplicate + # rejection from being reported as a package that never reached the feed. + function Test-Published([int[]]$RetryDelaysSeconds = @()) { + foreach ($delay in @(0) + $RetryDelaysSeconds) { + if ($delay -gt 0) { Start-Sleep -Seconds $delay } + if ((Invoke-WebRequest -Uri $feedUrl -Method Head -SkipHttpErrorCheck).StatusCode -eq 200) { return $true } + } + return $false + } + + $recovery = "NuGet.org will not accept this version again. If a previous run published it but failed before attaching evidence, attach that run's retained nupkg-signed, sbom, release-evidence and dependency-scan artifacts to the release manually." + + if (Test-Published) { + throw "$($env:PACKAGE_ID) $($env:VERSION) is already on NuGet.org, so this run must not attach its evidence to the release: the published package may be a different build. $recovery" + } + + dotnet nuget push $package --api-key $env:NUGET_API_KEY --source "https://api.nuget.org/v3/index.json" + if ($LASTEXITCODE -eq 0) { + Write-Host "Published $($env:PACKAGE_ID) $($env:VERSION)." + exit 0 + } + + Write-Host "::warning title=Push reported a failure::Checking whether the package reached NuGet.org anyway." + if (Test-Published -RetryDelaysSeconds @(5, 15, 30)) { + throw "dotnet nuget push reported a failure but $($env:PACKAGE_ID) $($env:VERSION) is on NuGet.org. $recovery" + } + + throw "dotnet nuget push failed and $($env:PACKAGE_ID) $($env:VERSION) is not on NuGet.org." attach-to-release: name: Attach release evidence From ac22ff2af4c398ac01244c95b87d1f769d468666 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 31 Aug 2026 12:45:49 +0300 Subject: [PATCH 08/13] tweak ci workflow - add permission & clarification for it --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b226091..2aba5e17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,18 @@ jobs: name: Dependency Review runs-on: ubuntu-latest if: github.event_name == 'pull_request' + permissions: + contents: read + # comment-summary-in-pr needs this. A fork PR gets a read-only token regardless, so there + # the comment is skipped with a warning and the finding is left to the job summary. + pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v7.0.1 - name: Review dependency changes - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: high comment-summary-in-pr: on-failure From 44be32fef04b68bc623b6a11d1784d68f2f633d0 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 31 Aug 2026 12:46:20 +0300 Subject: [PATCH 09/13] Tweak the budget calculation - based on CoPilot code review --- eng/Check-BundleBudget.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/Check-BundleBudget.ps1 b/eng/Check-BundleBudget.ps1 index 199e6a5e..b8d8bc50 100644 --- a/eng/Check-BundleBudget.ps1 +++ b/eng/Check-BundleBudget.ps1 @@ -107,10 +107,10 @@ foreach ($group in $budget.groups) { } $groupResults += $result - if ($result.RawKiB -gt $group.maxRawKiB) { + if ($raw -gt ($group.maxRawKiB * 1KB)) { $problems += "Group '$($group.id)' is $($result.RawKiB) KiB raw, over its $($group.maxRawKiB) KiB budget." } - if ($null -ne $group.maxGzipKiB -and $result.GzipKiB -gt $group.maxGzipKiB) { + if ($null -ne $group.maxGzipKiB -and $gzip -gt ($group.maxGzipKiB * 1KB)) { $problems += "Group '$($group.id)' is $($result.GzipKiB) KiB gzipped, over its $($group.maxGzipKiB) KiB budget." } } @@ -135,10 +135,10 @@ foreach ($total in $budget.totals) { } $totalResults += $result - if ($result.RawKiB -gt $total.maxRawKiB) { + if ($raw -gt ($total.maxRawKiB * 1KB)) { $problems += "Total '$($total.id)' is $($result.RawKiB) KiB raw, over its $($total.maxRawKiB) KiB budget." } - if ($null -ne $total.maxGzipKiB -and $result.GzipKiB -gt $total.maxGzipKiB) { + if ($null -ne $total.maxGzipKiB -and $gzip -gt ($total.maxGzipKiB * 1KB)) { $problems += "Total '$($total.id)' is $($result.GzipKiB) KiB gzipped, over its $($total.maxGzipKiB) KiB budget." } } From c8ffa34fc0d05de47704008ab1dc457e2a1c9a25 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 31 Aug 2026 13:04:25 +0300 Subject: [PATCH 10/13] Address dependency scanning code review comment about being too lenient --- .../igniteui-blazor-lite-release.yml | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 21587a25..9ef60849 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -64,8 +64,7 @@ jobs: if-no-files-found: error # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. - # It installs no npm packages and runs no bundler, so JavaScript dependency code is never - # resident on the runner the private key is written to. + # It installs no npm packages and runs no bundler, so JavaScript dependency code is never present on the runner the private key is written to. build: name: Build library needs: build-assets @@ -438,21 +437,55 @@ jobs: - name: Scan NuGet and npm dependencies shell: bash run: | - set -uo pipefail + set -euo pipefail mkdir -p artifacts/dependency-scan dotnet restore ./src/IgniteUI.Blazor.Lite.csproj > /dev/null - dotnet list ./src/IgniteUI.Blazor.Lite.csproj package --vulnerable --include-transitive \ - > artifacts/dependency-scan/nuget-vulnerable.txt 2>&1 || true - - # `npm audit` exits non-zero when it reports anything at or above the audit level. - npm audit --omit=dev > artifacts/dependency-scan/npm-audit.txt 2>&1 && npm_status=0 || npm_status=$? - npm audit --omit=dev --json > artifacts/dependency-scan/npm-audit.json 2>&1 || true - nuget_status=0 - if grep -q 'has the following vulnerable packages' artifacts/dependency-scan/nuget-vulnerable.txt; then - nuget_status=1 + dotnet list ./src/IgniteUI.Blazor.Lite.csproj package --vulnerable --include-transitive \ + --format json --output-version 1 \ + > artifacts/dependency-scan/nuget-vulnerable.json \ + 2> artifacts/dependency-scan/nuget-vulnerable.stderr.txt || nuget_status=$? + if [ "$nuget_status" -ne 0 ]; then + cat artifacts/dependency-scan/nuget-vulnerable.stderr.txt >&2 + echo "::error title=NuGet vulnerability scan failed::dotnet list package exited with status $nuget_status." + exit "$nuget_status" + fi + if ! jq -e ' + .version == 1 and + (.parameters | type == "string") and + (.sources | type == "array") and + (.projects | type == "array" and length > 0) and + all(.projects[]; (.path | type == "string" and length > 0)) + ' artifacts/dependency-scan/nuget-vulnerable.json > /dev/null; then + echo "::error title=NuGet vulnerability scan failed::dotnet list package did not produce a valid version 1 JSON report." + exit 1 + fi + nuget_vulnerabilities=$(jq '[.. | objects | .vulnerabilities? | arrays | length] | add // 0' artifacts/dependency-scan/nuget-vulnerable.json) + jq . artifacts/dependency-scan/nuget-vulnerable.json > artifacts/dependency-scan/nuget-vulnerable.txt + + npm_status=0 + npm audit --omit=dev --audit-level=info --json \ + > artifacts/dependency-scan/npm-audit.json \ + 2> artifacts/dependency-scan/npm-audit.stderr.txt || npm_status=$? + if ! jq -e ' + (.auditReportVersion | type == "number") and + (.vulnerabilities | type == "object") and + (.metadata.vulnerabilities.total | type == "number") and + (has("error") | not) + ' artifacts/dependency-scan/npm-audit.json > /dev/null; then + cat artifacts/dependency-scan/npm-audit.stderr.txt >&2 + echo "::error title=npm vulnerability scan failed::npm audit did not produce a valid JSON report." + exit 1 + fi + npm_vulnerabilities=$(jq '.metadata.vulnerabilities.total' artifacts/dependency-scan/npm-audit.json) + if ! { [ "$npm_status" -eq 0 ] && [ "$npm_vulnerabilities" -eq 0 ]; } && + ! { [ "$npm_status" -eq 1 ] && [ "$npm_vulnerabilities" -gt 0 ]; }; then + cat artifacts/dependency-scan/npm-audit.stderr.txt >&2 + echo "::error title=npm vulnerability scan failed::npm audit exited with status $npm_status and reported $npm_vulnerabilities vulnerabilities." + exit 1 fi + jq . artifacts/dependency-scan/npm-audit.json > artifacts/dependency-scan/npm-audit.txt { echo "### Dependency vulnerability scan" @@ -476,7 +509,7 @@ jobs: echo '' } >> "$GITHUB_STEP_SUMMARY" - if [ "$nuget_status" -ne 0 ] || [ "$npm_status" -ne 0 ]; then + if [ "$nuget_vulnerabilities" -gt 0 ] || [ "$npm_vulnerabilities" -gt 0 ]; then echo "::warning title=Vulnerable dependencies reported::${PACKAGE_ID} ${VERSION} was released with dependency advisories outstanding. See the run summary and the dependency-scan release asset." echo "> ⚠️ **Vulnerable dependencies were reported for this release.** Review the scan output above and open a servicing issue if a fix is required." >> "$GITHUB_STEP_SUMMARY" else From cde2b0a1d56940a4b945c12263f41b5aae9eb0e4 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 31 Aug 2026 14:27:12 +0300 Subject: [PATCH 11/13] CI workflow: permissions tweak for the comment-summary-in-pr and use SHAs for action versions --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aba5e17..229c0353 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Review dependency changes uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 @@ -39,12 +39,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # The SDK is pinned via global.json (10.0.x builds all TFMs); # older runtimes are needed to run tests targeting net8.0/net9.0. - name: Setup .NET - uses: actions/setup-dotnet@v6.0.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x @@ -59,7 +59,7 @@ jobs: run: dotnet format whitespace . --folder --exclude templates node_modules --verify-no-changes - name: Setup Node.js - uses: actions/setup-node@v7.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm @@ -113,7 +113,7 @@ jobs: run: cat CoverageReport/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" - name: Upload coverage report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-report path: CoverageReport From 4c748a7fe3558cc4c4bbd7c9f07528d979d130aa Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 31 Aug 2026 16:56:51 +0300 Subject: [PATCH 12/13] ci (release): Split the conformance docs and the SBOM output check out of this PR The accessibility conformance and performance documents move to their own pull requests against master, so they can be reviewed as documents rather than as an appendix to a workflow refactor. The README section keeps only the supply chain prose it actually still owns. The 'Verify SBOM output' step and the two SBOM_* budget variables that only it read move to a separate branch: the check needs reworking, and leaving it here would hold the rest of the workflow behind that rework. --- .../igniteui-blazor-lite-release.yml | 57 ---------------- CHANGELOG.md | 1 - README.md | 5 +- docs/accessibility-conformance.md | 62 ------------------ docs/performance.md | 65 ------------------- 5 files changed, 1 insertion(+), 189 deletions(-) delete mode 100644 docs/accessibility-conformance.md delete mode 100644 docs/performance.md diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 9ef60849..169c81ed 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -18,10 +18,6 @@ env: REPOSITORY_URL: https://github.com/IgniteUI/igniteui-blazor # Public, deliberately pinned identity. The strong-name counterpart lives in eng/IG.publickey.hex. EXPECTED_CERT_SHA256_PATH: "eng/IG.authenticode-certificates.sha256" - # Only NuGet components declare a license locally, so the floor sits below the measured 73.3%. - SBOM_MIN_DECLARED_LICENSE_PERCENT: "50" - # 60 components ship today. Catches over-reporting the blocklist misses; alpha.1 hit 583. - SBOM_MAX_COMPONENTS: "150" jobs: # Runs npm lifecycle scripts and webpack, so it is deliberately kept free of every credential: @@ -628,59 +624,6 @@ jobs: -V Information } - - name: Verify SBOM output - shell: pwsh - run: | - $name = "${env:PACKAGE_ID}.${env:VERSION}.nupkg" - $manifests = @('spdx-2.2\_manifest\spdx_2.2', 'spdx-3.0\_manifest\spdx_3.0') | - ForEach-Object { "${{ github.workspace }}\sbom\$_\manifest.spdx.json" } - - foreach ($path in $manifests) { - if (-not (Test-Path -LiteralPath $path) -or (Get-Item -LiteralPath $path).Length -eq 0) { - throw "SBOM manifest missing or empty: $path" - } - } - - $spdx = Get-Content -LiteralPath $manifests[0] -Raw | ConvertFrom-Json - $packages = @($spdx.packages) - $names = $packages.name - $ceiling = [int]$env:SBOM_MAX_COMPONENTS - $floor = [double]$env:SBOM_MIN_DECLARED_LICENSE_PERCENT - - # licenseDeclared comes from local package metadata; nothing is fetched over the network. - $declared = @($packages | Where-Object { $_.licenseDeclared -and $_.licenseDeclared -ne 'NOASSERTION' }).Count - $percent = [math]::Round(100 * $declared / [math]::Max($packages.Count, 1), 1) - - $buildOnly = @( - 'webpack', 'typescript', 'prettier', 'tslint', 'ts-loader', 'lint-staged', 'cross-env', 'html-webpack-plugin', - 'bunit', 'xunit', 'NUnit', 'Moq', 'coverlet.collector', 'Microsoft.NET.Test.Sdk', - 'Microsoft.Playwright.NUnit', 'Microsoft.AspNetCore.Mvc.Testing', 'BlazingStory' - ) - $leaked = @($buildOnly | Where-Object { $names -contains $_ }) - $shipped = @('igniteui-webcomponents', 'lit-html', 'Microsoft.AspNetCore.Components.Web') - $missing = @($shipped | Where-Object { $names -notcontains $_ }) - - # The blocklist only catches names someone thought of; the ceiling catches the rest. - $problems = @( - if (-not ($spdx.files.fileName -like "*$name")) { "does not reference $name" } - if ($leaked.Count) { "contains build-only packages ($($leaked -join ', ')); generate it from a production-only restore and install" } - if ($missing.Count) { "omits shipped dependencies ($($missing -join ', '))" } - if ($packages.Count -gt $ceiling) { "lists $($packages.Count) components, over the $ceiling ceiling" } - if ($percent -lt $floor) { "declares a license for only $percent% of $($packages.Count) components, under the $floor% floor" } - ) - - @( - "### SBOM", - "- Components: $($packages.Count) (ceiling $ceiling), files: $($spdx.files.Count)", - "- Declared licenses: $declared ($percent%, floor $floor%)" - ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY - - if ($problems.Count) { - throw "SBOM validation failed. The SPDX 2.2 document:`n- $($problems -join "`n- ")" - } - - Write-Host "SBOM covers $($packages.Count) components and $($spdx.files.Count) files; $percent% declare a license." - - name: Reverify package before attestation id: verify-before-attestation shell: pwsh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6fafc2..21568efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Every release now publishes an SPDX 2.2 and SPDX 3.0 SBOM covering the NuGet and npm dependencies the package actually ships, plus Sigstore build-provenance and SBOM attestations bound to the SHA-256 digest of the signed package. All of it is attached to the GitHub release next to the package and its checksum. Verify with `gh attestation verify .nupkg -R IgniteUI/igniteui-blazor`. -- Published [accessibility conformance](docs/accessibility-conformance.md) and [performance](docs/performance.md) documents. - Bundle size budgets in `eng/bundle-budgets.json`, enforced during the release. An asset that grows past its budget, or a new asset nobody budgeted for, fails the release. - Dependency vulnerability scanning. Pull requests are gated by a dependency review that fails on a new High or Critical advisory; every release additionally scans the dependencies it actually ships and attaches the report to the GitHub release. diff --git a/README.md b/README.md index fbf3bf5c..1c86185f 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ After the above steps, open the solution in Visual Studio or run the stories pro dotnet run --project stories/IgniteUI.Blazor.Stories.csproj ``` -## Supply chain, accessibility and performance +## Supply chain Every release publishes an SPDX 2.2 and SPDX 3.0 SBOM, a Sigstore build-provenance attestation, and an SBOM attestation, all bound to the SHA-256 digest of the signed package that was pushed to NuGet.org. They are attached to the corresponding [GitHub release](https://github.com/IgniteUI/igniteui-blazor/releases) alongside the package and its checksum. To verify a package you downloaded: @@ -201,9 +201,6 @@ gh attestation verify IgniteUI.Blazor.Lite..nupkg -R IgniteUI/igniteui- dotnet nuget verify IgniteUI.Blazor.Lite..nupkg ``` -- [Accessibility conformance](docs/accessibility-conformance.md) — the WCAG 2.2 AA claim, its scope, how it is verified, and the known unfixed failures. -- [Performance targets and measurements](docs/performance.md) — the enforced bundle size budgets and the runtime targets. - [Dock Manager]: https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/layouts/dock-manager [Commercial]: https://www.infragistics.com/legal/license [MIT]: https://github.com/IgniteUI/igniteui-blazor/blob/master/LICENSE diff --git a/docs/accessibility-conformance.md b/docs/accessibility-conformance.md deleted file mode 100644 index b7f50302..00000000 --- a/docs/accessibility-conformance.md +++ /dev/null @@ -1,62 +0,0 @@ -# Accessibility conformance - -This document records the accessibility conformance claim for `IgniteUI.Blazor.Lite`, the scope that claim covers, how it is verified, and which failures are known and unfixed at the time of writing. - -It is deliberately specific about what has and has not been verified. A conformance document that implies more testing than was performed is worse than no document, because a consumer who needs the claim cannot tell which parts to trust. - -## Conformance claim - -`IgniteUI.Blazor.Lite` targets **WCAG 2.2 Level AA**. - -The components in this package are thin .NET wrappers around the custom elements published by [`igniteui-webcomponents`](https://github.com/IgniteUI/igniteui-webcomponents). Roles, names, states, keyboard interaction, and focus management are implemented in the underlying web component; the wrapper's accessibility responsibility is to pass parameters through faithfully and to avoid breaking the element's own contract. Accessibility defects therefore usually belong to one of two places, and this document distinguishes them. - -## Scope - -| Dimension | Covered | -| --- | --- | -| Components | Every `Igb*` component exported from `IgniteUI.Blazor.Controls` | -| Render modes | Interactive Server and Interactive WebAssembly | -| Browsers | Chromium, Firefox, and WebKit current stable | -| Assistive technology | NVDA, JAWS, and VoiceOver — see the smoke matrix below | - -Static server rendering is explicitly **out of scope for the conformance claim**. Components in that mode render as unupgraded custom elements with no interactive behaviour and no ARIA semantics, because the custom element definitions are never executed. See [render mode support](https://www.infragistics.com/products/ignite-ui-blazor/blazor/components/general-getting-started-blazor-web-app#add-ignite-ui-for-blazor-component) for the supported configurations. - -## Contributor requirements - -[`CONTRIBUTING.md`](../.github/CONTRIBUTING.md) requires every contributor to implement and test against Section 508, WCAG, WAI-ARIA, and full keyboard navigation, and the pull request template carries an explicit accessibility verification checkbox. That is the standing bar for new work in this repository. - -## Verification method - -Three layers, once in place, with different cadences: - -1. **Automated scanning.** An axe-core scan will run over every component in the Playwright integration suite, asserting the `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, and `wcag22aa` rule sets. It will gate pull requests and the release, and the resulting report will be attached to the GitHub release as evidence. -2. **Keyboard operation.** Will be covered by the same suite: tab order, roving tab stops, arrow-key navigation, activation, and focus restoration. -3. **Screen reader smoke testing.** Manual, once per major release, against the matrix below. - -Automated scanning will catch roughly the subset of WCAG that is machine-checkable. It is a regression net, not a conformance proof — the once-per-major manual assessment is what substantiates the AA claim. - -### Status of the automation - -**The axe-core scan and the keyboard suite described above are not yet in place.** They are being implemented on a separate branch against the existing Playwright integration suite in [`tests/IgniteUI.Blazor.Lite.IntegrationTests`](../tests/IgniteUI.Blazor.Lite.IntegrationTests). Until that lands, layers 1 and 2 above are a documented commitment rather than an enforced gate, and no per-release scan artefact exists. - -### Screen reader smoke matrix - -| Screen reader | Browser | Platform | Last recorded run | -| --- | --- | --- | --- | -| NVDA | Chrome | Windows 11 | not yet recorded | -| JAWS | Chrome | Windows 11 | not yet recorded | -| VoiceOver | Safari | macOS | not yet recorded | - -The first recorded run lands with the next release. Rows are filled in with the release version and date the run was performed against; a row that says "not yet recorded" means exactly that. - -## Known failures - -Accepted, unfixed accessibility defects at Level AA are listed here for as long as they remain unfixed. - -| Issue | Component | Summary | Origin | -| --- | --- | --- | --- | -| [#336](https://github.com/IgniteUI/igniteui-blazor/issues/336) | `IgbRadioGroup` / `IgbRadio` | A radio rendered after the group has upgraded is not adopted into the group: it receives no group name, leaves a second tabbable element in the group, and is excluded from arrow-key reconciliation. Keyboard operation, focus order, and the group relationship exposed to assistive technology all degrade together. Binding a value repairs the duplicate checked state but not membership, name, or arrow navigation. | Upstream `igniteui-webcomponents` | - -## Reporting an accessibility problem - -Open an issue at [github.com/IgniteUI/igniteui-blazor/issues](https://github.com/IgniteUI/igniteui-blazor/issues) describing the component, the render mode, the assistive technology and browser, and the expected versus observed behaviour. If the defect is in the underlying custom element it will be reproduced against [`igniteui-webcomponents`](https://github.com/IgniteUI/igniteui-webcomponents) and tracked there, with the tracking issue linked back into the table above. diff --git a/docs/performance.md b/docs/performance.md deleted file mode 100644 index d20a98a5..00000000 --- a/docs/performance.md +++ /dev/null @@ -1,65 +0,0 @@ -# Performance targets and measurements - -This document is the published performance budget for `IgniteUI.Blazor.Lite`. It exists so that a size or latency regression is a decision somebody makes on the record, rather than something a consumer discovers after upgrading. - -Budgets are enforced, not aspirational: [`eng/bundle-budgets.json`](../eng/bundle-budgets.json) holds the numbers and [`eng/Check-BundleBudget.ps1`](../eng/Check-BundleBudget.ps1) fails the release when an asset exceeds one. The `evidence` job of the release workflow runs that check against the assets that were actually built and attaches `performance-report.md` and `performance-report.json` to the GitHub release. - -## Scope - -These budgets cover the static web assets the package ships under `_content/IgniteUI.Blazor`. They do not cover the consuming application's own bundle, the Blazor framework payload, or the .NET runtime download in a WebAssembly host — those are outside anything this package controls. - -## Asset size budgets - -Measured on 2026-08-27 from a production webpack build (`npm run build` followed by `npm run copythemes`) on Node 22, against `igniteui-webcomponents` 7.2.4. - -| Group | Measured raw KiB | Raw budget | Measured gzip KiB | Gzip budget | -| --- | ---: | ---: | ---: | ---: | -| `loader` | 2.5 | 8 | 1.0 | 4 | -| `app` | 408.8 | 460 | 103.8 | 118 | -| `web-components-core` | 272.2 | 310 | 34.5 | 40 | -| `web-components` | 1912.4 | 2100 | 255.6 | 285 | -| `lazy-chunks` | 88.2 | 120 | 27.8 | 40 | -| `license-notices` | 0.7 | 8 | n/a | n/a | -| `source-maps` | 6119.1 | 6900 | n/a | n/a | -| `themes` | 312.8 | 345 | 30.9 | 36 | - -| Total | Measured raw KiB | Raw budget | Measured gzip KiB | Gzip budget | -| --- | ---: | ---: | ---: | ---: | -| `served-javascript` | 2684.2 | 2950 | 422.6 | 470 | -| `served-assets` | 2997.0 | 3300 | 453.5 | 510 | -| `package-static-web-assets` | 9116.8 | 10240 | n/a | n/a | - -Bundle filenames are content-hashed, so budgets are expressed as patterns rather than filenames, and a file's group is whichever pattern matches it first. `eng/bundle-budgets.json` lists specific patterns before catch-alls for exactly this reason — for example `app.*.bundle.js` is listed ahead of the generic `*.bundle.js`, so an app bundle is budgeted as `app`, not folded into `lazy-chunks`. An asset that matches no pattern at all fails the check, so a new bundle cannot enter the package without someone budgeting for it. - -Two things worth knowing about these numbers: - -- Source maps are two thirds of the package on disk but are never requested unless a developer opens devtools, so they carry a raw budget and no gzip budget. `served-assets` is the number that describes what a user actually downloads. -- Themes ship as eight prebuilt stylesheets and a consumer references one of them, so the `themes` figure is the whole set, not the per-page cost. - -## Runtime targets - -The following targets apply to the reference scenario — the Interactive Server test bed in [`tests/IgniteUI.Blazor.Lite.TestBed`](../tests/IgniteUI.Blazor.Lite.TestBed) rendering a single component on a warm server over a local connection, measured on the CI runner class. - -| Metric | Target | -| --- | --- | -| Time from `blazor.web.js` start to the package's JS initializer resolving | < 250 ms | -| First component upgrade (custom element defined and rendered) after initializer | < 150 ms | -| Property write from .NET to reflected DOM state | < 50 ms | -| User interaction to `EventCallback` invocation on the server | < 100 ms plus circuit round-trip | - -**These runtime targets are published but not yet measured per release.** The bundle-size half of this budget is enforced today; the timing harness that produces the runtime half is tracked separately and lands with the accessibility automation. Until it does, treat the table above as the committed target and the absence of a recorded measurement as a known gap rather than a passing result. - -## Changing a budget - -Raising a budget is allowed and sometimes correct — a new component or an upstream `igniteui-webcomponents` release legitimately adds bytes. What is not allowed is raising it silently. Update the number in `eng/bundle-budgets.json`, update the measured column in this table, and say why in the changelog entry for the release that carries the increase. - -## Reproducing locally - -```pwsh -npm ci -npm run build -npm run copythemes -./eng/Check-BundleBudget.ps1 -``` - -The report is written to `artifacts/perf/`. Pass `-ReportOnly` to measure without failing, which is what you want when reseeding budgets after an intentional increase. From b2969d9718187f9590145c5e429b88ae96e78d10 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 31 Aug 2026 16:57:26 +0300 Subject: [PATCH 13/13] ci (release): Reinstate the SBOM output check for rework Restores the 'Verify SBOM output' step and the two SBOM_* budget variables it reads, unchanged, so the rework has a baseline to diff against rather than starting from a deleted step. The check is not in a state to merge. It asserts SBOM quality through a hand-maintained blocklist of build-only package names, a component ceiling and a declared-license floor - three proxies that each fail in a different direction: the blocklist only catches names somebody thought to add, the ceiling is a single number covering two ecosystems, and the license floor is set below the measured value rather than at it. Draft until that is replaced with something that checks the property we actually care about. --- .../igniteui-blazor-lite-release.yml | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.github/workflows/igniteui-blazor-lite-release.yml b/.github/workflows/igniteui-blazor-lite-release.yml index 169c81ed..9ef60849 100644 --- a/.github/workflows/igniteui-blazor-lite-release.yml +++ b/.github/workflows/igniteui-blazor-lite-release.yml @@ -18,6 +18,10 @@ env: REPOSITORY_URL: https://github.com/IgniteUI/igniteui-blazor # Public, deliberately pinned identity. The strong-name counterpart lives in eng/IG.publickey.hex. EXPECTED_CERT_SHA256_PATH: "eng/IG.authenticode-certificates.sha256" + # Only NuGet components declare a license locally, so the floor sits below the measured 73.3%. + SBOM_MIN_DECLARED_LICENSE_PERCENT: "50" + # 60 components ship today. Catches over-reporting the blocklist misses; alpha.1 hit 583. + SBOM_MAX_COMPONENTS: "150" jobs: # Runs npm lifecycle scripts and webpack, so it is deliberately kept free of every credential: @@ -624,6 +628,59 @@ jobs: -V Information } + - name: Verify SBOM output + shell: pwsh + run: | + $name = "${env:PACKAGE_ID}.${env:VERSION}.nupkg" + $manifests = @('spdx-2.2\_manifest\spdx_2.2', 'spdx-3.0\_manifest\spdx_3.0') | + ForEach-Object { "${{ github.workspace }}\sbom\$_\manifest.spdx.json" } + + foreach ($path in $manifests) { + if (-not (Test-Path -LiteralPath $path) -or (Get-Item -LiteralPath $path).Length -eq 0) { + throw "SBOM manifest missing or empty: $path" + } + } + + $spdx = Get-Content -LiteralPath $manifests[0] -Raw | ConvertFrom-Json + $packages = @($spdx.packages) + $names = $packages.name + $ceiling = [int]$env:SBOM_MAX_COMPONENTS + $floor = [double]$env:SBOM_MIN_DECLARED_LICENSE_PERCENT + + # licenseDeclared comes from local package metadata; nothing is fetched over the network. + $declared = @($packages | Where-Object { $_.licenseDeclared -and $_.licenseDeclared -ne 'NOASSERTION' }).Count + $percent = [math]::Round(100 * $declared / [math]::Max($packages.Count, 1), 1) + + $buildOnly = @( + 'webpack', 'typescript', 'prettier', 'tslint', 'ts-loader', 'lint-staged', 'cross-env', 'html-webpack-plugin', + 'bunit', 'xunit', 'NUnit', 'Moq', 'coverlet.collector', 'Microsoft.NET.Test.Sdk', + 'Microsoft.Playwright.NUnit', 'Microsoft.AspNetCore.Mvc.Testing', 'BlazingStory' + ) + $leaked = @($buildOnly | Where-Object { $names -contains $_ }) + $shipped = @('igniteui-webcomponents', 'lit-html', 'Microsoft.AspNetCore.Components.Web') + $missing = @($shipped | Where-Object { $names -notcontains $_ }) + + # The blocklist only catches names someone thought of; the ceiling catches the rest. + $problems = @( + if (-not ($spdx.files.fileName -like "*$name")) { "does not reference $name" } + if ($leaked.Count) { "contains build-only packages ($($leaked -join ', ')); generate it from a production-only restore and install" } + if ($missing.Count) { "omits shipped dependencies ($($missing -join ', '))" } + if ($packages.Count -gt $ceiling) { "lists $($packages.Count) components, over the $ceiling ceiling" } + if ($percent -lt $floor) { "declares a license for only $percent% of $($packages.Count) components, under the $floor% floor" } + ) + + @( + "### SBOM", + "- Components: $($packages.Count) (ceiling $ceiling), files: $($spdx.files.Count)", + "- Declared licenses: $declared ($percent%, floor $floor%)" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + if ($problems.Count) { + throw "SBOM validation failed. The SPDX 2.2 document:`n- $($problems -join "`n- ")" + } + + Write-Host "SBOM covers $($packages.Count) components and $($spdx.files.Count) files; $percent% declare a license." + - name: Reverify package before attestation id: verify-before-attestation shell: pwsh