diff --git a/RELEASING.md b/RELEASING.md index 6e9f2bacc..1e78be655 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,6 +13,27 @@ with its own release cadence. Each client release advertises which protocol versions it supports via a generated `SUPPORTED_PROTOCOL_VERSIONS` constant and a checked-in `clients//release-metadata.json`. +For a coordinated release, use **one PR with two commits**: + +1. Prepare and validate the release manifests, changelogs, and generated files + at `X.Y.Z`. Record this release commit's SHA; all seven tags point here. +2. Advance `PROTOCOL_VERSION` to the next development version and promote + `X.Y.Z` in `STABLE_DOC_VERSIONS` **in the same commit and PR**. Keep the + released compatibility baselines unchanged unless intentionally revising + the compatibility policy. + +Create the bare `vX.Y.Z` tag locally at the first commit before validating the +second commit with `npm test` and `npm run docs:build`. After all required CI +checks and collaborator approvals pass, push each release tag separately, +verify the remote tags point at the release SHA, then merge the PR with a merge +commit. **Do not enable auto-merge before pushing the tags.** Tag pushes start +publishing before the PR merges; this ordering ensures the tag needed by the +documentation build already exists when the merge triggers deployment. + +See [Updating published documentation](#updating-published-documentation) and +the [release skill](skills/release-ahp/SKILL.md) for the full coordinated flow. +Do not defer documentation promotion to another PR. + ## Changelog fragments Normal feature/fix PRs do **not** edit `CHANGELOG.md` files directly. They add @@ -77,6 +98,10 @@ want to validate fragment JSON without consuming it. ## Per-client release flow +These recipes describe individual artifact releases. For coordinated releases, +use the two-commit PR and pre-merge tag order above instead of each recipe's +merge-then-tag order. + ### Rust (`rust/vX.Y.Z`) 1. Bump `[workspace.package].version` in `clients/rust/Cargo.toml` (this @@ -212,9 +237,27 @@ trigger started the run. ## Updating published documentation -After pushing the release's `vX.Y.Z` tag, prepend `X.Y.Z` to -`STABLE_DOC_VERSIONS` in `docs/.vitepress/versions.ts` and merge that change -to `main` so the new release becomes the default documentation version. +In the **same commit that bumps the next development version**, prepend the +released `X.Y.Z` to `STABLE_DOC_VERSIONS` in `docs/.vitepress/versions.ts`, +preserving older entries. For example, releasing `1.1.0` and starting `1.2.0` +sets `PROTOCOL_VERSION = '1.2.0'` and +`STABLE_DOC_VERSIONS = ['1.1.0', '1.0.0']` in the same PR. + +The homepage's specification label and the default docs version both use the +first stable entry; the draft label follows `PROTOCOL_VERSION` separately. +Pushing release tags alone does not promote the website. + +The docs builder materializes stable pages from bare `vX.Y.Z` tags. Create the +new tag locally at the release commit for `npm run docs:build`, then push it +with the other release tags after CI and review approval but **before merging** +the combined PR. Never tag the development-bump commit or merge while the +required remote tag is missing. + +After merge, confirm `Deploy Documentation` succeeds and check the live +homepage label, default docs, and version selector: `X.Y.Z` must be the default, +older releases must remain available, and only the next development version +must be labelled `(draft)`. The release is not complete until this deployment +is verified; a passing local build is not sufficient. ## What CI guards against diff --git a/docs/.vitepress/versions.ts b/docs/.vitepress/versions.ts index e0ef5f6f1..4024ea055 100644 --- a/docs/.vitepress/versions.ts +++ b/docs/.vitepress/versions.ts @@ -5,7 +5,7 @@ * matching `v` git tag; `npm run docs:versions` materializes it * into `docs/versions//`. */ -export const STABLE_DOC_VERSIONS: readonly string[] = ['1.0.0'] +export const STABLE_DOC_VERSIONS: readonly string[] = ['1.1.0', '1.0.0'] /** Top-level docs sections that are versioned. */ export const VERSIONED_SECTIONS = ['guide', 'specification', 'reference'] as const diff --git a/scripts/build-doc-versions.ts b/scripts/build-doc-versions.ts index 06fbbec34..1afee913f 100644 --- a/scripts/build-doc-versions.ts +++ b/scripts/build-doc-versions.ts @@ -6,6 +6,7 @@ import os from 'os'; import path from 'path'; import { pipeline } from 'stream/promises'; import { fileURLToPath } from 'url'; +import { readDocSidebarSource } from './read-doc-sidebar.js'; import { STABLE_DOC_VERSIONS, VERSIONED_SECTIONS, @@ -76,11 +77,12 @@ async function materialize(version: string): Promise { }); const sidebarFile = path.join(directory, 'sidebar.json'); + const sidebarSource = readDocSidebarSource( + fs.readFileSync(path.join(directory, 'docs/.vitepress/config.mts'), 'utf8'), + ); execFileSync('npx', ['tsx', '--eval', ` import { writeFileSync } from 'node:fs'; - import('./docs/.vitepress/config.mts').then(({ default: config }) => { - writeFileSync(process.env.AHP_DOCS_SIDEBAR, JSON.stringify(config.themeConfig.sidebar)); - }); + writeFileSync(process.env.AHP_DOCS_SIDEBAR, JSON.stringify(${sidebarSource})); `], { cwd: directory, stdio: 'inherit', diff --git a/scripts/read-doc-sidebar.test.ts b/scripts/read-doc-sidebar.test.ts new file mode 100644 index 000000000..f5ae950b8 --- /dev/null +++ b/scripts/read-doc-sidebar.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readDocSidebarSource } from './read-doc-sidebar.js'; + +test('reads the plain sidebar used by older documentation releases', () => { + const sidebar = "{ '/guide/': [{ text: 'Guide', link: '/guide/start' }] }"; + assert.equal(readDocSidebarSource( + `export default defineConfig({ themeConfig: { sidebar: ${sidebar} } });`, + ), sidebar); +}); + +test('reads the release sidebar without importing versioned documentation', () => { + const sidebar = "{ '/guide/': [{ text: 'New page', link: '/guide/new' }] }"; + assert.equal(readDocSidebarSource(` + import { createVersionedSidebar } from './missing-doc-versions'; + export default withMermaid(defineConfig({ + themeConfig: { sidebar: createVersionedSidebar(${sidebar}) }, + })); + `), sidebar); +}); + +test('rejects missing and unsupported sidebars explicitly', () => { + for (const config of [ + 'export default {};', + 'export default { themeConfig: { sidebar: loadSidebar() } };', + 'export default { themeConfig: { sidebar: createVersionedSidebar() } };', + ]) { + assert.throws(() => readDocSidebarSource(config), /Expected themeConfig.sidebar/); + } +}); diff --git a/scripts/read-doc-sidebar.ts b/scripts/read-doc-sidebar.ts new file mode 100644 index 000000000..133935d2f --- /dev/null +++ b/scripts/read-doc-sidebar.ts @@ -0,0 +1,22 @@ +import { Node, Project, SyntaxKind } from 'ts-morph'; + +/** Read the release's own sidebar without loading its versioned site config. */ +export function readDocSidebarSource(config: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const source = project.createSourceFile('config.mts', config); + const theme = source.getDescendantsOfKind(SyntaxKind.PropertyAssignment) + .find((property) => property.getName() === 'themeConfig') + ?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression); + const property = theme?.getProperty('sidebar'); + const initializer = property && Node.isPropertyAssignment(property) + ? property.getInitializer() + : undefined; + const sidebar = initializer && Node.isCallExpression(initializer) + && initializer.getExpression().getText() === 'createVersionedSidebar' + ? initializer.getArguments()[0] + : initializer; + if (!sidebar || !Node.isObjectLiteralExpression(sidebar)) { + throw new Error('Expected themeConfig.sidebar to be an object or createVersionedSidebar(object).'); + } + return sidebar.getText(); +} diff --git a/skills/release-ahp/SKILL.md b/skills/release-ahp/SKILL.md index cf0737fd4..abe98e61f 100644 --- a/skills/release-ahp/SKILL.md +++ b/skills/release-ahp/SKILL.md @@ -1,6 +1,6 @@ --- name: release-ahp -description: Cut a coordinated AHP release (spec + all 5 clients) — bumps every manifest, dates the CHANGELOGs, opens the release PR with auto-merge, and walks through pushing the six per-artifact tags. Use when the user says "release AHP X.Y.Z", "publish AHP", "cut a release", "tag the release", or similar. The skill also captures the recovery procedure for tags whose publish workflow didn't fire. +description: Cut a coordinated AHP release (spec + all 6 clients) — bumps manifests, dates CHANGELOGs, promotes website docs alongside the development bump in one PR, and pushes seven release tags before merging. Use when the user says "release AHP X.Y.Z", "publish AHP", "cut a release", "tag the release", or similar. Includes recovery for missing publish triggers. --- # Releasing AHP @@ -11,8 +11,8 @@ canonical reference for **what** to do per artifact; this skill captures the **order of operations** and the foot-guns that bite during a multi-artifact release. -The repo ships six independently-versioned artifacts but they are released -together by convention — pin the same `X.Y.Z` across all five client +The repo ships seven independently-versioned artifacts but they are released +together by convention — pin the same `X.Y.Z` across all six client manifests and the spec on every release unless the user explicitly asks otherwise. @@ -31,18 +31,24 @@ is for coordinated cross-artifact releases. ## Mental model -A release is **two commits in one PR**, plus **six tags pushed at the first -commit's SHA after merge**: +A release is **two commits in one PR**, plus **seven tags pushed at the first +commit's SHA after CI and review approval but before merge**: 1. **Release commit** — consumes `docs/.changes/*.json` fragments into dated `## [X.Y.Z]` CHANGELOG sections, bumps every client manifest from the previous version → `X.Y.Z`, and regenerates `release-metadata.json` - for every client. **All six tags must point at this commit's SHA.** + for every client. **All seven tags must point at this commit's SHA.** 2. **Post-release commit** — bumps `PROTOCOL_VERSION` in - `types/version/registry.ts` to the next planned version, prepends it to - `SUPPORTED_PROTOCOL_VERSIONS`, regenerates each - `Version.generated.{rs,kt,swift,go}`, and adds a - `## [X.Y+1.0] — Unreleased` placeholder in the root spec `CHANGELOG.md`. + `types/version/registry.ts` to the next planned version, regenerates native + version constants, adds an Unreleased spec changelog placeholder, and + prepends the **released** version to `STABLE_DOC_VERSIONS` in + `docs/.vitepress/versions.ts`. Keep the released compatibility baselines in + `SUPPORTED_PROTOCOL_VERSIONS` unchanged unless intentionally revising policy. + +Create the bare Swift/docs tag locally before validating commit 2. Push tags +only after the PR is approved and CI passes, then enable merge. Publishing +therefore starts before merge, and the documentation deployment triggered by +merge can resolve the new stable tag. **No follow-up docs PR is needed.** ## Step-by-step @@ -51,9 +57,9 @@ commit's SHA after merge**: Before touching anything, confirm with the user: - **Release version** (`X.Y.Z`) — the version to use when consuming - `docs/.changes/*.json` into the six changelogs. + `docs/.changes/*.json` into the seven changelogs. - **Next development version** (default: bump minor, i.e. `X.(Y+1).0`). -- **Release scope** (default: all six artifacts; rare to release one in +- **Release scope** (default: all seven artifacts; rare to release one in isolation). ### 1. Branch + commit 1 (release commit) @@ -62,7 +68,7 @@ Create `release/ahp-` off `main`. Then in a single commit: - **CHANGELOGs** — run `npm run changelog:release -- --version X.Y.Z --date ` - to collapse `docs/.changes/*.json` into the six changelogs. This creates + to collapse `docs/.changes/*.json` into the seven changelogs. This creates or replaces each `## [X.Y.Z] — ` section and adds the `Spec version` / `Implements AHP X.Y.Z.` line automatically. - **Manifests** — bump every native version file to `X.Y.Z`: @@ -73,6 +79,7 @@ Create `release/ahp-` off `main`. Then in a single commit: - `clients/typescript/package.json` `version`. - `clients/swift/VERSION` (bare semver, no `v` prefix, trailing newline). - `clients/go/VERSION` (bare semver, no `v` prefix, trailing newline). + - `clients/dotnet/VERSION` (bare semver, no `v` prefix, trailing newline). - **Lockfiles** — refresh after the manifest bump: - In `clients/typescript/`: `npm install --no-audit --no-fund` (no-op if already up to date but writes the version into `package-lock.json`). @@ -95,16 +102,28 @@ Commit message: release: AHP X.Y.Z ``` -**Record this commit's SHA.** Every release tag points at it. +**Record this commit's SHA and create the bare Swift/docs tag locally.** +Every release tag points at this SHA. Do not push tags yet. + +```sh +RELEASE_SHA=$(git rev-parse HEAD) +git tag vX.Y.Z "$RELEASE_SHA" +``` -### 2. Commit 2 (post-release bump) +### 2. Commit 2 (post-release bump and docs promotion) On the same branch, in a second commit: - `types/version/registry.ts`: - `PROTOCOL_VERSION = 'X.(Y+1).0'`. - - `SUPPORTED_PROTOCOL_VERSIONS = ['X.(Y+1).0', 'X.Y.Z', ...]` (newest - first; keep any older entries the previous list had). + - Leave `SUPPORTED_PROTOCOL_VERSIONS` unchanged: it contains released + compatibility baselines, not every release or development version. +- In `docs/.vitepress/versions.ts`, prepend `X.Y.Z` to + `STABLE_DOC_VERSIONS`, keeping older entries. For example, releasing `1.1.0` + and starting `1.2.0` sets `PROTOCOL_VERSION = '1.2.0'` and + `STABLE_DOC_VERSIONS = ['1.1.0', '1.0.0']` in this same commit. + Both the homepage specification label and the default docs use the first + stable entry; only the draft label follows `PROTOCOL_VERSION`. - Run `npm run generate` at repo root — this regenerates every client's `Version.generated.*` and every `release-metadata.json`. - In the root spec `CHANGELOG.md`, add a @@ -112,7 +131,9 @@ On the same branch, in a second commit: `Spec version: \`X.(Y+1).0\`` line) below the existing `## [Unreleased]` section. Per-client changelogs already keep their empty `## [Unreleased]` section after fragment consumption. -- Run `npm test` again. +- Run `npm test` and `npm run docs:build`. The local `vX.Y.Z` tag from step 1 + supplies the new stable snapshot. Verify the default release pages, older + version routes, and next-development draft label. Commit message: @@ -120,76 +141,65 @@ Commit message: chore: bump PROTOCOL_VERSION to X.(Y+1).0 for ongoing development ``` -### 3. Open the PR with auto-merge +### 3. Open the PR and obtain approval (do not enable auto-merge yet) ```sh git push -u origin release/ahp-X.Y.Z gh pr create --base main --head release/ahp-X.Y.Z \ --title "release: AHP X.Y.Z" \ --body-file <(...) -gh pr merge --auto --merge ``` -**Use `--merge`, not `--squash`** — the tags need commit 1's SHA to -survive the merge. A squash collapses both commits into a new SHA on -`main`, and the workflows' `PROTOCOL_VERSION` check would then fail -because post-merge HEAD is on the next-dev version. A merge commit -preserves both original commits as reachable parents. - -If the repo's branch protection forces squash, fall back to either: - -- Tag the SHA **before** the squash-merge completes (the commit stays - retained once tagged), or -- Use the "Rebase and merge" strategy (also preserves both SHAs). - -Always confirm before merging which strategy preserves commit 1's SHA on -`main`, and resolve any ambiguity with the user. +Wait for all required CI checks and collaborator approvals. Confirm merge +commits are allowed; otherwise stop and agree on a release strategy before +publishing tags. Do not push release tags while the PR still needs changes. +If either commit changes, revalidate the release SHA and docs build before +continuing; never move an already-published tag. The PR body should include: - The release SHA in a copy-pastable variable assignment. -- The six tag-push commands (see step 5 below). -- A note that Kotlin and TypeScript publish via Azure DevOps pipelines +- The seven tag-push commands (see step 4 below), noting that they must run + before auto-merge is enabled. +- The docs promotion and successful `npm run docs:build` validation. +- A note that Kotlin, TypeScript, and .NET publish via Azure DevOps pipelines (they appear under [agent-host-protocol pipelines in vscode-engineering](https://dev.azure.com/vscode/VSCode/_build)), not GitHub Actions. -### 4. After merge — verify the release SHA is reachable - -```sh -git checkout main -git pull --ff-only origin main -git cat-file -t # must print "commit" -``` - -If it doesn't exist (squash happened despite intent), open a follow-up -discussion with the user before tagging. Do **not** tag the squashed -commit on `main` — its registry shows the post-release version, and -every publish workflow will reject the tag. +### 4. Push the seven tags before merge -### 5. Push the six tags +The PR must be approved and CI green before this step: pushing these tags +starts publishing from the validated release commit before it reaches `main`. +The bare `vX.Y.Z` tag was already created locally in step 1. ```sh RELEASE_SHA= -git tag spec/v $RELEASE_SHA -git tag rust/v $RELEASE_SHA -git tag kotlin/v $RELEASE_SHA -git tag typescript/v $RELEASE_SHA -git tag v $RELEASE_SHA # Swift — bare per RELEASING.md -git tag clients/go/v $RELEASE_SHA -git push origin \ - spec/v rust/v kotlin/v \ - typescript/v v clients/go/v +git tag spec/vX.Y.Z "$RELEASE_SHA" +git tag rust/vX.Y.Z "$RELEASE_SHA" +git tag kotlin/vX.Y.Z "$RELEASE_SHA" +git tag typescript/vX.Y.Z "$RELEASE_SHA" +git tag clients/go/vX.Y.Z "$RELEASE_SHA" +git tag dotnet/vX.Y.Z "$RELEASE_SHA" +git push origin spec/vX.Y.Z +git push origin rust/vX.Y.Z +git push origin kotlin/vX.Y.Z +git push origin typescript/vX.Y.Z +git push origin vX.Y.Z +git push origin clients/go/vX.Y.Z +git push origin dotnet/vX.Y.Z +git ls-remote --tags origin '*/vX.Y.Z' 'vX.Y.Z' ``` -The six tag schemes are deliberate and not interchangeable. Bare +Verify all seven remote tags point at `RELEASE_SHA` before enabling merge. +Push tags separately so each emits its own publish event. + +The seven tag schemes are deliberate and not interchangeable. Bare `vX.Y.Z` is reserved for Swift (SwiftPM only resolves root-level tags); `clients/go/vX.Y.Z` is required by the Go module proxy's sub-module resolution. -- After pushing the tags, prepend `X.Y.Z` to `STABLE_DOC_VERSIONS` in `docs/.vitepress/versions.ts` and merge that change to `main` so it becomes the default documentation version. - -### 6. Confirm publish runs started +### 5. Confirm publish runs started For each tag, verify the corresponding workflow run is queued or in progress: @@ -208,20 +218,46 @@ Expect to see four GH Actions runs (one per tag below) within seconds: | `vX.Y.Z` | `Publish Swift Package` | SwiftPM (tag-resolved) | | `clients/go/vX.Y.Z` | `Publish Go Module` | Go module proxy (tag-resolved) | -The remaining two tags trigger Azure DevOps pipelines that don't appear +The remaining three tags trigger Azure DevOps pipelines that don't appear in `gh run list`: | Tag | ADO Pipeline | Publishes to | | -------------------- | ------------------------------------ | ------------ | | `kotlin/vX.Y.Z` | `clients/kotlin/pipeline.yml` | Maven Central via ESRP | | `typescript/vX.Y.Z` | `clients/typescript/pipeline.yml` | npm via ESRP | +| `dotnet/vX.Y.Z` | `clients/dotnet/pipeline.yml` | NuGet.org via ESRP (approval required) | -For Kotlin/TypeScript, link the user to the AHP pipelines in ADO -(`vscode-engineering` tenant) to confirm the runs started. Both +For Kotlin/TypeScript/.NET, link the user to the AHP pipelines in ADO +(`vscode-engineering` tenant) to confirm the runs started. These pipelines can also be triggered manually from the ADO UI as a hotfix escape hatch — the validation steps inside each pipeline are identical to the tag-triggered path. +### 6. Merge the combined PR and verify the deployed website + +Only after all remote tags are verified and publish runs have started: + +```sh +gh pr merge --auto --merge +``` + +**Use `--merge`, not `--squash` or rebase-and-merge**, to keep the tagged release +SHA reachable from `main`. + +After merge: + +```sh +git fetch origin main +git merge-base --is-ancestor "$RELEASE_SHA" origin/main +``` + +Do not tag the post-release commit: its registry is already on the next +development version. Wait for `Deploy Documentation` to succeed, then verify +the live homepage shows `vX.Y.Z`, default docs use `X.Y.Z`, the version selector +includes older stable releases, and only the next development version is +labelled `(draft)`. Track publish approvals/failures explicitly. Do not mark the +release complete based only on package publication or a local docs build. + ## Recovery — tag pushed but no publish run fired This has happened. Symptom: tag exists on `origin` but `gh run list` @@ -248,6 +284,11 @@ trigger can be re-run from the Actions UI without tag thrash. ## Common foot-guns +- **Deferring docs promotion or merging before tags exist** — update + `STABLE_DOC_VERSIONS` alongside the development bump in the same PR. Create + the bare tag locally for validation, push it after CI/review approval, and + only then enable merge. Otherwise the website stays on the old release or + its build cannot resolve the new snapshot. - **GPG signing lock during `git commit`** — if you see `gpg: keydb_search failed: Operation timed out`, run `gpgconf --kill all && rm -f ~/.gnupg/public-keys.d/pubring.db.lock`