diff --git a/AGENTS.md b/AGENTS.md index 89011093..1284bd6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,18 +7,20 @@ for protocol types are in `.github/instructions/general-instructions.instructions.md`. Release mechanics are in [`RELEASING.md`](RELEASING.md). -## Updating CHANGELOGs +## Adding changelog fragments This repo ships six independently-versioned artifacts (the spec plus the Rust / Kotlin / Swift / TypeScript / Go clients), each with its own `CHANGELOG.md` in Keep-a-Changelog format. The publish workflows refuse to release a tag whose matching `## [X.Y.Z]` heading is -missing, so every user-visible change should land its CHANGELOG bullet -in the same PR as the code. +missing. To avoid merge conflicts in shared `CHANGELOG.md` files, +normal PRs add JSON changelog fragments under `docs/.changes/` instead +of editing the changelogs directly. Release PRs collapse those fragments +into the six changelogs with `npm run changelog:release`. ### When to add an entry -Add a one-line bullet under `## [Unreleased]` whenever your change is +Add a one-line fragment whenever your change is **user-visible**: - A new, removed, renamed, or behaviourally-changed action, command, state @@ -28,9 +30,9 @@ Add a one-line bullet under `## [Unreleased]` whenever your change is functions/types, transport options, reducer outputs, etc.). - A bug fix that changes observable behaviour for a consumer of the spec or any client. -- A security-relevant change (always also add a `### Security` subsection). +- A security-relevant change (use `"type": "security"`). -**Skip the CHANGELOG** when the change is purely: +**Skip the fragment** when the change is purely: - Edits under `**/generated/**` (those mirror a `types/` change that should have its own entry). @@ -38,38 +40,52 @@ Add a one-line bullet under `## [Unreleased]` whenever your change is - Tests, CI, lint config, formatting, internal refactors with no observable effect. -### Which CHANGELOG(s) to update +### Which artifact(s) to target -Map source paths to changelogs: +Each fragment may specify a `targets` array. Omit `targets` when the same +entry applies to the spec and all clients (the common case for protocol +surface changes). Set `targets` to a subset when the change is only visible +to specific artifacts. -| Source path touched | CHANGELOG(s) that need an entry | +Map source paths to fragment targets: + +| Source path touched | Fragment target(s) | | --- | --- | -| `types/**` (protocol surface) | Root `CHANGELOG.md` **and** every `clients//CHANGELOG.md` (a spec change ripples to every client). | -| `clients/rust/**` (non-generated) | `clients/rust/CHANGELOG.md` only. | -| `clients/kotlin/**` (non-generated) | `clients/kotlin/CHANGELOG.md` only. | -| `clients/swift/**` (non-generated) | `clients/swift/CHANGELOG.md` only. | -| `clients/typescript/**` (non-generated) | `clients/typescript/CHANGELOG.md` only. | -| `clients/go/**` (non-generated) | `clients/go/CHANGELOG.md` only. | -| `schema/**` | Root `CHANGELOG.md` (the schema is a spec output). | -| `scripts/generate*.ts` that changes any client's generated output | Every affected client's `CHANGELOG.md`. | +| `types/**` (protocol surface) | Omit `targets` (spec + all clients) unless the visibility is intentionally narrower. | +| `clients/rust/**` (non-generated) | `"targets": ["rust"]` | +| `clients/kotlin/**` (non-generated) | `"targets": ["kotlin"]` | +| `clients/swift/**` (non-generated) | `"targets": ["swift"]` | +| `clients/typescript/**` (non-generated) | `"targets": ["typescript"]` | +| `clients/go/**` (non-generated) | `"targets": ["go"]` | +| `schema/**` | `"targets": ["spec"]` | +| `scripts/generate*.ts` that changes any client's generated output | Omit `targets` or list every affected target. | ### Format -Use the standard Keep-a-Changelog subsection headers — `Added`, `Changed`, -`Deprecated`, `Removed`, `Fixed`, `Security` — under `## [Unreleased]`. -Create the subsection if it doesn't already exist. One bullet per change. - -```markdown -## [Unreleased] +Create a uniquely named JSON file under `docs/.changes/`, usually +`docs/.changes/YYYYMMDD-short-slug.json`. Use lowercase Keep-a-Changelog +types: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`. +Do not include the leading Markdown bullet in `message`. + +```json +{ + "type": "added", + "message": "`session/cancelTurn` action for client-initiated turn cancellation.", + "issues": [123] +} +``` -### Added -- `session/cancelTurn` action for client-initiated turn cancellation. +Scoped client-only example: -### Changed -- `AhpClient.connect` now rejects with `AhpProtocolError` (not - `Error`) on negotiation failure. +```json +{ + "type": "fixed", + "message": "`AhpClient.connect` now rejects with `AhpProtocolError` on negotiation failure.", + "targets": ["typescript"] +} ``` -Do **not** invent a `## [X.Y.Z]` heading — that's reserved for release time -and is added by the maintainer cutting the release per -[`RELEASING.md`](RELEASING.md). +Do **not** edit `CHANGELOG.md` files for normal feature/fix PRs and do +not invent a `## [X.Y.Z]` heading. Changelogs are updated by the release +maintainer per [`RELEASING.md`](RELEASING.md). Run +`npm run verify:change-fragments` to validate fragment JSON. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9737f30c..c91fb9ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ against them. ```bash npm install # install root tooling npm run generate # regenerate every client + schemas -npm test # typecheck + lint + verify:release-metadata + reducer tests +npm test # typecheck + lint + release/changelog verification + reducer tests ``` Per-client builds (run only what's relevant to your change): @@ -51,34 +51,59 @@ and the one-time admin setup for each environment — live in [`RELEASING.md`](RELEASING.md). For the protocol-level versioning policy, see [`docs/specification/versioning.md`](docs/specification/versioning.md). -## Updating CHANGELOGs +## Adding changelog fragments -This repo ships five independently-versioned artifacts (spec + four clients), +This repo ships six independently-versioned artifacts (spec + five clients), each with its own `CHANGELOG.md` in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. The publish workflows refuse to release a tag whose matching -`## [X.Y.Z]` heading is missing, so every user-visible change should land its -CHANGELOG bullet in the same PR as the code. +`## [X.Y.Z]` heading is missing. Normal PRs should not edit those shared +changelog files directly; add a JSON changelog fragment under `docs/.changes/` +instead. Release PRs collapse those fragments into the six changelogs. -**Add a one-line bullet under `## [Unreleased]`** when your change is +**Add a one-line fragment** when your change is user-visible: a new / removed / renamed / behaviourally-changed action, command, state field, error, notification, version constant, or public client API; an observable bug fix; or anything security-relevant. **Skip the -CHANGELOG** for generated code (`**/generated/**`), docs, tests, CI, lint +fragment** for generated code (`**/generated/**`), docs, tests, CI, lint config, formatting, or internal refactors with no observable effect. -Path → changelog map: +Fragments live directly under `docs/.changes/` and use this shape: -| Source path touched | CHANGELOG(s) that need an entry | +```json +{ + "type": "added", + "message": "`session/cancelTurn` action for client-initiated turn cancellation.", + "issues": [123] +} +``` + +`type` must be one of `added`, `changed`, `deprecated`, `removed`, `fixed`, or +`security`. `message` is the changelog bullet text without a leading `-`. +`issues` is optional. + +Omit `targets` when the entry applies to the spec and all clients (the common +case for protocol additions). Add `targets` to scope the entry to a subset: + +```json +{ + "type": "fixed", + "message": "`AhpClient.connect` now rejects with `AhpProtocolError` on negotiation failure.", + "targets": ["typescript"] +} +``` + +Path → fragment target map: + +| Source path touched | Fragment target(s) | | --- | --- | -| `types/**` (protocol surface) | Root `CHANGELOG.md` **and** every `clients//CHANGELOG.md` (a spec change ripples to every client). | -| `clients//**` (non-generated) | That client's `CHANGELOG.md` only. | -| `schema/**` | Root `CHANGELOG.md` (the schema is a spec output). | -| `scripts/generate*.ts` that changes any client's generated output | Every affected client's `CHANGELOG.md`. | - -Use the standard Keep-a-Changelog subsection headers — `Added`, `Changed`, -`Deprecated`, `Removed`, `Fixed`, `Security` — under `## [Unreleased]`. One -bullet per change. Don't invent a `## [X.Y.Z]` heading — that's reserved for -release time per [`RELEASING.md`](RELEASING.md). +| `types/**` (protocol surface) | Omit `targets` (spec + all clients) unless intentionally narrower. | +| `clients//**` (non-generated) | That client only, e.g. `["rust"]`. | +| `schema/**` | `["spec"]` | +| `scripts/generate*.ts` that changes any client's generated output | Omit `targets` or list every affected target. | + +Run `npm run verify:change-fragments` to validate fragments. Don't invent a +`## [X.Y.Z]` heading or edit changelogs directly for normal PRs — that's +reserved for release time per [`RELEASING.md`](RELEASING.md). This rule is also encoded in [`AGENTS.md`](AGENTS.md) so AI coding agents working in the repo follow the same convention. diff --git a/RELEASING.md b/RELEASING.md index d2c48d63..43a47573 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,6 +13,31 @@ 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`. +## Changelog fragments + +Normal feature/fix PRs do **not** edit `CHANGELOG.md` files directly. They add +one or more JSON fragments under `docs/.changes/`; omitting `targets` applies +the entry to the spec and all five clients, while `targets` can scope an entry +to any subset of `spec`, `rust`, `kotlin`, `typescript`, `swift`, and `go`. + +Before tagging a coordinated release, collapse those fragments into the six +Keep-a-Changelog files: + +```sh +npm run changelog:release -- --version X.Y.Z --date YYYY-MM-DD +``` + +By default the command targets all artifacts. For a single-artifact hotfix, pass +`--targets rust` (or `spec`, `kotlin`, `typescript`, `swift`, `go`; comma-join +multiple targets for a subset). The command creates or replaces the +`## [X.Y.Z] — YYYY-MM-DD` section in each selected artifact changelog, adds the +`Spec version` / `Implements AHP` line, groups fragment messages under standard +`Added` / `Changed` / `Fixed` subsections, and deletes consumed fragment files. +If a fragment also targets artifacts not included in `--targets`, the command +rewrites that fragment with the remaining targets so a later release can still +consume it. Run `npm run verify:change-fragments` before release if you only +want to validate fragment JSON without consuming it. + ## Tag conventions | Artifact | Tag pattern | Workflow | Registry / discovery | @@ -59,8 +84,8 @@ and a checked-in `clients//release-metadata.json`. `[workspace.dependencies]` and any per-crate dependency declarations. 3. Run `npm run generate:metadata` and commit the regenerated `clients/rust/release-metadata.json`. -4. Rotate `clients/rust/CHANGELOG.md`: move the `## [Unreleased]` section to - `## [X.Y.Z] — YYYY-MM-DD` with an `Implements AHP ` line. +4. Collapse Rust-scoped changelog fragments with + `npm run changelog:release -- --version X.Y.Z --targets rust`. 5. Merge to `main`. 6. Tag: `git tag rust/v0.X.Y && git push origin rust/v0.X.Y`. 7. `publish-rust.yml` validates, then publishes `ahp-types`, `ahp`, and @@ -72,7 +97,8 @@ and a checked-in `clients//release-metadata.json`. `-SNAPSHOT` suffix (the publish workflow rejects snapshot tags). 2. Run `npm run generate:metadata` and commit the regenerated `clients/kotlin/release-metadata.json`. -3. Rotate `clients/kotlin/CHANGELOG.md`. +3. Collapse Kotlin-scoped changelog fragments with + `npm run changelog:release -- --version X.Y.Z --targets kotlin`. 4. Merge to `main`. 5. Tag: `git tag kotlin/v0.X.Y && git push origin kotlin/v0.X.Y`. 6. `clients/kotlin/pipeline.yml` (Azure DevOps) validates the tag, @@ -96,8 +122,8 @@ use PATs to trigger ADO from GHA. 2. `cd clients/typescript && npm install` to refresh the lockfile. 3. Run `npm run generate:metadata` from the repo root and commit the regenerated `clients/typescript/release-metadata.json`. -4. Rotate `clients/typescript/CHANGELOG.md`: move `## [Unreleased]` to - `## [X.Y.Z] — YYYY-MM-DD` with an `Implements AHP ` line. +4. Collapse TypeScript-scoped changelog fragments with + `npm run changelog:release -- --version X.Y.Z --targets typescript`. 5. Merge to `main`. 6. Tag: `git tag typescript/v0.X.Y && git push origin typescript/v0.X.Y`. 7. The ADO pipeline picks up the tag, validates it against @@ -118,7 +144,8 @@ trigger started the run. leading `v`, no `-SNAPSHOT`). 2. Run `npm run generate:metadata` and commit the regenerated `clients/swift/release-metadata.json`. -3. Rotate `clients/swift/CHANGELOG.md`. +3. Collapse Swift-scoped changelog fragments with + `npm run changelog:release -- --version X.Y.Z --targets swift`. 4. Merge to `main`. 5. Tag: `git tag v0.X.Y && git push origin v0.X.Y`. **Note the absence of any prefix** — this is the one place in the repo where bare semver tags @@ -134,7 +161,8 @@ trigger started the run. `v`, no `-SNAPSHOT`). 2. Run `npm run generate:metadata` and commit the regenerated `clients/go/release-metadata.json`. -3. Rotate `clients/go/CHANGELOG.md`. +3. Collapse Go-scoped changelog fragments with + `npm run changelog:release -- --version X.Y.Z --targets go`. 4. Merge to `main`. 5. Tag: `git tag clients/go/v0.X.Y && git push origin clients/go/v0.X.Y`. **The `clients/go/` prefix is required** — it is what the Go module @@ -156,7 +184,8 @@ trigger started the run. new symbols. 3. Run `npm run generate` to refresh schemas, generated client sources, and metadata. -4. Rotate the root `CHANGELOG.md`. +4. Collapse spec-scoped changelog fragments with + `npm run changelog:release -- --version X.Y.Z --targets spec`. 5. Merge to `main`. 6. Tag: `git tag spec/v0.X.Y && git push origin spec/v0.X.Y`. 7. `publish-spec.yml` validates, regenerates the JSON schemas from the @@ -169,6 +198,7 @@ trigger started the run. | --- | --- | | `Version.generated.{rs,kt,swift,go}` ↔ `types/version/registry.ts` | Per-language CI job re-runs `npm run generate:` and fails on diff. | | `release-metadata.json` ↔ native manifest + registry | `npm run verify:release-metadata` (also gated on every publish workflow). | +| `docs/.changes/*.json` shape | `npm run verify:change-fragments` validates fragment type, message, targets, and issue references before release collapse. | | Native package version ↔ matching CHANGELOG entry | `npm run verify:changelog` (in CI, and re-run in `publish-rust.yml` / `publish-swift.yml` / `publish-go.yml` / both ADO `pipeline.yml`s). | | Tag ↔ manifest version | Every tag-driven publish workflow's "Verify tag matches" step. | | Tag-derived version ↔ CHANGELOG entry | Every tag-driven publish workflow's `grep -qE '^## \[\]'` step (defense-in-depth alongside `verify:changelog`). | diff --git a/docs/.changes/.gitkeep b/docs/.changes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/package.json b/package.json index 7d43c696..0e6ff629 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,14 @@ "generate:metadata": "tsx scripts/generate.ts --metadata", "verify:release-metadata": "tsx scripts/verify-release-metadata.ts", "verify:changelog": "tsx scripts/verify-changelog.ts", + "verify:change-fragments": "tsx scripts/verify-change-fragments.ts", + "changelog:release": "tsx scripts/consume-change-fragments.ts", "docs:dev": "vitepress dev docs", "docs:build": "npm run generate && vitepress build docs", "docs:preview": "vitepress preview docs", "typecheck": "tsc --noEmit -p types/tsconfig.json", "lint": "eslint", - "test": "npm run typecheck && npm run lint && npm run verify:release-metadata && npm run verify:changelog && npx c8 --include types/reducers.ts --check-coverage --branches 100 tsx --test types/*.test.ts types/version/*.test.ts scripts/*.test.ts" + "test": "npm run typecheck && npm run lint && npm run verify:release-metadata && npm run verify:changelog && npm run verify:change-fragments && npx c8 --include types/reducers.ts --check-coverage --branches 100 tsx --test types/*.test.ts types/version/*.test.ts scripts/*.test.ts" }, "repository": { "type": "git", diff --git a/scripts/change-fragments.test.ts b/scripts/change-fragments.test.ts new file mode 100644 index 00000000..6a70ab22 --- /dev/null +++ b/scripts/change-fragments.test.ts @@ -0,0 +1,206 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + applyReleaseToChangelog, + parseChangeFragment, + renderChangeSections, + type ChangeFragment, +} from './change-fragments.js'; + +describe('change fragments', () => { + it('defaults targets to every changelog artifact', () => { + const result = parseChangeFragment( + 'docs/.changes/example.json', + JSON.stringify({ type: 'added', message: 'New protocol field.' }), + ); + + assert.deepEqual(result.errors, []); + assert.deepEqual(result.fragment?.targets, ['spec', 'rust', 'kotlin', 'typescript', 'swift', 'go']); + }); + + it('validates scoped targets and issue numbers', () => { + const result = parseChangeFragment( + 'docs/.changes/bad.json', + JSON.stringify({ + type: 'added', + message: '- Includes a bullet', + targets: ['typescript', 'typescript', 'python'], + issues: [0, 12, 12], + }), + ); + + assert.deepEqual( + result.errors.map((error) => error.message), + [ + '`message` must not include the leading Markdown bullet', + 'duplicate target typescript', + 'invalid target "python"; expected one of spec, rust, kotlin, typescript, swift, go', + 'invalid issue number 0; expected a positive integer', + 'duplicate issue number 12', + ], + ); + }); + + it('renders only fragments targeting the requested artifact', () => { + const fragments: ChangeFragment[] = [ + { + filePath: 'docs/.changes/all.json', + type: 'added', + message: 'Shared entry.', + targets: ['spec', 'rust', 'kotlin', 'typescript', 'swift', 'go'], + issues: [123], + }, + { + filePath: 'docs/.changes/rust.json', + type: 'fixed', + message: 'Rust-only fix.', + targets: ['rust'], + issues: [], + }, + ]; + + assert.deepEqual(renderChangeSections(fragments, 'rust'), [ + '### Added', + '', + '- Shared entry. (#123)', + '', + '### Fixed', + '', + '- Rust-only fix.', + ]); + assert.deepEqual(renderChangeSections(fragments, 'typescript'), [ + '### Added', + '', + '- Shared entry. (#123)', + ]); + }); + + it('creates a dated release section after Unreleased', () => { + const next = applyReleaseToChangelog( + [ + '# Changelog', + '', + 'Intro.', + '', + '## [Unreleased]', + '', + '## [0.5.2] — 2026-07-09', + '', + 'Implements AHP 0.5.2.', + '', + ].join('\n'), + 'typescript', + '0.6.0', + '2026-07-10', + [ + { + filePath: 'docs/.changes/entry.json', + type: 'changed', + message: 'Updated client API.', + targets: ['typescript'], + issues: [], + }, + ], + ); + + assert.equal( + next, + [ + '# Changelog', + '', + 'Intro.', + '', + '## [Unreleased]', + '', + '## [0.6.0] — 2026-07-10', + '', + 'Implements AHP 0.6.0.', + '', + '### Changed', + '', + '- Updated client API.', + '', + '## [0.5.2] — 2026-07-09', + '', + 'Implements AHP 0.5.2.', + '', + ].join('\n'), + ); + }); + + it('replaces an existing version placeholder section', () => { + const next = applyReleaseToChangelog( + [ + '# Changelog', + '', + '## [Unreleased]', + '', + 'Holding text.', + '', + '## [0.6.0] — Unreleased', + '', + 'Spec version: `0.6.0`', + '', + '## [0.5.2] — 2026-07-09', + '', + ].join('\n'), + 'spec', + '0.6.0', + '2026-07-10', + [ + { + filePath: 'docs/.changes/spec.json', + type: 'added', + message: 'Added a protocol feature.', + targets: ['spec'], + issues: [], + }, + ], + ); + + assert.equal( + next, + [ + '# Changelog', + '', + '## [Unreleased]', + '', + 'Holding text.', + '', + '## [0.6.0] — 2026-07-10', + '', + 'Spec version: `0.6.0`', + '', + '### Added', + '', + '- Added a protocol feature.', + '', + '## [0.5.2] — 2026-07-09', + '', + ].join('\n'), + ); + }); + + it('preserves an existing release section when no fragments are supplied', () => { + const existing = [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [0.6.0] — 2026-07-10', + '', + 'Implements AHP 0.6.0.', + '', + '### Added', + '', + '- Existing release note.', + '', + ].join('\n'); + + assert.equal( + applyReleaseToChangelog(existing, 'typescript', '0.6.0', '2026-07-10', []), + existing, + ); + }); +}); diff --git a/scripts/change-fragments.ts b/scripts/change-fragments.ts new file mode 100644 index 00000000..2b2bffb2 --- /dev/null +++ b/scripts/change-fragments.ts @@ -0,0 +1,325 @@ +import fs from 'fs'; +import path from 'path'; + +export const CHANGELOG_TARGETS = ['spec', 'rust', 'kotlin', 'typescript', 'swift', 'go'] as const; +export type ChangelogTarget = (typeof CHANGELOG_TARGETS)[number]; + +export const CHANGE_TYPES = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'] as const; +export type ChangeType = (typeof CHANGE_TYPES)[number]; + +export const CHANGE_TYPE_HEADINGS: Record = { + added: 'Added', + changed: 'Changed', + deprecated: 'Deprecated', + removed: 'Removed', + fixed: 'Fixed', + security: 'Security', +}; + +export interface ChangeFragment { + readonly filePath: string; + readonly type: ChangeType; + readonly message: string; + readonly targets: readonly ChangelogTarget[]; + readonly issues: readonly number[]; +} + +export interface FragmentValidationError { + readonly filePath: string; + readonly message: string; +} + +interface ParsedMarkdownSection { + heading: string; + body: string[]; +} + +const ALLOWED_FRAGMENT_KEYS = new Set(['type', 'message', 'targets', 'issues']); +const DEFAULT_TARGETS: readonly ChangelogTarget[] = CHANGELOG_TARGETS; + +export function changesDir(rootDir: string): string { + return path.join(rootDir, 'docs', '.changes'); +} + +export function changelogPathForTarget(target: ChangelogTarget, rootDir: string): string { + switch (target) { + case 'spec': + return path.join(rootDir, 'CHANGELOG.md'); + case 'rust': + return path.join(rootDir, 'clients', 'rust', 'CHANGELOG.md'); + case 'kotlin': + return path.join(rootDir, 'clients', 'kotlin', 'CHANGELOG.md'); + case 'typescript': + return path.join(rootDir, 'clients', 'typescript', 'CHANGELOG.md'); + case 'swift': + return path.join(rootDir, 'clients', 'swift', 'CHANGELOG.md'); + case 'go': + return path.join(rootDir, 'clients', 'go', 'CHANGELOG.md'); + } +} + +export function releaseIntroForTarget(target: ChangelogTarget, version: string): string { + return target === 'spec' + ? `Spec version: \`${version}\`` + : `Implements AHP ${version}.`; +} + +export function listChangeFragmentPaths(rootDir: string): string[] { + const dir = changesDir(rootDir); + if (!fs.existsSync(dir)) { + return []; + } + return fs.readdirSync(dir) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => path.join(dir, name)); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isChangeType(value: unknown): value is ChangeType { + return typeof value === 'string' && (CHANGE_TYPES as readonly string[]).includes(value); +} + +function isChangelogTarget(value: unknown): value is ChangelogTarget { + return typeof value === 'string' && (CHANGELOG_TARGETS as readonly string[]).includes(value); +} + +function validateTargets(filePath: string, value: unknown, errors: FragmentValidationError[]): readonly ChangelogTarget[] { + if (value === undefined) { + return DEFAULT_TARGETS; + } + if (!Array.isArray(value) || value.length === 0) { + errors.push({ filePath, message: '`targets` must be a non-empty array when present' }); + return []; + } + const targets: ChangelogTarget[] = []; + const seen = new Set(); + for (const entry of value) { + if (!isChangelogTarget(entry)) { + errors.push({ + filePath, + message: `invalid target ${JSON.stringify(entry)}; expected one of ${CHANGELOG_TARGETS.join(', ')}`, + }); + continue; + } + if (seen.has(entry)) { + errors.push({ filePath, message: `duplicate target ${entry}` }); + continue; + } + seen.add(entry); + targets.push(entry); + } + return targets; +} + +function validateIssues(filePath: string, value: unknown, errors: FragmentValidationError[]): readonly number[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + errors.push({ filePath, message: '`issues` must be an array of positive integers when present' }); + return []; + } + const issues: number[] = []; + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== 'number' || !Number.isInteger(entry) || entry <= 0) { + errors.push({ filePath, message: `invalid issue number ${JSON.stringify(entry)}; expected a positive integer` }); + continue; + } + if (seen.has(entry)) { + errors.push({ filePath, message: `duplicate issue number ${entry}` }); + continue; + } + seen.add(entry); + issues.push(entry); + } + return issues; +} + +export function parseChangeFragment(filePath: string, raw: string): { fragment?: ChangeFragment; errors: FragmentValidationError[] } { + const errors: FragmentValidationError[] = []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + errors.push({ + filePath, + message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + }); + return { errors }; + } + + if (!isRecord(parsed)) { + return { + errors: [{ filePath, message: 'fragment must be a JSON object' }], + }; + } + + for (const key of Object.keys(parsed)) { + if (!ALLOWED_FRAGMENT_KEYS.has(key)) { + errors.push({ filePath, message: `unknown field ${JSON.stringify(key)}` }); + } + } + + if (!isChangeType(parsed.type)) { + errors.push({ + filePath, + message: `\`type\` must be one of ${CHANGE_TYPES.join(', ')}`, + }); + } + + if (typeof parsed.message !== 'string' || parsed.message.trim().length === 0) { + errors.push({ filePath, message: '`message` must be a non-empty string' }); + } else if (parsed.message !== parsed.message.trim()) { + errors.push({ filePath, message: '`message` must not have leading or trailing whitespace' }); + } else if (parsed.message.startsWith('- ')) { + errors.push({ filePath, message: '`message` must not include the leading Markdown bullet' }); + } else if (/\r|\n/.test(parsed.message)) { + errors.push({ filePath, message: '`message` must be a single line' }); + } + + const targets = validateTargets(filePath, parsed.targets, errors); + const issues = validateIssues(filePath, parsed.issues, errors); + + if (errors.length > 0) { + return { errors }; + } + + return { + fragment: { + filePath, + type: parsed.type as ChangeType, + message: parsed.message as string, + targets, + issues, + }, + errors, + }; +} + +export function readChangeFragments(rootDir: string): { fragments: ChangeFragment[]; errors: FragmentValidationError[] } { + const dir = changesDir(rootDir); + if (!fs.existsSync(dir)) { + return { + fragments: [], + errors: [{ filePath: dir, message: 'docs/.changes directory is missing' }], + }; + } + + const fragments: ChangeFragment[] = []; + const errors: FragmentValidationError[] = []; + for (const filePath of listChangeFragmentPaths(rootDir)) { + const result = parseChangeFragment(filePath, fs.readFileSync(filePath, 'utf-8')); + fragments.push(...(result.fragment ? [result.fragment] : [])); + errors.push(...result.errors); + } + return { fragments, errors }; +} + +function issueSuffix(issues: readonly number[]): string { + if (issues.length === 0) { + return ''; + } + return ` (${issues.map((issue) => `#${issue}`).join(', ')})`; +} + +export function renderChangeSections(fragments: readonly ChangeFragment[], target: ChangelogTarget): string[] { + const lines: string[] = []; + for (const type of CHANGE_TYPES) { + const matching = fragments.filter((fragment) => fragment.type === type && fragment.targets.includes(target)); + if (matching.length === 0) { + continue; + } + if (lines.length > 0) { + lines.push(''); + } + lines.push(`### ${CHANGE_TYPE_HEADINGS[type]}`, ''); + for (const fragment of matching) { + lines.push(`- ${fragment.message}${issueSuffix(fragment.issues)}`); + } + } + return lines; +} + +function parseMarkdownSections(markdown: string): { preamble: string[]; sections: ParsedMarkdownSection[] } { + const lines = markdown.replace(/\r\n/g, '\n').trimEnd().split('\n'); + const firstSectionIndex = lines.findIndex((line) => line.startsWith('## ')); + if (firstSectionIndex < 0) { + return { preamble: lines, sections: [] }; + } + + const preamble = lines.slice(0, firstSectionIndex); + const sections: ParsedMarkdownSection[] = []; + let index = firstSectionIndex; + while (index < lines.length) { + const heading = lines[index]; + let end = index + 1; + while (end < lines.length && !lines[end].startsWith('## ')) { + end++; + } + sections.push({ heading, body: lines.slice(index + 1, end) }); + index = end; + } + return { preamble, sections }; +} + +function renderMarkdownSections(preamble: readonly string[], sections: readonly ParsedMarkdownSection[]): string { + const lines: string[] = [...preamble]; + for (const section of sections) { + lines.push(section.heading, ...section.body); + } + return `${lines.join('\n').trimEnd()}\n`; +} + +function releaseSectionBody(target: ChangelogTarget, version: string, fragments: readonly ChangeFragment[]): string[] { + const changeLines = renderChangeSections(fragments, target); + const body = ['', releaseIntroForTarget(target, version)]; + if (changeLines.length > 0) { + body.push('', ...changeLines); + } + body.push(''); + return body; +} + +export function applyReleaseToChangelog( + markdown: string, + target: ChangelogTarget, + version: string, + date: string, + fragments: readonly ChangeFragment[], +): string { + const parsed = parseMarkdownSections(markdown); + const releaseHeading = `## [${version}] — ${date}`; + const releaseBody = releaseSectionBody(target, version, fragments); + const existingRelease = parsed.sections.findIndex((section) => section.heading.startsWith(`## [${version}]`)); + if (existingRelease >= 0) { + if (fragments.length === 0) { + return `${markdown.replace(/\r\n/g, '\n').trimEnd()}\n`; + } + const next = parsed.sections.slice(); + next[existingRelease] = { heading: releaseHeading, body: releaseBody }; + return renderMarkdownSections(parsed.preamble, next); + } + + const unreleasedIndex = parsed.sections.findIndex((section) => section.heading === '## [Unreleased]'); + if (unreleasedIndex < 0) { + throw new Error(`${target} CHANGELOG is missing a '## [Unreleased]' section`); + } + + const next = parsed.sections.slice(); + next.splice(unreleasedIndex + 1, 0, { heading: releaseHeading, body: releaseBody }); + return renderMarkdownSections(parsed.preamble, next); +} + +export function formatFragmentErrors(errors: readonly FragmentValidationError[], rootDir: string): string { + return errors + .map((error) => { + const relative = path.relative(rootDir, error.filePath) || error.filePath; + return ` ${relative}: ${error.message}`; + }) + .join('\n'); +} diff --git a/scripts/consume-change-fragments.ts b/scripts/consume-change-fragments.ts new file mode 100644 index 00000000..d8af82c5 --- /dev/null +++ b/scripts/consume-change-fragments.ts @@ -0,0 +1,150 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { + applyReleaseToChangelog, + CHANGELOG_TARGETS, + type ChangelogTarget, + changelogPathForTarget, + formatFragmentErrors, + type ChangeFragment, + readChangeFragments, +} from './change-fragments.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +interface Options { + readonly version: string; + readonly date: string; + readonly dryRun: boolean; + readonly targets: readonly ChangelogTarget[]; +} + +function usage(): never { + console.error( + 'Usage: npm run changelog:release -- --version X.Y.Z [--date YYYY-MM-DD] [--targets spec,rust,kotlin,typescript,swift,go] [--dry-run]', + ); + process.exit(1); +} + +function parseTargets(raw: string | undefined): readonly ChangelogTarget[] { + if (!raw) { + usage(); + } + const targets: ChangelogTarget[] = []; + const seen = new Set(); + for (const target of raw.split(',')) { + if (!(CHANGELOG_TARGETS as readonly string[]).includes(target)) { + usage(); + } + const typedTarget = target as ChangelogTarget; + if (seen.has(typedTarget)) { + usage(); + } + seen.add(typedTarget); + targets.push(typedTarget); + } + if (targets.length === 0) { + usage(); + } + return targets; +} + +function parseArgs(argv: readonly string[]): Options { + let version: string | undefined; + let date: string | undefined = new Date().toISOString().slice(0, 10); + let dryRun = false; + let targets: readonly ChangelogTarget[] = CHANGELOG_TARGETS; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + switch (arg) { + case '--version': + version = argv[++index]; + break; + case '--date': + date = argv[++index]; + break; + case '--dry-run': + dryRun = true; + break; + case '--targets': + targets = parseTargets(argv[++index]); + break; + default: + usage(); + } + } + + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { + usage(); + } + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + usage(); + } + return { version, date, dryRun, targets }; +} + +function fragmentTouchesTargets(fragment: ChangeFragment, targets: ReadonlySet): boolean { + return fragment.targets.some((target) => targets.has(target)); +} + +function writeRemainingFragment(fragment: ChangeFragment, remainingTargets: readonly ChangelogTarget[]): void { + const next = { + type: fragment.type, + message: fragment.message, + targets: remainingTargets, + ...(fragment.issues.length > 0 ? { issues: fragment.issues } : {}), + }; + fs.writeFileSync(fragment.filePath, `${JSON.stringify(next, null, 2)}\n`); +} + +function main(): void { + const options = parseArgs(process.argv.slice(2)); + const { fragments, errors } = readChangeFragments(ROOT); + if (errors.length > 0) { + console.error('❌ cannot consume invalid change fragments:'); + console.error(formatFragmentErrors(errors, ROOT)); + process.exit(1); + } + + const selectedTargets = new Set(options.targets); + const fragmentsToConsume = fragments.filter((fragment) => fragmentTouchesTargets(fragment, selectedTargets)); + if (fragmentsToConsume.length === 0) { + console.error('❌ no change fragments found for the selected target(s); refusing to rewrite changelogs'); + process.exit(1); + } + + for (const target of options.targets) { + const changelogPath = changelogPathForTarget(target, ROOT); + const current = fs.readFileSync(changelogPath, 'utf-8'); + const next = applyReleaseToChangelog(current, target, options.version, options.date, fragmentsToConsume); + if (!options.dryRun) { + fs.writeFileSync(changelogPath, next); + } + } + + if (!options.dryRun) { + for (const fragment of fragments) { + if (!fragmentTouchesTargets(fragment, selectedTargets)) { + continue; + } + const remainingTargets = fragment.targets.filter((target) => !selectedTargets.has(target)); + if (remainingTargets.length === 0) { + fs.unlinkSync(fragment.filePath); + } else { + writeRemainingFragment(fragment, remainingTargets); + } + } + } + + const action = options.dryRun ? 'Would consume' : 'Consumed'; + console.log( + `${action} ${fragmentsToConsume.length} change fragment(s) for ${options.version} ` + + `target(s): ${options.targets.join(', ')}.`, + ); +} + +main(); diff --git a/scripts/verify-change-fragments.ts b/scripts/verify-change-fragments.ts new file mode 100644 index 00000000..07295589 --- /dev/null +++ b/scripts/verify-change-fragments.ts @@ -0,0 +1,20 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { formatFragmentErrors, readChangeFragments } from './change-fragments.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +function main(): void { + const { fragments, errors } = readChangeFragments(ROOT); + if (errors.length > 0) { + console.error('❌ change-fragment verification failed:'); + console.error(formatFragmentErrors(errors, ROOT)); + process.exit(1); + } + + console.log(`✅ change-fragment verification passed (${fragments.length} fragment(s))`); +} + +main(); diff --git a/skills/groom-backlog/SKILL.md b/skills/groom-backlog/SKILL.md index d3ae329f..6761e794 100644 --- a/skills/groom-backlog/SKILL.md +++ b/skills/groom-backlog/SKILL.md @@ -33,7 +33,7 @@ Lean on the `explore` agent to parallelize research across many PRs / issues wit Before touching the backlog, ground yourself in the system the same way you would before any non-trivial change here: - Read the repo's own source and docs: the canonical types under `types/` (the source of truth), the generated `schema/`, the prose in `docs/specification/` and `docs/guide/`, and `AGENTS.md`, `CONTRIBUTING.md`, and `RELEASING.md`. The editorial rules for changing protocol types live in [`.github/instructions/general-instructions.instructions.md`](../../.github/instructions/general-instructions.instructions.md) — follow them. -- Understand how a `types/` change ripples outward: every protocol change regenerates `schema/` and each client's `**/generated/**` mirror, may need a hand-written client update, must keep the conformance fixtures under `types/test-cases/` in sync, and lands a CHANGELOG bullet in **every** affected artifact (per `AGENTS.md` → "Updating CHANGELOGs"). The version surface lives in `types/version/registry.ts` (`PROTOCOL_VERSION`, `SUPPORTED_PROTOCOL_VERSIONS`). +- Understand how a `types/` change ripples outward: every protocol change regenerates `schema/` and each client's `**/generated/**` mirror, may need a hand-written client update, must keep the conformance fixtures under `types/test-cases/` in sync, and lands a `docs/.changes` fragment for **every** affected artifact (per `AGENTS.md` → "Adding changelog fragments"). The version surface lives in `types/version/registry.ts` (`PROTOCOL_VERSION`, `SUPPORTED_PROTOCOL_VERSIONS`). - Refresh your understanding of the protocol's public surface and its **published, versioned artifacts** — the crates, the npm package, the Maven/JVM library, the Swift package, and the Go module, all generated from `types/`. AHP is a contract that external clients implement and external products consume, so "is this follow-up still relevant?" is answered against the current spec, the generated and hand-maintained clients in this repo, and what the protocol already guarantees — not against any single implementation. You don't need to memorize everything up front, but a follow-up audit and an issue triage are only as good as your understanding of the current state of the system. Invest in that first. @@ -60,7 +60,7 @@ Ground yourself in the local conventions so your issues match the house style an - **Issue templates.** This repo has no `.github/ISSUE_TEMPLATE/` forms today, so new issues are free-form — which means a follow-up issue needs to carry its own structure and completeness. - **The quality bar for a follow-up issue.** A good follow-up issue stands on its own: a **Context** section linking the originating PR *and* the specific review thread *and* the `types/`/client code or spec prose in question, a **Problem** statement, a concrete **Proposed fix**, an **Affected artifacts** note (which of `types/` / `schema/` / each `clients//` / `docs/` / the CHANGELOGs the fix touches), a **Tests / conformance** list (the `types/test-cases/` fixtures or client tests it needs), and an **Out of scope** section. Well-scoped existing issues are the model; read a few current open ones to match the house depth (`gh issue list` / `gh issue view`). - **Where PRs call out future work.** Look in the PR **body** (`## What` / `## Why` / `## How` notes, "out of scope", "future work", "follow-up", "deferred"), in **review threads** (a reviewer asks for something and the author defers it), and in **the code the PR landed** (`TODO`, `FIXME`, "Known … gap", "deferred from PR #…" in `types/` comments, hand-written client source, or docs). The inverse — promised work with *no* issue — is exactly what phase 1 hunts for. -- **`types/` is the canonical wire contract.** A change to the protocol surface is never local: it regenerates `schema/` and every client's `**/generated/**` mirror, may require a matching hand-written client change, must keep the conformance fixtures in `types/test-cases/` aligned, and lands a CHANGELOG bullet in each affected artifact. `AGENTS.md` and [`.github/instructions/general-instructions.instructions.md`](../../.github/instructions/general-instructions.instructions.md) are the authorities on when and how to touch that tree; the [versioning policy](../../docs/specification/versioning.md) governs anything that moves `PROTOCOL_VERSION`. Follow them. +- **`types/` is the canonical wire contract.** A change to the protocol surface is never local: it regenerates `schema/` and every client's `**/generated/**` mirror, may require a matching hand-written client change, must keep the conformance fixtures in `types/test-cases/` aligned, and lands a `docs/.changes` fragment in each affected artifact's scope. `AGENTS.md` and [`.github/instructions/general-instructions.instructions.md`](../../.github/instructions/general-instructions.instructions.md) are the authorities on when and how to touch that tree; the [versioning policy](../../docs/specification/versioning.md) governs anything that moves `PROTOCOL_VERSION`. Follow them. ## The triage pass @@ -90,7 +90,7 @@ Treat the following as the shape of the work, not a rigid script. Adapt the orde 1. **Prioritize** the "implementable now" shortlist by importance and urgency. Security and spec-correctness items lead; then conformance gaps and behaviour bugs affecting consumers (e.g. a client mirror or reducer that diverges from `types/`); then enhancements and docs. State the ordering and the reasoning. 2. **Cluster** closely-related issues that can be implemented and reviewed together into a single PR; keep unrelated work in separate, focused PRs (per `CONTRIBUTING.md`). -3. **Implement in priority order.** For each issue (or tight cluster): work on a focused branch, make the change, and keep the whole chain in lockstep when the wire contract moves — edit `types/`, run `npm run generate` so `schema/` and every client's `**/generated/**` mirror regenerate, update any hand-written client surface and the `types/test-cases/` conformance fixtures, refresh the docs, and add the CHANGELOG bullet(s) the change requires (per `AGENTS.md`). **Validate** with the repo's own build / lint / test gates (see appendix). Don't consider a change done until it builds and passes. Open the PR with `Closes #N` (list each issue in a cluster). +3. **Implement in priority order.** For each issue (or tight cluster): work on a focused branch, make the change, and keep the whole chain in lockstep when the wire contract moves — edit `types/`, run `npm run generate` so `schema/` and every client's `**/generated/**` mirror regenerate, update any hand-written client surface and the `types/test-cases/` conformance fixtures, refresh the docs, and add the `docs/.changes` fragment(s) the change requires (per `AGENTS.md`). **Validate** with the repo's own build / lint / test gates (see appendix). Don't consider a change done until it builds and passes. Open the PR with `Closes #N` (list each issue in a cluster). - If the user asked you to confirm before acting (e.g. "check with me first", "don't change anything yet"), present the audit results, the triage classification, and the proposed implementation order, and **wait for approval** before opening/closing issues or writing code. ### Report @@ -112,7 +112,7 @@ If the user wants a durable artifact, the report can be written to the repo's gi - **Match the house style.** New issues meet the quality bar above and carry the labels a maintainer would apply; don't invent labels (there is no `follow-up` label) — suggest one if it's warranted. Closed issues get an explanatory comment and a disposition label. - **Stay inside this repository.** This is a public repo; keep the entire pass within `microsoft/agent-host-protocol` and don't reference, read, or act on any other (non-public) repository. If an issue's resolution belongs elsewhere, describe it for the user instead of acting on it. - **Pause before high-consequence batches.** When a pass would bulk-close many issues or kick off a multi-PR implementation push, and the user hasn't clearly said "just do it," present the plan and get a go-ahead first. -- **The wire contract moves as one unit.** When a change touches `types/`, the regenerated `schema/` + client mirrors, the conformance fixtures, the docs, and the CHANGELOG bullets move together — never hand-edit a `**/generated/**` file. A breaking protocol change is gated by the [versioning policy](../../docs/specification/versioning.md); don't ship one without honoring it. +- **The wire contract moves as one unit.** When a change touches `types/`, the regenerated `schema/` + client mirrors, the conformance fixtures, the docs, and the scoped `docs/.changes` fragment move together — never hand-edit a `**/generated/**` file. A breaking protocol change is gated by the [versioning policy](../../docs/specification/versioning.md); don't ship one without honoring it. - **Correctness wins, within the versioning policy.** Don't shrink a correct implementation to keep the diff small, and don't add migrations/deprecations/phased rollouts unless the versioning policy or the user calls for them. - **Validate before declaring done.** Run the repo's build/lint/test and resolve any fallout from every change you implement. @@ -197,7 +197,7 @@ cat types/version/registry.ts ```sh npm install # root tooling npm run generate # regenerate every client mirror + schemas from types/ -npm test # typecheck + lint + verify:release-metadata + verify:changelog + reducer tests +npm test # typecheck + lint + release/changelog verification + reducer tests # Per-client (run only what your change touches): cd clients/typescript && npm ci && npm test && npm run build diff --git a/skills/release-ahp/SKILL.md b/skills/release-ahp/SKILL.md index f5cce227..740715fa 100644 --- a/skills/release-ahp/SKILL.md +++ b/skills/release-ahp/SKILL.md @@ -34,16 +34,15 @@ is for coordinated cross-artifact releases. A release is **two commits in one PR**, plus **six tags pushed at the first commit's SHA after merge**: -1. **Release commit** — date-stamps every `## [X.Y.Z]` CHANGELOG heading, - 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.** +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.** 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 reopens - `## [Unreleased]` in every CHANGELOG (plus a - `## [X.Y+1.0] — Unreleased` placeholder in the root spec `CHANGELOG.md`). + `Version.generated.{rs,kt,swift,go}`, and adds a + `## [X.Y+1.0] — Unreleased` placeholder in the root spec `CHANGELOG.md`. ## Step-by-step @@ -51,8 +50,8 @@ commit's SHA after merge**: Before touching anything, confirm with the user: -- **Release version** (`X.Y.Z`) — must equal the `## [X.Y.Z]` heading - already accumulated under `## [Unreleased]` in every CHANGELOG. +- **Release version** (`X.Y.Z`) — the version to use when consuming + `docs/.changes/*.json` into the six changelogs. - **Next development version** (default: bump minor, i.e. `X.(Y+1).0`). - **Release scope** (default: all six artifacts; rare to release one in isolation). @@ -61,12 +60,11 @@ Before touching anything, confirm with the user: Create `release/ahp-` off `main`. Then in a single commit: -- **CHANGELOGs** — for each of the 6 files (`CHANGELOG.md`, - `clients/{rust,kotlin,typescript,swift,go}/CHANGELOG.md`): - - Replace `## [Unreleased]` (or `## [X.Y.Z] — Unreleased`) with - `## [X.Y.Z] — `. - - In each per-client CHANGELOG, add a single line `Implements AHP X.Y.Z.` - under the new heading if not already present. +- **CHANGELOGs** — run + `npm run changelog:release -- --version X.Y.Z --date ` + to collapse `docs/.changes/*.json` into the six 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`: - `clients/rust/Cargo.toml` `[workspace.package].version` **and** the `version = "X.Y.Z"` pins on `ahp-types`/`ahp` inside @@ -81,10 +79,11 @@ Create `release/ahp-` off `main`. Then in a single commit: - In `clients/rust/`: `cargo update -w` (rewrites only the workspace crates in `Cargo.lock` — outside crates stay pinned). - **Metadata** — at repo root: `npm run generate:metadata`. -- **Verify** — at repo root: `npm test`. This runs both - `verify:release-metadata` and `verify:changelog`, which together gate - the release: a mismatch between a manifest version and its CHANGELOG - heading will fail here and not at tag-push time. +- **Verify** — at repo root: `npm test`. This runs + `verify:change-fragments`, `verify:release-metadata`, and + `verify:changelog`, which together gate the release: malformed fragments, + a metadata drift, or a missing CHANGELOG heading will fail here and not at + tag-push time. > **Do not** bump `PROTOCOL_VERSION` in this commit. The tag-push > workflows validate that the registry version matches the tag's version, @@ -108,10 +107,11 @@ On the same branch, in a second commit: first; keep any older entries the previous list had). - Run `npm run generate` at repo root — this regenerates every client's `Version.generated.*` and every `release-metadata.json`. -- Reopen `## [Unreleased]` at the top of every CHANGELOG, **above** the - newly date-stamped `## [X.Y.Z]` heading. In the root spec - `CHANGELOG.md`, also add a `## [X.(Y+1).0] — Unreleased` placeholder - (with a `Spec version: \`X.(Y+1).0\`` line). +- In the root spec `CHANGELOG.md`, add a + `## [X.(Y+1).0] — Unreleased` placeholder (with a + `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. Commit message: