From 53aa9ebbfb7c5d4b9a2b89048fcf7003f4e33efd Mon Sep 17 00:00:00 2001 From: "@nieprzecietny_kowalski" Date: Wed, 20 May 2026 11:15:20 +0200 Subject: [PATCH 1/3] Bump dependencies to latest within license constraints - MailKit 4.13.0 -> 4.16.0 (MIT) - Microsoft.Identity.Web 3.11.0 -> 4.9.0 (MIT) - Microsoft.NET.Test.Sdk 17.* -> 18.* (MIT) - coverlet.msbuild 6.* -> 10.0.1 (MIT) - System.Linq.Dynamic.Core 1.6.6 -> 1.7.2 (Apache-2.0) - SciSharp.TensorFlow.Redist 2.3.1 -> 2.16.0 (Apache-2.0) Held back MediatR 13+/14 due to license change (Apache-2.0 -> RPL-1.5). Microsoft.Extensions.* / EntityFrameworkCore wildcards left untouched. --- .../TailoredApps.Shared.Email.Office365.csproj | 6 +++--- .../TailoredApps.Shared.EntityFramework.csproj | 2 +- .../TailoredApps.Shared.DateTime.Tests.csproj | 4 ++-- .../TailoredApps.Shared.Email.Tests.csproj | 6 +++--- .../TailoredApps.Shared.EntityFramework.Tests.csproj | 4 ++-- .../TailoredApps.Shared.MediatR.ML.Tests.csproj | 6 +++--- .../TailoredApps.Shared.Payments.Tests.csproj | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/TailoredApps.Shared.Email.Office365/TailoredApps.Shared.Email.Office365.csproj b/src/TailoredApps.Shared.Email.Office365/TailoredApps.Shared.Email.Office365.csproj index c1a2ea8..4afe2b2 100644 --- a/src/TailoredApps.Shared.Email.Office365/TailoredApps.Shared.Email.Office365.csproj +++ b/src/TailoredApps.Shared.Email.Office365/TailoredApps.Shared.Email.Office365.csproj @@ -28,9 +28,9 @@ - - - + + + diff --git a/src/TailoredApps.Shared.EntityFramework/TailoredApps.Shared.EntityFramework.csproj b/src/TailoredApps.Shared.EntityFramework/TailoredApps.Shared.EntityFramework.csproj index a3e4737..5c05396 100644 --- a/src/TailoredApps.Shared.EntityFramework/TailoredApps.Shared.EntityFramework.csproj +++ b/src/TailoredApps.Shared.EntityFramework/TailoredApps.Shared.EntityFramework.csproj @@ -29,7 +29,7 @@ - + diff --git a/tests/TailoredApps.Shared.DateTime.Tests/TailoredApps.Shared.DateTime.Tests.csproj b/tests/TailoredApps.Shared.DateTime.Tests/TailoredApps.Shared.DateTime.Tests.csproj index 1c6c731..bece141 100644 --- a/tests/TailoredApps.Shared.DateTime.Tests/TailoredApps.Shared.DateTime.Tests.csproj +++ b/tests/TailoredApps.Shared.DateTime.Tests/TailoredApps.Shared.DateTime.Tests.csproj @@ -7,13 +7,13 @@ - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/TailoredApps.Shared.Email.Tests/TailoredApps.Shared.Email.Tests.csproj b/tests/TailoredApps.Shared.Email.Tests/TailoredApps.Shared.Email.Tests.csproj index 1a0244d..a902c1b 100644 --- a/tests/TailoredApps.Shared.Email.Tests/TailoredApps.Shared.Email.Tests.csproj +++ b/tests/TailoredApps.Shared.Email.Tests/TailoredApps.Shared.Email.Tests.csproj @@ -7,19 +7,19 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/TailoredApps.Shared.EntityFramework.Tests/TailoredApps.Shared.EntityFramework.Tests.csproj b/tests/TailoredApps.Shared.EntityFramework.Tests/TailoredApps.Shared.EntityFramework.Tests.csproj index 0894c10..72c436d 100644 --- a/tests/TailoredApps.Shared.EntityFramework.Tests/TailoredApps.Shared.EntityFramework.Tests.csproj +++ b/tests/TailoredApps.Shared.EntityFramework.Tests/TailoredApps.Shared.EntityFramework.Tests.csproj @@ -9,13 +9,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/TailoredApps.Shared.MediatR.ML.Tests/TailoredApps.Shared.MediatR.ML.Tests.csproj b/tests/TailoredApps.Shared.MediatR.ML.Tests/TailoredApps.Shared.MediatR.ML.Tests.csproj index a9c3eb0..02519ee 100644 --- a/tests/TailoredApps.Shared.MediatR.ML.Tests/TailoredApps.Shared.MediatR.ML.Tests.csproj +++ b/tests/TailoredApps.Shared.MediatR.ML.Tests/TailoredApps.Shared.MediatR.ML.Tests.csproj @@ -6,15 +6,15 @@ - + - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj b/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj index 0774d42..ebad191 100644 --- a/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj +++ b/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj @@ -7,14 +7,14 @@ - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers; buildtransitive From d8ce2c21ab3e90a5eb1e038c5326868e900bd217 Mon Sep 17 00:00:00 2001 From: "@nieprzecietny_kowalski" Date: Wed, 20 May 2026 12:05:08 +0200 Subject: [PATCH 2/3] Add license-aware dependency bump script and workflow scripts/Bump-Deps.ps1 scans every csproj, queries nuget.org for the latest stable version of each PackageReference, and rewrites the Version attribute when: - the version is not a wildcard - the latest version's SPDX license is in the allowlist (default: MIT, Apache-2.0) - the latest version's SPDX matches the currently pinned version's SPDX (catches relicensing like MediatR 13+ Apache-2.0 -> RPL-1.5) .github/workflows/bump-deps.yml runs the script on a weekly schedule and on manual dispatch, verifies the build, then opens a PR against master via peter-evans/create-pull-request. Workflow_dispatch supports a dry-run input and a custom license allowlist. --- .github/workflows/bump-deps.yml | 78 +++++++++++++ scripts/Bump-Deps.ps1 | 190 ++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 .github/workflows/bump-deps.yml create mode 100644 scripts/Bump-Deps.ps1 diff --git a/.github/workflows/bump-deps.yml b/.github/workflows/bump-deps.yml new file mode 100644 index 0000000..4d3fcfa --- /dev/null +++ b/.github/workflows/bump-deps.yml @@ -0,0 +1,78 @@ +name: 'Bump dependencies' + +on: + workflow_dispatch: + inputs: + allowed-licenses: + description: 'Comma-separated SPDX allowlist' + required: false + default: 'MIT,Apache-2.0' + dry-run: + description: 'Dry run (no commits, no PR)' + required: false + default: 'false' + schedule: + # Weekly, Mondays at 06:00 UTC + - cron: '0 6 * * 1' + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Run bump script + id: bump + shell: pwsh + run: | + $licenses = '${{ inputs.allowed-licenses || ''MIT,Apache-2.0'' }}' -split ',' + $dryRun = '${{ inputs.dry-run }}' -eq 'true' + $args = @{ + AllowedLicenses = $licenses + GithubOutput = $true + } + if ($dryRun) { $args.DryRun = $true } + ./scripts/Bump-Deps.ps1 @args + + - name: Verify build + if: steps.bump.outputs.bumped == 'true' && inputs.dry-run != 'true' + run: | + dotnet restore TailoredApps.Shared.sln + dotnet build TailoredApps.Shared.sln --no-restore --nologo + + - name: Create pull request + if: steps.bump.outputs.bumped == 'true' && inputs.dry-run != 'true' + uses: peter-evans/create-pull-request@v7 + with: + commit-message: 'chore(deps): automated dependency bump' + title: 'chore(deps): automated dependency bump' + body: | + Automated bump produced by `.github/workflows/bump-deps.yml`. + + Allowed licenses: `${{ inputs.allowed-licenses || 'MIT,Apache-2.0' }}` + + ### Bumps and skips + See workflow run log for the full BUMP/SKIP report. + Packages with license drift or licenses outside the allowlist are skipped automatically. + + ### Test plan + - [x] `dotnet restore` (workflow) + - [x] `dotnet build` (workflow) + - [ ] CI green + - [ ] Spot-check API surface for any breaking changes + branch: chore/auto-bump-deps + base: master + delete-branch: true + labels: dependencies, automated diff --git a/scripts/Bump-Deps.ps1 b/scripts/Bump-Deps.ps1 new file mode 100644 index 0000000..721d290 --- /dev/null +++ b/scripts/Bump-Deps.ps1 @@ -0,0 +1,190 @@ +<# +.SYNOPSIS + Bump NuGet PackageReference versions to latest stable, with license-awareness. + +.DESCRIPTION + Scans all *.csproj files, queries nuget.org for latest stable versions, and + rewrites values when safe. + + Rules: + - Wildcard versions (e.g. "10.*", "2.*") are left alone — they self-update at restore. + - The latest version's SPDX license expression must be in -AllowedLicenses. + - If the SPDX license of the currently-pinned version differs from the latest's, + the bump is SKIPPED (license-change rule — catches relicensing like MediatR). + - Packages with no declared license expression are skipped by default + (override with -AllowUndeclaredLicense). + - Pre-release versions are ignored. + +.PARAMETER AllowedLicenses + SPDX expressions considered acceptable. Defaults to MIT and Apache-2.0. + +.PARAMETER AllowUndeclaredLicense + If set, packages whose latest version has no licenseExpression are bumped anyway. + +.PARAMETER DryRun + Print the bump plan but do not modify files. + +.PARAMETER Path + Root path to scan. Defaults to current directory. + +.PARAMETER GithubOutput + If set, also writes a summary line to $env:GITHUB_OUTPUT for CI consumption + (key: bumped, value: true|false). + +.EXAMPLE + .\scripts\Bump-Deps.ps1 -DryRun +.EXAMPLE + .\scripts\Bump-Deps.ps1 -AllowedLicenses MIT,Apache-2.0,BSD-3-Clause +#> +[CmdletBinding()] +param( + [string[]]$AllowedLicenses = @('MIT', 'Apache-2.0'), + [switch]$AllowUndeclaredLicense, + [switch]$DryRun, + [string]$Path = '.', + [switch]$GithubOutput +) + +$ErrorActionPreference = 'Stop' + +$script:cache = @{} +function Get-PackageInfo { + param([string]$Name) + if ($script:cache.ContainsKey($Name)) { return $script:cache[$Name] } + $lower = $Name.ToLowerInvariant() + $url = "https://api.nuget.org/v3/registration5-gz-semver2/$lower/index.json" + try { + $info = Invoke-RestMethod -Uri $url -ErrorAction Stop + } catch { + $info = $null + } + $script:cache[$Name] = $info + return $info +} + +function Get-CatalogEntries { + param($Info) + $entries = @() + foreach ($page in $Info.items) { + # Some pages are inlined; others must be fetched. + if ($page.items) { + $entries += $page.items + } else { + try { + $inner = Invoke-RestMethod -Uri $page.'@id' -ErrorAction Stop + if ($inner.items) { $entries += $inner.items } + } catch { } + } + } + return $entries +} + +function Get-LatestStableEntry { + param($Entries) + $stable = $Entries | + Where-Object { $_.catalogEntry.listed -ne $false -and $_.catalogEntry.version -notmatch '-' } + if (-not $stable) { return $null } + return ($stable | + Sort-Object { + $v = $_.catalogEntry.version -replace '\+.*$','' + try { [version]$v } catch { [version]'0.0.0.0' } + } | + Select-Object -Last 1).catalogEntry +} + +function Get-LicenseForVersion { + param($Entries, [string]$Version) + foreach ($e in $Entries) { + if ($e.catalogEntry.version -eq $Version) { return $e.catalogEntry.licenseExpression } + } + return $null +} + +$root = Resolve-Path $Path +$csprojs = Get-ChildItem -Path $root -Recurse -Filter *.csproj +Write-Host "Scanning $($csprojs.Count) csproj files under $root" -ForegroundColor Cyan + +$bumpedAny = $false +$summary = @() + +foreach ($file in $csprojs) { + $xml = New-Object System.Xml.XmlDocument + $xml.PreserveWhitespace = $true + $xml.Load($file.FullName) + + $changed = $false + $refs = $xml.SelectNodes('//PackageReference') + foreach ($ref in $refs) { + $name = $ref.GetAttribute('Include') + if (-not $name) { continue } + $cur = $ref.GetAttribute('Version') + if (-not $cur) { continue } + if ($cur -match '\*') { continue } # leave wildcards alone + + $info = Get-PackageInfo -Name $name + if (-not $info) { + Write-Warning "no nuget metadata for $name (skipping)" + continue + } + $entries = Get-CatalogEntries -Info $info + if (-not $entries) { + Write-Warning "no catalog entries for $name (skipping)" + continue + } + $latest = Get-LatestStableEntry -Entries $entries + if (-not $latest) { continue } + if ($latest.version -eq $cur) { continue } + + $curLic = Get-LicenseForVersion -Entries $entries -Version $cur + $latestLic = $latest.licenseExpression + + if (-not $latestLic) { + if (-not $AllowUndeclaredLicense) { + Write-Host "SKIP $name ${cur} -> $($latest.version): no declared license" -ForegroundColor Yellow + $summary += "SKIP $name -> $($latest.version) (no declared license)" + continue + } + } elseif ($curLic -and $curLic -ne $latestLic) { + Write-Host "SKIP $name ${cur} -> $($latest.version): license changed ($curLic -> $latestLic)" -ForegroundColor Yellow + $summary += "SKIP $name -> $($latest.version) (license $curLic -> $latestLic)" + continue + } elseif ($latestLic -notin $AllowedLicenses) { + Write-Host "SKIP $name ${cur} -> $($latest.version): license $latestLic not in allowlist" -ForegroundColor Yellow + $summary += "SKIP $name -> $($latest.version) (license $latestLic not allowed)" + continue + } + + Write-Host "BUMP $name ${cur} -> $($latest.version) ($latestLic) in $($file.Name)" -ForegroundColor Green + $summary += "BUMP $name $cur -> $($latest.version) ($latestLic)" + $ref.SetAttribute('Version', $latest.version) + $changed = $true + $bumpedAny = $true + } + + if ($changed -and -not $DryRun) { + # Preserve file encoding (utf-8 no BOM is the .NET default for csproj). + $settings = New-Object System.Xml.XmlWriterSettings + $settings.OmitXmlDeclaration = -not $xml.FirstChild.NodeType.Equals([System.Xml.XmlNodeType]::XmlDeclaration) + $settings.Encoding = New-Object System.Text.UTF8Encoding($false) + $settings.Indent = $false + $writer = [System.Xml.XmlWriter]::Create($file.FullName, $settings) + try { $xml.Save($writer) } finally { $writer.Dispose() } + } +} + +Write-Host "" +Write-Host "=== Summary ===" -ForegroundColor Cyan +if ($summary.Count -eq 0) { + Write-Host "No changes." -ForegroundColor Gray +} else { + $summary | ForEach-Object { Write-Host $_ } +} + +if ($GithubOutput -and $env:GITHUB_OUTPUT) { + "bumped=$($bumpedAny.ToString().ToLower())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + $sumPath = Join-Path ([System.IO.Path]::GetTempPath()) "bump-summary.txt" + $summary | Out-File -FilePath $sumPath -Encoding utf8 + "summary-file=$sumPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 +} + +if ($DryRun) { Write-Host "(dry run — no files modified)" -ForegroundColor Gray } From 1b68f6f56904d99cf20240565f480393b53066b4 Mon Sep 17 00:00:00 2001 From: "@nieprzecietny_kowalski" Date: Wed, 20 May 2026 12:53:54 +0200 Subject: [PATCH 3/3] Add ignore-list guard with version ceiling to bump script Some packages relicense without publishing an SPDX expression on NuGet (e.g. MediatR 13+ went Apache-2.0 -> RPL-1.5, but neither 12.x nor 14.x publishes a licenseExpression). The license-change rule cannot detect this. Add an explicit -Ignore hashtable mapping package name to exclusive version ceiling; the script clamps the latest candidate to the highest stable version below that ceiling, or skips entirely if the ceiling value is $null. Default ignore entry blocks MediatR >= 13.0.0. --- scripts/Bump-Deps.ps1 | 57 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/scripts/Bump-Deps.ps1 b/scripts/Bump-Deps.ps1 index 721d290..133e5ff 100644 --- a/scripts/Bump-Deps.ps1 +++ b/scripts/Bump-Deps.ps1 @@ -31,6 +31,12 @@ If set, also writes a summary line to $env:GITHUB_OUTPUT for CI consumption (key: bumped, value: true|false). +.PARAMETER Ignore + Hashtable of package name -> max allowed version (exclusive ceiling) or $null + to block all bumps. Used to enforce known license-change cutoffs that the + SPDX check cannot detect (e.g. MediatR 13+ switched to RPL-1.5 without + declaring SPDX). Names are matched case-insensitively. + .EXAMPLE .\scripts\Bump-Deps.ps1 -DryRun .EXAMPLE @@ -42,9 +48,19 @@ param( [switch]$AllowUndeclaredLicense, [switch]$DryRun, [string]$Path = '.', - [switch]$GithubOutput + [switch]$GithubOutput, + [hashtable]$Ignore = @{ + # MediatR 13+ relicensed Apache-2.0 -> RPL-1.5 (Lucky Penny Software). + # SPDX is not published, so the license-change rule cannot catch it. + 'MediatR' = '13.0.0' + } ) +# Normalize ignore keys to lowercase for case-insensitive lookup. +$normalizedIgnore = @{} +foreach ($k in $Ignore.Keys) { $normalizedIgnore[$k.ToLowerInvariant()] = $Ignore[$k] } +$Ignore = $normalizedIgnore + $ErrorActionPreference = 'Stop' $script:cache = @{} @@ -121,6 +137,16 @@ foreach ($file in $csprojs) { if (-not $cur) { continue } if ($cur -match '\*') { continue } # leave wildcards alone + $lname = $name.ToLowerInvariant() + if ($Ignore.ContainsKey($lname)) { + $ceiling = $Ignore[$lname] + if ($null -eq $ceiling) { + Write-Host "SKIP $name (ignore list, all bumps blocked)" -ForegroundColor Yellow + $summary += "SKIP $name (ignore list)" + continue + } + } + $info = Get-PackageInfo -Name $name if (-not $info) { Write-Warning "no nuget metadata for $name (skipping)" @@ -133,6 +159,35 @@ foreach ($file in $csprojs) { } $latest = Get-LatestStableEntry -Entries $entries if (-not $latest) { continue } + + # Apply ignore-list version ceiling, if any. + if ($Ignore.ContainsKey($lname) -and $null -ne $Ignore[$lname]) { + $ceiling = [version]($Ignore[$lname]) + try { $latestVer = [version]($latest.version -replace '\+.*$','') } catch { $latestVer = $null } + if ($latestVer -and $latestVer -ge $ceiling) { + # Latest is at or above ceiling — find highest stable strictly below it. + $belowCeiling = $entries | + Where-Object { + $_.catalogEntry.listed -ne $false -and + $_.catalogEntry.version -notmatch '-' + } | + Where-Object { + try { ([version]($_.catalogEntry.version -replace '\+.*$','')) -lt $ceiling } catch { $false } + } | + Sort-Object { + try { [version]($_.catalogEntry.version -replace '\+.*$','') } catch { [version]'0.0.0.0' } + } | + Select-Object -Last 1 + if (-not $belowCeiling) { + Write-Host "SKIP $name ${cur} -> $($latest.version): ignore-list ceiling $ceiling, no version below it" -ForegroundColor Yellow + $summary += "SKIP $name (no version below ignore ceiling $ceiling)" + continue + } + Write-Host "CLAMP $name latest=$($latest.version) -> $($belowCeiling.catalogEntry.version) (ignore ceiling $ceiling)" -ForegroundColor DarkYellow + $latest = $belowCeiling.catalogEntry + } + } + if ($latest.version -eq $cur) { continue } $curLic = Get-LicenseForVersion -Entries $entries -Version $cur