|
| 1 | +# Pluggable changelog generators |
| 2 | + |
| 3 | +> Status: **design / deferred** (2026-06-11). Do the work on a feature branch off `jcamp` |
| 4 | +> (see memory `branch-off-jcamp`). No commits without explicit approval. |
| 5 | +
|
| 6 | +## Goal |
| 7 | + |
| 8 | +Let a changelog generator be **swapped out** without forking the tool, so a repo can render |
| 9 | +release notes its own way (different grouping, emoji, locale, JSON-for-a-website, etc.) while the |
| 10 | +engine still owns *what the release is* (bumps, cascade, version stamping). |
| 11 | + |
| 12 | +The mechanism is the one this tool already lives by: **delegate to an external command over a |
| 13 | +defined I/O contract**, exactly like it shells out to `changeset` / `git` / `gh` |
| 14 | +(see `eliminate-node-engine-design.md`, `project-north-star`). A changelog plugin is "just another |
| 15 | +CLI we call." No Go `plugin` `.so`, no gRPC, no WASM — a subprocess with JSON on stdin and rendered |
| 16 | +text on stdout. |
| 17 | + |
| 18 | +**Non-negotiable design rule: dogfood it.** The built-in formatter is *the reference implementation |
| 19 | +of the same contract*, not a privileged special case the contract was bolted onto afterward. See |
| 20 | +"Dogfooding" below — this is what keeps the contract honest. |
| 21 | + |
| 22 | +## Why subprocess + JSON (and not the alternatives) |
| 23 | + |
| 24 | +| Option | Verdict | |
| 25 | +|---|---| |
| 26 | +| Go `plugin` (`.so`) | **No.** Linux/macOS only, exact-version/flag/dep lockstep, no unload. Trap. | |
| 27 | +| HashiCorp `go-plugin` (gRPC) | Overkill. Plugins here are one-shot pure functions, not long-lived chatty servers. Adds gRPC/protobuf deps. | |
| 28 | +| WASM (`wazero`, pure-Go zero-dep) | Reserve for **untrusted** community plugins needing a sandbox. Forces a WASM toolchain + host/guest ABI on authors. Not worth it for trusted, local generators. | |
| 29 | +| **Subprocess + JSON contract** | **Yes.** Zero new deps, cross-platform, language-agnostic (shell/Node/Rust/anything), and identical to the delegation model already in use. | |
| 30 | + |
| 31 | +A changelog generator is a pure function — `(changes + version + package) → rendered notes` — which |
| 32 | +is the ideal shape for a stateless one-shot subprocess. |
| 33 | + |
| 34 | +## The contract |
| 35 | + |
| 36 | +### Invocation |
| 37 | + |
| 38 | +The engine spawns the configured generator command once **per package being released** (matching the |
| 39 | +per-`ModuleChangelog` loop in `ChangelogFileWriter.GenerateChangelogFilesAsync`). It writes a single |
| 40 | +JSON object to the plugin's **stdin** and reads the result from **stdout**. |
| 41 | + |
| 42 | +- **stdout** → the rendered release entry (the block inserted under the package's `# Title`, i.e. the |
| 43 | + `## <version>` section). The engine still owns file placement, the `# Title` header, and insertion |
| 44 | + position — the plugin renders *one entry*, not the whole file. |
| 45 | +- **stderr** → surfaced to the user as plugin diagnostics (never parsed). |
| 46 | +- **exit 0** → success. **Non-zero** → abort the release and report the plugin's stderr (consistent |
| 47 | + with hook failure semantics in `release-command-design.md`). |
| 48 | + |
| 49 | +One-shot per package keeps plugins trivially stateless and parallelizable, and means a crashing |
| 50 | +plugin can't corrupt more than the entry it was rendering. |
| 51 | + |
| 52 | +### Input schema (stdin) |
| 53 | + |
| 54 | +Versioned from day one — this is the thing we can't break later. |
| 55 | + |
| 56 | +```jsonc |
| 57 | +{ |
| 58 | + "apiVersion": 1, |
| 59 | + "package": { |
| 60 | + "name": "Buoy.Web.Api", // ModuleName |
| 61 | + "displayName": "Buoy Web API", // PackageTitle ?? ModuleName |
| 62 | + "currentVersion": "1.3.2", // CurrentVersion |
| 63 | + "newVersion": "1.4.0" // ResolvedVersion (already includes prerelease/snapshot suffix) |
| 64 | + }, |
| 65 | + "bump": "minor", // HighestBumpType: major | minor | patch |
| 66 | + "changes": [ |
| 67 | + { |
| 68 | + "bump": "minor", // per-change BumpType |
| 69 | + "summary": "Add buoy telemetry endpoint\n\nLong-form body kept verbatim.", |
| 70 | + // Enrichment the engine already resolves for the built-in renderer: |
| 71 | + "commit": "a1b2c3d", // nullable |
| 72 | + "pr": 412, // nullable |
| 73 | + "author": "jcamp" // nullable |
| 74 | + } |
| 75 | + ], |
| 76 | + "dependencyUpdates": [ // the "Updated dependencies" section, pre-resolved |
| 77 | + { "name": "Buoy.Core", "version": "2.1.0" } |
| 78 | + ], |
| 79 | + "context": { // mirrors release-command ${...} context where meaningful |
| 80 | + "tag": "Buoy.Web.Api@1.4.0", |
| 81 | + "repoRoot": "/abs/path" |
| 82 | + } |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +Field shape is derived directly from `ModuleChangelog` (`ModuleChangelog.cs`) and the |
| 87 | +`getReleaseLine` enrichment in `ChangelogReleaseLine.cs`, so no new data needs to be computed — the |
| 88 | +engine already has all of it at render time. |
| 89 | + |
| 90 | +### Output (stdout) |
| 91 | + |
| 92 | +Plain UTF-8 text: the rendered entry, **excluding** the `# Title` file header. The engine trims a |
| 93 | +single trailing newline and inserts it at the documented position (after line 2 for an existing file; |
| 94 | +as the body for a new one — see `ChangelogFileWriter`). Whether the output is post-processed by the |
| 95 | +native formatter (`changelog-formatter-native-design`) is controlled by the existing `format` setting; |
| 96 | +default behavior TBD (see Open questions). |
| 97 | + |
| 98 | +### Versioning rule |
| 99 | + |
| 100 | +`apiVersion` is an integer. The engine sends the highest version it speaks; a plugin that doesn't |
| 101 | +recognize it must exit non-zero with a clear message rather than guess. Additive fields don't bump the |
| 102 | +version; removing/renaming a field or changing its meaning does. |
| 103 | + |
| 104 | +## Discovery & configuration |
| 105 | + |
| 106 | +Configured in `.changeset/release.jsonc` (JSONC; see `release-command-design.md`): |
| 107 | + |
| 108 | +```jsonc |
| 109 | +{ |
| 110 | + "changelog": { |
| 111 | + "generator": "default" // built-in (see Dogfooding) — the default |
| 112 | + // "generator": "changeset-changelog-keepachangelog" // resolved on $PATH by convention |
| 113 | + // "generator": "./scripts/our-notes.mjs" // explicit path |
| 114 | + // "generator": { "command": ["node", "gen.js"], "args": ["--emoji"] } // full form |
| 115 | + } |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +Resolution order for a string value: |
| 120 | +1. `"default"` (and any other built-in id) → in-process reference generator. |
| 121 | +2. A path (contains `/` or `\`, or starts with `.`) → executed directly. |
| 122 | +3. A bare name → looked up on `$PATH` by the `changeset-changelog-*` convention (the way `git` finds |
| 123 | + `git-foo`). Lets plugins be installed as ordinary binaries/npm-bins and discovered by name. |
| 124 | + |
| 125 | +## Dogfooding (the load-bearing requirement) |
| 126 | + |
| 127 | +The built-in changelog renderer is **not** a code path that bypasses the plugin system. It is the |
| 128 | +reference implementation behind the same interface the subprocess protocol mirrors: |
| 129 | + |
| 130 | +``` |
| 131 | +IChangelogGenerator (one interface) |
| 132 | +├── BuiltinChangelogGenerator — in-process; today's ChangelogFileWriter logic, refactored to |
| 133 | +│ consume the ChangelogRequest model and return the rendered entry |
| 134 | +└── SubprocessChangelogGenerator — serializes the same ChangelogRequest to stdin, reads stdout |
| 135 | +``` |
| 136 | + |
| 137 | +Concretely: |
| 138 | +- Refactor `ChangelogFileWriter` so its rendering core takes a **`ChangelogRequest`** (the exact |
| 139 | + object serialized to plugin stdin) and returns the rendered entry string — the same value a |
| 140 | + subprocess plugin returns on stdout. File I/O / insertion stays in the engine, outside both |
| 141 | + implementations. |
| 142 | +- The built-in is selected by `"generator": "default"` and runs in-process (no fork — dogfooding means |
| 143 | + *same contract/model*, not literally shelling out to ourselves, which would be slow and pointless). |
| 144 | +- **Golden parity:** the existing changelog goldens (`E2E/Parity/ChangelogGoldenTests`, |
| 145 | + `ChangelogFileWriterTests`) must pass unchanged when routed through `BuiltinChangelogGenerator`. |
| 146 | + That test is the proof the request model carries everything a real generator needs — if a golden |
| 147 | + can't be reproduced from the `ChangelogRequest` alone, the contract is missing a field, and we find |
| 148 | + out *because our own renderer breaks*, not because a third party complains. |
| 149 | + |
| 150 | +This is the whole point: by forcing our default through the contract, the contract can't silently grow |
| 151 | +gaps that only external plugins would hit. |
| 152 | + |
| 153 | +## Phasing |
| 154 | + |
| 155 | +1. Define `ChangelogRequest` (`apiVersion: 1`) + refactor `ChangelogFileWriter` rendering core to |
| 156 | + consume it → `BuiltinChangelogGenerator : IChangelogGenerator`. Goldens green. **No external |
| 157 | + surface yet** — pure internal dogfood. |
| 158 | +2. Add `SubprocessChangelogGenerator` + `changelog.generator` config resolution (string forms first). |
| 159 | +3. Document the stdin/stdout contract publicly; ship a trivial reference plugin (a ~20-line Node or |
| 160 | + shell script that reproduces `keepachangelog`) as both an example and a contract conformance test. |
| 161 | +4. (Later, only if needed) `wazero` sandbox for untrusted plugins — same `ChangelogRequest`, different |
| 162 | + transport. |
| 163 | + |
| 164 | +## Open questions |
| 165 | + |
| 166 | +- **Formatter interaction:** does plugin stdout get run through the native formatter |
| 167 | + (`changelog-formatter-native-design`), or is the plugin responsible for its own formatting? Leaning: |
| 168 | + respect the `format` setting and post-process by default, with a per-generator `"format": false` |
| 169 | + opt-out for plugins that emit intentional layout. |
| 170 | +- **Batch vs per-package:** per-package is simpler and the default; a future `"mode": "batch"` could |
| 171 | + pass all packages in one call for generators that want a single aggregated CHANGELOG. Defer until |
| 172 | + asked. |
| 173 | +- **Whole-file vs entry:** keeping the plugin to "render one entry" preserves the engine's |
| 174 | + insertion/idempotency guarantees. A "take the whole file" mode is more powerful but hands |
| 175 | + correctness to the plugin — defer. |
| 176 | +- **This is the knope gap:** knope has no plugin system at all. Even the minimal subprocess contract is |
| 177 | + a cheap, real differentiator (see `knope-evaluation`). |
0 commit comments