Add a release-manager agent that orchestrates the release skills (#1794)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b8535f33-08ac-44ef-84d1-aab96770ae3a
This commit is contained in:
Jeff Handley 2026-08-05 14:24:16 -07:00 • committed by GitHub
commit 514cf68af1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1586 additions and 46 deletions

View file

@ -22,7 +22,7 @@ Read `src/Directory.Build.props` on the current branch and extract:
The candidate version is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`). Display the current candidate version to the user.
Determine the previous release tag from `gh release list` (most recent **published** release). Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. The lookup is branch-aware: from a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; from `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md) for details.
Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the target commit, not the most recently published by date. Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. The lookup is branch-aware: from a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; from `main`, there is no MAJOR filter. See [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup) for details, including why date ordering picks the wrong tag.
### Step 2: Assess and Determine Next Version

View file

@ -66,7 +66,7 @@ This is purely about how to *compute* the next version. It does **not** declare
### Branch context
The "previous release" lookup is constrained to tags matching `v{MAJOR}.*` when assessing from a `release/{MAJOR}.x` servicing branch. On `main`, the lookup is unconstrained (most recent published release globally).
The "previous release" lookup selects the highest semver among published releases that are ancestors of the target commit, constrained to tags matching `v{MAJOR}.*` when assessing from a `release/{MAJOR}.x` servicing branch. On `main`, there is no MAJOR filter. It is not a date-ordered lookup; see [release-branches.md](../../shared-resources/release-branches.md#previous-release-tag-lookup).
The MAJOR/MINOR/PATCH classification criteria above are unchanged regardless of branch.

View file

@ -18,6 +18,27 @@ Use the shared [release branch reference](../shared-resources/release-branches.m
Work through each step sequentially. Present findings at each step and get user confirmation before proceeding. Skip any step that has no applicable items.
### Step 0: Sync With Upstream
Every later step reads branches, tags, and file contents from the local repository. Stale local refs
produce assessments that are wrong in ways that look plausible: a missing tag makes a released
version invisible, and a stale branch hides merged PRs. Establish a complete, current view before
reading anything.
1. Identify the remote that points at the canonical repository (`modelcontextprotocol/csharp-sdk`).
Do not assume it is named `origin` — in a fork-based checkout `origin` is often the fork:
`git remote -v`
2. Fetch that remote's branches **and tags**, pruning deleted refs:
`git fetch {upstream} --prune --prune-tags --tags`
3. Confirm the tag for the most recent published release exists locally and resolves:
`git rev-parse --verify v{previous}^{commit}`
Report what changed as a result of the fetch — new tags, updated branch heads — so the user can see
whether the starting state was stale.
Read every subsequent step's branch state from the remote-tracking refs (`{upstream}/main`,
`{upstream}/release/{MAJOR}.x`), not from local branches, which may lag or have diverged.
### Step 1: Select Source Branch
List candidate source/base branches via:
@ -40,9 +61,21 @@ The user may provide:
- **No context** — show the last 5 commits on the selected source/base branch (noting HEAD) and offer the option to enter a branch or tag name instead
Once the target is established:
1. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). Use the selected source/base branch context: on `release/{MAJOR}.x`, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally.
1. Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the target commit (exclude drafts with `--exclude-drafts`). Do not order by publication date; see [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup) for why the two differ and what breaks. On `release/{MAJOR}.x`, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, there is no MAJOR filter.
2. Get the full list of PRs merged between the previous release tag and the target commit on the selected branch.
3. Read `src/Directory.Build.props` **at the target commit**. Extract `<VersionPrefix>` and `<VersionSuffix>`; the **candidate version** is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`).
4. **Verify the previous release tag is an ancestor of the target commit:**
`git merge-base --is-ancestor v{previous} {target}`
If it is not an ancestor, stop and report. The two histories have diverged, which means the
selected source branch is not a continuation of the previous release. Every downstream
conclusion would be wrong: the PR range would be computed across unrelated history, and the
ApiCompat baseline in Step 7 would report the previous release's entire API surface as removed.
This is a source-selection problem, not a compatibility problem — do not attempt to suppress it.
The usual cause is that the previous release shipped from a different branch than the one
selected. Re-run Step 1 and choose the branch that actually contains the previous release, or
confirm with the user that a divergent source is intended and why.
### Step 3: Categorize and Attribute
@ -98,7 +131,7 @@ After the version is confirmed:
2. Update `src/Directory.Build.props`:
- Set `<VersionPrefix>` to the confirmed stable component
- Set `<VersionSuffix>` for prerelease versions, or clear it for stable versions; add the element if it is missing
- Update `<PackageValidationBaselineVersion>` when appropriate. For the `2.0.0-preview` series, baseline is `1.3.0` (latest shipped 1.x). For subsequent stable releases, baseline is the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR.
- Update `<PackageValidationBaselineVersion>` when appropriate, per the rule in [references/apicompat-apidiff.md](references/apicompat-apidiff.md#updating-the-baseline-version). Read the current value from `src/Directory.Build.props` and derive the correct one from the versions actually published; never copy a version from an example. Show the derivation — current value, published versions considered, resulting value, and whether it changes — and get confirmation before editing. **If the value changes, the [baseline-transition suppression audit](references/apicompat-apidiff.md#baseline-transition-suppression-audit) is mandatory.**
3. Build the solution to verify the version change compiles: `dotnet build`
This step creates local changes only — nothing is committed or pushed yet.
@ -109,11 +142,19 @@ Run API compatibility validation against the baseline version. Follow [reference
1. Run `dotnet pack` to trigger package validation against `PackageValidationBaselineVersion`
2. Capture the ApiCompat output (compatibility issues, warnings, suppressions)
3. If there are unexpected compatibility breaks:
3. **If `PackageValidationBaselineVersion` changed in this release, run the baseline-transition suppression audit before interpreting anything else.** Moving the baseline makes suppressions written for the old baseline stale, and the resulting failure looks exactly like a mass breaking change.
4. If there are unexpected compatibility breaks:
- **First, check whether the output says `Unnecessary suppressions found`.** That is a hard failure in its own right, and the CP0001/CP0002/CP0005 lines beneath it are the listing of *unused suppression entries*, not live API breaks. Regenerate the suppression file and cross-check the API diff before believing them.
- **Then sanity-check the scale.** A large number of errors reporting *missing* API surface —
especially spanning whole feature areas — almost always means the baseline does not belong to
this branch's history, or that stale suppressions are being listed. Re-verify the ancestry check
from Step 2 before interpreting a single error. Never suppress your way out of this.
- Cross-reference with the breaking change audit from Step 4
- Present any unaccounted breaks to the user
- If breaks are intentional, add appropriate entries to `CompatibilitySuppressions.xml` in the affected project directory
4. Record the ApiCompat results for inclusion in the PR description
- If breaks are intentional, add appropriate entries to `CompatibilitySuppressions.xml` in the affected project directory — only after the suppression audit is complete
5. **Never adjust the thing being validated against in order to pass.** Do not change `PackageValidationBaselineVersion` to silence errors, do not set `ApiCompatPermitUnnecessarySuppressions`, do not `NoWarn` CP diagnostics, and do not disable package validation. The baseline is determined by what shipped; suppressions record user-confirmed intentional breaks. If validation fails unexpectedly, stop and report.
6. Confirm the plain CI-equivalent run passes with no generation flags: `dotnet clean -c Release; dotnet pack -c Release`
7. Record the per-package ApiCompat results — baseline, generated entry count, retained/removed suppressions, plain pack result — for Step 12 and the PR description
### Step 8: Generate API Diff Report
@ -154,19 +195,63 @@ Stage all documentation changes for inclusion in the release commit.
Compose the release notes that will appear in the PR description and serve as the foundation for the **publish-release** skill. This is a draft — the final release notes will be refreshed when the GitHub release is created.
1. **Preamble** — Draft a short paragraph summarizing the release theme. Present it to the user for review and editing. The preamble is **required**.
2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link.
2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link, using the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links).
3. **What's Changed** — chronological; includes breaking change PRs
4. **Documentation Updates** — chronological
5. **Test Improvements** — chronological
6. **Repository Infrastructure Updates** — chronological
7. **Acknowledgements**:
- New contributors (first contribution in this release)
- Issue reporters (cite resolving PRs)
- Issue reporters (cite resolving PRs) — **excluding maintainers**. Acknowledgements exist to
thank the community; a maintainer filing an issue in their own repository is ordinary
project work, not a contribution to credit. Determine maintainer status via
`gh api repos/{owner}/{repo}/collaborators/{user}/permission --jq .permission` and omit
anyone with `admin` or `write`. Maintainers still appear in the reviewers bullet.
- PR reviewers (single bullet, sorted by review count, no count shown)
8. **Full Changelog** link using the exact tag, including any suffix (for example, `v1.3.1` or `v2.0.0-preview.1`)
Omit empty sections. Present each section for user review before proceeding. Tag references in templates use `v{version}` exactly, including prerelease suffixes; the Full Changelog link compares the previous tag to the suffixed tag when applicable.
### Step 10b: Review Categorization and Acknowledgements With the User
**Do this before committing, and never defer it to the Step 12 summary.** Showing the finished
notes is not a substitute for this step. A complete, well-formatted set of release notes reads as
correct and does not invite scrutiny; users routinely approve it and then find miscategorized
entries afterward, once the PR is already open. Ask targeted questions while the answers are still
cheap to apply.
Present two compact review artifacts and stop for a response after each.
**1. Categorization table.** Every PR, its assigned section, and the reason — not just the
borderline ones, since the user cannot correct a call they were not shown:
| PR | Title | Section | Why |
|---|---|---|---|
| #{number} | {title} | {section} | {what the placement turned on} |
Then explicitly surface the judgment calls, naming the PRs and the reasoning that made each one
close:
> These were the close calls: {PRs} touch code but not shipped packages, so I placed them under
> {section}. Any of these belong in a different section?
Flag as a close call any PR that touches `samples/` or `tests/` but not `src/`, any PR placed in
"What's Changed" whose changes are confined to non-shipping paths, and any PR whose title suggests
a different section than the one you assigned.
**2. Acknowledgements roster.** Each person, why they are listed, and their maintainer status:
| Person | Reason | Maintainer? |
|---|---|---|
| @{handle} | {contribution or issue, and the PR that resolved it} | {yes/no — if yes, omit per Step 10 item 7} |
Show entries you excluded and why, so the user can overrule the omission. Ask directly whether the
remaining list is right, since acknowledgement errors are about people and are the least
comfortable thing to correct after publication.
Apply any corrections before Step 11. Record what changed so the same misclassification is not
reintroduced when publish-release refreshes the notes for late-arriving PRs.
### Step 11: Commit Changes
Commit all changes to the `release-{version}` branch:
@ -191,7 +276,7 @@ Present **all** of the following details to the user for review. The user must c
docs/experimental.md — Added new experimental API reference
```
6. **Draft release notes** — the complete release notes from Step 10
7. **API Compatibility results** — the ApiCompat output from Step 7
7. **API Compatibility results** — the per-package table from Step 7: baseline version, generated suppression count, retained/removed stale suppressions, and plain-pack result. Do not state that ApiCompat passed without these. Call out any change to `PackageValidationBaselineVersion` or to any suppression file explicitly.
8. **API Diff report** — the API diff from Step 8
9. **Proposed PR title** (e.g., `Release v2.0.0-preview.1`, `Release v1.3.1`)
10. **Proposed PR description** — the assembled content combining release notes, ApiCompat, and ApiDiff
@ -199,6 +284,11 @@ Present **all** of the following details to the user for review. The user must c
After presenting all details, explicitly ask the user:
> Would you like to push the branch and create the pull request?
Confirm the Step 10b review actually happened before asking. If categorization and acknowledgements
were never reviewed as their own decisions, go back and do that first — this gate is about
publishing mechanics, and burying content questions in it is how miscategorized entries reach an
open PR.
**Do not proceed without explicit "yes" confirmation.**
### Step 13: Push Branch and Create Pull Request
@ -213,12 +303,22 @@ Only after explicit user confirmation in Step 12:
- **Description**: The assembled PR description (see PR Description Template below)
- **Labels**: Apply appropriate labels (e.g., `release`)
3. Present the PR URL to the user
4. **Monitor CI to completion.** Creating the PR does not end this step. Watch every check on the new head SHA until it reaches a terminal state:
```sh
gh pr checks {pr-number} --watch
```
Then report a per-check table and an overall verdict of **green**, **running**, or **blocked**. Do not hand off with only the PR URL and an invitation to review — the user should not be the one to discover a red build.
5. **On failure**, retrieve the logs yourself (`gh run view {run-id} --log-failed`), distinguish product/API validation failures from infrastructure or tooling flakiness, and diagnose before proposing a rerun. For ApiCompat failures, apply the interpretation rules in [references/apicompat-apidiff.md](references/apicompat-apidiff.md) before concluding the release is breaking. Present the diagnosis and a proposed fix, then stop — pushing a fix needs the same explicit approval as the original push.
6. **Restart monitoring after every subsequent push** to the release branch, against the new head SHA. Checks from a previous SHA are stale and must not be reported as current.
**Important**: No draft GitHub release is created at this point. The **publish-release** skill handles release creation after this PR is merged.
## Edge Cases
- **PR spans categories**: categorize by primary intent
- **PR spans categories**: categorize by primary intent, and surface it as a close call at Step 10b
- **PR adds sample code or tests but no `src/` changes**: Documentation Updates or Test Improvements, not "What's Changed" — the shipped packages did not change
- **Issue reporter is a maintainer**: omit the acknowledgement; show it as an exclusion at Step 10b so the user can overrule
- **User recategorizes after the PR is open**: update the PR body, and record the correction so publish-release does not re-derive the original category
- **Copilot timeline missing**: fall back to `Co-authored-by` trailers to determine whether `@Copilot` should be a co-author; if still unclear, use `@Copilot` as primary author
- **No breaking changes**: omit the Breaking Changes section from release notes entirely
- **Single breaking change**: use the same numbered format as multiple
@ -227,12 +327,21 @@ Only after explicit user confirmation in Step 12:
- **Proposed MAJOR does not match branch MAJOR**: if the proposed version's MAJOR doesn't match the branch's MAJOR (for example, proposing `2.0.0-preview.2` on `release/1.x`), flag this as a warning and ask the user to confirm. Do not hard-fail. This is informational, not a policy enforcement.
- **Prerelease bump**: when the candidate version has a suffix like `preview.N`, the SemVer assessment may simply increment `N` rather than computing MAJOR/MINOR/PATCH. Refer to the SemVer assessment guide's Prereleases section.
- **No previous release**: if this is the first release, there is no previous tag; gather all PRs merged to the target
- **Previous release tag is not an ancestor of the target**: stop and re-select the source branch per Step 2. Do not compute a PR range or interpret ApiCompat results across divergent history, and do not suppress the resulting errors
- **Previous release tag missing locally**: re-run the Step 0 fetch with `--tags` before concluding the release does not exist; a tag absent locally is far more often a stale checkout than an unpublished release
- **ApiCompat tooling unavailable**: fall back to `dotnet pack` output; note in the PR description that full ApiCompat was run via package validation only
- **`Unnecessary suppressions found` in ApiCompat output**: the CP lines that follow are unused suppression entries, not live breaks. Run the baseline-transition suppression audit and cross-check the API diff before treating the release as breaking
- **Baseline version changed during preparation**: run the suppression audit for every shipping package, and decide deliberately between advancing the baseline (clearing stale suppressions) and keeping the existing one. Report the choice and its rationale at Step 12
- **ApiCompat passes locally but CI fails**: check whether local runs used generation flags. Only `dotnet clean -c Release; dotnet pack -c Release` reproduces CI
- **A check never starts**: a workflow skipped by a path filter or stuck in a queue is not a pass. Compare against the check set on previous release PRs before declaring green
- **Checks green on an earlier SHA**: stale. Re-watch against the current head after every push
- **CI fails for infrastructure reasons**: a single rerun is reasonable if the cause is clearly runner, network, or feed related. State the reason. Never rerun a product or API validation failure to make it disappear
- **API diff tool installation fails**: do not fall back to a manual summary; pause and present the installation error to the user, offering options to troubleshoot, skip the API diff section, or abort the release preparation
- **No changelogs in repo**: skip changelog updates; note in the summary
- **Branch already exists**: if `release-{version}` already exists locally or remotely, ask the user whether to reuse it, delete and recreate, or choose a different name
- **PackageValidationBaselineVersion update**: for the `2.0.0-preview` series, use `1.3.0`; for subsequent stable releases, use the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR
- **CompatibilitySuppressions.xml**: when intentional breaks are found, add suppression entries and include the file in the commit; existing suppressions should be preserved
- **PackageValidationBaselineVersion update**: derive it per [references/apicompat-apidiff.md](references/apicompat-apidiff.md#updating-the-baseline-version) from the versions actually published, and show the derivation for confirmation. A change to this property makes the baseline-transition suppression audit mandatory
- **CompatibilitySuppressions.xml**: when intentional breaks are found, add suppression entries and include the file in the commit. Preserve existing suppressions **unless the baseline moved** — the audit may prove tracked entries stale, in which case removing them is the fix, not a regression
- **Versioning link for a brand-new MAJOR**: the `/v{MAJOR}/versioning.html` path does not exist until the release is published and the Publish Docs workflow runs. The link is forward-referencing at prepare time, like the release-notes tag link. Use the slugged form anyway; do not fall back to the unslugged URL.
- **User declines PR creation**: if the user declines at Step 12, leave the local branch intact so they can review, modify, or push manually
## PR Description Template
@ -248,7 +357,7 @@ The PR description combines release notes, ApiCompat, and ApiDiff into a single
### Breaking Changes
Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies.
Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies.
1. **Description #PR**
* Detail of the break
@ -303,14 +412,14 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers
The release notes section within the PR description uses the same format as the final GitHub release notes (used by the **publish-release** skill). This ensures consistency between the PR and the published release. Tag examples such as `v2.0.0-preview.1` are valid and should be used verbatim when the version has a prerelease suffix.
Omit empty sections. The preamble is **always required** — it is not inside a section heading.
Omit empty sections. The preamble is **always required** — it is not inside a section heading. The versioning link uses the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links).
```markdown
[Preamble — REQUIRED. Summarize the release theme.]
## Breaking Changes
Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies.
Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies.
1. **Description #PR**
* Detail of the break

View file

@ -8,16 +8,16 @@ The SDK uses NuGet's [Package Validation](https://learn.microsoft.com/dotnet/fun
```xml
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>1.0.0</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>{baseline}</PackageValidationBaselineVersion>
```
Read the current values rather than assuming them, and check whether any individual project overrides them — a project that opts out of validation still ships, and needs to be reported as unvalidated rather than quietly skipped.
### Running ApiCompat
1. **Pack the SDK packages** to trigger validation:
1. **Pack the SDK packages** to trigger validation. Enumerate the packable projects under `src/` and pack each one; the set of shipping packages grows over time, so do not work from a remembered list:
```sh
dotnet pack src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
dotnet pack src/ModelContextProtocol/ModelContextProtocol.csproj
dotnet pack src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj
dotnet pack src/{project}/{project}.csproj
```
Or pack all at once:
```sh
@ -28,17 +28,96 @@ The SDK uses NuGet's [Package Validation](https://learn.microsoft.com/dotnet/fun
3. **Interpret results:**
- **No issues**: The API is backward-compatible with the baseline. This is the expected result for PATCH and MINOR releases.
- **`Unnecessary suppressions found`**: **Read this before concluding anything else.** See [Reading a failing run](#reading-a-failing-run) below — the CP lines that follow it are usually *not* live breaks.
- **Compatibility errors**: The API has breaking changes relative to the baseline. These should align with the breaking change audit from Step 3 of the prepare-release skill.
- **Suppressions needed**: If intentional breaking changes are confirmed, add entries to `CompatibilitySuppressions.xml` in the affected project directory.
- **Suppressions needed**: If intentional breaking changes are confirmed, add entries to `CompatibilitySuppressions.xml` in the affected project directory — but only after completing the [baseline-transition suppression audit](#baseline-transition-suppression-audit).
### Reading a failing run
`Unnecessary suppressions found` is itself a **hard failure**, not a warning attached to some other
problem. When it appears, the `CP0001` / `CP0002` / `CP0005` lines printed after it are the tool's
**detailed listing of the suppression entries it considers unused**. They are not a list of live API
breaks, even though they are formatted identically and appear under the same error banner.
Misreading that listing is how a routine release turns into a phantom emergency. In this repo it
produced 312 apparent breaking changes across the Core Tasks API on a release whose only real
change was one additive method — and it did so convincingly, because 312 lines of CP0001 for
missing types reads exactly like a catastrophic regression.
Before classifying a release as breaking, confirm which of the two you are looking at:
1. **Regenerate the suppression file** (see the audit below). If the generated output is *empty*,
there are no live breaks and every tracked entry is stale.
2. **Cross-check the direct API diff.** If ApiDiff shows only the additions you expect, the CP lines
are not describing reality.
Never work around this with `ApiCompatPermitUnnecessarySuppressions`, `NoWarn` for CP diagnostics,
or by disabling baseline validation. Those hide the signal that tells you the suppressions and the
baseline have drifted apart, which is the one thing you need to know.
### Updating the Baseline Version
- **MAJOR version bump**: Update `<PackageValidationBaselineVersion>` to the previous release version so that ApiCompat validates against the last stable release of the prior MAJOR version. After the new MAJOR release is published, the baseline stays at the new version for future comparisons.
- **MINOR or PATCH version bump**: Keep `<PackageValidationBaselineVersion>` at the last MAJOR release version (e.g., keep `1.0.0` when releasing `1.1.0` or `1.0.1`).
**A baseline that trails `VersionPrefix` is the expected steady state, not a stale value.** Through a MAJOR series the baseline deliberately stays put while `VersionPrefix` advances, so seeing `2.0.0` alongside a published `2.1.0` means the rule is being followed. Do not "fix" the gap — bumping the baseline mid-series triggers the audit below and invites the released API surface to be re-baselined against itself, silently discarding the compatibility guarantee the property exists to enforce.
**Any change to this property triggers the [baseline-transition suppression audit](#baseline-transition-suppression-audit).** Do not change it and interpret the resulting failures as breaking changes — the failures are expected until the suppressions are reconciled.
### Baseline-transition suppression audit
**Whenever `PackageValidationBaselineVersion` changes, run this audit before interpreting any
ApiCompat failure and before adding a single suppression entry.**
Suppression entries are scoped to the baseline they were generated against. They record "this
difference from *that* baseline is intentional." Move the baseline and the differences change, so
entries written for the old baseline may describe nothing at all — the API they excused is now
present on both sides. The tool reports those orphans as unnecessary, and the build fails.
For **every shipping project**:
1. Inventory the tracked suppressions:
```sh
ls src/*/CompatibilitySuppressions.xml
```
2. Regenerate what the *current* baseline actually requires, into a throwaway file so the tracked
one is not overwritten while you are still deciding:
```sh
dotnet clean src/{Project}/{Project}.csproj -c Release
dotnet pack src/{Project}/{Project}.csproj -c Release \
/p:ApiCompatGenerateSuppressionFile=true \
/p:ApiCompatSuppressionOutputFile={unique-temp-path}
```
Use the **final candidate version and the final baseline** — regenerating against a version you
are about to change invalidates the result.
3. Compare the generated entries against the tracked file, by count and by content.
| Generated | Tracked | Meaning | Action |
|---|---|---|---|
| Empty | Non-empty | Every tracked entry is stale for this baseline | Clear or delete the tracked file |
| Non-empty | Matches | Suppressions are current | Leave them alone |
| Non-empty | Differs | Some entries stale, some breaks genuinely need suppressing | Reconcile entry by entry, and confirm each remaining break with the user |
4. After clearing stale entries, rerun the plain CI-equivalent pack with no generation flags, and
require it to pass on its own:
```sh
dotnet clean -c Release
dotnet pack -c Release
```
Reverting the baseline is the other valid resolution, and sometimes the better one — it keeps the
release diff minimal. Choose deliberately between "advance the baseline and clear the stale
suppressions" and "keep the existing baseline", rather than letting the choice be made by whichever
one silences the error first. Either way, the baseline is determined by what shipped, never selected
to make validation pass.
### Compatibility Suppressions
When intentional breaking changes are confirmed, create or update `CompatibilitySuppressions.xml` in the affected project directory. The repo already uses this pattern — see `src/ModelContextProtocol.Core/CompatibilitySuppressions.xml` for examples.
When intentional breaking changes are confirmed, create or update `CompatibilitySuppressions.xml` in the affected project directory — the conventional location, which is auto-discovered.
**A suppression file has three valid outcomes, not one.** Entries get *added* when a new intentional break needs suppressing, *retained* when they still describe a real break against the current baseline, and *cleared* when the baseline moved and they no longer describe anything. Treating the file as append-only is what turned 312 obsolete entries into a release-blocking failure that read as a mass breaking change. Preservation is the default only while the baseline holds still; once it moves, the [audit](#baseline-transition-suppression-audit) decides what stays, and removing entries it proves stale is the fix rather than a regression.
Do not use a tracked file as a template for what entries should look like — it may legitimately be empty, and its contents describe whatever baseline it was generated against, not yours. Generate entries instead.
```xml
<?xml version="1.0" encoding="utf-8"?>
@ -53,7 +132,28 @@ When intentional breaking changes are confirmed, create or update `Compatibility
</Suppressions>
```
The exact suppression entries are generated by the pack command when it reports errors — copy the suggested suppression XML from the build output. Remember that suppressions are needed **per target framework** (net10.0, net9.0, net8.0, netstandard2.0).
The exact suppression entries are generated by the pack command when it reports errors — copy the suggested suppression XML from the build output, or generate the file directly with `/p:ApiCompatGenerateSuppressionFile=true`. Remember that suppressions are needed **per target framework** (net10.0, net9.0, net8.0, netstandard2.0).
#### Wiring the suppression file
A `CompatibilitySuppressions.xml` sitting in the project directory is **auto-discovered**. That is
the convention this repo uses, and it needs no wiring at all. Do not add MSBuild properties or items
to point at a file that is already found by convention — duplicate or incorrect wiring is easy to
add while chasing a failure and hard to spot afterward, and it ships in the release commit.
If you do need an explicit path:
| Name | Kind | Use |
|---|---|---|
| `CompatibilitySuppressionFilePath` | **Property** | The supported way to point at a suppression file explicitly |
| `ApiCompatSuppressionFile` | **Item** | Not a property. Setting it via `/p:` does nothing |
| `ApiCompatSuppressionOutputFile` | **Property** | Where `ApiCompatGenerateSuppressionFile=true` writes its output |
Retaining an empty suppressions file is fine when you want to keep the file in place after clearing
stale entries. It must still be **valid XML** — an empty `<Suppressions>` root, not a zero-byte
file — and it must preserve the repository's byte conventions for these files, including the
UTF-8 BOM and the final newline. A file that differs only in BOM or trailing newline produces a
confusing diff and can trip tooling that round-trips it.
### Common Diagnostic IDs
@ -191,14 +291,37 @@ _or_
[Diff or table of changes]
```
### In the User Summary (Step 11)
### In the User Summary (Step 12)
Present a condensed version for the user review:
Present a condensed version for the user review. **Report every shipping package, and do not state
that ApiCompat passed without these four facts** — "passed" is not meaningful without knowing what
it was validated against and whether stale suppressions were masking or manufacturing the result:
- **ApiCompat**: pass/fail with count of issues and suppressions per package
- **ApiDiff**: count of additions, removals, and changes per package
| Package | Baseline | Generated entries | Retained / removed | Plain pack |
|---|---|---|---|---|
| {package} | {baseline} | 0 | 0 retained / 312 removed | ✅ |
| {package} | {baseline} | 0 | 0 / 0 | ✅ |
Enumerate the packable projects under `src/` rather than working from a remembered list; the set
grows. A package that does not participate in validation still gets a row, reporting why — a first
release has no baseline to compare against, and that is a fact worth stating rather than an absence
worth hiding.
- **Baseline** — the `PackageValidationBaselineVersion` actually used, and whether it changed during
this release
- **Generated entries** — count from `ApiCompatGenerateSuppressionFile=true` at the final version
and baseline
- **Retained / removed** — tracked suppressions kept versus cleared as stale
- **Plain pack** — result of the CI-equivalent `dotnet clean -c Release; dotnet pack -c Release`
with no generation flags, which is the run CI will reproduce
Then the summary lines:
```
API Compatibility: ✅ All 3 packages pass (2 existing suppressions in Core)
API Compatibility: ✅ All {n} packages pass against v{baseline} ({n} stale suppressions removed from {package})
API Diff: +12 additions, -2 removals, ~3 changes across all packages
```
If the baseline changed, or any suppression file was modified, say so explicitly and explain why.
A silent baseline or suppression edit is the kind of change that passes local validation and then
fails CI.

View file

@ -11,14 +11,30 @@ Feature work, bug fixes, API improvements, performance enhancements, and any oth
- Changes that span code + docs (categorize based on the primary intent)
### Documentation Updates
PRs whose **sole purpose** is documentation. Examples:
PRs whose **sole purpose** is documentation, guidance, or examples. Examples:
- Fixing typos in docs
- Adding or improving XML doc comments (when not part of a functional change)
- Updating conceptual documentation (e.g., files in `docs/`)
- README updates
- Adding CONTRIBUTING.md or similar guides
- **Adding or improving samples** under `samples/`, including new executable sample projects
- Clarifying how consumers should use existing behavior, even when the PR touches tests to
demonstrate or lock in that behavior
**Important**: A PR that changes code AND updates docs should go in "What's Changed" — only pure documentation PRs belong here. However, documentation PRs should still be studied during the breaking change audit, as they may document changes that were not properly flagged as breaking.
**The test that matters is whether the shipped packages changed**, not whether the PR contains
code. A PR that adds a whole new sample application is still a documentation update: nothing in
`src/` shipped differently because of it. Ask "would a consumer upgrading the NuGet package
observe any difference?" If no, it belongs here.
**Important**: A PR that changes shipped product code under `src/` AND updates docs should go in
"What's Changed" — only PRs that leave the shipped surface untouched belong here. However,
documentation PRs should still be studied during the breaking change audit, as they may document
changes that were not properly flagged as breaking.
Categorize conservatively: when a PR could plausibly land in either "What's Changed" or
"Documentation Updates", prefer "Documentation Updates" and surface the call to the user at the
categorization review. Overstating a docs PR as product work inflates the apparent scope of a
release, and it is the error users notice and correct.
### Repository Infrastructure Updates
PRs that maintain the development environment but don't affect the shipped product or test coverage. Examples:
@ -114,3 +130,6 @@ Sort entries within each section by **merge date** (chronological order, oldest
* @user submitted issue #1234 (resolved by #5678)
* @user1 @user2 @user3 reviewed pull requests
```
Do not acknowledge maintainers as issue reporters or new contributors; see Step 10 item 7. They
belong only in the reviewers bullet.

View file

@ -16,6 +16,19 @@ Use the shared [release branch reference](../shared-resources/release-branches.m
Work through each step sequentially. Present findings at each step and get user confirmation before proceeding.
### Step 0: Sync With Upstream
This skill reads the merged release PR, the commit range since the previous release, and the
previous release tag. All three come from local refs that may be stale — most importantly, the
merge commit for the release PR will not exist locally until you fetch.
1. Identify the remote pointing at `modelcontextprotocol/csharp-sdk` (`git remote -v`) — do not
assume it is `origin`.
2. `git fetch {upstream} --prune --prune-tags --tags`
3. Confirm the merged release PR's merge commit resolves locally.
Report anything the fetch changed before continuing.
### Step 1: Identify the Prepare-Release PR
The user may provide:
@ -31,7 +44,7 @@ Verify the PR is merged. Extract:
### Step 2: Determine Version and Commit Range
1. Read `src/Directory.Build.props` at the merge commit to confirm `<VersionPrefix>` and `<VersionSuffix>`. The tag is `v{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present; for example, `<VersionPrefix>2.0.0</VersionPrefix>` + `<VersionSuffix>preview.1</VersionSuffix>` → `v2.0.0-preview.1`.
2. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). The lookup is branch-aware: when the merge commit is on a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md).
2. Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the merge commit (exclude drafts with `--exclude-drafts`). Do not order by publication date. When the merge commit is on a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, there is no MAJOR filter. See [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup).
3. Identify the full commit range: previous release tag → merge commit.
### Step 3: Check for Additional PRs
@ -67,7 +80,9 @@ Re-categorize all PRs in the commit range (including any new ones from Step 3).
1. **Re-run the breaking change audit** using the **breaking-changes** skill if new PRs were found that may introduce breaks. Otherwise, carry forward the results from the prepare-release PR.
2. **Re-categorize** all PRs into sections (What's Changed, Documentation, Tests, Infrastructure).
3. **Re-attribute** co-authors for any new PRs by harvesting `Co-authored-by` trailers from all commits in each PR.
4. **Update acknowledgements** to include contributors from new PRs.
4. **Update acknowledgements** to include contributors from new PRs, excluding maintainers as issue reporters (see prepare-release Step 10 item 7).
5. **Carry forward the prepare-release categorization decisions.** If the user recategorized a PR or removed an acknowledgement during preparation, honor that. Re-deriving categories from scratch will silently reintroduce the exact corrections they already made.
6. **Review with the user** using the categorization table and acknowledgements roster from prepare-release Step 10b — at minimum for PRs new since preparation, and for any entry whose section you changed. Do not fold this into the Step 9 draft-creation gate.
### Step 5: Review README and Validate Code Samples
@ -81,7 +96,24 @@ Re-run the README content checklist from [../prepare-release/references/readme-c
2. **Snippet validation** -- Extract `csharp`-fenced code blocks from `src/PACKAGE.md` and `README.md`, build the temporary test project, and report results. Follow [../prepare-release/references/readme-snippets.md](../prepare-release/references/readme-snippets.md) for the full procedure.
3. **Delete** the temporary project after validation.
If issues are found, present them to the user with proposed fixes. Any fixes must be applied as a separate commit before the draft release is created.
If issues are found, present them to the user with proposed fixes.
**Applying them is not a local commit.** The release PR is already merged, so its branch is gone;
fixes belong on the base branch this release ships from (`main` or `release/{MAJOR}.x`), which is
protected. Open a small PR for them, let CI run, and merge it — do not push to the base branch
directly, and do not amend or re-tag anything already reviewed.
Then **re-target the draft release**, which is pinned to the previously approved merge commit and
therefore does not contain the fix:
```sh
gh release edit v{version} --target {new-merge-commit-sha}
```
Regenerate the release notes afterward so the commit range covers the new PR, and re-run the Step 6
section review for anything that changed. If the user prefers not to take the fix in this release,
that is a valid choice — leave the draft pinned where it is and note the deferred item, rather than
carrying a fix that the tag will not include.
**Edge Cases:**
- **Stale package closure** -- A package introduced between prepare-release and now may not be listed. Add it to `src/PACKAGE.md` and `README.md`.
@ -102,7 +134,7 @@ Highlight any changes from the prepare-release draft (new entries, reordered ent
### Step 7: Preamble
Every release **must** have a preamble — a short paragraph summarizing the release theme that appears before the first `##` heading. The preamble is not optional. The preamble may mention the presence of breaking changes as part of the theme summary, but the versioning documentation link belongs under the Breaking Changes heading (see template), not in the preamble.
Every release **must** have a preamble — a short paragraph summarizing the release theme that appears before the first `##` heading. The preamble is not optional. The preamble may mention the presence of breaking changes as part of the theme summary, but the versioning documentation link belongs under the Breaking Changes heading (see template), not in the preamble. That link must use the `v{MAJOR}` slug for the version being released.
Extract the draft preamble from the prepare-release PR description and present it alongside a freshly drafted alternative (accounting for any new PRs).
@ -123,11 +155,65 @@ Display release metadata for user review:
- **Target**: merge commit SHA, its message, the merge commit's branch (the prepare-release PR base), and the prepare-release PR link
After confirmation:
- Create with `gh release create --draft {tag} --target {merge-commit-branch}` (always `--draft`), using the prerelease tag verbatim when present
- Create with `gh release create --draft {tag} --target {merge-commit-sha}` (always `--draft`), using the prerelease tag verbatim when present
- **Target the full commit SHA, never a branch name.** A draft sits unpublished until a human
reviews and publishes it, which can be hours. `--target` is resolved when the tag is created --
at publish time, not now -- so a branch name silently re-resolves to whatever landed on that
branch in the meantime. The tag would then be cut at a commit nobody reviewed, and the release
notes would describe a different commit than the one shipped. The SHA you displayed above is the
commit the user approved; pass that exact SHA.
- **Never publish.** If the user asks to publish, decline and instruct them to publish manually.
Pinning the SHA costs nothing, because a draft release does not create the git tag. GitHub stores
the target and creates the tag only when the release is published, so the tag remains uncreated and
the draft fully editable while it waits.
That is also what makes a late-arriving commit easy to absorb. If the user decides to include work
that merged after the draft was created, do not delete and recreate the release: repoint it with
`gh release edit {tag} --target {new-commit-sha}`, then regenerate the release notes for the new
range and present them for approval again. Never move the target without revising the notes to
match -- a target change silently alters what shipped.
Then hand off to the user with the publishing checklist:
> The draft release is ready at {release URL}. Before publishing:
>
> 1. Review the release notes line by line — this is the last review before they are public.
> 2. Check **Set as a pre-release** if this is a prerelease.
> 3. Once you have signed off on the notes, **remove the AI-generated disclosure note** from the
> bottom of the body. It is there because the draft was AI-drafted; after your thorough review
> and sign-off, the published notes stand as your reviewed work.
> 4. Click **Publish release**.
The disclosure is removed by the **user**, as part of their sign-off — never remove it yourself, and
never remove it from a pull request description, an issue, or a comment. If the user asks you to
edit the draft body after they have removed it, do not reintroduce it.
When the user requests revisions after the initial creation, always rewrite the complete body as a file — never perform in-place string replacements. See [references/formatting.md](references/formatting.md).
### Step 10: Watch for Publication
Do not end the skill by asking the user to report back when they have published. Poll the release
until it is no longer a draft:
```sh
gh release view v{version} --json isDraft,publishedAt,tagName,isPrerelease
```
Poll at a modest interval — this gate is human-paced and may span hours or a session boundary. Say
that you are watching rather than going silent.
When `isDraft` becomes `false`:
1. Record the publication time from `publishedAt`, not from when the poll noticed.
2. Confirm the tag that was actually created and whether the release was marked as a prerelease.
Both were the user's to set and cannot be inferred.
3. **Hand off to the verify-release skill immediately.** Publishing starts the Release and Publish
Docs workflows in parallel at that moment; verification that begins late misses them mid-flight.
If the user reports publishing but the API still shows a draft, trust the API and say so — an
unsaved draft looks identical to a published release from the browser.
## Edge Cases
- **No new PRs since preparation**: proceed normally — the prepare-release notes are used as the foundation with no warnings
@ -139,18 +225,23 @@ When the user requests revisions after the initial creation, always rewrite the
- **PR spans categories**: categorize by primary intent
- **Copilot timeline missing**: fall back to `Co-authored-by` trailers to determine whether `@Copilot` should be a co-author; if still unclear, use `@Copilot` as primary author
- **No breaking changes**: omit the Breaking Changes section entirely
- **Versioning link carried over from the prepare-release draft**: the draft may contain an unslugged or wrong-MAJOR versioning link. Correct it to the `v{MAJOR}` slug of the version being released before the draft release is created.
- **Versioning link for a brand-new MAJOR**: the `/v{MAJOR}/versioning.html` path is created by the Publish Docs workflow when the release is published. It is expected to 404 until then; use the slugged form regardless.
- **Single breaking change**: use the same numbered format as multiple
- **Draft edited but not published**: the user is still reviewing, and may be removing the AI disclosure. Take no action and do not reintroduce anything they removed
- **Draft disappears without a published release**: it may have been deleted, or published under a different tag. Check for a published release before assuming it was abandoned, then ask
- **Published tag differs from the prepared version**: stop and confirm with the user before verifying. Verifying the wrong version is worse than not verifying
## Release Notes Template
Omit empty sections. The preamble is **always required** — it is not inside a section heading. Tags may include prerelease suffixes, such as `v2.0.0-preview.1`, and Full Changelog compare links should use the exact tag.
Omit empty sections. The preamble is **always required** — it is not inside a section heading. Tags may include prerelease suffixes, such as `v2.0.0-preview.1`, and Full Changelog compare links should use the exact tag. The versioning link uses the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links).
```markdown
[Preamble — REQUIRED. Summarize the release theme.]
## Breaking Changes
Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies.
Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies.
1. **Description #PR**
* Detail of the break

View file

@ -27,7 +27,7 @@ When the user requests changes to existing release notes:
1. Fetch the current release body and save it to a local file
2. **Breaking change audit**: Run the full breaking-changes skill audit on the commit range, just as for new release notes — this includes examining PRs, reconciling labels, offering to comment on PRs, and getting user confirmation. Also extract any breaking changes already documented in the existing release body; these must be preserved and reconciled with the audit results.
3. **Preamble check**: Verify the release has a preamble (text before the first `##` heading). If missing, compose one. The versioning documentation link belongs under the `## Breaking Changes` heading, not in the preamble.
3. **Preamble check**: Verify the release has a preamble (text before the first `##` heading). If missing, compose one. The versioning documentation link belongs under the `## Breaking Changes` heading, not in the preamble, and must use the `v{MAJOR}` slug for the released version — see [release-branches.md](../../shared-resources/release-branches.md#versioning-documentation-links).
4. Write the **entire** corrected body to a separate local file (ensuring proper line breaks between all sections, entries, and paragraphs)
5. Run `git diff --no-index` between the original and updated files and **always** present the raw diff output directly in the response as a fenced code block with `diff` syntax highlighting. Do not summarize or paraphrase the diff — always show the complete diff to the user. Require explicit confirmation before uploading. For published releases (not drafts), also offer to save the original body to a permanent local file, noting that GitHub does not retain prior versions of release notes.
6. Upload the complete file using `gh release edit --notes-file <file>`
@ -47,8 +47,25 @@ After every release body update:
- [ ] Preamble exists before the first `##` heading
- [ ] If `## Breaking Changes` section exists, it begins with the versioning docs link paragraph before the numbered list
- [ ] The versioning docs link uses the `v{MAJOR}` slug for the released version (e.g. `/v2/versioning.html`), never the unslugged `/versioning.html`
- [ ] Line count matches expected structure (~80+ lines for a typical release)
- [ ] Section headings (`## Breaking Changes`, `## What's Changed`, etc.) each appear on their own line
- [ ] Bullet entries are each on their own line
- [ ] No stray characters at the start of the body
- [ ] Preview the release on GitHub to confirm rendering
## AI Disclosure
A draft release body created by an agent carries a concise AI-generated disclosure at the bottom:
```markdown
> [!NOTE]
> These release notes were drafted with GitHub Copilot and reviewed before publishing.
```
Keep it on the draft. Removing it is the **user's** decision, made as part of their final sign-off
once they have reviewed the notes line by line and are satisfied the content is theirs. Never remove
it on your own initiative, and never reintroduce it after the user has removed it.
This exception applies only to release notes, which get a dedicated human review before publishing.
Disclosures on pull request descriptions, issues, and comments always remain.

View file

@ -25,12 +25,61 @@ Official NuGet.org publishes happen only when a GitHub Release is created from a
## Previous-release tag lookup
- On `main`: most recent published release globally (use `gh release list --exclude-drafts --limit 50` and pick the highest semver). No MAJOR filter.
- On `release/{MAJOR}.x`: most recent published release whose tag matches `v{MAJOR}.*`. Drafts are excluded.
Select the **highest semver** among published releases that are **ancestors of the target commit**,
excluding drafts:
```sh
gh release list --exclude-drafts --limit 50
```
- On `main`: no MAJOR filter — the highest semver ancestor wins.
- On `release/{MAJOR}.x`: restrict candidates to tags matching `v{MAJOR}.*`.
**"Highest semver" and "most recent by date" are not the same rule, and the difference is not
hypothetical.** Ship `v2.1.0` from `main`, then a `v2.0.1` servicing patch from `release/2.0.x`, and
the most recently *published* release is `v2.0.1` while the highest semver is `v2.1.0`. Ordering by
date picks a tag that is not on `main` at all, which produces a bogus PR range and makes ApiCompat
report the entire API surface as removed. Order by version, not by publication time.
The ancestry constraint is what makes this safe across branches, so verify it rather than assuming
the version ordering implied it — a tag can be both the highest semver and unreachable from the
target. `prepare-release` Step 2 performs this check explicitly.
This is purely a baseline-selection rule. It does **not** change the breaking-change policy. See [the versioning docs](https://csharp.sdk.modelcontextprotocol.io/versioning.html) for the policy.
## Work-branch naming
## Versioning documentation links
The documentation site is published per major version under a `v{MAJOR}` slug (`/v1/`, `/v2/`). Any
link to the versioning documentation from **release notes** — both the release-notes link and the
paragraph under the `## Breaking Changes` heading — must point at the slugged instance for the
version being released:
```
https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html
```
The slug is derived from the **MAJOR component of the version being released**, not from the branch:
| Version being released | Versioning link |
| ---------------------- | --------------- |
| `1.3.1` | `https://csharp.sdk.modelcontextprotocol.io/v1/versioning.html` |
| `2.0.0-preview.1` | `https://csharp.sdk.modelcontextprotocol.io/v2/versioning.html` |
| `2.0.0` | `https://csharp.sdk.modelcontextprotocol.io/v2/versioning.html` |
The branch is normally consistent with this — `release/1.x` releases `1.x` versions and `main`
currently releases `2.x` — but the version is what determines the slug. If a release's MAJOR ever
disagrees with its branch's MAJOR, follow the version.
Prerelease suffixes do not affect the slug: `2.0.0-preview.1` and `2.0.0` both use `/v2/`.
The unslugged `https://csharp.sdk.modelcontextprotocol.io/versioning.html` redirects to the site's
default version, which tracks the newest release. It is therefore unstable for a published release's
notes — a later MAJOR would silently repoint it. Never use the unslugged form in release notes.
**First release of a new MAJOR**: the `/v{MAJOR}/` path does not exist until the Publish Docs
workflow runs, which happens when the GitHub release is published. The link is forward-referencing
at prepare and publish time, exactly like the release-notes tag link, and resolves once the release
is published. The **verify-release** skill confirms it.
Prepare-release work branches are named `release-{version}` (flat, hyphen-separated):
- `release-2.0.0-preview.1`

190
.github/skills/verify-release/SKILL.md vendored Normal file
View file

@ -0,0 +1,190 @@
---
name: verify-release
description: Verify a published release of the C# MCP SDK. Monitors the Release and Publish Docs workflows triggered by publishing a GitHub release, confirms the packages are listed on NuGet.org, and confirms the versioned documentation site reflects the release. Use when asked to verify a release, check whether a release published correctly, monitor the release or docs workflow, confirm packages on NuGet, or check whether the docs site updated.
compatibility: Requires gh CLI with repo access for workflow runs and releases, and network access to nuget.org and csharp.sdk.modelcontextprotocol.io.
---
# Verify Release
Verify that a published release of `modelcontextprotocol/csharp-sdk` fully shipped. Publishing a
GitHub release triggers **two workflows in parallel**, and the release is not done until both have
succeeded and both of their outputs are confirmed live.
| Workflow | File | Trigger | Produces |
|---|---|---|---|
| Release | [`.github/workflows/release.yml`](../../workflows/release.yml) | `release: published` | NuGet packages published to NuGet.org |
| Publish Docs | [`.github/workflows/docs.yml`](../../workflows/docs.yml) | `release: published` | The versioned docs site at <https://csharp.sdk.modelcontextprotocol.io> |
Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles
and release tag conventions.
> **Safety: This skill is read-only by default.** It inspects workflow runs, releases, and published
> artifacts. The only actions it may take are re-running a failed workflow or dispatching a docs
> refresh, and both require explicit user confirmation.
## Process
Work through each step sequentially. Present findings at each step and get user confirmation before
taking any action.
### Step 1: Identify the Release
The user may provide:
- **A version or tag** (e.g., `2.0.0-preview.1`, `v1.3.1`) — use directly
- **No context** — list recent releases with `gh release list --limit 10` and ask the user to select
Confirm the release is **published**, not a draft:
```
gh release view {tag} --json tagName,isDraft,isPrerelease,publishedAt,targetCommitish,url
```
If the release is still a draft, **stop**. Neither workflow has run — nothing is published, and no
verification is possible. Tell the user the draft must be published in the GitHub UI first, and
that publishing is a deliberate human action this skill will not perform.
Record the tag, the published timestamp, and the target commitish for the following steps.
### Step 2: Locate Both Workflow Runs
Find the runs triggered by publishing this release. A release-event run carries the **tag name in
`headBranch`**, which is an exact identifier — use it rather than correlating on timestamps:
```
gh run list --workflow release.yml --event release --branch v{version} --limit 5 --json databaseId,status,conclusion,headBranch,headSha,createdAt,url
gh run list --workflow docs.yml --event release --branch v{version} --limit 5 --json databaseId,status,conclusion,headBranch,headSha,createdAt,url
```
**Do not identify runs by "the most recent run" or "created at or after `publishedAt`."** Those
match any release published in the same window, so a concurrent or closely-following release —
including a servicing patch published from another branch minutes later — can be reported as this
release's result, showing a green run for the wrong tag. Confirm `headBranch` equals `v{version}`
on every run before evaluating it.
Cross-check `headSha` against the release's target commitish recorded in Step 1. A mismatch means
the tag moved between drafting and publishing, and the run validated something other than what was
reviewed — stop and report it rather than evaluating the run.
If more than one run matches the tag, the workflow was re-run; evaluate the **latest attempt** and
say that earlier attempts existed rather than silently reporting only the newest.
Present both runs with their status, conclusion, and URL. Watch them **together** — they run
concurrently and either can fail independently. Do not report success for the release until both
are accounted for.
If a run cannot be found for either workflow, report which one is missing and check whether the
workflow is disabled or whether its `if` repository guard excluded the run (both workflows only run
in the `modelcontextprotocol/csharp-sdk` repository, not in forks).
### Step 3: Evaluate the Release Workflow
Report the run's conclusion. If it failed, identify the failing job and step and summarize the
error:
```
gh run view {run-id} --log-failed
```
A failure here does **not** roll back the release — the GitHub release and its tag remain, and the
workflow is simply re-run once the cause is addressed. Re-running is safe and is usually the right
first move. Recommend it, but **do not re-run without explicit user confirmation**.
> **Never run `dotnet nuget push` and never handle NuGet API keys.** Package publishing happens only
> through the workflow.
### Step 4: Evaluate the Publish Docs Workflow
Report the run's conclusion, accounting for these docs-specific behaviors:
- **Superseded runs are not failures.** The workflow uses a `pages` concurrency group with
`cancel-in-progress: true`. Every run rediscovers the current releases and rebuilds the whole site
from scratch, so a newer run fully supersedes the one it cancels. Report a cancelled run as
*superseded* and follow the newer run instead.
- **Version discovery reads published releases.** For each major version >= 1, the workflow takes
the most recently published non-draft release tagged `v{MAJOR}.*`. A draft release contributes
nothing.
- **Every major is rebuilt.** Each major's docs are built from that major's latest release tag into
its own path (`/v1/`, `/v2/`). A new MAJOR adds a new path; the site root redirects to the newest
release, prereleases included.
- **Orchestration comes from `main`.** The scripts and picker assets are always checked out from
`main`, while each version's content comes from its release tag. A docs fix that lives only in a
release branch will not affect orchestration.
If it failed, summarize the failing step. Common causes are a docs build failure in one version's
worktree (`make generate-docs`) or a Pages deployment error.
### Step 5: Confirm the Published Packages
Confirm the exact released version is listed for each shipping package on NuGet.org.
Listing can lag a successful workflow run by several minutes. If the workflow succeeded but the
version is not yet visible, say so explicitly and offer to re-check — **do not report this as a
failure**. Distinguish "published but not yet indexed" from "not published."
Report each package with its status, and flag any shipping package missing from the release.
### Step 6: Confirm the Documentation Site
Confirm <https://csharp.sdk.modelcontextprotocol.io> reflects this release:
1. **Version path** — the major-version path for this release (for example `/v2/`) is live and
serving the new content.
2. **Version picker** — the picker offers this release's major version.
3. **Root redirect** — the site root redirects to the expected default version, which is the newest
release by publish date, prereleases included.
4. **Versioning page** — the slugged versioning page for this release,
`https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html`, resolves. Release notes
link to it from the Breaking Changes section, and for the first release of a new MAJOR that path
only comes into existence with this workflow run. Confirm the release notes use the slugged form
and not the unslugged `/versioning.html`, which tracks the site default and can silently repoint
when a later MAJOR ships.
GitHub Pages caches aggressively, so a short delay after a successful deploy is normal.
Distinguish "deployed but not yet propagated" from "deployed wrong."
### Step 7: Report
Summarize the verification as a table covering both workflows and both published outputs, and state
plainly whether the release is fully verified or what remains outstanding.
| Check | Status |
|---|---|
| Release workflow | ✅ succeeded — {run URL} |
| Publish Docs workflow | ✅ succeeded — {run URL} |
| Packages on NuGet.org | ✅ {version} listed for all N packages |
| Docs site | ✅ `/v2/` live, picker updated, root redirects |
## Remediation
Both remediations require explicit user confirmation.
**Re-run a failed workflow:**
```
gh run rerun {run-id} --failed
```
**Refresh the docs without a new release** — when documentation content needs correcting after the
release, the docs workflow accepts a manual dispatch that rebuilds one major version's content from
an arbitrary ref, without minting a product release:
```
gh workflow run docs.yml --field docs_ref={branch-tag-or-commit}
```
The ref's major version, read from `src/Directory.Build.props`, must have a published release; the
workflow fails fast if it does not. This replaces only the matching major's HTML — orchestration
and all other versions are unaffected.
## Edge Cases
- **Release is still a draft** — stop; neither workflow has run. The user must publish in the GitHub UI.
- **Docs run cancelled** — expected under the `pages` concurrency group; report as superseded and follow the newer run.
- **Only one workflow ran** — check whether the other is disabled, or whether the repository guard excluded it (forks do not run either workflow).
- **Workflow succeeded but NuGet version not listed** — indexing lag; re-check before reporting a failure.
- **Workflow succeeded but docs not visible** — Pages caching; re-check before reporting a failure.
- **Docs site missing the new major version** — confirm the release is published and non-draft, then confirm the tag matches `v{MAJOR}.*`.
- **Root redirects to an unexpected version** — the default is the newest release *by publish date*, including prereleases. A prerelease published after a stable release becomes the default; this is by design.
- **Release workflow failed after partial publish** — some packages may already be on NuGet.org. NuGet versions cannot be unpublished; re-running skips already-published versions. Report exactly which packages are listed before recommending a re-run.
- **Versioning link is unslugged or points at the wrong MAJOR** — release notes must link to `/v{MAJOR}/versioning.html` for the released version. Report it so the user can correct the body; the unslugged form tracks the site default and will repoint when a later MAJOR ships.
- **Verifying an older release** — the docs workflow only ever reflects each major's *latest* release, so an older release's docs path will have been overwritten by a newer one. Verify packages only and note this.