From 82c4cd3a157e4ea3c276582907a410136fbafb42 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 16 Sep 2026 11:13:35 +0000 Subject: [PATCH] Make the SDK pin job actually pin SDKs `update-sdks.yml` has been a silent no-op in every repository for as long as the feed has carried a prerelease. Each defect below was confirmed against the live NuGet feed rather than read off the source: - `[System.Version]::Parse` throws on `2.28.1-pre.1`, and the `catch` reported "may not be published to NuGet.org" and returned null, which the caller read as "no update available". Every lookup, every week, for every package. - `-like "ktsu.Sdk.*"` is False for `ktsu.Sdk`, and the csproj pattern required a suffix too, so the package nearly every repository pins was invisible. VST pins only that one, reported "No ktsu SDKs found", and sits on 2.8.0. - One version was recorded per package, first seen wins, and the package was skipped when that one was already current. A dependency bump that updates some entries and not others therefore left the rest behind permanently. - The `[\d\.]+` replacement read `2.0.0` out of `2.0.0-pre.1` and rewrote only that part, producing a version that had never been published. - `[bool]"false"` is `$true`, so `force_update` never forced anything. - `git push origin main` hardcoded a branch name. The replacement makes the reference the unit of work rather than the package, so a repository whose global.json and project files disagree is repaired even when no newer version exists. That is the property worth having: Dependabot already chases versions, but nothing guaranteed a repository ended up on one of them, and a build resolving two versions of the same SDK is not reproducible. The logic moves to scripts/update-sdks.ps1 so it can be run and tested outside Actions, which is the other half of why this went unnoticed for so long. Its tests are the defects above, each as a case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014RABe2NufFc9hwm94iB3Rf --- .github/workflows/update-sdks.yml | 149 ++++++++++++++++ CLAUDE.md | 4 + docs/sdk-pinning.md | 113 +++++++++++++ docs/shared-ci.md | 8 + scripts/tests/update-sdks.tests.ps1 | 119 +++++++++++++ scripts/update-sdks.ps1 | 252 ++++++++++++++++++++++++++++ 6 files changed, 645 insertions(+) create mode 100644 .github/workflows/update-sdks.yml create mode 100644 docs/sdk-pinning.md create mode 100644 scripts/tests/update-sdks.tests.ps1 create mode 100644 scripts/update-sdks.ps1 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 }