From 462cf7d58e41838d9a1ddc256af4b9940d1ac55e Mon Sep 17 00:00:00 2001 From: Abhitej John Date: Wed, 24 Jun 2026 00:23:26 -0700 Subject: [PATCH 1/4] 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> --- .config/dotnet-tools.json | 13 ++ .github/workflows/version-bump-command.yml | 169 ++++++++++++++++ .github/workflows/weekly-version-sync.yml | 115 +++++++++++ CONTRIBUTING.md | 25 +++ eng/version/Sync-PluginVersions.ps1 | 209 ++++++++++++++++++++ eng/version/nuget.config | 23 +++ plugins/dotnet-ai/version.json | 10 + plugins/dotnet-aspnetcore/version.json | 10 + plugins/dotnet-blazor/version.json | 10 + plugins/dotnet-data/version.json | 10 + plugins/dotnet-diag/version.json | 10 + plugins/dotnet-experimental/version.json | 10 + plugins/dotnet-maui/version.json | 10 + plugins/dotnet-msbuild/version.json | 10 + plugins/dotnet-nuget/version.json | 10 + plugins/dotnet-template-engine/version.json | 10 + plugins/dotnet-test/version.json | 10 + plugins/dotnet-upgrade/version.json | 10 + plugins/dotnet/version.json | 10 + plugins/dotnet11/version.json | 10 + 20 files changed, 694 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .github/workflows/version-bump-command.yml create mode 100644 .github/workflows/weekly-version-sync.yml create mode 100644 eng/version/Sync-PluginVersions.ps1 create mode 100644 eng/version/nuget.config create mode 100644 plugins/dotnet-ai/version.json create mode 100644 plugins/dotnet-aspnetcore/version.json create mode 100644 plugins/dotnet-blazor/version.json create mode 100644 plugins/dotnet-data/version.json create mode 100644 plugins/dotnet-diag/version.json create mode 100644 plugins/dotnet-experimental/version.json create mode 100644 plugins/dotnet-maui/version.json create mode 100644 plugins/dotnet-msbuild/version.json create mode 100644 plugins/dotnet-nuget/version.json create mode 100644 plugins/dotnet-template-engine/version.json create mode 100644 plugins/dotnet-test/version.json create mode 100644 plugins/dotnet-upgrade/version.json create mode 100644 plugins/dotnet/version.json create mode 100644 plugins/dotnet11/version.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000000..c9d7f5d98b --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "nbgv": { + "version": "3.10.85", + "commands": [ + "nbgv" + ], + "rollForward": false + } + } +} diff --git a/.github/workflows/version-bump-command.yml b/.github/workflows/version-bump-command.yml new file mode 100644 index 0000000000..f245d9a2f5 --- /dev/null +++ b/.github/workflows/version-bump-command.yml @@ -0,0 +1,169 @@ +# Admin-triggered version stamping. A maintainer comments `/version-bump` on a +# PR; this computes each changed plugin's post-merge version with NBGV and writes +# it into both manifests on the PR branch, then pushes the commit. +# +# Scope: same-repo (in-repo) branches only — the workflow cannot push to a fork's +# branch. Fork PRs are deferred to the weekly backstop (weekly-version-sync.yml). +# +# NOTE: issue_comment always runs the workflow YAML from the default branch, so +# edits here only take effect once merged to main. +name: version-bump-command + +on: + issue_comment: + types: [created] + +concurrency: + group: version-bump-${{ github.event.issue.number }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + bump: + if: >- + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/version-bump') + runs-on: ubuntu-latest + steps: + - name: Check actor permissions + id: perms + env: + GH_TOKEN: ${{ github.token }} + ACTOR: ${{ github.event.comment.user.login }} + run: | + PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${ACTOR}/permission" --jq '.permission') + echo "Actor ${ACTOR} has permission: $PERMISSION" + if [[ "$PERMISSION" != "admin" && "$PERMISSION" != "write" && "$PERMISSION" != "maintain" ]]; then + echo "::error::/version-bump requires write access" + gh api --method POST "repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments" \ + -f body="@${ACTOR} \`/version-bump\` requires write access to this repository." >/dev/null + exit 1 + fi + + - name: Resolve PR + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + PR=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}") + HEAD_REPO=$(echo "$PR" | jq -r '.head.repo.full_name') + BASE_REPO=$(echo "$PR" | jq -r '.base.repo.full_name') + HEAD_REF=$(echo "$PR" | jq -r '.head.ref') + HEAD_SHA=$(echo "$PR" | jq -r '.head.sha') + BASE_SHA=$(echo "$PR" | jq -r '.base.sha') + { + echo "head_ref=$HEAD_REF" + echo "head_sha=$HEAD_SHA" + echo "base_sha=$BASE_SHA" + } >> "$GITHUB_OUTPUT" + if [[ "$HEAD_REPO" != "$BASE_REPO" ]]; then + echo "is_fork=true" >> "$GITHUB_OUTPUT" + else + echo "is_fork=false" >> "$GITHUB_OUTPUT" + fi + + - name: Reject fork PRs + if: steps.pr.outputs.is_fork == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api --method POST "repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments" \ + -f body="\`/version-bump\` only works on branches in this repository. This is a fork PR — the weekly version sync will stamp it after merge." >/dev/null + echo "Fork PR — skipping." + + - name: Checkout PR branch + if: steps.pr.outputs.is_fork == 'false' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # Pin to the exact head SHA that was authorized, not the mutable branch + # name — a race-push after the maintainer comments cannot change what runs. + ref: ${{ steps.pr.outputs.head_sha }} + fetch-depth: 0 + persist-credentials: true + + # SECURITY: this job runs in the privileged default-branch context with a + # contents:write token. Never execute tooling that the PR author controls. + # Overlay the version tooling, the tool manifest, the SDK pin, and the locked + # NuGet config from main so only trusted code runs and tools can only be + # restored from nuget.org; the PR's plugin *content* is consumed as data. + # These overlays are reverted before the commit step so the PR's own copies + # of these files are never modified. + - name: Pin trusted tooling from main + if: steps.pr.outputs.is_fork == 'false' + run: | + git fetch --no-tags --depth=1 origin main + git checkout FETCH_HEAD -- \ + eng/version/Sync-PluginVersions.ps1 \ + eng/version/nuget.config \ + .config/dotnet-tools.json \ + global.json + + - name: Setup .NET SDK + if: steps.pr.outputs.is_fork == 'false' + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 + with: + global-json-file: global.json + + - name: Restore tools + if: steps.pr.outputs.is_fork == 'false' + run: dotnet tool restore --configfile eng/version/nuget.config + + - name: Stamp plugin versions + if: steps.pr.outputs.is_fork == 'false' + id: stamp + shell: pwsh + env: + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + run: | + $head = git rev-parse HEAD + $mergeBase = git merge-base $env:BASE_SHA $head + + $report = & "$PWD/eng/version/Sync-PluginVersions.ps1" ` + -BaseCommit $mergeBase -HeadCommit $head -PredictSquashMerge -OnlyChanged -Write | + ConvertFrom-Json + + if (-not $report -or $report.Count -eq 0) { + "stamped=false" >> $env:GITHUB_OUTPUT + "summary=No plugin content needs a version bump in this PR." >> $env:GITHUB_OUTPUT + exit 0 + } + + $rows = ($report | ForEach-Object { "- ``$($_.plugin)``: $($_.current) → **$($_.computed)**" }) -join "`n" + "stamped=true" >> $env:GITHUB_OUTPUT + # Multi-line outputs need a heredoc-style delimiter. + $delim = "EOF_SUMMARY_$(Get-Random)" + "summary<<$delim" >> $env:GITHUB_OUTPUT + "Stamped plugin versions:`n$rows" >> $env:GITHUB_OUTPUT + "$delim" >> $env:GITHUB_OUTPUT + + - name: Commit and push + if: steps.pr.outputs.is_fork == 'false' && steps.stamp.outputs.stamped == 'true' + env: + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Drop the main-overlaid tooling so only the regenerated manifests are committed; + # the PR's own versions of these files are restored to the working tree + index. + git checkout HEAD -- \ + eng/version/Sync-PluginVersions.ps1 \ + eng/version/nuget.config \ + .config/dotnet-tools.json \ + global.json + git add plugins/*/plugin.json plugins/*/.codex-plugin/plugin.json + git commit -m "Stamp plugin versions via /version-bump" + git push origin "HEAD:${HEAD_REF}" + + - name: Comment result + if: steps.pr.outputs.is_fork == 'false' && always() + env: + GH_TOKEN: ${{ github.token }} + SUMMARY: ${{ steps.stamp.outputs.summary }} + run: | + if [[ -z "$SUMMARY" ]]; then SUMMARY="Could not compute plugin versions — see the workflow run for details."; fi + gh api --method POST "repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments" \ + -f body="$SUMMARY" >/dev/null diff --git a/.github/workflows/weekly-version-sync.yml b/.github/workflows/weekly-version-sync.yml new file mode 100644 index 0000000000..fa6032d3f8 --- /dev/null +++ b/.github/workflows/weekly-version-sync.yml @@ -0,0 +1,115 @@ +# Weekly backstop. Computes every plugin's authoritative version on `main` with +# NBGV and, for any plugin whose content changed without a matching version bump, +# opens ONE pull request that stamps the correct versions and explains why. +# +# This is the safety net that makes per-PR bumping optional: anything a +# contributor or the /version-bump command missed is reconciled here. Running on +# main (post-merge) is the single source of truth, so the computed heights can +# never collide across PRs. +name: weekly-version-sync + +on: + schedule: + - cron: '0 9 * * 1' # Mondays 09:00 UTC + workflow_dispatch: + +concurrency: + group: weekly-version-sync + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + # Never run on forks (avoids burning a fork owner's Actions minutes). + if: github.repository == 'dotnet/skills' + runs-on: ubuntu-latest + env: + SYNC_BRANCH: bot/weekly-version-sync + steps: + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Setup .NET SDK + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 + with: + global-json-file: global.json + + - name: Restore tools + run: dotnet tool restore --configfile eng/version/nuget.config + + - name: Compute and stamp versions + id: stamp + shell: pwsh + run: | + # Authoritative compute on main HEAD; write drifted plugins in place. + $report = & "$PWD/eng/version/Sync-PluginVersions.ps1" -OnlyChanged -Write | ConvertFrom-Json + + if (-not $report -or $report.Count -eq 0) { + "has_changes=false" >> $env:GITHUB_OUTPUT + "No plugin versions drifted — nothing to sync." >> $env:GITHUB_STEP_SUMMARY + exit 0 + } + + # Build the PR body, explaining each bump with the commits that caused it. + $sections = foreach ($r in $report) { + $name = $r.plugin + $lastStamp = git log -1 --format='%H' -- "plugins/$name/plugin.json" + $range = if ($lastStamp) { "$lastStamp..HEAD" } else { 'HEAD' } + $commits = git log --format='- %h %s' $range -- "plugins/$name" ` + ":(exclude)plugins/$name/plugin.json" ` + ":(exclude)plugins/$name/.codex-plugin/plugin.json" ` + ":(exclude)plugins/$name/version.json" + $why = if ($commits) { ($commits -join "`n") } else { '- (no attributable commits found)' } + @( + "### ``$name``: $($r.current) → $($r.computed)" + "" + "Changes since the last version bump:" + "" + $why + "" + ) -join "`n" + } + + $body = @( + "Automated weekly version sync. The following plugins changed since their" + "last version bump; this PR stamps the NBGV-computed version into their" + "``plugin.json`` and ``.codex-plugin/plugin.json``." + "" + "Patch numbers are derived from git history — review the reasons below, then merge." + "" + ($sections -join "`n") + ) -join "`n" + + Set-Content -Path pr-body.md -Value $body + "has_changes=true" >> $env:GITHUB_OUTPUT + $body >> $env:GITHUB_STEP_SUMMARY + + - name: Open or update sync PR + if: steps.stamp.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$SYNC_BRANCH" + git add plugins/*/plugin.json plugins/*/.codex-plugin/plugin.json + git commit -m "Weekly plugin version sync" + git push --force origin "$SYNC_BRANCH" + + EXISTING=$(gh pr list --head "$SYNC_BRANCH" --state open --json number --jq '.[0].number // empty') + if [[ -n "$EXISTING" ]]; then + gh pr edit "$EXISTING" --body-file pr-body.md + echo "Updated existing PR #$EXISTING" + else + gh pr create \ + --base main \ + --head "$SYNC_BRANCH" \ + --title "Weekly plugin version sync" \ + --body-file pr-body.md + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e5c7849ae..52cfff9e4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,7 @@ To create a new plugin: 1. Add `plugins//plugin.json` and a `skills/` directory beneath it. 2. Add a matching entry in `.github/plugin/marketplace.json`, `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, and `.agents/plugins/marketplace.json`. Keep plugin entries consistent across all marketplace manifests (including `plugins[].source` format) to reduce drift and make future updates safer. + Also add a `plugins//version.json` (copy an existing one) so the plugin participates in automated versioning. Start its `plugin.json` version at `0.1.0`. 3. Add a CODEOWNERS entry for the new plugin and its tests (see [Code ownership](#code-ownership)). 4. Add the plugin to the **What's Included** table in the root `README.md`. 5. Create a `tests//` directory for skill tests. @@ -68,6 +69,30 @@ Skills in `dotnet-experimental`: Place experimental skills under `plugins/dotnet-experimental/skills/` with matching tests in `tests/dotnet-experimental/`. +## Plugin versioning + +Each plugin is versioned independently. The version lives in two byte-identical files — +`plugins//plugin.json` and `plugins//.codex-plugin/plugin.json` — and consumers +(Copilot CLI, Claude, Codex, Cursor) read it directly from this repository. + +Versioning is automated with [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning). +A per-plugin `plugins//version.json` scopes the git height to that plugin's subtree, so the +**patch** number is derived from history — you do not edit it by hand. + +What this means when you contribute: + +- **Don't hand-edit the `version` field** in either `plugin.json`. The patch number is computed and + stamped automatically, and a manual edit will be overwritten. +- **The only version field you may change is the base** (`"version"`) in `plugins//version.json`, + and only to declare a deliberate **minor or major** release of that plugin (e.g. `0.1` → `0.2` or `1.0`). + Changing the base resets the patch number to `0`. +- After a PR changes a plugin's content, bumping its version is optional: + - A maintainer can comment **`/version-bump`** on a same-repo PR to stamp the new version onto the branch. + - Otherwise the **weekly version sync** opens a PR that stamps any plugin whose content changed without a + version bump, explaining each change. Nothing is ever missed. + +Version-only changes do not trigger skill evaluations. + ## Before you start - Search existing issues and pull requests to avoid duplicates. diff --git a/eng/version/Sync-PluginVersions.ps1 b/eng/version/Sync-PluginVersions.ps1 new file mode 100644 index 0000000000..291e363c6d --- /dev/null +++ b/eng/version/Sync-PluginVersions.ps1 @@ -0,0 +1,209 @@ +#!/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/ + 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//plugin.json + plugins//.codex-plugin/plugin.json + + This one script backs both versioning entry points: + * version-bump-command.yml -> -BaseCommit -HeadCommit -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) => .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) => .0; + * otherwise (ordinary content change) => .(heightAtBase + 1), because the squash adds + exactly one height-bearing commit on top of the merge base. + +.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 ($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' + +# 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 } + $json = & dotnet @nbgvArgs 2>$null + if ($LASTEXITCODE -ne 0 -or -not $json) { + throw "nbgv get-version failed for $PluginDir (commit '$Commit')" + } + 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 +} + +# Plugins whose version-affecting files changed between two commits. The two stamped manifests +# (plugin.json, .codex-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$' + } | + 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 { + $Plugins = Get-ChildItem -Path $pluginsRoot -Directory | + Where-Object { Test-Path (Join-Path $_.FullName 'version.json') } | + Select-Object -ExpandProperty Name | + Sort-Object +} + +$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' + + if (-not (Test-Path (Join-Path $pluginDir 'version.json'))) { continue } + + $current = (Get-Content $manifest -Raw | ConvertFrom-Json).version + # Also read the Codex-facing manifest so we can detect (and repair) the case where + # the two manifests have drifted apart — e.g. a hand-edit updated one but not the other. + $currentCodex = if (Test-Path $codexManifest) { + (Get-Content $codexManifest -Raw | ConvertFrom-Json).version + } else { $null } + + if ($PredictSquashMerge) { + $newBase = Get-VersionBase -Plugin $name + $oldBase = Get-VersionBase -Plugin $name -Commit $BaseCommit + if (-not $oldBase -or $oldBase -ne $newBase) { + # 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 = "$newBase.0" + } + else { + $heightAtBase = (Get-NbgvInfo -PluginDir $pluginDir -Commit $BaseCommit).VersionHeight + $computed = "$newBase.$([int]$heightAtBase + 1)" + } + } + 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" + } + + $changed = ($computed -ne $current) -or + ($null -ne $currentCodex -and $computed -ne $currentCodex) + + if ($OnlyChanged -and -not $changed) { continue } + + if ($Write -and $changed) { + [void](Set-ManifestVersion -Path $manifest -Version $computed) + [void](Set-ManifestVersion -Path $codexManifest -Version $computed) + } + + $results.Add([ordered]@{ + plugin = $name + current = $current + computed = $computed + changed = $changed + }) +} + +$results | ConvertTo-Json -AsArray -Compress diff --git a/eng/version/nuget.config b/eng/version/nuget.config new file mode 100644 index 0000000000..419124bb0e --- /dev/null +++ b/eng/version/nuget.config @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/plugins/dotnet-ai/version.json b/plugins/dotnet-ai/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-ai/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-aspnetcore/version.json b/plugins/dotnet-aspnetcore/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-aspnetcore/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-blazor/version.json b/plugins/dotnet-blazor/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-blazor/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-data/version.json b/plugins/dotnet-data/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-data/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-diag/version.json b/plugins/dotnet-diag/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-diag/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-experimental/version.json b/plugins/dotnet-experimental/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-experimental/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-maui/version.json b/plugins/dotnet-maui/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-maui/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-msbuild/version.json b/plugins/dotnet-msbuild/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-msbuild/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-nuget/version.json b/plugins/dotnet-nuget/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-nuget/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-template-engine/version.json b/plugins/dotnet-template-engine/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-template-engine/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-test/version.json b/plugins/dotnet-test/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-test/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet-upgrade/version.json b/plugins/dotnet-upgrade/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-upgrade/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet/version.json b/plugins/dotnet/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} diff --git a/plugins/dotnet11/version.json b/plugins/dotnet11/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet11/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} From 766115fa34c1300f29a523f6bff0c21d41cf598e Mon Sep 17 00:00:00 2001 From: Abhitej John Date: Wed, 24 Jun 2026 11:05:38 -0700 Subject: [PATCH 2/4] 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> --- .github/workflows/weekly-version-sync.yml | 3 +-- CONTRIBUTING.md | 2 +- eng/version/Sync-PluginVersions.ps1 | 14 +++++++++++--- plugins/dotnet-test-migration/version.json | 10 ++++++++++ 4 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 plugins/dotnet-test-migration/version.json diff --git a/.github/workflows/weekly-version-sync.yml b/.github/workflows/weekly-version-sync.yml index fa6032d3f8..ccceb354c8 100644 --- a/.github/workflows/weekly-version-sync.yml +++ b/.github/workflows/weekly-version-sync.yml @@ -63,8 +63,7 @@ jobs: $range = if ($lastStamp) { "$lastStamp..HEAD" } else { 'HEAD' } $commits = git log --format='- %h %s' $range -- "plugins/$name" ` ":(exclude)plugins/$name/plugin.json" ` - ":(exclude)plugins/$name/.codex-plugin/plugin.json" ` - ":(exclude)plugins/$name/version.json" + ":(exclude)plugins/$name/.codex-plugin/plugin.json" $why = if ($commits) { ($commits -join "`n") } else { '- (no attributable commits found)' } @( "### ``$name``: $($r.current) → $($r.computed)" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1f6a5a2b2..6804852e4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ Place experimental skills under `plugins/dotnet-experimental/skills/` with match ## Plugin versioning -Each plugin is versioned independently. The version lives in two byte-identical files — +Each plugin is versioned independently. The same version is duplicated in two manifest files — `plugins//plugin.json` and `plugins//.codex-plugin/plugin.json` — and consumers (Copilot CLI, Claude, Codex, Cursor) read it directly from this repository. diff --git a/eng/version/Sync-PluginVersions.ps1 b/eng/version/Sync-PluginVersions.ps1 index 291e363c6d..bbe76ad619 100644 --- a/eng/version/Sync-PluginVersions.ps1 +++ b/eng/version/Sync-PluginVersions.ps1 @@ -99,9 +99,17 @@ function Get-NbgvInfo { param([string] $PluginDir, [string] $Commit) $nbgvArgs = @('nbgv', 'get-version', '-p', $PluginDir, '-f', 'json') if ($Commit) { $nbgvArgs += $Commit } - $json = & dotnet @nbgvArgs 2>$null - if ($LASTEXITCODE -ne 0 -or -not $json) { - throw "nbgv get-version failed for $PluginDir (commit '$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) } diff --git a/plugins/dotnet-test-migration/version.json b/plugins/dotnet-test-migration/version.json new file mode 100644 index 0000000000..fa0ac64c71 --- /dev/null +++ b/plugins/dotnet-test-migration/version.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ + ".", + ":!plugin.json", + ":!.codex-plugin/plugin.json", + ":!version.json" + ] +} From 22133dc44622e576b13ebe9e69837b9dd0978f1b Mon Sep 17 00:00:00 2001 From: Abhitej John Date: Tue, 30 Jun 2026 11:12:09 -0700 Subject: [PATCH 3/4] Harden version automation per maintainer review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address @Evangelink's review on the per-plugin versioning workflows. These issue_comment/schedule workflows run from main and get no CI signal on the PR, so each fix is validated with a local NBGV git harness instead. Workflow runtime/git plumbing (version-bump-command.yml, weekly-version-sync.yml): - Drop --depth=1 from the "pin trusted tooling from main" fetch. A shallow fetch writes .git/shallow and flips the whole repo shallow (proven locally), and NBGV refuses to compute height on a shallow repo. fetch-depth: 0 already fetched main, so the full fetch is cheap and keeps the supply-chain pin intact. - Add a guard that throws if `git merge-base BASE_SHA head` is empty, instead of letting nbgv fail with an opaque "-PredictSquashMerge requires -BaseCommit". - setup-dotnet: install dotnet-version 8.0.x alongside global-json-file. nbgv targets net8.0; global.json pins an SDK that ships only a net11 preview runtime, onto which a release-targeted tool will not roll forward. setup-dotnet installs both inputs, giving nbgv a runtime to run on. Prediction correctness (Sync-PluginVersions.ps1): - Only add +1 to the predicted height when a height-bearing file actually changed. A version.json-only non-base edit is height-neutral, so predicting +1 over-bumps and the weekly sync would later correct it downward — a visible version regression. New Test-HeightBearingChange checks the diff with the canonical exclusions. - Validate the version.json base is major.minor; a 3-part base (e.g. 0.1.0) was silently normalized by NBGV into a passing SimpleVersion on the weekly path. - Reject non-canonical pathFilters (raised in cross-model review). The predict height math assumes version.json and the two stamped manifests are excluded and that the filter set is stable between base and head; a hand-edited pathFilters would silently diverge the post-merge height from the prediction. The height-excluded file list is now a single source of truth shared by the guard and Test-HeightBearingChange. CONTRIBUTING.md: note that concurrently bumped PRs can predict the same patch and that the weekly sync reconciles the collision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/version-bump-command.yml | 13 ++++- .github/workflows/weekly-version-sync.yml | 4 ++ CONTRIBUTING.md | 4 +- eng/version/Sync-PluginVersions.ps1 | 67 ++++++++++++++++++++-- 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/.github/workflows/version-bump-command.yml b/.github/workflows/version-bump-command.yml index f245d9a2f5..147f1fb020 100644 --- a/.github/workflows/version-bump-command.yml +++ b/.github/workflows/version-bump-command.yml @@ -95,7 +95,11 @@ jobs: - name: Pin trusted tooling from main if: steps.pr.outputs.is_fork == 'false' run: | - git fetch --no-tags --depth=1 origin main + # NOTE: do NOT pass --depth here. A shallow fetch writes .git/shallow and flips the + # WHOLE repo to shallow (even though checkout used fetch-depth: 0), and NBGV refuses to + # compute version height on a shallow repo. fetch-depth: 0 already fetched main, so this + # full-depth fetch is cheap and keeps the repo non-shallow. + git fetch --no-tags origin main git checkout FETCH_HEAD -- \ eng/version/Sync-PluginVersions.ps1 \ eng/version/nuget.config \ @@ -106,6 +110,10 @@ jobs: if: steps.pr.outputs.is_fork == 'false' uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: + # nbgv targets net8.0; global.json pins an SDK that ships only a preview runtime, onto + # which a release-targeted tool will not roll forward. Install an 8.0.x runtime so nbgv + # has a runtime to execute on, alongside the global.json SDK. + dotnet-version: '8.0.x' global-json-file: global.json - name: Restore tools @@ -121,6 +129,9 @@ jobs: run: | $head = git rev-parse HEAD $mergeBase = git merge-base $env:BASE_SHA $head + if (-not $mergeBase) { + throw "Could not determine a merge base between base ($env:BASE_SHA) and head ($head). Ensure main is fully fetched (non-shallow)." + } $report = & "$PWD/eng/version/Sync-PluginVersions.ps1" ` -BaseCommit $mergeBase -HeadCommit $head -PredictSquashMerge -OnlyChanged -Write | diff --git a/.github/workflows/weekly-version-sync.yml b/.github/workflows/weekly-version-sync.yml index ccceb354c8..b8d88ac37b 100644 --- a/.github/workflows/weekly-version-sync.yml +++ b/.github/workflows/weekly-version-sync.yml @@ -38,6 +38,10 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: + # nbgv targets net8.0; global.json pins an SDK that ships only a preview runtime, onto + # which a release-targeted tool will not roll forward. Install an 8.0.x runtime so nbgv + # has a runtime to execute on, alongside the global.json SDK. + dotnet-version: '8.0.x' global-json-file: global.json - name: Restore tools diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6804852e4f..0863f9acc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,9 @@ What this means when you contribute: - Otherwise the **weekly version sync** opens a PR that stamps any plugin whose content changed without a version bump, explaining each change. Nothing is ever missed. -Version-only changes do not trigger skill evaluations. +Patch numbers are predicted from git history, so two PRs bumped concurrently can land the same patch +number for a plugin; the weekly sync recomputes the authoritative height on `main` and reconciles any +collision. Version-only changes do not trigger skill evaluations. ## Before you start diff --git a/eng/version/Sync-PluginVersions.ps1 b/eng/version/Sync-PluginVersions.ps1 index bbe76ad619..39ad59290c 100644 --- a/eng/version/Sync-PluginVersions.ps1 +++ b/eng/version/Sync-PluginVersions.ps1 @@ -37,7 +37,9 @@ base-change commit, so NBGV resets the patch to 0); * the plugin is newly added (no version.json at the merge base) => .0; * otherwise (ordinary content change) => .(heightAtBase + 1), because the squash adds - exactly one height-bearing commit on top of the merge base. + 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 (=> .heightAtBase) and no spurious bump is predicted. .PARAMETER OnlyChanged Emit/stamp only plugins whose computed version differs from the value currently in @@ -71,6 +73,18 @@ if ($HeadCommit -and -not $BaseCommit) { $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path $pluginsRoot = Join-Path $repoRoot 'plugins' +# The files excluded from NBGV git height for every plugin: the two stamped manifests (which are +# outputs, not inputs) plus version.json itself. 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 three drifting apart. +$HeightExcludedFiles = @('plugin.json', '.codex-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 @@ -126,6 +140,18 @@ function Get-VersionBase { 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 two stamped manifests # (plugin.json, .codex-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 @@ -172,17 +198,46 @@ foreach ($name in $Plugins) { (Get-Content $codexManifest -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) { - $newBase = Get-VersionBase -Plugin $name $oldBase = Get-VersionBase -Plugin $name -Commit $BaseCommit - if (-not $oldBase -or $oldBase -ne $newBase) { + 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 = "$newBase.0" + $computed = "$base.0" } else { - $heightAtBase = (Get-NbgvInfo -PluginDir $pluginDir -Commit $BaseCommit).VersionHeight - $computed = "$newBase.$([int]$heightAtBase + 1)" + $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. 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 = if ($HeadCommit) { + [int](Test-HeightBearingChange -Plugin $name -From $BaseCommit -To $HeadCommit) + } else { 1 } + $computed = "$base.$($heightAtBase + $bumps)" } } else { From 259c663d20ee60e4d216e0019cea3ac86e82775f Mon Sep 17 00:00:00 2001 From: Abhitej John Date: Tue, 30 Jun 2026 11:19:42 -0700 Subject: [PATCH 4/4] Document that manifest-only edits don't bump the version Clarify in CONTRIBUTING that the two generated manifests and version.json are excluded from NBGV height, so editing only manifest metadata won't change the patch number or be picked up by /version-bump or the weekly sync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0863f9acc3..91aa4076ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,11 @@ Each plugin is versioned independently. The same version is duplicated in two ma Versioning is automated with [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning). A per-plugin `plugins//version.json` scopes the git height to that plugin's subtree, so the -**patch** number is derived from history — you do not edit it by hand. +**patch** number is derived from history — you do not edit it by hand. The two generated manifests +(`plugin.json`, `.codex-plugin/plugin.json`) and `version.json` itself are excluded from that height +via the `pathFilters`, so editing only manifest metadata (anything other than a deliberate base bump +in `version.json`) does **not** change the patch number and is **not** picked up by `/version-bump` or +the weekly sync. Touch a skill or other plugin content to bump the version. What this means when you contribute: