From 34fd5489fe9b580459d7f039a8bc40ed17b4f946 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:06:26 +0000 Subject: [PATCH 1/4] docs: move the changelog's title above its releases @semantic-release/changelog wrote every release above the whole file, title included, so the title had ended up below them. It now opens the file, and the line releases are written below closes it, as in CHANGELOG.satellites.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0164hf4jJ3KsP3tyC3Uoa55L --- CHANGELOG.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc7ee30..890c89a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# Changelog + +Generated by [semantic-release](https://github.com/semantic-release/semantic-release) from the Conventional +Commits on `main`, and updated when a stable release is cut. See +[ADR-0003](docs/adr/0003-hybrid-release-manual-stable-continuous-preview.md) for how releases work. + +The `0.1.0-preview.1` and `0.1.0-preview.2` packages predate this file and have no entries here; their contents +are in the commit history. + + + ## [0.2.1](https://github.com/AdCodicem/AdCodicem.ValueObjects/compare/v0.2.0...v0.2.1) (2026-09-30) ### Packaging @@ -34,14 +45,3 @@ * **security:** link the private vulnerability reporting form ([fe4fec7](https://github.com/AdCodicem/AdCodicem.ValueObjects/commit/fe4fec7b062c2801146be8f33d1f8b0bfdcb2b60)) * **site:** fail on a missing homepage example, correct the versioning notes ([0959b6d](https://github.com/AdCodicem/AdCodicem.ValueObjects/commit/0959b6df76de65b405407daf616ac05741782e72)) * **site:** serve stable, preview and archived documentation versions ([ae7c6a2](https://github.com/AdCodicem/AdCodicem.ValueObjects/commit/ae7c6a2f32c47fb3a98090631a9bc5c07a674eeb)) - -# Changelog - -Generated by [semantic-release](https://github.com/semantic-release/semantic-release) from the Conventional -Commits on `main`, and updated when a stable release is cut. See -[ADR-0003](docs/adr/0003-hybrid-release-manual-stable-continuous-preview.md) for how releases work. - -The `0.1.0-preview.1` and `0.1.0-preview.2` packages predate this file and have no entries here; their contents -are in the commit history. - - From dbf2bacbf22659c44eb9d40d3f70cbcba5bceacf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:06:26 +0000 Subject: [PATCH 2/4] ci: keep the changelog's title on top when a release writes it .releaserc.json gives @semantic-release/changelog CHANGELOG.md's title, through the line releases are written below: the plugin keeps a title on top only when the file starts with it, and writes the next release right below it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0164hf4jJ3KsP3tyC3Uoa55L --- .releaserc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.releaserc.json b/.releaserc.json index e351ea3b..778e81d5 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -35,7 +35,8 @@ [ "@semantic-release/changelog", { - "changelogFile": "CHANGELOG.md" + "changelogFile": "CHANGELOG.md", + "changelogTitle": "# Changelog\n\nGenerated by [semantic-release](https://github.com/semantic-release/semantic-release) from the Conventional\nCommits on `main`, and updated when a stable release is cut. See\n[ADR-0003](docs/adr/0003-hybrid-release-manual-stable-continuous-preview.md) for how releases work.\n\nThe `0.1.0-preview.1` and `0.1.0-preview.2` packages predate this file and have no entries here; their contents\nare in the commit history.\n\n" } ], [ From f9ab6ebb2fe89dd1a2df8eccdd676189063ba97b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:06:26 +0000 Subject: [PATCH 3/4] ci: release a train through semantic-release, from the pack job's plan release-train-plugin.mjs releases one train of src/release-trains.json (ADR 0012) from the plan next-version.mjs --trains computes: its trains_json, in a file RELEASE_TRAIN_PLAN names, since a variable above 128 KiB, about 3,200 hashes, would stop every process semantic-release starts. It fails when semantic-release counts from another tag than the plan's, sees a commit the plan did not walk, this run's earlier release commits aside, or does not see one the plan walked, a [skip release] one aside. The release type is the plan's, the line rule included; the version must be the one packed, a dry run included. The notes list the train's commits, [skip release] ones included (a new notes list of each plan row), the core's with the commits no train owns, their breaking marks made plain, a revert's quoted header included, and any other train's end with the core it is packed against; they go below the marker of the train's changelog, which must hold no release above it. release-train.releaserc.json configures it with .releaserc.json's rules and notes, @semantic-release/git committing the changelogs and the documentation as chore(release): [skip ci], and a draft GitHub Release naming the train's bundle, its notes cut below GitHub's limit on a release body. release-train-plugin.test.mjs replays it in throwaway repositories: a dry run per train of each history of its table, a merge, a range of 3,300 commits, each refusal, and a real run of the core then the satellites, past the core's release commit. The test, the plugin, its configuration and the changelogs run release tooling. No workflow runs it yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0164hf4jJ3KsP3tyC3Uoa55L --- .github/CLAUDE.md | 30 ++ .github/scripts/ci-plan.sh | 22 +- .github/scripts/next-version.mjs | 8 +- .github/scripts/release-train-plugin.mjs | 231 +++++++++ .github/scripts/release-train-plugin.test.mjs | 484 ++++++++++++++++++ .github/scripts/release-train.releaserc.json | 60 +++ .github/workflows/ci.yml | 10 +- 7 files changed, 830 insertions(+), 15 deletions(-) create mode 100644 .github/scripts/release-train-plugin.mjs create mode 100644 .github/scripts/release-train-plugin.test.mjs create mode 100644 .github/scripts/release-train.releaserc.json diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 73af4c0e..a08a3a3c 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -138,6 +138,36 @@ The runbook (one-time settings, routines, the Dependabot procedure) is `docs/mai refuses to go on unless its version is the one packed, writes `CHANGELOG.md`, commits it with the snapshot, tags, pushes through `push-packages.sh`, and the site is redeployed. No build code runs beside the NuGet key, the App token or the OIDC token. +- `CHANGELOG.md` and `CHANGELOG.satellites.md` open with their title, which ends with the line + ``, every release below it. `.releaserc.json`'s `changelogTitle` is + `CHANGELOG.md`'s title, verbatim: `@semantic-release/changelog` keeps a title on top only when the file starts with + it, and otherwise writes the title, the notes, then the whole file. Change the title and the option together; + `release-train-plugin.test.mjs` holds them equal. +- `scripts/release-train-plugin.mjs`, with `scripts/release-train.releaserc.json`, releases one train through + semantic-release (ADR 0012), from the plan the pack job computes with `next-version.mjs --trains`: its `trains_json`, + in a file `RELEASE_TRAIN_PLAN` names, the train in `RELEASE_TRAIN`, this run's earlier release commits in + `RELEASE_RUN_COMMITS` and the version packed in `PACKED_VERSION` (its header lists them, each required, an empty one + included). The lists travel in the file, never in a variable: every process semantic-release starts inherits its + environment, and Linux refuses to start one with a variable above 128 KiB, about 3,200 hashes. It fails when + semantic-release counts from another tag than the plan's, sees a commit the plan did not walk, its earlier release + commits aside, or does not see one the plan walked, a `[skip release]` one aside. The type is the plan's + (`release-trains.mjs releaseType`, the line rule included), the version must be the one packed, a dry run included, + and the train's changelog is written below its marker, refused with a release above it. The notes list the train's + commits, `[skip release]` ones included (the row's `notes`), the core's with the commits no train owns, their breaking + marks made plain since they ship in no package, and any other train's end with the core it is packed against. The + configuration's analyzer rules, the slot aside, and notes options are `.releaserc.json`'s, which the test holds; its + release commit is `chore(release): [skip ci]`, and its GitHub Release names the train's bundle + (`AdCodicem.ValueObjects.satellites..sigstore.json` for the satellites). A caller's options take precedence + over the file's (`tagFormat`, `dryRun`). No workflow runs it yet: `release.yml` will, once it releases each train on + its own. +- `scripts/release-train-plugin.test.mjs` replays it in throwaway repositories (two trains at `0.2.1`, this repository's + scripts, configurations and changelogs, its `node_modules` linked, a bare remote), the plan computed there by + `next-version.mjs --trains`: a dry run per train of each history of its table (the analyzer's options, a train's own + breaking notes, `[skip release]` commits, and breaking commits no train owns, one of them reverted, among them), a + merge, a range of 3,300 commits, each refusal, and a real run of the core then the satellites. semantic-release runs + in a child process, since it hooks its process's standard streams, which carry `node:test`'s reports, with the + configuration minus its GitHub entry and an environment holding the plan alone, no CI variable. It also renders both + configurations' GitHub Release bodies. - `scripts/docs-snapshot.sh` takes the one version, or each train's (`core=X satellites=Y`: every train of `src/release-trains.json` once, each stable). The line it freezes is the core's, and the entry it writes in `website/released-versions.json` is that version or `{"core": X, "satellites": Y}`. `scripts/docs-snapshot.test.mjs` diff --git a/.github/scripts/ci-plan.sh b/.github/scripts/ci-plan.sh index 4fd122a0..ff55d70c 100755 --- a/.github/scripts/ci-plan.sh +++ b/.github/scripts/ci-plan.sh @@ -32,9 +32,10 @@ # # A push to main runs everything. A pull request runs what the files its merge commit changes against the base branch # (its first parent, hence a checkout depth of 2) select, each file by the first rule below that matches it: -# none CLAUDE.md files, docs/ but docs/docfx/, Markdown outside src/, website/ and skills/ but README.md, -# the repository's own settings and templates, and the workflows and scripts ci.yml never runs, each -# by name, so that a new one runs everything until it is given a rule; only lint.yml checks them. +# none CLAUDE.md files, docs/ but docs/docfx/, Markdown outside src/, website/ and skills/ but README.md +# and the changelogs (CHANGELOG.md, CHANGELOG..md), the repository's own settings and +# templates, and the workflows and scripts ci.yml never runs, each by name, so that a new one runs +# everything until it is given a rule; only lint.yml checks them. # documentation README.md, the package READMEs (src/PackageReadme.md, src/AdCodicem.ValueObjects/README.md), # website/docs/ and skills/value-objects/: the generator suite's tests that read them, # [Trait("Reads", "Documentation")]. The package READMEs are also packed, website/docs/ also built @@ -51,11 +52,12 @@ # and whose inputs release-trains.test.mjs holds to the trains each path releases, that test, # docs-snapshot.sh, which docs-snapshot.test.mjs runs up to the entry it writes in # website/released-versions.json, that test, release-pack.sh, whose checks of what nuget.org holds -# release-pack.test.mjs runs up to the pack, that test, and the weekly list of held-back majors -# (held-back-majors.mjs, its test, and dependabot.yml, whose shape its reader holds): the job -# "release tooling", which runs every .github/scripts/*.test.mjs. A project file under src/ runs it -# too, beside its graph lane: release-trains.mjs compile-items holds what MSBuild compiles to the -# trains. +# release-pack.test.mjs runs up to the pack, that test, the release-train plugin, its configuration, +# and the changelogs, whose shape and title release-train-plugin.test.mjs holds, that test, and the +# weekly list of held-back majors (held-back-majors.mjs, its test, and dependabot.yml, whose shape its +# reader holds): the job "release tooling", which runs every .github/scripts/*.test.mjs. A project +# file under src/ runs it too, beside its graph lane: release-trains.mjs compile-items holds what +# MSBuild compiles to the trains. # pack icon.png, .github/shipped-dependencies and the scripts that check the packed set. # graph src/, the unit suite's projects and tests/UnitTests.props, which they import, tests/Fixtures/ but # the Functions fixture, and the .NET code of the rules below: the projects dotnet-affected @@ -270,6 +272,7 @@ rule() { case "$1" in CLAUDE.md | */CLAUDE.md) echo none; return ;; README.md) echo documentation; return ;; + CHANGELOG.md | CHANGELOG.*.md) echo release; return ;; src/* | website/* | skills/*) ;; *.md) echo none; return ;; esac @@ -297,7 +300,8 @@ rule() { .github/scripts/semver.jq | .github/scripts/preview-gate.test.mjs | .github/scripts/held-back-majors.mjs | \ .github/scripts/held-back-majors.test.mjs | .github/scripts/docs-snapshot.sh | \ .github/scripts/docs-snapshot.test.mjs | .github/scripts/release-pack.sh | .github/scripts/release-pack.test.mjs | \ - .github/dependabot.yml) echo release ;; + .github/scripts/release-train-plugin.mjs | .github/scripts/release-train.releaserc.json | \ + .github/scripts/release-train-plugin.test.mjs | .github/dependabot.yml) echo release ;; .github/shipped-dependencies | .github/scripts/verify-packages.sh | .github/scripts/package-ids.sh | \ .github/scripts/shipped-dependencies-check.sh) echo pack ;; .github/scripts/dependabot-commit-type.sh | .github/scripts/package-assemblies.sh | .github/scripts/push-packages.sh) \ diff --git a/.github/scripts/next-version.mjs b/.github/scripts/next-version.mjs index 9692a105..714155b4 100755 --- a/.github/scripts/next-version.mjs +++ b/.github/scripts/next-version.mjs @@ -43,7 +43,10 @@ // _core_version, _previous_core_version and _line_moved; // trains, preview_versions, release_types, next_versions and last_versions // ("core=... satellites=..."); trains_json, all of it with the commits' hashes, on one -// line. One line per train goes to stderr, and a table to $GITHUB_STEP_SUMMARY when set. +// line, each train's notes beside its commits: the commits of its range attributed to it, +// [skip release] ones included, which the analyzer never reads but the release notes list, +// and the core's unowned, the commits of its range no train owns. One line per train goes +// to stderr, and a table to $GITHUB_STEP_SUMMARY when set. import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; @@ -171,6 +174,7 @@ async function trainOutput() { const row = { train, tag: from.tag, last: from.version, type, next, height: count, preview: `${next}-preview.${count}`, commits: own.map(({ hash }) => hash), range: attributed.map(({ hash }) => hash), + notes: attributed.filter((commit) => commit.trains.has(train)).map(({ hash }) => hash), }; if (train === "core") { row.unowned = attributed.filter((commit) => commit.trains.size === 0).map(({ hash }) => hash); @@ -211,7 +215,7 @@ async function trainOutput() { keyed.next_versions = each((row) => row.next); keyed.last_versions = each((row) => row.last); keyed.trains_json = JSON.stringify(rows.map((row) => ({ - ...row, type: row.type ?? "", commits: row.commits.join(" "), range: row.range.join(" "), + ...row, type: row.type ?? "", commits: row.commits.join(" "), range: row.range.join(" "), notes: row.notes.join(" "), ...(row.unowned ? { unowned: row.unowned.join(" ") } : {}), }))); diff --git a/.github/scripts/release-train-plugin.mjs b/.github/scripts/release-train-plugin.mjs new file mode 100644 index 00000000..d7f25f04 --- /dev/null +++ b/.github/scripts/release-train-plugin.mjs @@ -0,0 +1,231 @@ +// A semantic-release plugin that releases one train of src/release-trains.json +// (docs/adr/0012-version-the-packages-in-two-trains-independently-of-the-frameworks.md): the commits that release it, +// its version, its notes and its changelog, from the plan the pack job computed with next-version.mjs --trains. +// +// It keeps no state: each step reads the plan again, through the environment the release starts with. +// RELEASE_TRAIN the train, a name of src/release-trains.json +// RELEASE_TRAIN_PLAN a file holding the plan, next-version.mjs --trains's trains_json, of which the train's row +// gives its tag, its commits (the hashes of its range that release it), its range (every hash +// of ..HEAD as the plan walked it), its notes (the hashes attributed to it, +// [skip release] ones included), the core's unowned (the hashes no train owns, which its notes +// carry), and any other train's coreVersion and previousCoreVersion (the core it is packed +// against, and the one its last release was) +// RELEASE_RUN_COMMITS the release commits this run made for the trains before this one, which no plan walked +// PACKED_VERSION the version the pack job packed the train at; empty when it packed it at nothing new +// Every one must be set, an empty list or version included, so that a step never reads a variable the run forgot. The +// lists travel in a file: a variable reaches every process semantic-release starts, and Linux refuses to start one +// with a variable above 128 KiB, about 3,200 hashes. The caller's options must take precedence over the configuration +// file's (tagFormat, dryRun), as semantic-release's own merge gives them. +// +// Its options: { analyzer, notes }, the commit analyzer's and the release notes generator's, as .releaserc.json gives +// them, the analyzer's without a breaking rule, which src/release-trains.json gives each train. The commit analyzer, +// the notes generator and the changelog plugin are the ones semantic-release itself loads, resolved from its own +// directory as next-version.mjs resolves them, so that package-lock.json pins them. +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { changelogOf, lineOf, loadTrains, releaseType, tagPrefixOf } from "./release-trains.mjs"; + +/** The name a configuration lists this plugin under, which release-trains.mjs's analyzerRules reads. */ +export const PLUGIN = "./.github/scripts/release-train-plugin.mjs"; +/** The line every changelog keeps above its releases: its title ends there, and a release is written below it. */ +export const MARKER = ""; +const VARIABLES = ["RELEASE_TRAIN", "RELEASE_TRAIN_PLAN", "RELEASE_RUN_COMMITS", "PACKED_VERSION"]; +const STABLE = /^\d+\.\d+\.\d+$/; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const fromRoot = createRequire(join(root, "package.json")); +const fromSemanticRelease = createRequire( + join(dirname(fromRoot.resolve("semantic-release/package.json")), "lib", "plugins", "utils.js")); +const load = async (id) => import(pathToFileURL(fromSemanticRelease.resolve(id)).href); + +/** A refusal semantic-release reports as its own, code and all. */ +const refuse = (message) => Object.assign(new Error(message), { semanticRelease: true, code: "ERELEASETRAIN" }); +const hashes = (text) => new Set((text ?? "").split(/\s+/).filter(Boolean)); +const subject = (message) => (message ?? "").split("\n")[0]; +const skipsRelease = (message) => /\[skip\s+release]|\[release\s+skip]/i.test(message); + +/** The plan, as the environment and the file it names carry it, checked. */ +function plan({ env, cwd, options }) { + for (const name of VARIABLES) if (env[name] === undefined) throw refuse(`${name} is not set.`); + const train = env.RELEASE_TRAIN; + const model = loadTrains(join(cwd, "src", "release-trains.json")); + if (!model.names.includes(train)) throw refuse(`${train} is no train of src/release-trains.json.`); + const tagFormat = `${tagPrefixOf(train)}\${version}`; + if (options.tagFormat !== tagFormat) { + throw refuse(`The ${train} train's tags are ${tagFormat}, not ${options.tagFormat}: pass its tagFormat.`); + } + let rows; + try { + rows = JSON.parse(readFileSync(resolve(cwd, env.RELEASE_TRAIN_PLAN), "utf8")); + } catch (error) { + throw refuse(`The plan, ${env.RELEASE_TRAIN_PLAN}, cannot be read: ${error.message}`); + } + const row = Array.isArray(rows) ? rows.find((candidate) => candidate?.train === train) : undefined; + if (!row) throw refuse(`The plan, ${env.RELEASE_TRAIN_PLAN}, has no row for the ${train} train.`); + const lists = ["commits", "range", "notes", ...(train === "core" ? ["unowned"] : [])]; + for (const name of lists) { + if (typeof row[name] !== "string") throw refuse(`The ${train} train's plan has no ${name} list.`); + } + if (row.tag !== `${tagPrefixOf(train)}${row.last}` || !STABLE.test(row.last ?? "")) { + throw refuse(`The ${train} train's plan names no last release of the train: '${row.tag}'.`); + } + if (train !== "core") { + for (const name of ["coreVersion", "previousCoreVersion"]) { + if (!STABLE.test(row[name] ?? "")) throw refuse(`The ${train} train's plan's ${name} is not a stable version: '${row[name] ?? ""}'.`); + } + } + return { + train, + tag: row.tag, + breaking: model.trains.get(train).breaking, + commits: hashes(row.commits), + range: hashes(row.range), + notes: hashes(row.notes), + extra: train === "core" ? hashes(row.unowned) : new Set(), + run: hashes(env.RELEASE_RUN_COMMITS), + coreVersion: row.coreVersion, + previousCoreVersion: row.previousCoreVersion, + }; +} + +/** The changelog of the train and its title: the text from its start through the marker line, in the shape required. */ +function changelog(train, cwd) { + const file = changelogOf(train); + let text; + try { + text = readFileSync(join(cwd, file), "utf8"); + } catch { + throw refuse(`${file}, the ${train} train's changelog, cannot be read.`); + } + const end = text.indexOf(MARKER); + if (!text.startsWith("# ") || end < 0) { + throw refuse(`${file} must open with its "# " title and hold the line ${MARKER}, which releases are written below.`); + } + if (/^## /m.test(text.slice(0, end))) { + throw refuse(`${file} has a release above ${MARKER}: move the title and the line back above every release.`); + } + return { changelogFile: file, changelogTitle: text.slice(0, end + MARKER.length) }; +} + +/** The options a loaded plugin receives: this plugin's, without its own two, then the ones given. */ +const optionsFor = (pluginConfig, given) => { + const { analyzer, notes, ...shared } = pluginConfig; + return { ...shared, ...given }; +}; + +export async function verifyConditions(pluginConfig, context) { + const { train } = plan(context); + const { verifyConditions: verify } = await load("@semantic-release/changelog"); + await verify(changelog(train, context.cwd), { ...context, options: { ...context.options, prepare: undefined } }); +} + +/** + * The train's release type, from its own commits, the line rule applied (release-trains.mjs releaseType, as + * next-version.mjs computes it). Every commit semantic-release sees must be one the plan walked, but the release + * commits of this run, and every one the plan walked must be one semantic-release sees, but those it skips + * ([skip release]): a range read from another tag, or through a merge the plan did not follow, fails rather than drops. + */ +export async function analyzeCommits(pluginConfig, context) { + const { train, tag, breaking, commits, range, run, coreVersion, previousCoreVersion } = plan(context); + const from = context.lastRelease?.gitTag ?? "the first commit"; + if (from !== tag) { + throw refuse(`semantic-release counts the ${train} train from ${from}, the plan from ${tag}: dispatch release.yml again.`); + } + const seen = context.commits.filter(({ hash }) => !run.has(hash)); + for (const { hash, message } of seen) { + if (!range.has(hash)) { + throw refuse(`semantic-release sees ${hash.slice(0, 7)} ${subject(message)} since ${from}, which the ${train} ` + + "train's plan did not walk: dispatch release.yml again."); + } + } + const seenHashes = new Set(seen.map(({ hash }) => hash)); + for (const hash of range) { + if (seenHashes.has(hash)) continue; + let message; + try { + message = execFileSync("git", ["show", "-s", "--format=%B", hash], { cwd: context.cwd, encoding: "utf8" }); + } catch { + message = ""; + } + if (!skipsRelease(message)) { + throw refuse(`The ${train} train's plan walked ${hash.slice(0, 7)} ${subject(message)}, which semantic-release ` + + `does not see since ${from}: dispatch release.yml again.`); + } + } + const kept = seen.filter(({ hash }) => commits.has(hash)); + const { analyzeCommits: analyze } = await load("@semantic-release/commit-analyzer"); + const { type, lineMoved } = await releaseType({ + train, + breaking, + analyze: (rules, picked) => analyze(optionsFor(pluginConfig, rules), { ...context, commits: picked }), + config: { plugins: [[PLUGIN, pluginConfig]] }, + commits: kept, + coreVersion, + previousCoreVersion: train === "core" ? undefined : previousCoreVersion, + }); + const line = train === "core" ? "" : `, core ${previousCoreVersion} -> ${coreVersion}${lineMoved ? ", line moved" : ""}`; + context.logger.log(`${train}: ${kept.length} of ${seen.length} commits, ${type ?? "nothing"}${line}`); + return type ?? null; +} + +/** The version must be the one packed, a dry run included: semantic-release would otherwise release another. */ +export async function verifyRelease(pluginConfig, context) { + const { train } = plan(context); + const packed = context.env.PACKED_VERSION; + if (context.nextRelease.version !== packed) { + throw refuse(`semantic-release computed ${context.nextRelease.version} for the ${train} train, but the pack job ` + + `packed '${packed}'.`); + } +} + +/** The conventionalcommits preset's breakingHeaderPattern: a header it reads as breaking. */ +const BREAKING_HEADER = /^(\w*)(?:\((.*)\))?!: (.*)$/; +/** A header with every ! before a colon the preset would read as breaking taken out. */ +const plainHeader = (header) => { + let plain = header; + while (BREAKING_HEADER.test(plain)) { + const next = plain.replace(/^(\w*(?:\(.*\))?)!: /, "$1: "); + if (next === plain) break; + plain = next; + } + return plain; +}; +/** + * A message as the notes read it, its breaking marks (a ! before the colon, a BREAKING CHANGE note) made plain, the + * patterns conventional-commits-parser reads: a commit that ships in no package breaks nothing a release carries. A + * revert's quoted header is made plain the same way, so that the notes still pair it with the commit it reverts. + */ +const unbroken = (message) => message.split("\n").map((line, index) => { + if (index > 0) return line.replace(/^([\s|*]*)(?:BREAKING CHANGE|BREAKING-CHANGE)(?=[:\s])/i, "$1Note"); + const revert = /^((?:Revert|revert:)\s"?)(.*?)("?)$/i.exec(line); + return revert ? `${revert[1]}${plainHeader(revert[2])}${revert[3]}` : plainHeader(line); +}).join("\n"); + +/** + * The notes of the train's own commits, [skip release] ones included, the core's with the commits no train owns + * (documentation, the site, the decisions), their breaking marks made plain, and, for any other train, the core it is + * packed against. + */ +export async function generateNotes(pluginConfig, context) { + const { train, notes: own, extra, coreVersion, previousCoreVersion } = plan(context); + const kept = context.commits.filter(({ hash }) => own.has(hash) || extra.has(hash)) + .map((commit) => (own.has(commit.hash) ? commit : { ...commit, message: unbroken(commit.message) })); + const { generateNotes: generate } = await load("@semantic-release/release-notes-generator"); + const notes = await generate(optionsFor(pluginConfig, pluginConfig.notes ?? {}), { ...context, commits: kept }); + if (train === "core") return notes; + const moved = lineOf(coreVersion) !== lineOf(previousCoreVersion) + ? ` The core's line moved from ${lineOf(previousCoreVersion)} to ${lineOf(coreVersion)} since this train's last ` + + `release, which was packed against ${previousCoreVersion}.` + : ""; + return `${notes.trimEnd()}\n\nPacked against AdCodicem.ValueObjects ${coreVersion}.${moved}\n`; +} + +/** The release, written below the marker of the train's changelog, its title kept on top. */ +export async function prepare(pluginConfig, context) { + const { train } = plan(context); + const { prepare: write } = await load("@semantic-release/changelog"); + await write(changelog(train, context.cwd), context); +} diff --git a/.github/scripts/release-train-plugin.test.mjs b/.github/scripts/release-train-plugin.test.mjs new file mode 100644 index 00000000..e6110887 --- /dev/null +++ b/.github/scripts/release-train-plugin.test.mjs @@ -0,0 +1,484 @@ +// Tests of release-train-plugin.mjs and release-train.releaserc.json, and of the one-version changelog's title: +// node --test .github/scripts/release-train-plugin.test.mjs (ci.yml's job "release tooling"). +// +// Each case builds a throwaway repository: two trains (Core.A on the core, Sat.B on the satellites, a src/Shared file +// both link), this repository's scripts, configurations and changelogs, its node_modules linked, both trains tagged +// at 0.2.1 and a bare remote on disk. The plan comes from next-version.mjs --trains in that repository, as the pack job +// computes it, and semantic-release runs through its JavaScript API with release-train.releaserc.json, minus the +// GitHub entry, which needs the API, in an environment holding the plan alone: no CI variable reaches it. It runs in a +// process of its own, since it hooks the standard streams of its process, which carry the test runner's reports. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile, execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { analyzerRules, tagPrefixOf } from "./release-trains.mjs"; +import { MARKER } from "./release-train-plugin.mjs"; + +const scripts = dirname(fileURLToPath(import.meta.url)); +const root = join(scripts, "..", ".."); +const fromRoot = createRequire(join(root, "package.json")); +const SEMANTIC_RELEASE = fromRoot.resolve("semantic-release"); +const fromSemanticRelease = createRequire( + join(dirname(fromRoot.resolve("semantic-release/package.json")), "lib", "plugins", "utils.js")); +const changelogPlugin = await import(pathToFileURL(fromSemanticRelease.resolve("@semantic-release/changelog")).href); +const { template } = await import(pathToFileURL(fromSemanticRelease.resolve("lodash-es")).href); + +const read = (path) => readFileSync(path, "utf8"); +const releaserc = JSON.parse(read(join(root, ".releaserc.json"))); +const trainConfig = JSON.parse(read(join(scripts, "release-train.releaserc.json"))); +const entry = (config, name) => config.plugins.find((plugin) => (Array.isArray(plugin) ? plugin[0] : plugin) === name)?.[1]; +const LIST = JSON.stringify({ + core: { breaking: "minor", projects: ["Core.A"] }, + satellites: { breaking: "minor", projects: "*" }, +}, null, 2); +// Nothing of the machine's environment but what git and node need: no CI variable, no GitHub token, no step summary. +const BASE = { PATH: process.env.PATH, HOME: process.env.HOME, GIT_TERMINAL_PROMPT: "0" }; + +/** A throwaway repository with both trains at 0.2.1 and a bare remote, removed by its remove(). */ +function repository() { + const dir = mkdtempSync(join(tmpdir(), "release-train-")); + const work = join(dir, "work"); + const remote = join(dir, "remote.git"); + const git = (...args) => execFileSync("git", args, { cwd: work, encoding: "utf8", env: BASE }).trim(); + execFileSync("git", ["init", "--quiet", "--bare", "--initial-branch=main", remote], { env: BASE }); + mkdirSync(work); + git("init", "--quiet", "--initial-branch=main"); + for (const [key, value] of [["user.email", "test@example.invalid"], ["user.name", "Test"], ["commit.gpgsign", "false"], + ["tag.gpgSign", "false"], ["core.hooksPath", "/dev/null"]]) git("config", key, value); + const write = (path, text) => { + mkdirSync(dirname(join(work, path)), { recursive: true }); + writeFileSync(join(work, path), text); + }; + const commit = (message, files = {}) => { + for (const [path, text] of Object.entries(files)) write(path, text); + git("add", "--all"); + git("commit", "--quiet", "--allow-empty", "--message", message); + return git("rev-parse", "HEAD"); + }; + const project = (name) => write(`src/${name}/${name}.csproj`, [ + '', + " ", + ' ', + " ", + "", + ].join("\n")); + for (const file of ["release-trains.mjs", "next-version.mjs", "release-train-plugin.mjs", "release-train.releaserc.json"]) { + cpSync(join(scripts, file), join(work, ".github", "scripts", file)); + } + for (const file of [".releaserc.json", "CHANGELOG.md", "CHANGELOG.satellites.md"]) cpSync(join(root, file), join(work, file)); + symlinkSync(join(root, "node_modules"), join(work, "node_modules"), "dir"); + write(".gitignore", "node_modules\nartifacts/\n"); + write("src/release-trains.json", LIST); + write("src/Shared/Both.cs", "class Both {}\n"); + project("Core.A"); + project("Sat.B"); + // next-version.mjs reads each project's restore graph, for the trains a releases. + for (const name of ["Core.A", "Sat.B"]) { + write(`artifacts/obj/${name}/project.assets.json`, JSON.stringify({ version: 3, libraries: {} })); + } + const base = commit("build: the trains"); + git("tag", "v0.2.1"); + git("tag", "satellites-v0.2.1"); + git("remote", "add", "origin", remote); + git("push", "--quiet", "origin", "main", "--tags"); + return { dir, root: work, remote, git, write, commit, base, remove: () => rmSync(dir, { recursive: true, force: true }) }; +} + +/** The pack job's plan of HEAD, next-version.mjs --trains's rows, by train. */ +function planOf(repo) { + const out = execFileSync(process.execPath, [".github/scripts/next-version.mjs", "--trains"], + { cwd: repo.root, encoding: "utf8", env: BASE, stdio: ["ignore", "pipe", "ignore"] }); + const line = out.split("\n").find((text) => text.startsWith("trains_json=")); + return Object.fromEntries(JSON.parse(line.slice("trains_json=".length)).map((row) => [row.train, row])); +} + +/** The environment a train's release starts with, the plan written to a file of its own, as release.yml hands it over. */ +let plans = 0; +function envOf(repo, plan, train, { run = "", packed } = {}) { + const file = join(repo.dir, `plan-${++plans}.json`); + writeFileSync(file, JSON.stringify(Object.values(plan))); + const row = plan[train]; + return { + ...BASE, + RELEASE_TRAIN: train, + RELEASE_TRAIN_PLAN: file, + RELEASE_RUN_COMMITS: run, + PACKED_VERSION: packed ?? (row?.type ? row.next : ""), + }; +} + +// Runs semantic-release with the options given and writes what it returned, or the errors it threw, to a file. +const RUNNER = `import { writeFileSync } from "node:fs"; +const [module, options, out] = process.argv.slice(2); +const { default: semanticRelease } = await import(module); +try { + const result = await semanticRelease(JSON.parse(options), { cwd: process.cwd(), env: process.env }); + writeFileSync(out, JSON.stringify({ result: result && { nextRelease: result.nextRelease } })); +} catch (error) { + const messages = [error, ...(error.errors ?? [])].map(({ message }) => message); + writeFileSync(out, JSON.stringify({ error: { message: messages.join("\\n") } })); +} +`; + +/** semantic-release over a train: { result, error, log }, a dry run unless told otherwise. */ +async function release(repo, train, env, { dryRun = true, tagFormat = `${tagPrefixOf(train)}\${version}` } = {}) { + const config = JSON.parse(read(join(repo.root, ".github", "scripts", "release-train.releaserc.json"))); + config.plugins = config.plugins.filter((plugin) => plugin[0] !== "@semantic-release/github"); + const options = { ...config, tagFormat, dryRun, ci: false, repositoryUrl: pathToFileURL(repo.remote).href }; + const out = join(repo.dir, "outcome.json"); + rmSync(out, { force: true }); + writeFileSync(join(repo.dir, "run.mjs"), RUNNER); + let log; + try { + const { stdout, stderr } = await promisify(execFile)(process.execPath, + [join(repo.dir, "run.mjs"), pathToFileURL(SEMANTIC_RELEASE).href, JSON.stringify(options), out], + { cwd: repo.root, env, encoding: "utf8", maxBuffer: 1 << 26 }); + log = `${stdout}${stderr}`; + } catch (error) { + log = `${error.stdout ?? ""}${error.stderr ?? ""}`; + } + let outcome; + try { + outcome = JSON.parse(read(out)); + } catch { + outcome = { error: { message: "semantic-release ended without an outcome" } }; + } + return { ...outcome, log }; +} + +const released = (outcome, version, type) => { + assert.equal(outcome.error, undefined, `${outcome.error?.message}\n${outcome.log}`); + assert.notEqual(outcome.result, false, outcome.log); + assert.equal(outcome.result.nextRelease.version, version, outcome.log); + assert.equal(outcome.result.nextRelease.type, type, outcome.log); + return outcome.result.nextRelease.notes; +}; +const nothing = (outcome) => { + assert.equal(outcome.error, undefined, `${outcome.error?.message}\n${outcome.log}`); + assert.equal(outcome.result, false, outcome.log); +}; +const refused = (outcome, message) => { + assert.ok(outcome.error, `expected a refusal\n${outcome.log}`); + assert.match(outcome.error.message, message, outcome.log); +}; + +test("the train configuration's rules and notes are the one-version configuration's", () => { + const [train, one] = [analyzerRules(trainConfig), analyzerRules(releaserc)]; + assert.deepEqual(train.rules, one.rules); + const { releaseRules: _, ...options } = one.options; + assert.deepEqual({ ...train.options, releaseRules: undefined }, { ...options, releaseRules: undefined }); + const plugin = entry(trainConfig, "./.github/scripts/release-train-plugin.mjs"); + assert.deepEqual(plugin.notes, entry(releaserc, "@semantic-release/release-notes-generator")); + assert.equal(trainConfig.tagFormat, releaserc.tagFormat); + assert.deepEqual(trainConfig.branches, releaserc.branches); + const git = entry(trainConfig, "@semantic-release/git"); + for (const asset of entry(releaserc, "@semantic-release/git").assets) assert.ok(git.assets.includes(asset), asset); + assert.ok(git.assets.includes("CHANGELOG.*.md")); + assert.match(git.message, /^chore\(release\): \$\{nextRelease\.gitTag\} \[skip ci\]/); + const github = entry(trainConfig, "@semantic-release/github"); + assert.equal(github.draftRelease, true); + assert.equal(github.successCommentCondition, false); +}); + +test("the GitHub Release names each train's bundle and links each of its packages", () => { + const body = entry(trainConfig, "@semantic-release/github").releaseBodyTemplate; + const render = (train, ids, notes = "## notes") => template(body)({ + nextRelease: { notes, version: "0.3.0", gitTag: `${tagPrefixOf(train)}0.3.0` }, env: { RELEASE_TRAIN: train, RELEASE_PACKAGE_IDS: ids }, + }); + const core = render("core", "AdCodicem.ValueObjects AdCodicem.ValueObjects.Json"); + assert.match(core, /`AdCodicem\.ValueObjects\.0\.3\.0\.sigstore\.json`/); + assert.match(core, /\| `AdCodicem\.ValueObjects\.Json` \| \[0\.3\.0\]\(https:\/\/www\.nuget\.org\/packages\/AdCodicem\.ValueObjects\.Json\/0\.3\.0\) \|/); + assert.match(core, /adcodicem\.valueobjects\.json\/0\.3\.0\/lib\/net10\.0\/AdCodicem\.ValueObjects\.Json\.dll/); + const satellites = render("satellites", "AdCodicem.ValueObjects.Dapper AdCodicem.ValueObjects.Testing"); + assert.match(satellites, /`AdCodicem\.ValueObjects\.satellites\.0\.3\.0\.sigstore\.json`/); + assert.match(satellites, /gh attestation verify AdCodicem\.ValueObjects\.Dapper\.0\.3\.0\.nupkg/); + assert.match(satellites, /adcodicem\.valueobjects\.dapper\/0\.3\.0\/lib\/net10\.0\/AdCodicem\.ValueObjects\.Dapper\.dll/); + assert.doesNotMatch(satellites, /Json/); + assert.equal(render("core", "").trim(), "## notes"); + // GitHub refuses a release body above 125,000 characters: long notes are cut at a line, and the changelog has them. + const long = `## [0.3.0]\n\n### Documentation\n\n${"* a page no train owns, one of many ([abcdef0](https://example.invalid))\n".repeat(5000)}`; + for (const [train, file] of [["core", "CHANGELOG.md"], ["satellites", "CHANGELOG.satellites.md"]]) { + const cut = render(train, "AdCodicem.ValueObjects.Dapper", long); + assert.ok(cut.length < 125000, `${train}: ${cut.length}`); + assert.ok(cut.startsWith(long.slice(0, long.lastIndexOf("\n", 100000)))); + assert.ok(cut.includes(`…and more: the whole list is in [${file}](https://github.com/AdCodicem/AdCodicem.ValueObjects/blob/` + + `${tagPrefixOf(train)}0.3.0/${file}).`), train); + assert.match(cut, /### NuGet packages/); + } + assert.doesNotMatch(render("core", "AdCodicem.ValueObjects"), /and more/); +}); + +test("each changelog opens with its title and the marker, which the one-version configuration's title is", () => { + for (const file of ["CHANGELOG.md", "CHANGELOG.satellites.md"]) { + const text = read(join(root, file)); + assert.ok(text.startsWith("# "), file); + const end = text.indexOf(MARKER); + assert.ok(end > 0, file); + assert.doesNotMatch(text.slice(0, end), /^## /m, file); + } + const text = read(join(root, "CHANGELOG.md")); + assert.equal(entry(releaserc, "@semantic-release/changelog").changelogTitle, text.slice(0, text.indexOf(MARKER) + MARKER.length)); +}); + +test("a one-version release writes its entry right below the marker, under one title", async () => { + const dir = mkdtempSync(join(tmpdir(), "release-train-changelog-")); + try { + cpSync(join(root, "CHANGELOG.md"), join(dir, "CHANGELOG.md")); + const options = entry(releaserc, "@semantic-release/changelog"); + const logger = { log() {}, error() {}, success() {}, warn() {} }; + const context = { cwd: dir, logger, options: {}, nextRelease: { notes: "## [0.3.0](compare) (2026-11-02)\n\n### Features\n\n* a feature\n" } }; + await changelogPlugin.verifyConditions({ ...options }, context); + await changelogPlugin.prepare({ ...options }, context); + const before = read(join(root, "CHANGELOG.md")); + const after = read(join(dir, "CHANGELOG.md")); + const title = options.changelogTitle; + assert.ok(after.startsWith(`${title}\n\n## [0.3.0](compare) (2026-11-02)\n`), after.slice(0, 900)); + assert.equal(after.match(/^# Changelog$/gm).length, 1); + assert.ok(after.includes(before.slice(title.length).trim()), "every earlier entry is kept below"); + assert.ok(after.indexOf("## [0.3.0]") < after.indexOf("## [0.2.1]")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a dry run of each train computes the plan's version, from its own commits", async (t) => { + const A = { "src/Core.A/A.cs": "class A {}\n" }; + const B = { "src/Sat.B/B.cs": "class B {}\n" }; + // Each history, the version each train releases at (null for none), and what its notes must and must not hold. + const cases = { + "a core fix": [(repo) => repo.commit("fix(a): a core fix", A), { core: ["0.2.2", "patch"], satellites: null }], + "a satellite feature": [(repo) => repo.commit("feat(b): a satellite feature", B), + { core: null, satellites: ["0.3.0", "minor"] }], + "a fix on each": [(repo) => { + repo.commit("fix(a): a core fix", A); + repo.commit("fix(b): a satellite fix", B); + }, { core: ["0.2.2", "patch"], satellites: ["0.2.2", "patch"] }], + "a fix in a Shared file both link": [(repo) => repo.commit("fix: in both", { "src/Shared/Both.cs": "class Both { }\n" }), + { core: ["0.2.2", "patch"], satellites: ["0.2.2", "patch"] }], + "a core feature, which moves the core's line": [(repo) => repo.commit("feat(a): a core feature", A), + { core: ["0.3.0", "minor"], satellites: ["0.3.0", "minor"] }], + "nothing that releases": [(repo) => { + repo.commit("docs: a page", { "docs/page.md": "# page\n" }); + repo.commit("ci: a workflow", { ".github/workflows/x.yml": "on: push\n" }); + }, { core: null, satellites: null }], + // The analyzer's options reach it: the train's breaking rule (a minor while it is 0.x), and the two rules of + // .releaserc.json that release a patch. + "a breaking satellite feature": [(repo) => repo.commit("feat(b)!: drop a thing\n\nBREAKING CHANGE: Thing is gone", B), + { core: null, satellites: ["0.3.0", "minor"] }, { satellites: [/### ⚠ BREAKING CHANGES\n\n\* \*\*b:\*\* Thing is gone/] }], + "a breaking satellite fix": [(repo) => repo.commit("fix(b)!: change a thing", B), + { core: null, satellites: ["0.3.0", "minor"] }, { satellites: [/### ⚠ BREAKING CHANGES\n\n\* \*\*b:\*\* change a thing/] }], + "a breaking core fix, which moves the core's line": [(repo) => repo.commit("fix(a)!: change a thing", A), + { core: ["0.3.0", "minor"], satellites: ["0.3.0", "minor"] }, + { core: [/### ⚠ BREAKING CHANGES\n\n\* \*\*a:\*\* change a thing/], satellitesNot: [/BREAKING/] }], + "a satellite's package metadata": [(repo) => repo.commit("build(pack): package metadata", { "src/Sat.B/Sat.B.props": "\n" }), + { core: null, satellites: ["0.2.2", "patch"] }], + "a satellite's package README": [(repo) => repo.commit("docs(readme): its package README", { "src/Sat.B/README.md": "# Sat.B\n" }), + { core: null, satellites: ["0.2.2", "patch"] }], + // semantic-release skips [skip release] commits in the analysis, and the notes still list a train's own. + "a satellite fix beside commits the analysis skips": [(repo) => { + repo.commit("fix(b): a satellite fix", B); + repo.commit("fix(b): a skipped satellite fix [skip release]", { "src/Sat.B/C.cs": "class C {}\n" }); + repo.commit("fix(a): a skipped core fix [skip release]", A); + repo.commit("docs: a page [release skip]", { "docs/page.md": "# page\n" }); + }, { core: null, satellites: ["0.2.2", "patch"] }, { satellites: [/a satellite fix/, /a skipped satellite fix \[skip release\]/] }], + "only a skipped satellite fix": [(repo) => repo.commit("fix(b): a skipped fix [skip release]", B), + { core: null, satellites: null }], + // A commit no train owns ships in no package, so its breaking marks are no break of the core's release; made plain, + // a reverted one still pairs with its revert, which the notes leave out as they do any reverted commit. + "a core fix beside breaking commits no train owns": [(repo) => { + repo.commit("fix(a): a core fix", A); + repo.commit("ci!: drop the old workflow\n\nBREAKING CHANGE: the x workflow no longer runs", { ".github/workflows/x.yml": "on: push\n" }); + repo.commit("docs!: rewrite the guide", { "docs/guide.md": "# guide\n" }); + repo.commit("ci(x)!: drop a(b)!: thing", { ".github/workflows/y.yml": "on: push\n" }); + repo.commit("docs!: a page soon reverted", { "docs/soon.md": "# soon\n" }); + repo.git("revert", "--no-edit", "HEAD"); + }, { core: ["0.2.2", "patch"], satellites: null }, + { core: [/a core fix/, /### Documentation\n\n\* rewrite the guide/], + coreNot: [/BREAKING/, /drop the old workflow/, /no longer runs/, /soon reverted/] }], + }; + for (const [name, [history, expected, wanted = {}]] of Object.entries(cases)) { + await t.test(name, async () => { + const repo = repository(); + try { + history(repo); + const plan = planOf(repo); + for (const train of ["core", "satellites"]) { + const want = expected[train]; + assert.deepEqual(plan[train].type ? [plan[train].next, plan[train].type] : null, want, `the plan of ${train}`); + const outcome = await release(repo, train, envOf(repo, plan, train)); + if (!want) { + nothing(outcome); + continue; + } + const notes = released(outcome, ...want); + for (const pattern of wanted[train] ?? []) assert.match(notes, pattern); + for (const pattern of wanted[`${train}Not`] ?? []) assert.doesNotMatch(notes, pattern); + if (train === "satellites") { + assert.match(notes, new RegExp(`Packed against AdCodicem\\.ValueObjects ${plan.satellites.coreVersion.replaceAll(".", "\\.")}\\.`)); + const moved = /The core's line moved from 0\.2 to 0\.3 since this train's last release, which was packed against 0\.2\.1\./; + if (plan.satellites.lineMoved) assert.match(notes, moved); + else assert.doesNotMatch(notes, moved); + } + } + } finally { + repo.remove(); + } + }); + } +}); + +test("a range longer than an environment variable can carry is planned and released", async () => { + const repo = repository(); + try { + // 3,300 commits no train owns, written at once by fast-import: their hashes alone pass Linux's 128 KiB cap on one + // variable, which every process semantic-release starts would inherit. + const count = 3300; + let stream = ""; + for (let index = 0; index < count; index++) { + const message = `docs: page ${index}\n`; + const text = `${index}\n`; + stream += `commit refs/heads/main\ncommitter Test ${1700000000 + index} +0000\n` + + `data ${Buffer.byteLength(message)}\n${message}${index === 0 ? "from refs/heads/main^0\n" : ""}` + + `M 644 inline docs/page.md\ndata ${Buffer.byteLength(text)}\n${text}\n`; + } + execFileSync("git", ["fast-import", "--quiet"], { cwd: repo.root, input: stream, env: BASE }); + repo.git("reset", "--quiet", "--hard", "main"); + repo.commit("fix(a): a core fix", { "src/Core.A/A.cs": "class A {}\n" }); + const plan = planOf(repo); + assert.equal(plan.core.range.split(" ").length, count + 1); + assert.ok(plan.core.range.length > 131072); + released(await release(repo, "core", envOf(repo, plan, "core")), "0.2.2", "patch"); + } finally { + repo.remove(); + } +}); + +test("a merge's commits are walked as the plan walked them, and a commit either did not see fails", async () => { + const repo = repository(); + try { + repo.git("checkout", "--quiet", "-b", "side"); + repo.commit("fix(b): on a branch", { "src/Sat.B/B.cs": "class B {}\n" }); + repo.git("checkout", "--quiet", "main"); + repo.commit("docs: on main", { "docs/page.md": "# page\n" }); + repo.git("merge", "--quiet", "--no-ff", "--no-edit", "side"); + const plan = planOf(repo); + assert.equal(plan.satellites.range.split(" ").length, 3); + released(await release(repo, "satellites", envOf(repo, plan, "satellites")), "0.2.2", "patch"); + + const [dropped, ...rest] = plan.satellites.range.split(" "); + const short = { ...plan, satellites: { ...plan.satellites, range: rest.join(" ") } }; + refused(await release(repo, "satellites", envOf(repo, short, "satellites")), + new RegExp(`semantic-release sees ${dropped.slice(0, 7)} .* since satellites-v0\\.2\\.1, which the satellites train's plan did not walk`)); + const long = { ...plan, satellites: { ...plan.satellites, range: `${plan.satellites.range} ${repo.base}` } }; + refused(await release(repo, "satellites", envOf(repo, long, "satellites")), + new RegExp(`The satellites train's plan walked ${repo.base.slice(0, 7)} build: the trains, which semantic-release does not see`)); + } finally { + repo.remove(); + } +}); + +test("a release refuses a version other than the one packed, a plan it cannot read, and a changelog out of shape", async () => { + const repo = repository(); + try { + repo.commit("fix(a): a core fix", { "src/Core.A/A.cs": "class A {}\n" }); + repo.commit("fix(b): a satellite fix", { "src/Sat.B/B.cs": "class B {}\n" }); + const plan = planOf(repo); + refused(await release(repo, "core", envOf(repo, plan, "core", { packed: "0.2.3" })), + /semantic-release computed 0\.2\.2 for the core train, but the pack job packed '0\.2\.3'/); + refused(await release(repo, "core", envOf(repo, plan, "core", { packed: "" })), + /semantic-release computed 0\.2\.2 for the core train, but the pack job packed ''/); + refused(await release(repo, "satellites", envOf(repo, plan, "satellites"), { tagFormat: "v${version}" }), + /The satellites train's tags are satellites-v\$\{version\}, not v\$\{version\}/); + for (const name of ["RELEASE_TRAIN", "RELEASE_TRAIN_PLAN", "RELEASE_RUN_COMMITS", "PACKED_VERSION"]) { + const { [name]: _, ...unset } = envOf(repo, plan, "core"); + refused(await release(repo, "core", unset), new RegExp(`${name} is not set`)); + } + refused(await release(repo, "planets", { ...envOf(repo, plan, "core"), RELEASE_TRAIN: "planets" }, { tagFormat: "planets-v${version}" }), + /planets is no train of src\/release-trains\.json/); + // The plan as the file carries it: unreadable, without the train's row, a list or a version missing from it. + refused(await release(repo, "core", { ...envOf(repo, plan, "core"), RELEASE_TRAIN_PLAN: join(repo.dir, "none.json") }), + /The plan, .*none\.json, cannot be read/); + refused(await release(repo, "satellites", envOf(repo, { core: plan.core }, "satellites")), + /The plan, .*, has no row for the satellites train/); + const without = (train, edit) => ({ ...plan, [train]: edit({ ...plan[train] }) }); + refused(await release(repo, "core", envOf(repo, without("core", ({ unowned, ...row }) => row), "core")), + /The core train's plan has no unowned list/); + for (const name of ["commits", "range", "notes"]) { + refused(await release(repo, "satellites", envOf(repo, without("satellites", (row) => ({ ...row, [name]: undefined })), "satellites")), + new RegExp(`The satellites train's plan has no ${name} list`)); + } + for (const name of ["coreVersion", "previousCoreVersion"]) { + refused(await release(repo, "satellites", envOf(repo, without("satellites", (row) => ({ ...row, [name]: "" })), "satellites")), + new RegExp(`The satellites train's plan's ${name} is not a stable version: ''`)); + } + refused(await release(repo, "core", envOf(repo, without("core", (row) => ({ ...row, tag: "v0.2" })), "core")), + /The core train's plan names no last release of the train: 'v0\.2'/); + // A plan counting from another release than semantic-release does: a release cut since, or a tag moved. + refused(await release(repo, "core", envOf(repo, without("core", (row) => ({ ...row, tag: "v0.2.0", last: "0.2.0" })), "core")), + /semantic-release counts the core train from v0\.2\.1, the plan from v0\.2\.0/); + rmSync(join(repo.root, "CHANGELOG.satellites.md")); + refused(await release(repo, "satellites", envOf(repo, plan, "satellites")), + /CHANGELOG\.satellites\.md, the satellites train's changelog, cannot be read/); + repo.write("CHANGELOG.satellites.md", `## [9.9.9]\n\n${read(join(root, "CHANGELOG.satellites.md"))}`); + refused(await release(repo, "satellites", envOf(repo, plan, "satellites")), + /CHANGELOG\.satellites\.md must open with its "# " title and hold the line/); + repo.write("CHANGELOG.satellites.md", read(join(root, "CHANGELOG.satellites.md")).replace(MARKER, `## [9.9.9]\n\n${MARKER}`)); + refused(await release(repo, "satellites", envOf(repo, plan, "satellites")), + /CHANGELOG\.satellites\.md has a release above /); + } finally { + repo.remove(); + } +}); + +test("a real run releases the core, then the satellites past the core's release commit", async () => { + const repo = repository(); + try { + const coreFix = repo.commit("fix(a): a core fix", { "src/Core.A/A.cs": "class A {}\n" }); + const satelliteFix = repo.commit("fix(b): a satellite fix", { "src/Sat.B/B.cs": "class B {}\n" }); + const page = repo.commit("docs: a page no train owns", { "docs/page.md": "# page\n" }); + const dispatched = repo.git("rev-parse", "HEAD"); + const plan = planOf(repo); + assert.equal(plan.core.unowned, page); + + released(await release(repo, "core", envOf(repo, plan, "core"), { dryRun: false }), "0.2.2", "patch"); + const first = repo.git("rev-parse", "HEAD"); + assert.equal(repo.git("rev-parse", `${first}^`), dispatched); + assert.equal(repo.git("log", "-1", "--format=%s"), "chore(release): v0.2.2 [skip ci]"); + assert.equal(repo.git("rev-parse", "v0.2.2^{commit}"), first); + const core = read(join(repo.root, "CHANGELOG.md")); + const title = core.slice(0, core.indexOf(MARKER) + MARKER.length); + assert.ok(core.startsWith(`${title}\n\n## [0.2.2]`), core.slice(0, 900)); + const entryOf = (text) => text.slice(text.indexOf("## [0.2.2]"), text.indexOf("## [0.2.1]") > 0 ? text.indexOf("## [0.2.1]") : undefined); + assert.match(entryOf(core), new RegExp(coreFix.slice(0, 7))); + assert.match(entryOf(core), new RegExp(page.slice(0, 7)), "the core's notes carry the commits no train owns"); + assert.doesNotMatch(entryOf(core), new RegExp(satelliteFix.slice(0, 7))); + + // The core's release commit is no commit of the satellites' plan: named, it is left out; not named, it fails. + refused(await release(repo, "satellites", envOf(repo, plan, "satellites")), + new RegExp(`semantic-release sees ${first.slice(0, 7)} chore\\(release\\): v0\\.2\\.2 \\[skip ci\\]`)); + released(await release(repo, "satellites", envOf(repo, plan, "satellites", { run: first }), { dryRun: false }), "0.2.2", "patch"); + const second = repo.git("rev-parse", "HEAD"); + assert.equal(repo.git("rev-parse", `${second}^`), first); + assert.equal(repo.git("log", "-1", "--format=%s"), "chore(release): satellites-v0.2.2 [skip ci]"); + assert.equal(repo.git("rev-parse", "satellites-v0.2.2^{commit}"), second); + assert.equal(repo.git("diff", "--name-only", first, second), "CHANGELOG.satellites.md"); + const satellites = read(join(repo.root, "CHANGELOG.satellites.md")); + const satellitesTitle = satellites.slice(0, satellites.indexOf(MARKER) + MARKER.length); + assert.ok(satellites.startsWith(`${satellitesTitle}\n\n## [0.2.2]`), satellites); + assert.match(satellites, new RegExp(satelliteFix.slice(0, 7))); + assert.doesNotMatch(satellites, new RegExp(`${coreFix.slice(0, 7)}|${page.slice(0, 7)}`)); + assert.match(satellites, /Packed against AdCodicem\.ValueObjects 0\.2\.2\.\n/); + for (const tag of ["v0.2.2", "satellites-v0.2.2"]) { + assert.equal(execFileSync("git", ["rev-parse", `${tag}^{commit}`], { cwd: repo.remote, encoding: "utf8" }).trim(), + repo.git("rev-parse", `${tag}^{commit}`), `${tag} on the remote`); + } + assert.equal(execFileSync("git", ["rev-parse", "main"], { cwd: repo.remote, encoding: "utf8" }).trim(), second); + } finally { + repo.remove(); + } +}); diff --git a/.github/scripts/release-train.releaserc.json b/.github/scripts/release-train.releaserc.json new file mode 100644 index 00000000..77ff6ef4 --- /dev/null +++ b/.github/scripts/release-train.releaserc.json @@ -0,0 +1,60 @@ +{ + "branches": ["main"], + "tagFormat": "v${version}", + "plugins": [ + [ + "./.github/scripts/release-train-plugin.mjs", + { + "analyzer": { + "preset": "conventionalcommits", + "releaseRules": [ + { "type": "build", "scope": "pack", "release": "patch" }, + { "type": "docs", "scope": "readme", "release": "patch" } + ] + }, + "notes": { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "build", "scope": "pack", "section": "Packaging" }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "refactor", "section": "Refactoring", "hidden": false }, + { "type": "test", "hidden": true }, + { "type": "ci", "hidden": true }, + { "type": "chore", "hidden": true } + ] + } + } + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "CHANGELOG.md", + "CHANGELOG.*.md", + "website/versions.json", + "website/released-versions.json", + "website/versioned_docs/**", + "website/versioned_sidebars/**" + ], + "message": "chore(release): ${nextRelease.gitTag} [skip ci]\n\n${nextRelease.notes}" + } + ], + [ + "@semantic-release/github", + { + "releaseBodyTemplate": "<% const budget = 100000; const file = env.RELEASE_TRAIN === \"core\" ? \"CHANGELOG.md\" : \"CHANGELOG.\" + env.RELEASE_TRAIN + \".md\"; let notes = nextRelease.notes; if (notes.length > budget) { notes = notes.slice(0, notes.lastIndexOf(\"\\n\", budget)) + \"\\n\\n…and more: the whole list is in [\" + file + \"](https://github.com/AdCodicem/AdCodicem.ValueObjects/blob/\" + nextRelease.gitTag + \"/\" + file + \").\\n\"; } %><%= notes %>\n<% const packageIds = (env.RELEASE_PACKAGE_IDS || \"\").split(/\\s+/).filter(Boolean); const bundle = env.RELEASE_TRAIN === \"core\" ? \"AdCodicem.ValueObjects\" : \"AdCodicem.ValueObjects.\" + env.RELEASE_TRAIN; const sample = env.RELEASE_TRAIN === \"core\" ? \"AdCodicem.ValueObjects.Json\" : packageIds[0]; if (packageIds.length) { %>\n### NuGet packages\n\n| Package | Version |\n| --- | --- |\n<% packageIds.forEach(function (id) { %>| `<%= id %>` | [<%= nextRelease.version %>](https://www.nuget.org/packages/<%= id %>/<%= nextRelease.version %>) |\n<% }); %>\nThe `.nupkg` and `.snupkg` files attached to this release carry a signed build provenance attestation (`<%= bundle %>.<%= nextRelease.version %>.sigstore.json`). To check one:\n\n```sh\ngh attestation verify <%= packageIds[0] %>.<%= nextRelease.version %>.nupkg --repo AdCodicem/AdCodicem.ValueObjects\n```\n\nThe assemblies inside the packages are attested too. nuget.org re-signs each `.nupkg`, which changes its digest, but leaves the assemblies as they were packed, so a package restored from nuget.org is checked through one of them:\n\n```sh\ngh attestation verify ~/.nuget/packages/<%= sample.toLowerCase() %>/<%= nextRelease.version %>/lib/net10.0/<%= sample %>.dll --repo AdCodicem/AdCodicem.ValueObjects --signer-workflow AdCodicem/AdCodicem.ValueObjects/.github/workflows/release.yml\n```\n<% } %>", + "assets": [ + { "path": "artifacts/release/*.nupkg" }, + { "path": "artifacts/release/*.snupkg" } + ], + "draftRelease": true, + "successCommentCondition": false + } + ] + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 448898a2..4b13ade2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -492,10 +492,12 @@ jobs: # a change releases, the preview gate's decisions against recorded # nuget.org states, the entry docs-snapshot.sh writes in # website/released-versions.json, the checks release-pack.sh makes of - # nuget.org before it packs, and the weekly list of the majors Dependabot - # holds back. Before the steps that read the tags and the restore, so that - # they report whatever those find. The glob is quoted so that Node expands - # it: a directory argument fails from Node 21 on ("Cannot find module"). + # nuget.org before it packs, each train's release through the + # release-train plugin in throwaway repositories, and the weekly list of + # the majors Dependabot holds back. Before the steps that read the tags + # and the restore, so that they report whatever those find. The glob is + # quoted so that Node expands it: a directory argument fails from Node 21 + # on ("Cannot find module"). - name: Release scripts run: node --test '.github/scripts/*.test.mjs' From ce05a6e90a54ee8227ce283eb3a5577c447441c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:35:49 +0000 Subject: [PATCH 4/4] ci: cut a GitHub Release's notes below GitHub's limit on its body GitHub refuses a release body above 125,000 characters, and the notes grow with the range: about 160 characters a commit, 64,000 for the 397 commits since v0.2.1. .releaserc.json's releaseBodyTemplate now cuts them at the last line below 100,000 characters and links CHANGELOG.md at the tag, which holds them whole, as the release commit does; the release-train configuration already does so for each train's changelog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0164hf4jJ3KsP3tyC3Uoa55L --- .github/CLAUDE.md | 4 ++++ .github/scripts/release-train-plugin.test.mjs | 15 +++++++++++++++ .releaserc.json | 2 +- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index a08a3a3c..956f0b9b 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -168,6 +168,10 @@ The runbook (one-time settings, routines, the Dependabot procedure) is `docs/mai in a child process, since it hooks its process's standard streams, which carry `node:test`'s reports, with the configuration minus its GitHub entry and an environment holding the plan alone, no CI variable. It also renders both configurations' GitHub Release bodies. +- GitHub refuses a release body above 125,000 characters, and a train's notes grow with its range (about 160 characters + a commit, the core's carrying every commit no train owns): both configurations' `releaseBodyTemplate` cut the notes at + the last line below 100,000 characters and link the train's changelog at the tag, which holds them whole, as the + release commit's message does. - `scripts/docs-snapshot.sh` takes the one version, or each train's (`core=X satellites=Y`: every train of `src/release-trains.json` once, each stable). The line it freezes is the core's, and the entry it writes in `website/released-versions.json` is that version or `{"core": X, "satellites": Y}`. `scripts/docs-snapshot.test.mjs` diff --git a/.github/scripts/release-train-plugin.test.mjs b/.github/scripts/release-train-plugin.test.mjs index e6110887..c432a3ab 100644 --- a/.github/scripts/release-train-plugin.test.mjs +++ b/.github/scripts/release-train-plugin.test.mjs @@ -213,6 +213,21 @@ test("the GitHub Release names each train's bundle and links each of its package assert.doesNotMatch(render("core", "AdCodicem.ValueObjects"), /and more/); }); +test("the one-version GitHub Release cuts long notes the same way", () => { + const body = entry(releaserc, "@semantic-release/github").releaseBodyTemplate; + const render = (notes) => template(body)({ + nextRelease: { notes, version: "0.3.0", gitTag: "v0.3.0" }, env: { RELEASE_PACKAGE_IDS: "AdCodicem.ValueObjects AdCodicem.ValueObjects.Json" }, + }); + const short = render("## notes\n"); + assert.ok(short.startsWith("## notes\n")); + assert.doesNotMatch(short, /and more/); + assert.match(short, /`AdCodicem\.ValueObjects\.0\.3\.0\.sigstore\.json`/); + const cut = render(`## [0.3.0]\n\n${"* a line of the notes, one of many, long enough to add up quickly\n".repeat(5000)}`); + assert.ok(cut.length < 125000, String(cut.length)); + assert.ok(cut.includes("…and more: the whole list is in [CHANGELOG.md](https://github.com/AdCodicem/AdCodicem.ValueObjects/blob/v0.3.0/CHANGELOG.md).")); + assert.match(cut, /\| `AdCodicem\.ValueObjects\.Json` \|/); +}); + test("each changelog opens with its title and the marker, which the one-version configuration's title is", () => { for (const file of ["CHANGELOG.md", "CHANGELOG.satellites.md"]) { const text = read(join(root, file)); diff --git a/.releaserc.json b/.releaserc.json index 778e81d5..2d19c032 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -62,7 +62,7 @@ [ "@semantic-release/github", { - "releaseBodyTemplate": "<%= nextRelease.notes %>\n<% const packageIds = (env.RELEASE_PACKAGE_IDS || \"\").split(/\\s+/).filter(Boolean); if (packageIds.length) { %>\n### NuGet packages\n\n| Package | Version |\n| --- | --- |\n<% packageIds.forEach(function (id) { %>| `<%= id %>` | [<%= nextRelease.version %>](https://www.nuget.org/packages/<%= id %>/<%= nextRelease.version %>) |\n<% }); %>\nThe `.nupkg` and `.snupkg` files attached to this release carry a signed build provenance attestation (`AdCodicem.ValueObjects.<%= nextRelease.version %>.sigstore.json`). To check one:\n\n```sh\ngh attestation verify AdCodicem.ValueObjects.<%= nextRelease.version %>.nupkg --repo AdCodicem/AdCodicem.ValueObjects\n```\n\nThe assemblies inside the packages are attested too. nuget.org re-signs each `.nupkg`, which changes its digest, but leaves the assemblies as they were packed, so a package restored from nuget.org is checked through one of them:\n\n```sh\ngh attestation verify ~/.nuget/packages/adcodicem.valueobjects.json/<%= nextRelease.version %>/lib/net10.0/AdCodicem.ValueObjects.Json.dll --repo AdCodicem/AdCodicem.ValueObjects --signer-workflow AdCodicem/AdCodicem.ValueObjects/.github/workflows/release.yml\n```\n<% } %>", + "releaseBodyTemplate": "<% const budget = 100000; const file = \"CHANGELOG.md\"; let notes = nextRelease.notes; if (notes.length > budget) { notes = notes.slice(0, notes.lastIndexOf(\"\\n\", budget)) + \"\\n\\n…and more: the whole list is in [\" + file + \"](https://github.com/AdCodicem/AdCodicem.ValueObjects/blob/\" + nextRelease.gitTag + \"/\" + file + \").\\n\"; } %><%= notes %>\n<% const packageIds = (env.RELEASE_PACKAGE_IDS || \"\").split(/\\s+/).filter(Boolean); if (packageIds.length) { %>\n### NuGet packages\n\n| Package | Version |\n| --- | --- |\n<% packageIds.forEach(function (id) { %>| `<%= id %>` | [<%= nextRelease.version %>](https://www.nuget.org/packages/<%= id %>/<%= nextRelease.version %>) |\n<% }); %>\nThe `.nupkg` and `.snupkg` files attached to this release carry a signed build provenance attestation (`AdCodicem.ValueObjects.<%= nextRelease.version %>.sigstore.json`). To check one:\n\n```sh\ngh attestation verify AdCodicem.ValueObjects.<%= nextRelease.version %>.nupkg --repo AdCodicem/AdCodicem.ValueObjects\n```\n\nThe assemblies inside the packages are attested too. nuget.org re-signs each `.nupkg`, which changes its digest, but leaves the assemblies as they were packed, so a package restored from nuget.org is checked through one of them:\n\n```sh\ngh attestation verify ~/.nuget/packages/adcodicem.valueobjects.json/<%= nextRelease.version %>/lib/net10.0/AdCodicem.ValueObjects.Json.dll --repo AdCodicem/AdCodicem.ValueObjects --signer-workflow AdCodicem/AdCodicem.ValueObjects/.github/workflows/release.yml\n```\n<% } %>", "assets": [ { "path": "artifacts/packages/*.nupkg" }, { "path": "artifacts/packages/*.snupkg" }