diff --git a/.github/workflows/update-sdks.yml b/.github/workflows/update-sdks.yml new file mode 100644 index 0000000..f60228d --- /dev/null +++ b/.github/workflows/update-sdks.yml @@ -0,0 +1,149 @@ +name: Update SDKs + +# Reusable. Repositories call this from their own update-sdks.yml; see docs/shared-ci.md. +on: + workflow_call: + inputs: + version: + description: > + Pin every ktsu SDK reference to this version instead of resolving the latest release. + Use it to hold a repository back, or to move the whole organization onto one version + deliberately. Empty means resolve from NuGet. + required: false + type: string + default: "" + dotnet-version: + description: The .NET SDK feature band used for the verification build. + required: false + type: string + default: "10.0" + runs-on: + description: > + The runner for the verification build. Windows by default, because a repository that + targets Windows cannot be built anywhere else and this workflow pushes straight to the + default branch on the strength of that build passing. + required: false + type: string + default: windows-latest + +# Read-only at the workflow level; the single job that writes asks for exactly what it needs. +# A called workflow cannot hold more permission than its caller, so the caller grants +# contents: write as well. +permissions: + contents: read + +jobs: + update-sdks: + name: Update ktsu SDKs + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 20 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + lfs: true + submodules: recursive + persist-credentials: true + + - name: Configure Git + shell: pwsh + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Fetched rather than checked out so it never lands in the workspace. A second checkout + # would put a directory full of files inside the tree the script scans and `git status` + # inspects, and both would have to learn to ignore it. `release` is the same ref the + # caller reached this workflow through, so the script and the workflow move together. + - name: Fetch the update script + shell: pwsh + run: | + $uri = 'https://raw.githubusercontent.com/ktsu-dev/.github/release/scripts/update-sdks.ps1' + Invoke-WebRequest -Uri $uri -OutFile "$env:RUNNER_TEMP/update-sdks.ps1" + + - name: Update SDK versions + id: update + shell: pwsh + env: + SDK_VERSION: ${{ inputs.version }} + run: | + $arguments = @{ Path = '.' } + if ($env:SDK_VERSION) { $arguments.Version = $env:SDK_VERSION } + + # The script writes its reasoning to the host and returns the summary, so $result is + # the summary alone. + $result = & "$env:RUNNER_TEMP/update-sdks.ps1" @arguments + + "changed=$($result.Changed.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + if (-not $result.Changed) { return } + + $summary = ($result.Updates | ForEach-Object { "- $($_.Name): $($_.From) -> $($_.To)" }) -join "`n" + "summary<> $env:GITHUB_OUTPUT + $summary >> $env:GITHUB_OUTPUT + "SDK_SUMMARY_EOF" >> $env:GITHUB_OUTPUT + + # Deliberately after the update rather than next to Checkout. setup-dotnet registers a + # post-job step that saves the NuGet cache, and that step fails the run if + # ~/.nuget/packages was never created. On the far more common no-change path every step + # below is skipped, nothing restores, and the cache save errors out on a green repo. + # Running it here also means the cache key is computed after global.json has been + # rewritten, which is what the note below wants. + - name: Setup .NET ${{ inputs.dotnet-version }} + if: steps.update.outputs.changed == 'true' + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ inputs.dotnet-version }}.x + # Keyed on the files that actually pin versions. See the same note in dotnet.yml. + cache: true + cache-dependency-path: | + **/*.csproj + **/Directory.Packages.props + **/global.json + + - name: Restore + if: steps.update.outputs.changed == 'true' + run: dotnet restore + + - name: Build + if: steps.update.outputs.changed == 'true' + run: dotnet build --no-restore --configuration Release + + - name: Test + if: steps.update.outputs.changed == 'true' + run: dotnet test --no-build --configuration Release --report-trx + + # The push goes to the branch this run checked out, which on a schedule is the default + # branch. Reading it from the ref rather than hardcoding `main` keeps the workflow + # correct in a repository whose default branch is named anything else. + - name: Commit and push + if: steps.update.outputs.changed == 'true' + shell: pwsh + env: + SUMMARY: ${{ steps.update.outputs.summary }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + git add -A + $message = "Pin ktsu SDKs to one version`n`n$env:SUMMARY`n`nBuilt and tested by $env:RUN_URL" + git commit -m $message + git push origin "HEAD:$env:GITHUB_REF_NAME" + + - name: Summary + if: always() + shell: pwsh + env: + SUMMARY: ${{ steps.update.outputs.summary }} + run: | + if ("${{ steps.update.outputs.changed }}" -eq "true") { + "## ktsu SDKs updated`n`n$env:SUMMARY`n`nBuilt, tested, and pushed." >> $env:GITHUB_STEP_SUMMARY + } + elseif ("${{ job.status }}" -eq "success") { + "## ktsu SDKs unchanged`n`nEvery reference was already on its target version." >> $env:GITHUB_STEP_SUMMARY + } + else { + "## ktsu SDK update failed`n`nSee the job log. A version that cannot be resolved fails the run rather than reporting no update." >> $env:GITHUB_STEP_SUMMARY + } diff --git a/CLAUDE.md b/CLAUDE.md index 909efb7..7e6f418 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,10 @@ This script: **PowerShell Scripts** (`scripts/`): - `get-github-repos.ps1`: Comprehensive GitHub API client for organization/user repository metadata with automatic gh CLI authentication and graceful rate limiting - `fix-markdown.ps1`: Advanced markdown linting and auto-fixing with config file support +- `update-sdks.ps1`: Pins every `ktsu.Sdk*` MSBuild SDK reference in a repository to one agreed + version, so a partially applied dependency bump cannot leave a repository building against two. + Run by the shared `update-sdks.yml`; tested by `scripts/tests/update-sdks.tests.ps1`. See + [`docs/sdk-pinning.md`](./docs/sdk-pinning.md) - `clean-python-cache.ps1`: Utility for cleaning Python cache directories - `discard-changes.ps1`: Git utility for discarding changes - `update-docs.ps1`: Documentation update automation diff --git a/docs/sdk-pinning.md b/docs/sdk-pinning.md new file mode 100644 index 0000000..d25811c --- /dev/null +++ b/docs/sdk-pinning.md @@ -0,0 +1,113 @@ +# One ktsu SDK version per repository + +`update-sdks.yml` pins every `ktsu.Sdk*` MSBuild SDK reference in a repository to one agreed +version, weekly and on demand. The logic lives in [`scripts/update-sdks.ps1`] so it can be run +and tested outside Actions; the workflow is the schedule and the guard rails around it. + +## Why this exists alongside Dependabot + +Dependabot already raises version bumps, and its `ktsu` group raises them together. What it +does not guarantee is that a repository ends up on *one* version: a grouped pull request that +updates some entries and not others is a normal outcome, and the result builds against two +versions of the same SDK. That is not a reproducible build, and it is not visible in a diff +that looks like a routine bump. + +So the two are not redundant. Dependabot chases versions; this converges them. The unit of +work here is the **reference**, not the package — a repository whose `global.json` and project +files disagree is repaired even when no newer version exists. + +## What it looks at + +Every `global.json` under `msbuild-sdks`, and every `Sdk="…/…"` attribute in every `.csproj`. +A package is in the family when it is `ktsu.Sdk` exactly or begins with `ktsu.Sdk.`, so +`ktsu.Sdk` and `ktsu.Sdk.Tool` both count and an unrelated `ktsu.SdkAdjacent` does not. + +Versions are compared as semantic versions, so `2.9.0` sorts below `2.28.1` and a prerelease +sorts below the release it precedes. Prereleases are never a target. A reference already ahead +of the latest release — which is what a deliberate prerelease pin looks like — becomes the +target for the rest of the repository rather than being rolled backwards. + +Edits are textual and scoped to the version that follows the package name, so key order, +formatting, comments, unrelated entries and the trailing newline all survive. + +## What it does not do + +It does not open a pull request. It builds and tests the repository with the new pins and +pushes to the default branch only if that passes, which is the same bar a merge would clear. +A failure leaves the repository untouched and red, which is the signal that a version needs a +person. + +## The caller + +```yaml +name: Update SDKs + +on: + schedule: + - cron: "0 8 * * MON" + workflow_dispatch: + inputs: + version: + description: Pin every ktsu SDK to this version instead of the latest release + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# A called workflow cannot hold more permission than its caller, so the write the push needs +# is granted here. +permissions: + contents: write + +jobs: + update-sdks: + uses: ktsu-dev/.github/.github/workflows/update-sdks.yml@release + with: + version: ${{ inputs.version || '' }} +``` + +A repository that targets nothing Windows-specific can pass `runs-on: ubuntu-latest`. The +default is `windows-latest` because the verification build is what licenses the push, and a +repository with Windows targets cannot be built anywhere else. + +## Running it by hand + +```powershell +# Report and apply, resolving the latest release of each package from NuGet +./scripts/update-sdks.ps1 -Path ../SomeRepo + +# Move a repository onto a specific version +./scripts/update-sdks.ps1 -Path ../SomeRepo -Version 2.29.0 + +# Report without writing +./scripts/update-sdks.ps1 -Path ../SomeRepo -WhatIf +``` + +```powershell +./scripts/tests/update-sdks.tests.ps1 +``` + +Each test case is a failure the previous workflow actually had, so the file doubles as the +record of what was wrong with it. + +## What was wrong with the previous workflow + +It was a silent no-op in every repository, and had been for as long as the feed has carried a +prerelease. Confirmed against the live feed rather than read off the source: + +| Defect | Effect | +| ------ | ------ | +| `[System.Version]::Parse` on every published version | Throws on `2.28.1-pre.1`; the `catch` reported "may not be published to NuGet.org" and returned null, which the caller read as "no update available" | +| `-like "ktsu.Sdk.*"` and a `ktsu\.Sdk\.\w+` pattern | Both miss the bare `ktsu.Sdk`, the package nearly every repository pins. `VST`, which pins only that, reported "No ktsu SDKs found" and stayed on `2.8.0` | +| One recorded version per package, first seen wins | A package whose first-seen reference was current was skipped entirely, so the stragglers behind it never moved | +| A `[\d\.]+` replacement pattern | Read `2.0.0` out of `2.0.0-pre.1` and rewrote only that part, producing a version that was never published | +| `-not $env:FORCE_UPDATE` | `[bool]"false"` is `$true`, so the input never forced anything | +| `git push origin main` | Hardcoded a branch name | +| `ConvertTo-Json -Depth 10` round-trip | Reformatted the whole file to change one string | + +The four repositories on `ktsu.Sdk` `2.25.0` at the time of writing, and `VST` on `2.8.0`, are +what that adds up to. + +[`scripts/update-sdks.ps1`]: ../scripts/update-sdks.ps1 diff --git a/docs/shared-ci.md b/docs/shared-ci.md index b6190e0..5bd44c0 100644 --- a/docs/shared-ci.md +++ b/docs/shared-ci.md @@ -184,6 +184,13 @@ logs a warning naming it. The flag comes off when the warnings stop. A repository that has neither file reports no status, which is the intended signal for a repository that has opted out of shared CI rather than a bug to work around. +## Related shared workflows + +`update-sdks.yml` is reusable in the same way and reached through the same `release` tag, but +it is not part of the dispatcher: it runs on its own weekly schedule rather than on push, so +folding it into `ci-shared.yml` would run it on every commit. Repositories call it from their +own `update-sdks.yml`. See [`sdk-pinning.md`]. + ## Adding a pipeline 1. Add the workflow to this repository with `on: workflow_call`. @@ -195,4 +202,5 @@ No repository is edited unless it is changing what kind of repository it is. [`ci-shared.yml`]: ../.github/workflows/ci-shared.yml [`dependabot-auto-merge.md`]: ./dependabot-auto-merge.md +[`sdk-pinning.md`]: ./sdk-pinning.md [`update-readme.yml`]: ../.github/workflows/update-readme.yml diff --git a/scripts/tests/update-sdks.tests.ps1 b/scripts/tests/update-sdks.tests.ps1 new file mode 100644 index 0000000..c851048 --- /dev/null +++ b/scripts/tests/update-sdks.tests.ps1 @@ -0,0 +1,119 @@ +<# +.SYNOPSIS + Checks update-sdks.ps1 against the failures the workflow it replaces actually had. + +.DESCRIPTION + Every case here is a defect observed in the previous workflow against the real feed, + not a hypothetical. Run with pwsh: ./scripts/tests/update-sdks.tests.ps1 + + -Version is used throughout so the cases do not depend on the network or on which + version happens to be current the day they run. +#> +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:Script = Join-Path $PSScriptRoot '..' 'update-sdks.ps1' | Resolve-Path +$script:Failures = 0 +$script:Count = 0 + +function New-Fixture { + param([hashtable]$Files) + + $root = Join-Path ([System.IO.Path]::GetTempPath()) "update-sdks-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $root | Out-Null + foreach ($name in $Files.Keys) { + $path = Join-Path $root $name + New-Item -ItemType Directory -Path (Split-Path -Parent $path) -Force | Out-Null + Set-Content -LiteralPath $path -Value $Files[$name] -NoNewline + } + return $root +} + +function Test-Case { + param([string]$Name, [hashtable]$Files, [string]$Version, [hashtable]$Expected) + + $script:Count++ + $root = New-Fixture -Files $Files + try { + & $script:Script -Path $root -Version $Version 6>$null | Out-Null + + foreach ($file in $Expected.Keys) { + $actual = Get-Content -LiteralPath (Join-Path $root $file) -Raw + if ($actual -ne $Expected[$file]) { + $script:Failures++ + Write-Host "FAIL $Name" -ForegroundColor Red + Write-Host " $file expected:" -ForegroundColor Red + Write-Host " $($Expected[$file] -replace "`n", "`n ")" -ForegroundColor Red + Write-Host " actual:" -ForegroundColor Red + Write-Host " $($actual -replace "`n", "`n ")" -ForegroundColor Red + return + } + } + + Write-Host "ok $Name" -ForegroundColor Green + } + finally { + Remove-Item -LiteralPath $root -Recurse -Force + } +} + +# The bare ktsu.Sdk entry. "ktsu.Sdk" -like "ktsu.Sdk.*" is False and the old csproj pattern +# required a suffix, so the package nearly every repository pins was never tracked. VST, which +# pins only ktsu.Sdk, reported "No ktsu SDKs found" and sat on 2.8.0 while the org moved on. +Test-Case -Name 'the bare prefix package is tracked' -Version '2.29.0' ` + -Files @{ 'global.json' = '{"msbuild-sdks":{"ktsu.Sdk":"2.8.0"}}' } ` + -Expected @{ 'global.json' = '{"msbuild-sdks":{"ktsu.Sdk":"2.29.0"}}' } + +# A package that merely starts with the prefix is not in the family. +Test-Case -Name 'an adjacent package name is left alone' -Version '2.29.0' ` + -Files @{ 'global.json' = '{"msbuild-sdks":{"ktsu.SdkAdjacent":"1.0.0","MSTest.Sdk":"4.4.0"}}' } ` + -Expected @{ 'global.json' = '{"msbuild-sdks":{"ktsu.SdkAdjacent":"1.0.0","MSTest.Sdk":"4.4.0"}}' } + +# The headline requirement. A dependency update that bumps some entries and not others leaves +# the repository resolving two versions of the same SDK. The old code recorded the first +# version it saw per package and skipped the package entirely when that one was already +# current, so the stragglers stayed behind forever. +Test-Case -Name 'a partial bump is repaired even when one entry is already current' -Version '2.29.0' ` + -Files @{ 'global.json' = "{`n `"msbuild-sdks`": {`n `"ktsu.Sdk`": `"2.29.0`",`n `"ktsu.Sdk.Tool`": `"2.25.0`",`n `"ktsu.Sdk.App`": `"2.25.0`"`n }`n}" } ` + -Expected @{ 'global.json' = "{`n `"msbuild-sdks`": {`n `"ktsu.Sdk`": `"2.29.0`",`n `"ktsu.Sdk.Tool`": `"2.29.0`",`n `"ktsu.Sdk.App`": `"2.29.0`"`n }`n}" } + +# The same disagreement across file kinds rather than within one file. +Test-Case -Name 'global.json and a csproj are converged on one version' -Version '2.29.0' ` + -Files @{ + 'global.json' = '{"msbuild-sdks":{"ktsu.Sdk.Tool":"2.29.0"}}' + 'src/App/App.csproj' = '' + } ` + -Expected @{ + 'global.json' = '{"msbuild-sdks":{"ktsu.Sdk.Tool":"2.29.0"}}' + 'src/App/App.csproj' = '' + } + +# The old replacement pattern matched digits and dots only, so it rewrote the 2.0.0 inside +# 2.0.0-pre.1 and left the suffix dangling on a version that was never published. +Test-Case -Name 'a prerelease pin is replaced whole, not in part' -Version '2.29.0' ` + -Files @{ 'src/App/App.csproj' = '' } ` + -Expected @{ 'src/App/App.csproj' = '' } + +# Formatting, key order, unrelated entries and the trailing newline all survive, because the +# file is edited rather than re-serialized. +$formatted = "{`n `"sdk`": { `"version`": `"10.0.100`" },`n `"msbuild-sdks`": {`n `"MSTest.Sdk`": `"4.4.0`",`n `"ktsu.Sdk`": `"2.25.0`"`n },`n `"test`": { `"runner`": `"Microsoft.Testing.Platform`" }`n}`n" +Test-Case -Name 'unrelated content and formatting survive' -Version '2.29.0' ` + -Files @{ 'global.json' = $formatted } ` + -Expected @{ 'global.json' = $formatted.Replace('"ktsu.Sdk": "2.25.0"', '"ktsu.Sdk": "2.29.0"') } + +# Nothing to do must write nothing at all, so a scheduled run on a current repository has an +# empty diff rather than a whitespace-only commit. +Test-Case -Name 'an already-current repository is untouched' -Version '2.29.0' ` + -Files @{ 'global.json' = "{`n `"msbuild-sdks`": {`n `"ktsu.Sdk`": `"2.29.0`"`n }`n}`n" } ` + -Expected @{ 'global.json' = "{`n `"msbuild-sdks`": {`n `"ktsu.Sdk`": `"2.29.0`"`n }`n}`n" } + +Write-Host '' +if ($script:Failures -gt 0) { + Write-Host "$script:Failures of $script:Count case(s) failed." -ForegroundColor Red + exit 1 +} + +Write-Host "All $script:Count case(s) passed." -ForegroundColor Green diff --git a/scripts/update-sdks.ps1 b/scripts/update-sdks.ps1 new file mode 100644 index 0000000..ef18a98 --- /dev/null +++ b/scripts/update-sdks.ps1 @@ -0,0 +1,252 @@ +<# +.SYNOPSIS + Pins every ktsu MSBuild SDK reference in a repository to one agreed version. + +.DESCRIPTION + Scans global.json and every .csproj for ktsu SDK references, resolves the version each + package should be on, and rewrites every reference that disagrees. + + The unit of work is the reference, not the package. A repository whose global.json and + project files disagree about ktsu.Sdk is repaired even when no newer version exists, + because a partially applied dependency update leaves exactly that state and a build + that resolves two versions of the same SDK is not reproducible. + + Versions are compared as semantic versions, so 2.9.0 sorts below 2.28.1 and a + prerelease sorts below the release it precedes. Prereleases are never a target. A + reference already ahead of the latest release -- which is how a deliberate prerelease + pin looks -- is reported and left alone rather than rolled backwards. + + Edits are textual and scoped to the version that follows the package name, so a file's + formatting, key order, comments and trailing newline survive, and a prerelease pin is + replaced in full rather than losing its suffix. + +.PARAMETER Path + The repository root to scan. Defaults to the current directory. + +.PARAMETER Prefix + The SDK package name prefix. A package matches when it equals the prefix exactly or + begins with the prefix followed by a dot, so ktsu.Sdk and ktsu.Sdk.Tool both match + while ktsu.SdkAdjacent does not. + +.PARAMETER Version + Pins every matched package to this version instead of asking NuGet. Intended for + pinning a whole repository to a known version, and for testing without a network. + +.PARAMETER FeedUrl + The flat container base address to resolve versions from. + +.PARAMETER WhatIf + Reports what would change without writing anything. + +.OUTPUTS + A summary object with Changed, Files and Updates, so a caller can report the same + facts this writes to the host. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$Path = '.', + [string]$Prefix = 'ktsu.Sdk', + [string]$Version, + [string]$FeedUrl = 'https://api.nuget.org/v3-flatcontainer' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# A package belongs to the family when it is the prefix itself or sits under it. Matching on +# the prefix alone would be wrong in both directions: "ktsu.Sdk.*" as a wildcard misses the +# bare ktsu.Sdk, which is the package nearly every repository pins, and a bare StartsWith +# would claim an unrelated ktsu.SdkSomething. +function Test-InFamily { + param([string]$Name, [string]$Prefix) + + return $Name -eq $Prefix -or $Name.StartsWith("$Prefix.", [System.StringComparison]::Ordinal) +} + +function ConvertTo-SemanticVersion { + param([string]$Text) + + [System.Management.Automation.SemanticVersion]$parsed = $null + if ([System.Management.Automation.SemanticVersion]::TryParse($Text, [ref]$parsed)) { + return $parsed + } + + return $null +} + +# The flat container index lists every published version. It is documented as sorted, but +# sorting it here costs nothing and means a feed that ever stops being sorted -- or a mirror +# that never was -- does not silently hand back the wrong answer. +function Get-LatestReleasedVersion { + param([string]$PackageId, [string]$FeedUrl) + + # The flat container addresses packages in lower case. + $id = $PackageId.ToLowerInvariant() + $index = Invoke-RestMethod -Uri "$FeedUrl/$id/index.json" + + $released = + $index.versions | + ForEach-Object { ConvertTo-SemanticVersion $_ } | + Where-Object { $null -ne $_ -and -not $_.PreReleaseLabel } + + if (-not $released) { + return $null + } + + return ($released | Sort-Object -Descending)[0].ToString() +} + +# Every reference to one package in one file. Collecting them individually, rather than +# collapsing to one version per package, is what lets a disagreement be seen at all. +function Get-SdkReference { + param([string]$Root, [string]$Prefix) + + $references = [System.Collections.Generic.List[object]]::new() + + foreach ($file in Get-ChildItem -Path $Root -Recurse -File -Filter 'global.json') { + $text = Get-Content -LiteralPath $file.FullName -Raw + foreach ($match in [regex]::Matches($text, '"(?[^"\s]+)"\s*:\s*"(?[^"]+)"')) { + $name = $match.Groups['name'].Value + if (Test-InFamily -Name $name -Prefix $Prefix) { + $references.Add([pscustomobject]@{ File = $file.FullName; Name = $name; Version = $match.Groups['version'].Value }) + } + } + } + + foreach ($file in Get-ChildItem -Path $Root -Recurse -File -Filter '*.csproj') { + $text = Get-Content -LiteralPath $file.FullName -Raw + # Anything up to the closing quote, so a prerelease pin is captured whole. The old + # pattern stopped at digits and dots, which read 2.0.0 out of 2.0.0-pre.1 and then + # replaced only that part, leaving a version string that had never been published. + foreach ($match in [regex]::Matches($text, 'Sdk\s*=\s*"(?[^"/]+)/(?[^"]+)"')) { + $name = $match.Groups['name'].Value + if (Test-InFamily -Name $name -Prefix $Prefix) { + $references.Add([pscustomobject]@{ File = $file.FullName; Name = $name; Version = $match.Groups['version'].Value }) + } + } + } + + return $references +} + +function Set-SdkReference { + param([string]$FilePath, [string]$Name, [string]$Target) + + $original = Get-Content -LiteralPath $FilePath -Raw + $quoted = [regex]::Escape($Name) + + $updated = [regex]::Replace($original, "(?`"$quoted`"\s*:\s*`")[^`"]+(?`")", "`${head}$Target`${tail}") + $updated = [regex]::Replace($updated, "(?Sdk\s*=\s*`"$quoted/)[^`"]+(?`")", "`${head}$Target`${tail}") + + if ($updated -eq $original) { + return $false + } + + # -NoNewline writes exactly these bytes. The file already ends how it ends; re-serializing + # it through ConvertTo-Json would reformat every line that this change never touched. + Set-Content -LiteralPath $FilePath -Value $updated -NoNewline + return $true +} + +$root = (Resolve-Path -LiteralPath $Path).Path +$references = @(Get-SdkReference -Root $root -Prefix $Prefix) + +if ($references.Count -eq 0) { + Write-Host "No $Prefix references found under $root." + return [pscustomobject]@{ Changed = $false; Files = @(); Updates = @() } +} + +Write-Host "Found $($references.Count) $Prefix reference(s):" +foreach ($group in $references | Group-Object Name | Sort-Object Name) { + $pinned = ($group.Group.Version | Sort-Object -Unique) -join ', ' + Write-Host " $($group.Name): $pinned" +} + +$targets = @{} +foreach ($group in $references | Group-Object Name | Sort-Object Name) { + $name = $group.Name + + if ($Version) { + $targets[$name] = $Version + continue + } + + $latest = $null + try { + $latest = Get-LatestReleasedVersion -PackageId $name -FeedUrl $FeedUrl + } + catch { + # A lookup failure must not be reported as "up to date". That conflation is what let + # this run green and do nothing every week: a parse error inside the lookup was caught + # and turned into a null, and a null read as "no update available". + Write-Error "Could not resolve the latest version of $name from $FeedUrl : $($_.Exception.Message)" + continue + } + + if (-not $latest) { + Write-Error "$name has no released version on $FeedUrl." + continue + } + + # A reference ahead of the newest release is a deliberate prerelease pin. Converging on + # the release would be a downgrade, so the highest pinned version becomes the target and + # the rest of the repository is brought up to meet it. + $highest = ($group.Group.Version | ForEach-Object { ConvertTo-SemanticVersion $_ } | Where-Object { $null -ne $_ } | Sort-Object -Descending | Select-Object -First 1) + if ($highest -and $highest -gt (ConvertTo-SemanticVersion $latest)) { + Write-Host " $name is pinned to $highest, ahead of the latest release $latest; converging on $highest." + $targets[$name] = $highest.ToString() + continue + } + + $targets[$name] = $latest +} + +$stale = @($references | Where-Object { $targets.ContainsKey($_.Name) -and $_.Version -ne $targets[$_.Name] }) + +if ($stale.Count -eq 0) { + Write-Host "Every $Prefix reference is already on its target version." + return [pscustomobject]@{ Changed = $false; Files = @(); Updates = @() } +} + +$updates = + $stale | + Group-Object Name | + Sort-Object Name | + ForEach-Object { + [pscustomobject]@{ + Name = $_.Name + From = ($_.Group.Version | Sort-Object -Unique) -join ', ' + To = $targets[$_.Name] + References = $_.Count + } + } + +Write-Host 'Updating:' +foreach ($update in $updates) { + Write-Host " $($update.Name): $($update.From) -> $($update.To) ($($update.References) reference(s))" +} + +$changedFiles = [System.Collections.Generic.List[string]]::new() +foreach ($group in $stale | Group-Object File) { + $file = $group.Name + if (-not $PSCmdlet.ShouldProcess($file, 'Update SDK references')) { + continue + } + + $touched = $false + foreach ($name in ($group.Group.Name | Sort-Object -Unique)) { + if (Set-SdkReference -FilePath $file -Name $name -Target $targets[$name]) { + $touched = $true + } + } + + if ($touched) { + $changedFiles.Add([System.IO.Path]::GetRelativePath($root, $file)) + } +} + +foreach ($file in $changedFiles) { + Write-Host " wrote $file" +} + +return [pscustomobject]@{ Changed = $changedFiles.Count -gt 0; Files = $changedFiles.ToArray(); Updates = $updates }