Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions .github/workflows/update-sdks.yml
Original file line number Diff line number Diff line change
@@ -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<<SDK_SUMMARY_EOF" >> $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
}
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions docs/sdk-pinning.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions docs/shared-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Loading