mirror of
https://github.com/dotnet/skills.git
synced 2026-09-20 09:49:54 +08:00
ca1b47371f
* Add automated per-plugin versioning (NBGV) with /version-bump + weekly backstop WHY Tools that surface skills (Copilot CLI, Claude Code, Codex, Cursor) read a plugin's version directly from its checked-in manifest. With no versioning discipline, a plugin's behavior can change while its advertised version stays flat, so clients never learn to re-pull, and there is no human-readable signal of what changed. We want correct, current versions in the repo with minimal manual work and without bloating the marketplace clone. WHAT - Per-plugin semantic versioning via Nerdbank.GitVersioning (NBGV). Each plugin owns a version.json whose pathFilters exclude the generated manifests and the version.json itself, so version height tracks real content changes only. - The computed version is materialized into the checked-in manifests (plugin.json and .codex-plugin/plugin.json) so every consumer reads a current value with no build step on their side. - eng/version/Sync-PluginVersions.ps1 is the single workhorse. It resolves the set of changed plugins from a git diff, computes each version with nbgv (predicting the squash-merge height for PRs), and either reports or stamps. AUTOMATIONS (two, low-touch by design) - /version-bump: an admin/maintainer comments the command on a PR and the affected plugins are stamped on the PR branch. Gated on collaborator permission (admin/write/maintain); forks are rejected before any privileged step. No other PRs are auto-modified. - weekly-version-sync: a Monday backstop (and workflow_dispatch) that stamps any drift on main, opens/updates a single bot PR, and explains the per-plugin reason. This self-heals anything that merged without a bump. We deliberately did NOT auto-edit contributor PRs or add a noisy advisory comment bot; maintainers stay in control and the signal stays clean. SECURITY (multi-model adversarial review: GPT-5.5 + Gemini 3.1 Pro) - Supply chain (High, both models): dotnet tool restore would have honored a nuget.config authored in the PR tree, letting an attacker remap the nbgv package source to a malicious feed and run code in the privileged contents:write context. Mitigated with a trusted eng/version/nuget.config (clear + nuget.org-only + packageSourceMapping), overlaid from main and used via --configfile so PR-supplied configs are ignored. No nuget.config is tracked in the repo today, so this path was genuinely exploitable. - TOCTOU (Medium): /version-bump now checks out the authorized head SHA rather than the mutable branch name; a racing push fails non-fast-forward, which is the safe outcome. - Injection: Set-ManifestVersion uses a MatchEvaluator (not a replacement string) so a "$"-bearing version cannot re-expand, plus a strict major.minor.patch guard that throws on a malformed base, leaving manifests untouched. - A base-only version.json bump (0.1 -> 0.2) is correctly detected and stamped. VERIFIED End-to-end against a real NBGV git harness: content-scoped predict, base-only bump -> x.y.0, docs-only -> [], weekly drift stamping, malformed-base guard, and --configfile restore (exit 0). actionlint passes on both workflows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot review feedback - Add missing plugins/dotnet-test-migration/version.json so it participates in versioning (it was the only plugin without one; manifests are at 0.1.0). - CONTRIBUTING: the two manifests are not byte-identical; say the version is duplicated across two manifest files instead. - weekly-version-sync: include version.json in commit attribution so a base-only bump is explained rather than showing 'no attributable commits'. - Get-NbgvInfo: capture nbgv stderr and include it in the thrown error so CI failures are diagnosable, while keeping stdout clean for JSON parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
331 lines
18 KiB
PowerShell
331 lines
18 KiB
PowerShell
#!/usr/bin/env pwsh
|
|
#requires -Version 7
|
|
<#
|
|
.SYNOPSIS
|
|
Computes and (optionally) materializes per-plugin versions using Nerdbank.GitVersioning (NBGV).
|
|
|
|
.DESCRIPTION
|
|
dotnet/skills is consumed directly from the repository (no published mirror), so every
|
|
plugin's version must be written into the checked-in manifests. Each plugins/<name>
|
|
directory carries a version.json whose pathFilters scope NBGV's git height to that one
|
|
subtree, giving every plugin an independent patch number. version.json also excludes
|
|
itself and the two stamped manifests, so adopting it (and stamping it) never inflates
|
|
a plugin's height.
|
|
|
|
The computed version (e.g. "0.1.4") is materialized into BOTH manifests a plugin ships:
|
|
plugins/<name>/plugin.json
|
|
plugins/<name>/.codex-plugin/plugin.json
|
|
|
|
This one script backs both versioning entry points:
|
|
* version-bump-command.yml -> -BaseCommit <mergeBase> -HeadCommit <prHead> -PredictSquashMerge -OnlyChanged -Write (admin /version-bump)
|
|
* weekly-version-sync.yml -> -OnlyChanged -Write (backstop, on main HEAD)
|
|
|
|
.PARAMETER BaseCommit
|
|
Commit-ish at which to read each plugin's NBGV height. In -PredictSquashMerge mode this is
|
|
the PR's merge base (the main commit the squash will land on). Without -PredictSquashMerge it
|
|
defaults to the current HEAD (used by the weekly backstop running on main).
|
|
|
|
.PARAMETER HeadCommit
|
|
The PR head commit. When given, the plugin set is derived from the BaseCommit..HeadCommit diff
|
|
(only plugins whose height-bearing files changed), so -PredictSquashMerge bumps exactly the
|
|
plugins the PR touched. Requires -BaseCommit.
|
|
|
|
.PARAMETER PredictSquashMerge
|
|
Predict the version a plugin will have on main AFTER this PR squash-merges, instead of reading
|
|
the current height. Requires -BaseCommit (the merge base). The prediction handles three cases:
|
|
* the PR bumps the plugin's version.json base (0.1 -> 0.2) => <newBase>.0 (squash is the
|
|
base-change commit, so NBGV resets the patch to 0);
|
|
* the plugin is newly added (no version.json at the merge base) => <newBase>.0;
|
|
* otherwise (ordinary content change) => <base>.(heightAtBase + 1), because the squash adds
|
|
exactly one height-bearing commit on top of the merge base. A PR that edits only version.json
|
|
without changing the base (e.g. a pathFilters tweak) changes no height-bearing file, so the
|
|
height is unchanged (=> <base>.heightAtBase) and no spurious bump is predicted.
|
|
|
|
.PARAMETER OnlyChanged
|
|
Emit/stamp only plugins whose computed version differs from the value currently in
|
|
plugin.json (i.e. plugins that actually drifted).
|
|
|
|
.PARAMETER Write
|
|
Materialize the computed version into both manifests. Without it the script is read-only.
|
|
|
|
.OUTPUTS
|
|
A JSON array on stdout: [{ "plugin", "current", "computed", "changed" }, ...].
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string] $BaseCommit,
|
|
[string] $HeadCommit,
|
|
[switch] $PredictSquashMerge,
|
|
[switch] $OnlyChanged,
|
|
[switch] $Write
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
if ($PredictSquashMerge -and -not $BaseCommit) {
|
|
throw '-PredictSquashMerge requires -BaseCommit (the PR merge base).'
|
|
}
|
|
if ($PredictSquashMerge -and -not $HeadCommit) {
|
|
throw '-PredictSquashMerge requires -HeadCommit (the PR head) so the bump is scoped to the plugins the PR actually changed; without it the script would predict a +1 patch for every plugin.'
|
|
}
|
|
if ($HeadCommit -and -not $BaseCommit) {
|
|
throw '-HeadCommit requires -BaseCommit (the diff is BaseCommit..HeadCommit).'
|
|
}
|
|
|
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path
|
|
$pluginsRoot = Join-Path $repoRoot 'plugins'
|
|
|
|
# Every git command below uses repo-root-relative paths (e.g. "plugins/<name>"), so pin the working
|
|
# directory. Invoked from any other directory, git show/diff/log would otherwise operate on whatever
|
|
# repository owns the caller's cwd and silently compute the wrong height.
|
|
Set-Location -LiteralPath $repoRoot
|
|
|
|
# The files excluded from NBGV git height for every plugin: the stamped manifests (which are
|
|
# outputs, not inputs) plus version.json itself. plugin.json and .codex-plugin/plugin.json exist
|
|
# for every plugin; .claude-plugin/plugin.json is optional (only plugins that need an inline Claude
|
|
# manifest, e.g. dotnet-msbuild's binlog MCP server, carry one). Excluding a manifest a given plugin
|
|
# doesn't have is a harmless no-op for that plugin, and keeping it in the universal list means any
|
|
# plugin that later adds a .claude-plugin manifest is already height-neutral (won't self-bump when
|
|
# stamped). This is the single source of truth, mirrored by each plugin's version.json `pathFilters`,
|
|
# by Test-HeightBearingChange, and by the canonical-filter guard in the main loop. Keeping one list
|
|
# avoids the pieces drifting apart.
|
|
$HeightExcludedFiles = @('plugin.json', '.codex-plugin/plugin.json', '.claude-plugin/plugin.json', 'version.json')
|
|
|
|
# The canonical `pathFilters` array every plugin's version.json must contain: include the whole
|
|
# plugin subtree ('.') minus the height-excluded files above.
|
|
function Get-CanonicalFilters {
|
|
@('.') + ($HeightExcludedFiles | ForEach-Object { ":!$_" })
|
|
}
|
|
|
|
# Replace only the "version" value so the rest of the manifest stays byte-identical
|
|
# (avoids reflow/key-reorder noise that a full ConvertTo-Json round-trip would cause).
|
|
# Uses a MatchEvaluator (not a replacement string) so a '$' in the version can never be
|
|
# re-expanded as a regex substitution (e.g. "$1") and corrupt the JSON.
|
|
function Set-ManifestVersion {
|
|
param([string] $Path, [string] $Version)
|
|
if (-not (Test-Path $Path)) { return $false }
|
|
$text = [IO.File]::ReadAllText($Path)
|
|
$pattern = '("version"\s*:\s*")[^"]*(")'
|
|
$rx = [regex]::new($pattern)
|
|
if (-not $rx.IsMatch($text)) {
|
|
throw "No `"version`" field found in $Path"
|
|
}
|
|
$evaluator = [System.Text.RegularExpressions.MatchEvaluator]({
|
|
param($m) $m.Groups[1].Value + $Version + $m.Groups[2].Value
|
|
}.GetNewClosure())
|
|
$updated = $rx.Replace($text, $evaluator, 1)
|
|
if ($updated -ne $text) {
|
|
[IO.File]::WriteAllText($Path, $updated)
|
|
return $true
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Get-NbgvInfo {
|
|
param([string] $PluginDir, [string] $Commit)
|
|
$nbgvArgs = @('nbgv', 'get-version', '-p', $PluginDir, '-f', 'json')
|
|
if ($Commit) { $nbgvArgs += $Commit }
|
|
# Capture stderr separately so a failure surfaces the real NBGV/dotnet error
|
|
# in CI logs, while stdout stays clean JSON for ConvertFrom-Json.
|
|
$errFile = New-TemporaryFile
|
|
try {
|
|
$json = & dotnet @nbgvArgs 2>$errFile.FullName
|
|
if ($LASTEXITCODE -ne 0 -or -not $json) {
|
|
$stderr = (Get-Content $errFile.FullName -Raw).Trim()
|
|
throw "nbgv get-version failed for $PluginDir (commit '$Commit'): $stderr"
|
|
}
|
|
} finally {
|
|
Remove-Item $errFile.FullName -ErrorAction SilentlyContinue
|
|
}
|
|
return ($json | ConvertFrom-Json)
|
|
}
|
|
|
|
# The version.json `version` base (major.minor) for a plugin, read either from the working
|
|
# tree (current) or from a historical commit. Returns $null if the file is absent there.
|
|
function Get-VersionBase {
|
|
param([string] $Plugin, [string] $Commit)
|
|
if ($Commit) {
|
|
$raw = git show "${Commit}:plugins/$Plugin/version.json" 2>$null
|
|
if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null }
|
|
return ($raw | ConvertFrom-Json).version
|
|
}
|
|
return (Get-Content (Join-Path $pluginsRoot $Plugin 'version.json') -Raw | ConvertFrom-Json).version
|
|
}
|
|
|
|
# Whether the BaseCommit..HeadCommit diff touches any *height-bearing* file for a plugin — a
|
|
# file NBGV counts toward git height. The git pathspec excludes mirror the plugin's canonical
|
|
# version.json pathFilters (built from $HeightExcludedFiles), so a version.json-only edit that
|
|
# leaves the base unchanged is correctly treated as height-neutral. The main loop guarantees the
|
|
# pathFilters are canonical, so these excludes always match what NBGV actually computes.
|
|
function Test-HeightBearingChange {
|
|
param([string] $Plugin, [string] $From, [string] $To)
|
|
$excludes = $HeightExcludedFiles | ForEach-Object { ":(exclude)plugins/$Plugin/$_" }
|
|
$touched = git diff --name-only --diff-filter=ACMRD $From $To -- "plugins/$Plugin" @excludes
|
|
return [bool]$touched
|
|
}
|
|
|
|
# Plugins whose version-affecting files changed between two commits. The stamped manifests
|
|
# (plugin.json, .codex-plugin/plugin.json, and the optional .claude-plugin/plugin.json) are output,
|
|
# not input, so they're excluded; everything else under the plugin counts — including version.json,
|
|
# since a base bump (0.1 -> 0.2) with no other change must still be detected so /version-bump can
|
|
# stamp the reset patch.
|
|
# Used by /version-bump to scope -PredictSquashMerge to exactly the plugins the PR touched.
|
|
function Get-ChangedPlugins {
|
|
param([string] $From, [string] $To)
|
|
git diff --name-only --diff-filter=ACMRD $From $To |
|
|
Where-Object {
|
|
$_ -match '^plugins/[^/]+/' -and
|
|
$_ -notmatch '^plugins/[^/]+/plugin\.json$' -and
|
|
$_ -notmatch '^plugins/[^/]+/\.codex-plugin/plugin\.json$' -and
|
|
$_ -notmatch '^plugins/[^/]+/\.claude-plugin/plugin\.json$'
|
|
} |
|
|
ForEach-Object { ($_ -split '/')[1] } |
|
|
Sort-Object -Unique
|
|
}
|
|
|
|
# Resolve the plugin set: an explicit PR diff (BaseCommit..HeadCommit) scopes to the plugins the
|
|
# PR actually touched (required so -PredictSquashMerge only bumps those); otherwise every plugin
|
|
# that has a version.json (the weekly backstop reconciles them all on main).
|
|
if ($HeadCommit) {
|
|
$Plugins = @(Get-ChangedPlugins -From $BaseCommit -To $HeadCommit)
|
|
}
|
|
else {
|
|
# Weekly backstop: reconcile every real plugin. Enumerate by plugin.json (the marker of a
|
|
# shipped plugin) rather than version.json, and fail fast if any plugin is missing version.json,
|
|
# so a plugin can never be silently dropped from automated versioning.
|
|
$Plugins = Get-ChildItem -Path $pluginsRoot -Directory |
|
|
Where-Object { Test-Path (Join-Path $_.FullName 'plugin.json') } |
|
|
Select-Object -ExpandProperty Name |
|
|
Sort-Object
|
|
foreach ($p in $Plugins) {
|
|
if (-not (Test-Path (Join-Path $pluginsRoot $p 'version.json'))) {
|
|
throw "plugins/$p ships a plugin.json but has no version.json — every plugin must define a version base. Add plugins/$p/version.json (base = its current major.minor, e.g. 0.2)."
|
|
}
|
|
}
|
|
}
|
|
|
|
$results = [System.Collections.Generic.List[object]]::new()
|
|
|
|
foreach ($name in $Plugins) {
|
|
$pluginDir = Join-Path $pluginsRoot $name
|
|
$manifest = Join-Path $pluginDir 'plugin.json'
|
|
$codexManifest = Join-Path $pluginDir '.codex-plugin' 'plugin.json'
|
|
# Optional third manifest: some clients (e.g. Claude Code) need an inline plugin.json rather than
|
|
# the reference form the Codex manifest uses, so a plugin may carry .claude-plugin/plugin.json.
|
|
# Only plugins that need it have one (dotnet-msbuild today), so it's stamped when present rather
|
|
# than required.
|
|
$claudeManifest = Join-Path $pluginDir '.claude-plugin' 'plugin.json'
|
|
|
|
# A shipped plugin (one with a plugin.json) must define a version base; fail loudly rather than
|
|
# silently skipping it — symmetric with the weekly enumerate guard above, so /version-bump can't
|
|
# let a plugin.json-without-version.json reach main. A dir with no plugin.json (a deleted plugin,
|
|
# or a non-plugin helper dir surfaced by the diff) is genuinely not versioned, so skip it.
|
|
if (-not (Test-Path (Join-Path $pluginDir 'version.json'))) {
|
|
if (Test-Path $manifest) {
|
|
throw "plugins/$name ships a plugin.json but has no version.json — every plugin must define a version base. Add plugins/$name/version.json (base = its current major.minor, e.g. 0.2)."
|
|
}
|
|
continue
|
|
}
|
|
|
|
$current = (Get-Content $manifest -Raw | ConvertFrom-Json).version
|
|
# Both manifests must exist: the version is duplicated across plugin.json and the Codex-facing
|
|
# .codex-plugin/plugin.json, and every consumer reads one of them. If the Codex manifest were
|
|
# missing we'd silently stamp only plugin.json and ship mismatched versions across clients, so
|
|
# fail fast instead.
|
|
if (-not (Test-Path $codexManifest)) {
|
|
throw "plugins/$name is missing .codex-plugin/plugin.json — the version must be stamped into both manifests. Add plugins/$name/.codex-plugin/plugin.json."
|
|
}
|
|
# Read the Codex manifest too so we detect (and repair) the case where the two manifests have
|
|
# drifted apart — e.g. a hand-edit updated one but not the other.
|
|
$currentCodex = (Get-Content $codexManifest -Raw | ConvertFrom-Json).version
|
|
# The optional Claude manifest, read only when the plugin carries one. Track presence separately
|
|
# from the version value: keying "is it present?" off $currentClaude would conflate an absent file
|
|
# with a present-but-malformed one (missing/empty "version"). By gating on $hasClaudeManifest, a
|
|
# present manifest is always considered and stamped — and a missing/empty "version" fails loudly
|
|
# (the strict-mode read or Set-ManifestVersion throws) rather than being silently skipped.
|
|
$hasClaudeManifest = Test-Path $claudeManifest
|
|
$currentClaude = if ($hasClaudeManifest) {
|
|
(Get-Content $claudeManifest -Raw | ConvertFrom-Json).version
|
|
} else { $null }
|
|
|
|
# The version.json base must be major.minor (e.g. "0.1"); a malformed or 3-part base
|
|
# (e.g. "0.1.0") would otherwise pass through the non-predict path because NBGV normalizes
|
|
# it into a 3-part SimpleVersion that satisfies the computed-value guard below, silently
|
|
# producing a wrong/fixed version. Validate it here so both paths fail loudly instead.
|
|
$base = Get-VersionBase -Plugin $name
|
|
if ($base -notmatch '^\d+\.\d+$') {
|
|
throw "version.json base '$base' for plugin '$name' must be major.minor (e.g. 0.1) — check plugins/$name/version.json"
|
|
}
|
|
|
|
# pathFilters must stay exactly canonical. The predict-mode height math assumes version.json
|
|
# and the two stamped manifests are excluded from NBGV height (so a version.json-only edit is
|
|
# height-neutral) and that the filter set is identical at the merge base and head. A hand-edited
|
|
# pathFilters would silently break that assumption — the post-merge NBGV height would diverge
|
|
# from the prediction — so reject anything but the generated canonical set in both paths.
|
|
$canonicalFilters = @(Get-CanonicalFilters | Sort-Object)
|
|
$filtersProp = (Get-Content (Join-Path $pluginsRoot $name 'version.json') -Raw |
|
|
ConvertFrom-Json).PSObject.Properties['pathFilters']
|
|
$actualFilters = if ($filtersProp) { @($filtersProp.Value | Sort-Object) } else { @() }
|
|
if (($actualFilters -join "`n") -ne ($canonicalFilters -join "`n")) {
|
|
throw "version.json pathFilters for plugin '$name' must be the canonical set [$((Get-CanonicalFilters) -join ', ')] — check plugins/$name/version.json"
|
|
}
|
|
|
|
if ($PredictSquashMerge) {
|
|
$oldBase = Get-VersionBase -Plugin $name -Commit $BaseCommit
|
|
if (-not $oldBase -or $oldBase -ne $base) {
|
|
# Base bumped in this PR, or brand-new plugin: the squashed commit becomes the
|
|
# version-origin commit, so NBGV resets the patch to 0.
|
|
$computed = "$base.0"
|
|
}
|
|
else {
|
|
$heightAtBase = [int](Get-NbgvInfo -PluginDir $pluginDir -Commit $BaseCommit).VersionHeight
|
|
# The squash adds a height-bearing commit only if the PR actually changed a height-bearing
|
|
# file. -PredictSquashMerge requires -HeadCommit (guarded above), so the +1 is always scoped
|
|
# to that. A version.json-only edit that doesn't touch the base (e.g. a pathFilters/$schema/
|
|
# whitespace tweak) excludes itself from NBGV height, so it adds none — predicting +1 there
|
|
# would over-bump, and the weekly sync would later compute the true (lower) height and
|
|
# correct it downward: a visible version regression.
|
|
$bumps = [int](Test-HeightBearingChange -Plugin $name -From $BaseCommit -To $HeadCommit)
|
|
$computed = "$base.$($heightAtBase + $bumps)"
|
|
}
|
|
}
|
|
else {
|
|
$computed = (Get-NbgvInfo -PluginDir $pluginDir -Commit $BaseCommit).SimpleVersion
|
|
}
|
|
|
|
# Guard against a malformed version.json base (e.g. "0.2$1" or "1.x"): a bad value
|
|
# would otherwise be written verbatim into the manifests. NBGV versions are always
|
|
# numeric major.minor.patch, so anything else means the source data is wrong.
|
|
if ($computed -notmatch '^\d+\.\d+\.\d+$') {
|
|
throw "Computed version '$computed' for plugin '$name' is not a valid major.minor.patch — check plugins/$name/version.json"
|
|
}
|
|
|
|
# Both required manifests are guaranteed to exist (guarded above); the Claude manifest is optional.
|
|
# A mismatch in any present manifest — vs. the computed version — means the plugin drifted and
|
|
# needs a rewrite. Gate the Claude check on presence (not on its version value) so a present-but-
|
|
# malformed manifest still forces a rewrite and fails loudly at stamp time.
|
|
$changed = ($computed -ne $current) -or ($computed -ne $currentCodex) -or
|
|
($hasClaudeManifest -and $computed -ne $currentClaude)
|
|
|
|
if ($OnlyChanged -and -not $changed) { continue }
|
|
|
|
if ($Write -and $changed) {
|
|
[void](Set-ManifestVersion -Path $manifest -Version $computed)
|
|
[void](Set-ManifestVersion -Path $codexManifest -Version $computed)
|
|
# Stamp the optional Claude manifest only when the plugin actually has one.
|
|
if ($hasClaudeManifest) {
|
|
[void](Set-ManifestVersion -Path $claudeManifest -Version $computed)
|
|
}
|
|
}
|
|
|
|
$results.Add([ordered]@{
|
|
plugin = $name
|
|
current = $current
|
|
computed = $computed
|
|
changed = $changed
|
|
})
|
|
}
|
|
|
|
$results | ConvertTo-Json -AsArray -Compress
|