Skip to content

ci: release a train through semantic-release, from the pack job's plan - #224

Open
AdCodicem wants to merge 4 commits into
mainfrom
ccr-37c3f752-p5qgt7
Open

AdCodicem wants to merge 4 commits into
mainfrom
ccr-37c3f752-p5qgt7

Conversation

@AdCodicem

Copy link
Copy Markdown
Owner

What changed

This is PR 10b of the phase 2 plan of ADR 0012. The plan's PR 10 now lands in three parts:

No workflow runs the plugin yet.

release-train-plugin.mjs

The plugin releases one train from the plan the pack job computes with next-version.mjs --trains. It reads the plan's trains_json from a file named by RELEASE_TRAIN_PLAN. The environment carries only RELEASE_TRAIN, RELEASE_RUN_COMMITS (this run's earlier release commits) and PACKED_VERSION; each is required, an empty one included.

The lists travel in the file because semantic-release passes its environment to every process it starts. Linux refuses to start a process with a variable above 128 KiB, about 3,200 hashes, and a train's range grows by every commit until it releases.

Checks:

  • semantic-release must count from the plan's tag.
  • Every commit semantic-release sees must be one the plan walked, this run's earlier release commits aside.
  • Every commit the plan walked must be one semantic-release sees, a [skip release] one aside. A range read from another tag, or through a merge the plan did not follow, fails rather than drops a commit.
  • The type is the plan's: release-trains.mjs releaseType, the line rule included.
  • The version must be the one packed, on a dry run too (verifyRelease).

Notes:

  • A train's notes list its commits, [skip release] ones included. next-version.mjs's trains_json rows gain a notes list for this; the analyzer never reads it.
  • The core's notes also list the commits no train owns, as CHANGELOG.md does today. Their breaking marks (!, BREAKING CHANGE) are made plain, since such a commit ships in no package. A revert's quoted header is made plain too, so a reverted pair still drops out.
  • Any other train's notes end with "Packed against AdCodicem.ValueObjects ." When the core's line moved, they add a sentence saying so.
  • prepare writes the notes below the marker of the train's changelog. verifyConditions refuses a changelog without its # title and marker, or with a release above the marker.

release-train.releaserc.json

The configuration has three entries:

  • the plugin, with .releaserc.json's analyzer rules (the slot aside) and notes options;
  • @semantic-release/git, committing the changelogs and the documentation as chore(release): <tag> [skip ci];
  • @semantic-release/github, with a draft release, no success comment, and assets from artifacts/release/. Its body names the train's bundle (AdCodicem.ValueObjects.satellites.<version>.sigstore.json for the satellites).

The changelog's title

CHANGELOG.md now opens with its title, which ends with the marker, as CHANGELOG.satellites.md does. .releaserc.json gives @semantic-release/changelog that title, verbatim. The plugin keeps a title on top only when the file starts with it, and writes the next release right below it. So a one-version release cut before PR 11 writes where the per-train path will.

The release body's length

GitHub refuses a release body above 125,000 characters. The notes run about 160 characters a commit: 64,000 for the 397 commits since v0.2.1, so the limit is about 760 commits away. Both configurations' releaseBodyTemplate now cut the notes at the last line below 100,000 characters, and link the train's changelog at the tag, which holds them whole. That includes today's .releaserc.json (its own commit).

Tests

release-train-plugin.test.mjs (23 tests, about 30 s) runs in throwaway repositories. Each holds two trains at 0.2.1, this repository's scripts, configurations and changelogs, its node_modules linked, and a bare remote. The plan is computed there by next-version.mjs --trains. semantic-release runs in a child process, since it hooks its process's standard streams, which carry node:test's reports. Its environment holds the plan alone.

The cases:

  • Dry runs per train of 14 histories, each checked against the plan's version and type:
    • a fix or a feature of either train, and a Shared file;
    • a line move;
    • nothing that releases;
    • breaking feats and fixes, with their notes;
    • build(pack) and docs(readme);
    • [skip release] commits;
    • breaking commits no train owns, one of them reverted.
  • A merge, and a range one commit short or one too long.
  • A range of 3,300 commits. It fails when the lists are put back in the environment.
  • Every refusal.
  • A real run of the core, then the satellites past the core's release commit. It checks the tags on the remote, the changelogs' entries below the marker, the notes filtered per train, and the core's unowned docs commit.
  • The configurations: rules and notes equal to .releaserc.json's, the git and GitHub entries, and both release bodies rendered short and long.
  • The one-version prepare on the reordered CHANGELOG.md: the entry lands right below the marker, under one # Changelog.

CI: ci-plan.sh puts the plugin, its configuration, its test and the changelogs in the release lane, and its header names them.

Releasable because

  • No workflow runs the plugin.
  • The changelog title is a no-op until the next stable release, which then writes its entry right below the marker, as the test checks with .releaserc.json's own options.
  • The release-body cut leaves a body below 100,000 characters unchanged, byte for byte; it applies only above that.
  • The commit types are docs: and ci:, so nothing is released or previewed.

Validated

  • node --test '.github/scripts/*.test.mjs': 119 pass. The plugin's commit passes on its own (23). Also clean: shellcheck, actionlint 1.7.12, and EVENT_NAME=push ci-plan.sh.
  • 30 mutations of the plugin, each killed by the test. Among them:
    • each range check, the [skip release] exemption, the run's earlier release commits, the commits filter, and the last-release check;
    • verifyRelease, the notes list, the unowned commits (kept, plained, the revert pairing, the repeated !), the "Packed against" and line-move sentences;
    • the changelog's title and shape, every check of the plan file and variables;
    • the train's breaking rule, and the analyzer's options.
  • On this repository, next-version.mjs --trains gives notes equal to commits for both trains: no [skip release] commit in either range. trains_json is 62 KB.
  • Two adversarial review rounds. Their confirmed findings are fixed here:
    • the hash lists in variables;
    • the breaking marks of unowned commits in the core's notes;
    • [skip release] commits missing from a train's notes;
    • the analyzer's options, the skip exemption and several refusals left untested;
    • the reverted pair broken by the plain header;
    • the release body over GitHub's limit;
    • the ci-plan.sh header.

Checklist

  • The unit projects of the families you changed (dotnet test --project tests/<tests>, each family's tests in
    .github/ci-families.json) and dotnet test --project tests/AdCodicem.ValueObjects.GeneratorTests pass (the
    integration suite needs Docker). Not applicable: release tooling and docs only.
  • Codecov's patch check holds, under the rule in docs/adr/0006-coverage-is-a-signal-not-a-goal.md: what stays
    uncovered is a defensive branch, a compiler branch or a private member no input reaches, and the pull request says so. Not applicable.
  • A change to the surface a consumer writes against — an option on [ValueObject<T>] or [EntityId], a hook
    interface, a diagnostic, an error code, an extension method — is reflected in README.md, the matching page
    under website/docs/, and the agent skill in skills/value-objects/. Not applicable.
  • A new diagnostic is listed in AnalyzerReleases.Unshipped.md, or RS2008 fails the build. Not applicable.
  • A benchmark claim in the documentation still matches a run, or it was updated. Not applicable.
  • The pull request holds 100 commits or fewer (git rev-list --count origin/main..HEAD): 4.

Generated by Claude Code

@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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164hf4jJ3KsP3tyC3Uoa55L
.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164hf4jJ3KsP3tyC3Uoa55L
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): <tag> [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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164hf4jJ3KsP3tyC3Uoa55L
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164hf4jJ3KsP3tyC3Uoa55L
@codecov

codecov Bot commented Oct 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants