diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5726c68..7e78511 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,7 +13,13 @@ # on every download instead. # # After Dependabot bumps a version in Build/Dependencies, .github/workflows/dependency-lock-sync.yml -# refreshes the matching SHA-256 in the lock file. PR validation fails while the two disagree. +# refreshes the matching SHA-256 in the lock file and the version the SBOM inventory in +# Build/Dependencies.psd1 reports. It sweeps the open Dependabot pull requests weekly, so a bump +# arrives already synced. PR validation fails while any of the three disagree. +# +# Note that GitHub reads this file from the default branch only, so an ignore rule added here takes +# effect on Dependabot's next run - it does not retroactively close pull requests that are already +# open. Those have to be closed by hand. version: 2 updates: - package-ecosystem: "github-actions" @@ -49,6 +55,25 @@ updates: update-types: - "version-update:semver-major" + # The rest of the System.Text.Json closure - the Group = "SystemTextJson" artefacts in + # OmadaWeb.PS/DependencyLock.psd1. These are not independently upgradable: the module loads + # them with Assembly.LoadFrom, which applies no binding redirects, so the versions that get + # loaded have to be the ones the pinned System.Text.Json actually resolves. A bump of a single + # member is therefore never a change this module can take on its own - it moves when + # System.Text.Json moves, and Build/Update-DependencyLock.ps1 -Check enforces that these entries + # exist so a closure member can never be added to the lock without landing here too. + # + # Listing them without update-types ignores version updates only. They stay in the dependency + # graph and a published advisory against any of them still raises an alert, exactly as with + # Selenium.WebDriver in Legacy/ below. + - dependency-name: "Microsoft.Bcl.AsyncInterfaces" + - dependency-name: "System.Text.Encodings.Web" + - dependency-name: "System.Threading.Tasks.Extensions" + - dependency-name: "System.Runtime.CompilerServices.Unsafe" + - dependency-name: "System.Buffers" + - dependency-name: "System.Memory" + - dependency-name: "System.ValueTuple" + - package-ecosystem: "nuget" directory: "/Build/Dependencies/Legacy" schedule: diff --git a/.github/workflows/dependency-lock-sync.yml b/.github/workflows/dependency-lock-sync.yml index 60ee4ca..652a362 100644 --- a/.github/workflows/dependency-lock-sync.yml +++ b/.github/workflows/dependency-lock-sync.yml @@ -1,23 +1,35 @@ name: Dependency Lock Sync -# Refreshes OmadaWeb.PS/DependencyLock.psd1 after a version changed in Build/Dependencies. +# Refreshes OmadaWeb.PS/DependencyLock.psd1 and Build/Dependencies.psd1 after a version changed in +# Build/Dependencies. # # The module verifies every binary it downloads against the SHA-256 pinned in the lock file, so a -# version bump is only half an update: the hash has to be recomputed from the newly published -# package. PR Validation fails while the two disagree, so this is the step that makes such a pull -# request mergeable. +# version bump is only part of an update: the hash has to be recomputed from the newly published +# package, and the SBOM has to report the version that is actually loaded. PR Validation fails while +# any of the three disagree, so this is the step that makes such a pull request mergeable. # -# Run it from the Actions tab against the branch that carries the bump - typically a Dependabot -# branch - or do the same thing locally with: +# It runs weekly, a few hours after Dependabot's Monday run, over every open Dependabot pull request +# against Build/Dependencies - so a bump arrives already synced rather than waiting for someone to +# notice a red pull request. It can also be started by hand from the Actions tab for a single branch, +# which is what to use after a rebase. The same thing locally is: # # ./Build/Update-DependencyLock.ps1 -Refresh # -# Deliberately NOT triggered automatically on Dependabot's pull requests. Workflows triggered by -# Dependabot run with a read-only GITHUB_TOKEN that the permissions key cannot elevate, so an -# automatic push would fail with 403 on exactly the pull requests it is meant to serve. Making it -# work anyway would mean pull_request_target with a checkout of the pull request head, which hands a -# write-scoped token to code from the branch under review - not a trade this module should make to -# save one click. The -Check gate in PR Validation still blocks an unsynced bump from merging. +# Two things worth knowing before reading the jobs below. +# +# Trust. The sweep pushes to a branch it did not write, so it must not *execute* anything from that +# branch. It checks out two trees - the default branch and the pull request branch - and runs the +# script from the default branch against the other one as data, via -RepositoryRoot. Nothing from the +# pull request branch runs with the write-scoped token. This is also why the workflow is triggered by +# schedule and workflow_dispatch rather than by the pull request itself: both run in the context of +# the default branch, so the token is a normal one and no pull_request_target is involved. +# +# Consequences on the pull request. Pushing to a Dependabot branch stops Dependabot from rebasing it +# automatically; comment `@dependabot recreate` to get a fresh one. And because PR Validation is +# triggered by an `/validate` comment rather than by a push, syncing the lock does not revalidate the +# pull request - a maintainer still comments `/validate`, and the -Check gate still blocks an unsynced +# bump from merging. The human decision to take a version is unchanged; only the mechanical part is +# automated. on: workflow_dispatch: @@ -26,41 +38,121 @@ on: description: 'Branch carrying the version bump (e.g. dependabot/nuget/Build/Dependencies/Selenium.WebDriver-4.48.0)' required: true type: string + schedule: + # Monday 06:00 UTC. Dependabot is scheduled for Monday in .github/dependabot.yml and opens its + # pull requests in the small hours, so by now there is something to sync. + - cron: '0 6 * * 1' permissions: - contents: write + contents: read concurrency: - group: dependency-lock-sync-${{ inputs.branch }} - cancel-in-progress: true + # A manual run for one branch is independent of the weekly sweep, so it gets its own group. Two + # runs are never cancelled in favour of each other: the loser would leave a branch half-synced. + group: dependency-lock-sync-${{ github.event.inputs.branch || 'sweep' }} + cancel-in-progress: false jobs: + discover: + name: Find branches to sync + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + branches: ${{ steps.list.outputs.branches }} + count: ${{ steps.list.outputs.count }} + + steps: + - name: List the branches carrying a bump + id: list + uses: actions/github-script@v9 + with: + script: | + // workflow_dispatch names one branch explicitly and skips discovery entirely. + if (context.eventName === 'workflow_dispatch') { + const branch = context.payload.inputs.branch; + core.setOutput('branches', JSON.stringify([branch])); + core.setOutput('count', '1'); + return; + } + + const pulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + // Only Dependabot's own branches, and only those touching the manifests this lock + // tracks. Anything else either has no pins to refresh or is not ours to push to. + const branches = pulls + .filter(pull => pull.user.login === 'dependabot[bot]') + .filter(pull => pull.head.repo?.full_name === `${context.repo.owner}/${context.repo.repo}`) + .filter(pull => pull.head.ref.startsWith('dependabot/nuget/Build/Dependencies')) + .map(pull => pull.head.ref); + + core.info(`Branches to sync: ${branches.length ? branches.join(', ') : '(none)'}`); + core.setOutput('branches', JSON.stringify(branches)); + core.setOutput('count', String(branches.length)); + sync: name: Refresh pinned hashes + needs: discover + if: needs.discover.outputs.count != '0' runs-on: windows-latest + permissions: + contents: write + strategy: + # One failing branch must not stop the others from being synced. + fail-fast: false + matrix: + branch: ${{ fromJson(needs.discover.outputs.branches) }} steps: - - name: Checkout the branch + - name: Checkout the default branch + # The trusted tree. Only the script from here is ever executed. + uses: actions/checkout@v7 + with: + path: trusted + persist-credentials: false + + - name: Checkout the branch carrying the bump + # The untrusted tree. Read as data, written to, pushed - never executed. uses: actions/checkout@v7 with: - ref: ${{ inputs.branch }} + ref: ${{ matrix.branch }} + path: bump - - name: Refresh the dependency lock + - name: Refresh the dependency lock and the SBOM shell: pwsh - run: ./Build/Update-DependencyLock.ps1 -Refresh + # The Dependabot configuration is read from the trusted tree rather than from the branch, + # because that is the copy Dependabot itself obeys: it only ever reads dependabot.yml from the + # default branch. Taking it from the branch would fail the closure ignore-policy check on any + # bump opened before those rules landed - refusing to sync a branch over a rule that does not + # apply to it. PR Validation still checks the branch's own copy, where it belongs. + run: >- + ./trusted/Build/Update-DependencyLock.ps1 -Refresh + -RepositoryRoot (Resolve-Path ./bump).Path + -DependabotConfigPath (Resolve-Path ./trusted/.github/dependabot.yml).Path - - name: Commit the refreshed lock + - name: Commit the refreshed pins shell: pwsh + working-directory: bump + # The branch name arrives through the environment rather than being interpolated into the + # script, so a branch whose name contains shell metacharacters cannot become code here. + env: + BUMP_BRANCH: ${{ matrix.branch }} run: | git config user.email "devops@fortigi.nl" git config user.name "GitHub Actions" - $changed = git status --porcelain -- OmadaWeb.PS/DependencyLock.psd1 + $changed = git status --porcelain -- OmadaWeb.PS/DependencyLock.psd1 Build/Dependencies.psd1 if ([string]::IsNullOrWhiteSpace($changed)) { - Write-Host "Lock file already matches the manifest; nothing to commit." + Write-Host "Pins already match the manifest; nothing to commit." exit 0 } - git add OmadaWeb.PS/DependencyLock.psd1 - git commit -m "deps: refresh pinned SHA-256 hashes for the bumped package" - git push origin HEAD:${{ inputs.branch }} + git add OmadaWeb.PS/DependencyLock.psd1 Build/Dependencies.psd1 + git commit -m "deps: refresh pinned SHA-256 hashes and SBOM versions for the bumped package" + git push origin "HEAD:$env:BUMP_BRANCH" diff --git a/Build/Dependencies.psd1 b/Build/Dependencies.psd1 index bbfc6dc..7f58baa 100644 --- a/Build/Dependencies.psd1 +++ b/Build/Dependencies.psd1 @@ -151,7 +151,7 @@ Publisher = "Microsoft" Purl = "pkg:nuget/Microsoft.Bcl.AsyncInterfaces" Version = "8.0.0" - VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json 8.0.5 closure, and verified by SHA-256 before loading." + VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json closure, and verified by SHA-256 before loading. It moves only when System.Text.Json moves, so .github/dependabot.yml ignores version updates for it." LockId = "Microsoft.Bcl.AsyncInterfaces" Source = "https://www.nuget.org/packages/Microsoft.Bcl.AsyncInterfaces" Website = "https://dot.net/" @@ -169,7 +169,7 @@ Publisher = "Microsoft" Purl = "pkg:nuget/System.Text.Encodings.Web" Version = "8.0.0" - VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json 8.0.5 closure, and verified by SHA-256 before loading." + VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json closure, and verified by SHA-256 before loading. It moves only when System.Text.Json moves, so .github/dependabot.yml ignores version updates for it." LockId = "System.Text.Encodings.Web" Source = "https://www.nuget.org/packages/System.Text.Encodings.Web" Website = "https://dot.net/" @@ -187,7 +187,7 @@ Publisher = "Microsoft" Purl = "pkg:nuget/System.Threading.Tasks.Extensions" Version = "4.5.4" - VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json 8.0.5 closure, and verified by SHA-256 before loading." + VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json closure, and verified by SHA-256 before loading. It moves only when System.Text.Json moves, so .github/dependabot.yml ignores version updates for it." LockId = "System.Threading.Tasks.Extensions" Source = "https://www.nuget.org/packages/System.Threading.Tasks.Extensions" Website = "https://dot.net/" @@ -205,7 +205,7 @@ Publisher = "Microsoft" Purl = "pkg:nuget/System.Runtime.CompilerServices.Unsafe" Version = "4.5.3" - VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json 8.0.5 closure, and verified by SHA-256 before loading." + VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json closure, and verified by SHA-256 before loading. It moves only when System.Text.Json moves, so .github/dependabot.yml ignores version updates for it." LockId = "System.Runtime.CompilerServices.Unsafe" Source = "https://www.nuget.org/packages/System.Runtime.CompilerServices.Unsafe" Website = "https://dot.net/" @@ -223,7 +223,7 @@ Publisher = "Microsoft" Purl = "pkg:nuget/System.Buffers" Version = "4.5.1" - VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json 8.0.5 closure, and verified by SHA-256 before loading." + VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json closure, and verified by SHA-256 before loading. It moves only when System.Text.Json moves, so .github/dependabot.yml ignores version updates for it." LockId = "System.Buffers" Source = "https://www.nuget.org/packages/System.Buffers" Website = "https://dot.net/" @@ -241,7 +241,7 @@ Publisher = "Microsoft" Purl = "pkg:nuget/System.Memory" Version = "4.5.5" - VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json 8.0.5 closure, and verified by SHA-256 before loading." + VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json closure, and verified by SHA-256 before loading. It moves only when System.Text.Json moves, so .github/dependabot.yml ignores version updates for it." LockId = "System.Memory" Source = "https://www.nuget.org/packages/System.Memory" Website = "https://dot.net/" @@ -259,7 +259,7 @@ Publisher = "Microsoft" Purl = "pkg:nuget/System.ValueTuple" Version = "4.5.0" - VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json 8.0.5 closure, and verified by SHA-256 before loading." + VersionStrategy = "Pinned in OmadaWeb.PS/DependencyLock.psd1 as part of the System.Text.Json closure, and verified by SHA-256 before loading. It moves only when System.Text.Json moves, so .github/dependabot.yml ignores version updates for it." LockId = "System.ValueTuple" Source = "https://www.nuget.org/packages/System.ValueTuple" Website = "https://dot.net/" diff --git a/Build/Update-DependencyLock.ps1 b/Build/Update-DependencyLock.ps1 index f923f37..589664a 100644 --- a/Build/Update-DependencyLock.ps1 +++ b/Build/Update-DependencyLock.ps1 @@ -12,23 +12,39 @@ bumps a version there, this script recomputes the matching URL and hash, and PR validation fails for as long as the two disagree. + A bump touches three files, not one: the version in the Dependabot manifest, the version and + SHA-256 in the lock file, and the version the SBOM inventory in Build/Dependencies.psd1 reports + for the same component. This script keeps the second and third in step with the first. + -Check validates the lock file and reports drift without changing anything. It is what runs in CI: schema and formatting, one entry per artefact, versions matching the manifests, every - -ArtifactId used in the module present in the lock, and - unless -SkipDownload is given - the - published bytes still hashing to what is pinned. + -ArtifactId used in the module present in the lock, the SBOM reporting the versions that are + actually pinned, every member of the System.Text.Json closure covered by an ignore rule in + .github/dependabot.yml, and - unless -SkipDownload is given - the published bytes still hashing to + what is pinned. -Refresh takes the versions from the manifests, downloads each artefact, and writes back the - version, URL and hash of anything that moved. Only those three values are rewritten, in place, so - comments and the descriptive fields are preserved. + version, URL and hash of anything that moved, plus the matching version in the SBOM inventory. + Only those values are rewritten, in place, so comments and the descriptive fields are preserved. .PARAMETER Check Report drift and exit non-zero if any is found. Changes nothing. .PARAMETER Refresh - Rewrite version, URL and SHA-256 for artefacts whose manifest version has moved. + Rewrite version, URL and SHA-256 for artefacts whose manifest version has moved, and the version + of the SBOM component that mirrors each of them. .PARAMETER SkipDownload Skip everything that needs the network, leaving only the offline consistency checks. Only valid with -Check. .PARAMETER LockPath - Path to the lock file. Defaults to OmadaWeb.PS/DependencyLock.psd1 next to this script. + Path to the lock file. Defaults to OmadaWeb.PS/DependencyLock.psd1 under -RepositoryRoot. +.PARAMETER InventoryPath + Path to the SBOM inventory. Defaults to Build/Dependencies.psd1 under -RepositoryRoot. +.PARAMETER DependabotConfigPath + Path to the Dependabot configuration the ignore policy is read from. Defaults to + .github/dependabot.yml under -RepositoryRoot. +.PARAMETER RepositoryRoot + Working tree the manifests, lock file, SBOM inventory and module sources are read from. Defaults + to the repository this script lives in; the scheduled sweep in dependency-lock-sync.yml points it + at a second checkout so a trusted copy of this script refreshes a pull request branch. .EXAMPLE ./Build/Update-DependencyLock.ps1 -Check @@ -47,13 +63,34 @@ param( [parameter(Mandatory = $false, ParameterSetName = "Check")] [switch]$SkipDownload, [parameter(Mandatory = $false)] - [string]$LockPath = (Join-Path $PSScriptRoot ".." | Join-Path -ChildPath "OmadaWeb.PS" | Join-Path -ChildPath "DependencyLock.psd1"), + [string]$LockPath, + [parameter(Mandatory = $false)] + [string]$InventoryPath, + [parameter(Mandatory = $false)] + [string]$DependabotConfigPath, [parameter(Mandatory = $false)] [string]$RepositoryRoot = (Join-Path $PSScriptRoot ".." | Convert-Path) ) $ErrorActionPreference = "Stop" +$Script:IsRefresh = $PSCmdlet.ParameterSetName -eq "Refresh" + +# All three files are resolved from -RepositoryRoot unless they are named explicitly, so pointing the +# script at a second working tree - which is how the scheduled sweep in dependency-lock-sync.yml runs +# a trusted copy of this script against a pull request branch - takes one parameter rather than four. +if ([string]::IsNullOrWhiteSpace($LockPath)) { + $LockPath = Join-Path $RepositoryRoot "OmadaWeb.PS" | Join-Path -ChildPath "DependencyLock.psd1" +} + +if ([string]::IsNullOrWhiteSpace($InventoryPath)) { + $InventoryPath = Join-Path $RepositoryRoot "Build" | Join-Path -ChildPath "Dependencies.psd1" +} + +if ([string]::IsNullOrWhiteSpace($DependabotConfigPath)) { + $DependabotConfigPath = Join-Path $RepositoryRoot ".github" | Join-Path -ChildPath "dependabot.yml" +} + $Problems = [System.Collections.Generic.List[string]]::new() function Add-Problem { @@ -63,6 +100,21 @@ function Add-Problem { $Message | Write-Host -ForegroundColor Red } +function Add-Drift { + # Drift between a manifest version and what the lock or the SBOM records is a build failure under + # -Check and the whole point of the run under -Refresh. Reporting it as a problem in both modes + # would make -Refresh exit non-zero on exactly the bumps it was invoked to resolve, which stops + # the caller - dependency-lock-sync.yml - from ever reaching its commit step. + param([string]$Message) + + if ($Script:IsRefresh) { + $Message | Write-Host -ForegroundColor Yellow + return + } + + Add-Problem $Message +} + function Get-ManifestVersion { # Reads the PackageReference versions out of a Dependabot manifest. These files are never built, # so they are parsed as plain XML rather than through MSBuild. @@ -84,6 +136,136 @@ function Get-ManifestVersion { return $Versions } +function Remove-YamlQuote { + # YAML scalars may be double-quoted, single-quoted or bare, and all three mean the same string. + # Reading only one of the forms would turn a harmless reformatting of dependabot.yml into a build + # failure, so the quotes are stripped rather than matched. + param([string]$Value) + + $Trimmed = $Value.Trim() + if ($Trimmed.Length -ge 2) { + $Quote = $Trimmed[0] + if (($Quote -eq '"' -or $Quote -eq "'") -and $Trimmed[$Trimmed.Length - 1] -eq $Quote) { + return $Trimmed.Substring(1, $Trimmed.Length - 2) + } + } + return $Trimmed +} + +function Get-IgnoredDependencyName { + # Collects the dependency-name entries Dependabot is told to ignore for one manifest directory. + # + # Parsed by hand rather than with a YAML module: this has to run on Windows PowerShell 5.1 in CI, + # where no YAML parser ships in the box, and the shape being read is a fixed two-level list this + # repository writes itself. Only entries under the requested directory are returned, so ignoring + # a package for Legacy/ does not silently satisfy a check about the main manifest. + param([string]$ConfigPath, [string]$Directory) + + $Names = @() + if (-not (Test-Path $ConfigPath -PathType Leaf)) { + Add-Problem ("Dependabot configuration '{0}' does not exist, so the ignore policy cannot be verified." -f $ConfigPath) + return $Names + } + + $InRequestedUpdate = $false + $InIgnoreList = $false + foreach ($Line in (Get-Content -Path $ConfigPath)) { + if ($Line -match '^\s*-\s+package-ecosystem\s*:') { + # A new update block ends whatever the previous one was saying. + $InRequestedUpdate = $false + $InIgnoreList = $false + continue + } + + if ($Line -match '^\s*directory\s*:\s*(.+?)\s*$') { + # A directory line starts a new scope even without an intervening package-ecosystem, so + # an ignore list already being read ends here. + $InRequestedUpdate = ((Remove-YamlQuote $Matches[1]) -eq $Directory) + $InIgnoreList = $false + continue + } + + if (-not $InRequestedUpdate) { + continue + } + + if ($Line -match '^\s*ignore\s*:\s*$') { + $InIgnoreList = $true + continue + } + + if ($InIgnoreList -and $Line -match '^\s*-\s+dependency-name\s*:\s*(.+?)\s*$') { + $Names += Remove-YamlQuote $Matches[1] + } + } + return $Names +} + +function Get-InventoryComponent { + # Reads the SBOM inventory, keyed by the lock artefact each component mirrors. Components that + # name no LockId - the ones whose version is resolved at runtime - are not tracked here. + param([string]$Path) + + $ByLockId = @{} + if (-not (Test-Path $Path -PathType Leaf)) { + Add-Problem ("SBOM inventory '{0}' does not exist." -f $Path) + return $ByLockId + } + + $Inventory = Import-PowerShellDataFile -Path $Path + foreach ($Component in $Inventory.Components) { + if ([string]::IsNullOrWhiteSpace($Component.LockId)) { + continue + } + + # Keying by LockId silently keeps the last of any duplicates, which would leave the drift + # check comparing only one of them while -Refresh rewrites both. The lock refuses ambiguous + # ids for the same reason; so does this. + if ($ByLockId.ContainsKey($Component.LockId)) { + Add-Problem ("SBOM components '{0}' and '{1}' both mirror lock artefact '{2}'; each artefact must be mirrored once." -f $ByLockId[$Component.LockId].Name, $Component.Name, $Component.LockId) + continue + } + + $ByLockId[$Component.LockId] = $Component + } + return $ByLockId +} + +function Set-InventoryVersion { + # Rewrites the Version of one SBOM component in place, leaving every other line untouched. + # + # Components are keyed by LockId, which sits *after* Version in the block, so the most recent + # Version line is remembered and rewritten once the matching LockId is reached. The @{ that opens + # each component resets that memory, so a component without a LockId can never have the Version of + # the block before it rewritten by mistake. + param( + [string[]]$Line, + [string]$LockId, + [string]$Version + ) + + $VersionIndex = -1 + for ($Index = 0; $Index -lt $Line.Count; $Index++) { + if ($Line[$Index] -match '^\s*@\{\s*$') { + $VersionIndex = -1 + continue + } + if ($Line[$Index] -match '^(\s*Version\s*=\s*)"[^"]*"\s*$') { + $VersionIndex = $Index + continue + } + if ($Line[$Index] -match '^\s*LockId\s*=\s*"([^"]+)"\s*$' -and $Matches[1] -eq $LockId) { + if ($VersionIndex -lt 0) { + Add-Problem ("SBOM component for lock artefact '{0}' has no Version line to update." -f $LockId) + continue + } + $Line[$VersionIndex] -match '^(\s*Version\s*=\s*)"[^"]*"\s*$' | Out-Null + $Line[$VersionIndex] = '{0}"{1}"' -f $Matches[1], $Version + } + } + return $Line +} + function Get-FlatContainerUrl { param([string]$PackageId, [string]$Version) @@ -205,7 +387,7 @@ foreach ($Artifact in $Artifacts) { Add-Problem ("Artefact '{0}' claims to be tracked by '{1}', but that manifest has no PackageReference for '{2}'. Without one it gets no Dependabot alerts." -f $Artifact.Id, $Artifact.Manifest, $Artifact.PackageId) } elseif ($Declared[$Artifact.PackageId] -ne $Artifact.Version) { - Add-Problem ("Artefact '{0}' is pinned at version '{1}' but '{2}' declares '{3}'. Run Build/Update-DependencyLock.ps1 -Refresh." -f $Artifact.Id, $Artifact.Version, $Artifact.Manifest, $Declared[$Artifact.PackageId]) + Add-Drift ("Artefact '{0}' is pinned at version '{1}' but '{2}' declares '{3}'. Run Build/Update-DependencyLock.ps1 -Refresh." -f $Artifact.Id, $Artifact.Version, $Artifact.Manifest, $Declared[$Artifact.PackageId]) } } @@ -230,17 +412,58 @@ foreach ($Artifact in $Artifacts) { } } +# The SBOM reports the versions the module actually downloads, so it is a third file that has to move +# with a bump - and the one nothing used to update. Left unchecked it goes stale silently and the +# module ships an inventory that disagrees with what it loads. +$InventoryComponents = Get-InventoryComponent -Path $InventoryPath +foreach ($Artifact in ($Artifacts | Where-Object { $_.Verification -eq "Sha256" })) { + if (-not $InventoryComponents.ContainsKey($Artifact.Id)) { + continue + } + + $Component = $InventoryComponents[$Artifact.Id] + if ($Component.Version -ne $Artifact.Version) { + Add-Drift ("Artefact '{0}' is pinned at version '{1}' but the SBOM component '{2}' in '{3}' reports '{4}'. Run Build/Update-DependencyLock.ps1 -Refresh." -f $Artifact.Id, $Artifact.Version, $Component.Name, $InventoryPath, $Component.Version) + } +} + +# Members of the System.Text.Json closure cannot be upgraded one at a time - Assembly.LoadFrom applies +# no binding redirects, so the versions loaded have to be the ones the pinned System.Text.Json +# resolves. .github/dependabot.yml therefore carries an ignore rule for each of them. Asserting that +# here is what stops the two from drifting: adding a closure member to this lock without ignoring it +# fails the build, instead of producing a pull request that can never go green. +$IgnoredNames = Get-IgnoredDependencyName -ConfigPath $DependabotConfigPath -Directory "/Build/Dependencies" +foreach ($Artifact in ($Artifacts | Where-Object { $_.Group -eq "SystemTextJson" })) { + if ($IgnoredNames -notcontains $Artifact.PackageId) { + Add-Problem ("Artefact '{0}' belongs to the System.Text.Json closure, but '{1}' has no ignore rule for '{2}'. Dependabot would propose bumping it on its own, which is not an update this module can take." -f $Artifact.Id, $DependabotConfigPath, $Artifact.PackageId) + } +} + #endregion #region network checks and refresh if ($PSCmdlet.ParameterSetName -eq "Refresh") { $Line = @(Get-Content -Path $LockPath) + + # A missing inventory was already reported as a problem by the offline checks, which makes the run + # fail at the end with that message. Reading it unguarded here would pre-empt that with a bare + # file-not-found instead. + $InventoryLine = @() + if (Test-Path $InventoryPath -PathType Leaf) { + $InventoryLine = @(Get-Content -Path $InventoryPath) + } + $Changed = 0 + $InventoryChanged = 0 foreach ($Artifact in ($Artifacts | Where-Object { $_.Verification -eq "Sha256" })) { - $ManifestPath = Join-Path $RepositoryRoot $Artifact.Manifest - $Declared = Get-ManifestVersion -ManifestPath $ManifestPath + # The offline checks above already parsed every manifest once, keyed by its relative path. + $Declared = @{} + if ($ManifestVersions.ContainsKey($Artifact.Manifest)) { + $Declared = $ManifestVersions[$Artifact.Manifest] + } + $Version = $Artifact.Version if ($Declared.ContainsKey($Artifact.PackageId)) { $Version = $Declared[$Artifact.PackageId] @@ -249,6 +472,14 @@ if ($PSCmdlet.ParameterSetName -eq "Refresh") { $Url = Get-FlatContainerUrl -PackageId $Artifact.PackageId -Version $Version $Sha256 = Get-RemoteSha256 -Url $Url + # The SBOM can be stale even when the pin is not - it was never refreshed before this - so it + # is reconciled against the version on every run, not only when the lock file moves. + if ($InventoryComponents.ContainsKey($Artifact.Id) -and $InventoryComponents[$Artifact.Id].Version -ne $Version) { + " {0}: SBOM {1} -> {2}" -f $Artifact.Id, $InventoryComponents[$Artifact.Id].Version, $Version | Write-Host -ForegroundColor Yellow + $InventoryLine = Set-InventoryVersion -Line $InventoryLine -LockId $Artifact.Id -Version $Version + $InventoryChanged++ + } + if ($Version -eq $Artifact.Version -and $Url -eq $Artifact.Url -and $Sha256 -eq $Artifact.Sha256) { " {0} {1} unchanged" -f $Artifact.Id, $Version | Write-Host continue @@ -264,13 +495,23 @@ if ($PSCmdlet.ParameterSetName -eq "Refresh") { $Changed++ } + # Written without a BOM and with CRLF, matching the rest of the repository. + $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) + if ($Changed -gt 0) { - # Written without a BOM and with CRLF, matching the rest of the repository. - [System.IO.File]::WriteAllText($LockPath, (($Line -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false))) + [System.IO.File]::WriteAllText($LockPath, (($Line -join "`r`n") + "`r`n"), $Utf8NoBom) "Updated {0} artefact(s) in '{1}'." -f $Changed, $LockPath | Write-Host -ForegroundColor Green } - else { - "No changes; every pin already matches its manifest and its published bytes." | Write-Host -ForegroundColor Green + + if ($InventoryChanged -gt 0) { + [System.IO.File]::WriteAllText($InventoryPath, (($InventoryLine -join "`r`n") + "`r`n"), $Utf8NoBom) + "Updated {0} component(s) in '{1}'." -f $InventoryChanged, $InventoryPath | Write-Host -ForegroundColor Green + } + + # Only claim everything agrees when nothing was reported; otherwise this line would sit directly + # above the failure and contradict it. + if ($Changed -eq 0 -and $InventoryChanged -eq 0 -and $Problems.Count -eq 0) { + "No changes; every pin already matches its manifest, its published bytes and the SBOM." | Write-Host -ForegroundColor Green } } elseif (-not $SkipDownload) { diff --git a/OmadaWeb.PS/DependencyLock.psd1 b/OmadaWeb.PS/DependencyLock.psd1 index f82cefd..b997d95 100644 --- a/OmadaWeb.PS/DependencyLock.psd1 +++ b/OmadaWeb.PS/DependencyLock.psd1 @@ -120,7 +120,7 @@ Group = "SystemTextJson" TargetFramework = "Net4OrNetStandard" InstalledBy = "Install-SystemTextJson" - PinReason = "Member of the System.Text.Json 8.0.5 dependency closure." + PinReason = "Member of the System.Text.Json dependency closure, held at the version that closure resolves. Assembly.LoadFrom applies no binding redirects, so it moves only when System.Text.Json moves; .github/dependabot.yml ignores version updates for it." Description = "Transitive dependency of System.Text.Json on .NET Framework." } @{ @@ -134,7 +134,7 @@ Group = "SystemTextJson" TargetFramework = "Net4OrNetStandard" InstalledBy = "Install-SystemTextJson" - PinReason = "Member of the System.Text.Json 8.0.5 dependency closure." + PinReason = "Member of the System.Text.Json dependency closure, held at the version that closure resolves. Assembly.LoadFrom applies no binding redirects, so it moves only when System.Text.Json moves; .github/dependabot.yml ignores version updates for it." Description = "Transitive dependency of System.Text.Json on .NET Framework." } @{ @@ -148,7 +148,7 @@ Group = "SystemTextJson" TargetFramework = "Net4OrNetStandard" InstalledBy = "Install-SystemTextJson" - PinReason = "Member of the System.Text.Json 8.0.5 dependency closure." + PinReason = "Member of the System.Text.Json dependency closure, held at the version that closure resolves. Assembly.LoadFrom applies no binding redirects, so it moves only when System.Text.Json moves; .github/dependabot.yml ignores version updates for it." Description = "Transitive dependency of System.Text.Json on .NET Framework." } @{ @@ -162,7 +162,7 @@ Group = "SystemTextJson" TargetFramework = "Net4OrNetStandard" InstalledBy = "Install-SystemTextJson" - PinReason = "Member of the System.Text.Json 8.0.5 dependency closure." + PinReason = "Member of the System.Text.Json dependency closure, held at the version that closure resolves. Assembly.LoadFrom applies no binding redirects, so it moves only when System.Text.Json moves; .github/dependabot.yml ignores version updates for it." Description = "Transitive dependency of System.Text.Json on .NET Framework." } @{ @@ -176,7 +176,7 @@ Group = "SystemTextJson" TargetFramework = "Net4OrNetStandard" InstalledBy = "Install-SystemTextJson" - PinReason = "Member of the System.Text.Json 8.0.5 dependency closure." + PinReason = "Member of the System.Text.Json dependency closure, held at the version that closure resolves. Assembly.LoadFrom applies no binding redirects, so it moves only when System.Text.Json moves; .github/dependabot.yml ignores version updates for it." Description = "Transitive dependency of System.Text.Json on .NET Framework." } @{ @@ -190,7 +190,7 @@ Group = "SystemTextJson" TargetFramework = "Net4OrNetStandard" InstalledBy = "Install-SystemTextJson" - PinReason = "Member of the System.Text.Json 8.0.5 dependency closure." + PinReason = "Member of the System.Text.Json dependency closure, held at the version that closure resolves. Assembly.LoadFrom applies no binding redirects, so it moves only when System.Text.Json moves; .github/dependabot.yml ignores version updates for it." Description = "Transitive dependency of System.Text.Json on .NET Framework." } @{ @@ -204,7 +204,7 @@ Group = "SystemTextJson" TargetFramework = "Net4OrNetStandard" InstalledBy = "Install-SystemTextJson" - PinReason = "Member of the System.Text.Json 8.0.5 dependency closure." + PinReason = "Member of the System.Text.Json dependency closure, held at the version that closure resolves. Assembly.LoadFrom applies no binding redirects, so it moves only when System.Text.Json moves; .github/dependabot.yml ignores version updates for it." Description = "Transitive dependency of System.Text.Json on .NET Framework." } @{ diff --git a/SECURITY.md b/SECURITY.md index 558770d..e218dde 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -115,30 +115,51 @@ puts them in this repository's dependency graph, which is what makes Dependabot security-update pull requests possible for components that are never restored from a package manifest. -The flow after a bump — whether Dependabot proposes it or a maintainer does — is: +A bump touches three files, not one. The version lives in `Build/Dependencies`, the version and +SHA-256 live in the lock file, and the SBOM inventory in `Build/Dependencies.psd1` reports the version +that is actually loaded. The flow after a bump — whether Dependabot proposes it or a maintainer does +— is: 1. the version changes in `Build/Dependencies`; -2. the pinned hash is refreshed on that branch — either by running - [`.github/workflows/dependency-lock-sync.yml`](.github/workflows/dependency-lock-sync.yml) from - the Actions tab against it, or locally with the command below. It is not automatic: workflows - triggered by Dependabot get a read-only token, and the alternative that works around that would - hand a write-scoped token to code from the branch being reviewed; -3. PR Validation runs `Build/Update-DependencyLock.ps1 -Check`, which fails while the lock file and - the manifests disagree, or while a pinned hash no longer matches what the URL serves. +2. [`.github/workflows/dependency-lock-sync.yml`](.github/workflows/dependency-lock-sync.yml) + refreshes the pinned hash and the SBOM version on that branch. It sweeps every open Dependabot pull + request weekly, and can be run from the Actions tab against a single branch — after a rebase, say. + The sweep checks out two trees and runs the script from the default branch against the pull request + branch as data, so nothing from the branch under review executes with a write-scoped token; +3. PR Validation runs `Build/Update-DependencyLock.ps1 -Check`, which fails while the lock file, the + manifests and the SBOM disagree, or while a pinned hash no longer matches what the URL serves. + +Syncing a branch does not revalidate it — PR Validation is triggered by a `/validate` comment — so a +maintainer still validates and merges deliberately. Only the mechanical part is automated. Note that +pushing to a Dependabot branch also stops Dependabot rebasing it; `@dependabot recreate` gets a fresh +one. To do it by hand: ```powershell -./Build/Update-DependencyLock.ps1 -Refresh # repin versions and hashes from Build/Dependencies +./Build/Update-DependencyLock.ps1 -Refresh # repin versions, hashes and SBOM from Build/Dependencies ./Build/Update-DependencyLock.ps1 -Check # verify without changing anything ``` -Two pins are deliberately held back, both recorded with a `PinReason` in the lock file: -`Selenium.WebDriver` for Windows PowerShell 5.1 stays at 4.11.0, the last release that still ships a -`net4*` build, and `System.Text.Json` stays on the 8.x line, the last one that still targets -`net462`. Both still receive advisories — the frozen Selenium pin has its own manifest under -`Build/Dependencies/Legacy` for exactly that reason — but an advisory against either needs a human -decision rather than an automatic bump. +Some pins are deliberately held back, each recorded with a `PinReason` in the lock file, and each with +a matching `ignore` rule in [`.github/dependabot.yml`](.github/dependabot.yml) so Dependabot does not +keep proposing an update that cannot be taken: + +- `Selenium.WebDriver` for Windows PowerShell 5.1 stays at 4.11.0, the last release that still ships a + `net4*` build; +- `System.Text.Json` stays on the 8.x line, the last one that still targets `net462`; +- the rest of the System.Text.Json closure moves only when System.Text.Json does. The module loads + those assemblies with `Assembly.LoadFrom`, which applies no binding redirects, so the versions + loaded have to be the ones the pinned System.Text.Json resolves — bumping one on its own is not a + change this module can take. + +`-Check` asserts that last rule against the Dependabot configuration itself, so a closure member +cannot be added to the lock without an ignore rule landing with it. + +All of them still receive advisories — the frozen Selenium pin has its own manifest under +`Build/Dependencies/Legacy` for exactly that reason, and `ignore` suppresses version updates without +suppressing alerts — but an advisory against any of them needs a human decision rather than an +automatic bump. ### Why the WebView2 assemblies are bundled diff --git a/Tests/Unit/DependencyLock.Tests.ps1 b/Tests/Unit/DependencyLock.Tests.ps1 index 7aba054..3862966 100644 --- a/Tests/Unit/DependencyLock.Tests.ps1 +++ b/Tests/Unit/DependencyLock.Tests.ps1 @@ -10,6 +10,84 @@ BeforeAll { $Script:Lock = Import-PowerShellDataFile -Path $Script:LockPath $Script:Artifacts = @($Script:Lock.Artifacts) + + $Script:UpdateScript = Join-Path $Script:RepositoryRoot -ChildPath 'Build\Update-DependencyLock.ps1' + $Script:DependabotConfig = @(Get-Content -Path (Join-Path $Script:RepositoryRoot -ChildPath '.github\dependabot.yml')) + $Script:WorkFolder = Join-Path ([System.IO.Path]::GetTempPath()) ("OmadaWebLockTests_{0}" -f ([System.Guid]::NewGuid().ToString('N'))) + $null = New-Item -Path $Script:WorkFolder -ItemType Directory -Force + + function New-LockSandbox { + # A throwaway copy of everything Update-DependencyLock.ps1 reads, so a test can doctor one + # file and assert the gate notices. The script itself is always run from the real repository, + # which is also how the scheduled sweep invokes it: trusted script, untrusted tree. + $Root = Join-Path $Script:WorkFolder -ChildPath ([System.Guid]::NewGuid().ToString('N')) + foreach ($Relative in @('OmadaWeb.PS', 'Build\Dependencies', '.github')) { + $null = New-Item -Path (Join-Path $Root -ChildPath $Relative) -ItemType Directory -Force + } + + Copy-Item -Path $Script:LockPath -Destination (Join-Path $Root -ChildPath 'OmadaWeb.PS\DependencyLock.psd1') + Copy-Item -Path $Script:InventoryPath -Destination (Join-Path $Root -ChildPath 'Build\Dependencies.psd1') + Copy-Item -Path (Join-Path $Script:RepositoryRoot -ChildPath '.github\dependabot.yml') -Destination (Join-Path $Root -ChildPath '.github\dependabot.yml') + Copy-Item -Path (Join-Path $Script:RepositoryRoot -ChildPath 'Build\Dependencies') -Destination (Join-Path $Root -ChildPath 'Build') -Recurse -Force + Copy-Item -Path $Script:PrivatePath -Destination (Join-Path $Root -ChildPath 'OmadaWeb.PS') -Recurse -Force + + return $Root + } + + function Get-IgnoreRulePattern { + # Matches an ignore rule whatever its YAML quoting. Update-DependencyLock.ps1 treats + # double-quoted, single-quoted and bare scalars as the same string, so a test that insists on + # one of them would fail on a reformatting the script itself is happy with - or, worse, would + # quietly stop matching and leave the test exercising nothing. + param([string]$PackageId) + + return '^\s*-\s+dependency-name\s*:\s*(?:"|'')?{0}(?:"|'')?\s*$' -f [regex]::Escape($PackageId) + } + + function Test-IgnoreRule { + param([string]$PackageId) + + $Pattern = Get-IgnoreRulePattern $PackageId + return @($Script:DependabotConfig | Where-Object { $_ -match $Pattern }).Count -gt 0 + } + + function Remove-IgnoreRule { + # Strips one ignore rule from a sandbox config, and asserts it was actually there - otherwise + # the test would go on to assert a failure that never had a cause. + param([string]$Path, [string]$PackageId) + + $Pattern = Get-IgnoreRulePattern $PackageId + $Line = @(Get-Content -Path $Path) + $Kept = @($Line | Where-Object { $_ -notmatch $Pattern }) + $Kept.Count | Should -BeLessThan $Line.Count -Because "'$PackageId' must have an ignore rule to remove" + $Kept | Set-Content -Path $Path + } + + function Get-PinnedVersion { + # Versions are read from the lock rather than written into the test, so a legitimate bump does + # not break a test that is really about "these two files disagree". + param([string]$Id) + + $Artifact = @($Script:Artifacts | Where-Object { $_.Id -eq $Id }) + $Artifact.Count | Should -Be 1 -Because "the tests below doctor the pin for '$Id'" + return $Artifact[0].Version + } + + function Set-DoctoredVersion { + # Introduces one specific disagreement into a sandbox file, and asserts it actually landed - + # otherwise a renamed field would leave the test passing while exercising nothing. + param([string]$Path, [string]$Find, [string]$Replace) + + $Content = Get-Content -Path $Path -Raw + $Content | Should -BeLike "*$Find*" -Because "the test needs '$Find' present in '$Path' to doctor it" + $Content.Replace($Find, $Replace) | Set-Content -Path $Path -NoNewline + } +} + +AfterAll { + if ($Script:WorkFolder -and (Test-Path $Script:WorkFolder)) { + Remove-Item -Path $Script:WorkFolder -Recurse -Force -ErrorAction SilentlyContinue + } } Describe 'DependencyLock.psd1' -Tag 'Unit' { @@ -105,6 +183,116 @@ Describe 'Dependency manifests' -Tag 'Unit' { } } +Describe 'Update-DependencyLock.ps1 -Check' -Tag 'Unit' { + # -SkipDownload throughout: these assert the offline gates, and a unit test must not depend on + # nuget.org being reachable. + It 'Should pass on the repository as it stands' { + { & $Script:UpdateScript -Check -SkipDownload -RepositoryRoot (New-LockSandbox) } | Should -Not -Throw + } + + It 'Should fail when the SBOM reports a version the lock does not pin' { + # The gap that kept every Dependabot pull request red even once its hashes were refreshed: + # Build/Dependencies.psd1 was the third file that had to move, and nothing moved it. + $Root = New-LockSandbox + $InventoryFile = Join-Path $Root -ChildPath 'Build\Dependencies.psd1' + Set-DoctoredVersion -Path $InventoryFile -Find ('Version = "{0}"' -f (Get-PinnedVersion 'Newtonsoft.Json')) -Replace 'Version = "0.0.0"' + + { & $Script:UpdateScript -Check -SkipDownload -RepositoryRoot $Root } | Should -Throw -ExpectedMessage '*problem(s) found*' + } + + It 'Should fail when a closure member has no Dependabot ignore rule' { + $Root = New-LockSandbox + $ConfigFile = Join-Path $Root -ChildPath '.github\dependabot.yml' + Remove-IgnoreRule -Path $ConfigFile -PackageId 'System.Memory' + + { & $Script:UpdateScript -Check -SkipDownload -RepositoryRoot $Root } | Should -Throw -ExpectedMessage '*problem(s) found*' + } + + It 'Should fail when the lock and the Dependabot manifest disagree on a version' { + $Root = New-LockSandbox + $ManifestFile = Join-Path $Root -ChildPath 'Build\Dependencies\Dependencies.csproj' + Set-DoctoredVersion -Path $ManifestFile -Find ('"Newtonsoft.Json" Version="{0}"' -f (Get-PinnedVersion 'Newtonsoft.Json')) -Replace '"Newtonsoft.Json" Version="0.0.0"' + + { & $Script:UpdateScript -Check -SkipDownload -RepositoryRoot $Root } | Should -Throw -ExpectedMessage '*problem(s) found*' + } + + It 'Should fail when two SBOM components mirror the same lock artefact' { + # A duplicate LockId would leave the drift check comparing only one of them, so a stale + # version in the other could ship unnoticed. + $Root = New-LockSandbox + $InventoryFile = Join-Path $Root -ChildPath 'Build\Dependencies.psd1' + $Duplicate = @( + ' @{' + ' Name = "Newtonsoft.Json (duplicate)"' + ' Version = "0.0.0"' + ' LockId = "Newtonsoft.Json"' + ' }' + ) + $Line = @(Get-Content -Path $InventoryFile) + $Anchor = [array]::IndexOf($Line, ($Line | Where-Object { $_ -match '^\s*Components\s*=\s*@\(\s*$' } | Select-Object -First 1)) + $Anchor | Should -BeGreaterThan -1 -Because 'the duplicate has to be inserted into the component list' + @($Line[0..$Anchor]) + $Duplicate + @($Line[($Anchor + 1)..($Line.Count - 1)]) | Set-Content -Path $InventoryFile + + { & $Script:UpdateScript -Check -SkipDownload -RepositoryRoot $Root } | Should -Throw -ExpectedMessage '*problem(s) found*' + } + + It 'Should read the ignore list whichever way its YAML scalars are quoted' { + # Double quotes, single quotes and bare scalars all mean the same string in YAML. Reading only + # one form would turn a reformatting of dependabot.yml into a false build failure. + $Root = New-LockSandbox + $ConfigFile = Join-Path $Root -ChildPath '.github\dependabot.yml' + $Rewritten = @(Get-Content -Path $ConfigFile | ForEach-Object { + if ($_ -match '^(\s*-\s+dependency-name\s*:\s*)"([^"]+)"\s*$') { + "{0}'{1}'" -f $Matches[1], $Matches[2] + } + elseif ($_ -match '^(\s*directory\s*:\s*)"([^"]+)"\s*$') { + "{0}{1}" -f $Matches[1], $Matches[2] + } + else { + $_ + } + }) + $Rewritten | Set-Content -Path $ConfigFile + + { & $Script:UpdateScript -Check -SkipDownload -RepositoryRoot $Root } | Should -Not -Throw + } + + It 'Should not accept an ignore rule that belongs to a different manifest directory' { + # Legacy/ has its own ignore list. A rule there must not satisfy a requirement about the main + # manifest, or the closure check would pass on a configuration that does not govern it. + $Root = New-LockSandbox + $ConfigFile = Join-Path $Root -ChildPath '.github\dependabot.yml' + Remove-IgnoreRule -Path $ConfigFile -PackageId 'System.Memory' + # Re-add it under the Legacy directory block, which is the last one in the file. + Add-Content -Path $ConfigFile -Value ' - dependency-name: "System.Memory"' + + { & $Script:UpdateScript -Check -SkipDownload -RepositoryRoot $Root } | Should -Throw -ExpectedMessage '*problem(s) found*' + } +} + +Describe 'Dependabot ignore policy' -Tag 'Unit' { + # The System.Text.Json closure cannot be upgraded a member at a time: the module loads these + # assemblies with Assembly.LoadFrom, which applies no binding redirects, so the versions loaded + # have to be the ones the pinned System.Text.Json resolves. Dependabot has to be told, or it keeps + # opening pull requests that can never go green - which is what happened to #55 and #58. + It 'Should ignore version updates for every member of the System.Text.Json closure' { + $Closure = @($Script:Artifacts | Where-Object { $_.Group -eq 'SystemTextJson' }) + $Closure.Count | Should -BeGreaterThan 0 -Because 'the closure is what this rule protects' + + foreach ($Artifact in $Closure) { + Test-IgnoreRule $Artifact.PackageId | Should -BeTrue -Because "Dependabot would otherwise propose bumping '$($Artifact.PackageId)' on its own" + } + } + + It 'Should keep the frozen Selenium pin out of Dependabot version updates' { + # 4.11.0 is the last Selenium that ships a net4* build, so the Desktop pin must not move. + $Desktop = @($Script:Artifacts | Where-Object { $_.Id -eq 'Selenium.Desktop' }) + $Desktop.Count | Should -Be 1 + + Test-IgnoreRule 'Selenium.WebDriver' | Should -BeTrue -Because 'a newer Selenium cannot be loaded by Windows PowerShell 5.1 at all' + } +} + Describe 'SBOM inventory' -Tag 'Unit' { It 'Should record the same versions as the lock file' { $Inventory = Import-PowerShellDataFile -Path $Script:InventoryPath