Generate Missing Changesets
How to use
Use this to ensure all functional changes are captured before a release.
Prompt
Generate Missing Changesets
Commits are written for developers, changesets are read by users, and this skill
is the one place that translation happens. cebreus-release never rewrites your
wording.
commits ─script─▶ .temp/raw-changesets/ ─you─▶ .changeset/ ─script─▶ verified
capture one file per package prose same split cited, right file,
per severity under the ceilingScript behaviour, flags and artefacts: REFERENCE.md in this folder. Read it
when a report surprises you or you must explain what the script did — not
before step 1.
Binding rules
- Write every
.changeset/*.mdyourself, from the raw capture and nothing
else. Never invent, never infer from the code. - Never edit
.temp/. It is the evidence--verifychecks you against. - Never drop a commit. Marginal work — tests, chores, docs, CI, build —
goes in theInternalgroup, last, not in the bin. - Never stage or commit anything outside
.changeset/. - Never run
changeset init. It overwrites the config and drops the custom
changelog formatter. - Non-zero exit: report the message verbatim. If it names a flag that
clears the condition, that is an instruction — decide, re-run with it, say so
in your report. Any other non-zero exit is a STOP. Never fix forward, never
guess a flag. - Never rewrite git history. That is the user's call, never yours.
- The individual commit messages must still exist. On a branch that will be
squash-merged, run before the squash; if it is already squashed, STOP and say
so. On a trunk-based repository this is already satisfied — carry on.
1. Preflight
git status --porcelainMUST be empty. Otherwise STOP and list the unclean paths.
2. Capture
node .agents/skills/cebreus-generate-missing-changesets/generate-changesets.mjsCheck the JSON report, in order:
modematches the repository (workspaceorsingle).baselineis a commit hash.(full history)is correct only for a repo that
has never been released — otherwise STOP.commitsCapturedequalsgit rev-list --count --no-merges <baseline>..HEAD.changesetslists one entry per package per severity, each withraw,target,rawBullets,scopeFloorandmaxBullets. Every package you
expect to see changed is there, with a plausible bump.
If the script aborts listing non-conventional commit subjects, re-run with--allow-nonconventional and quote the list in your report.
3. Write the changesets
One changeset per raw file. Copy target from the report; do not compose it.
Copy the frontmatter from the raw file verbatim — same package, same bump.
Never move a bullet between files. A feat belongs in the minor changeset
and nowhere else.
Density. maxBullets is the ceiling and --verify fails above it.scopeFloor is what the merge rules make unavoidable: one bullet per named
scope, plus one per distinct type among the scope-less commits. The ceiling is
twice that, so on average two bullets per scope — merge per scope, and split a
scope only where it genuinely holds two stories. The ceiling is always above the
floor, so the rules can never be unsatisfiable.
The body is a flat list, no headings, in the raw file's group order
(breaking, features, fixes, improvements, internal). Headings would nest inside
the ones changeset version generates; the grouping is materialised later byregroup-changelog.mjs, from the prefixes.
- [<hash>] <type>(<scope>): <one sentence>
- [<hash>] <type>: <one sentence>
- [<hash>] [<hash>] <type>(<scope>): <one sentence> <- mergedPrefix — copy, never compose.
- Copy
type(scope):from the raw bullet. The written prefix MUST be one that
some cited commit carries;--verifychecks it against the capture. - Scope is optional, exactly as on the commit.
ci:,docs:,chore:keep
none. Never invent one, and never demote a type into the scope: writingchore(ci):for aci:commit is two errors at once. - The raw file's group titles (
Breaking changes,Features,Fixes,Improvements,Internal) are headings, not types. Arefactor(build):
bullet under### Improvementsstaysrefactor(build):. - When merging across types, use the prefix of the merged commit from the
highest-priority group.
Text.
- One sentence, past tense, plain British English. Write for someone who uses
the product and does not read code. - Say what changed for them and why it matters — not how it was implemented.
- No file paths, no identifiers, no placeholder wording such as "the file".
- Merge raw bullets that describe one user-visible change, carrying every merged
bullet's hash. Merge across commits, not only within one: twelve content
edits from the same admin screen are one sentence, not twelve. - Do not merge across scopes — the result is true of neither. Merge freely
within one scope, and across scope-less commits of the same type. - Keep the raw file's order inside each group.
- A bullet that says nothing a user could act on still survives: give it one
honest sentence inInternalrather than deleting it.
4. Verify
node .agents/skills/cebreus-generate-missing-changesets/generate-changesets.mjs --verifyThis replaces reviewing your own work. Never edit the audit file to make it
pass. On failure, fix the changesets and re-run.
Quote its density block — raw in, written out, ceiling — in your report.
5. Commit
git status --porcelainOnly paths under .changeset/ may appear; anything else is a STOP. .temp/
must be gitignored — if it appears, STOP.
.changeset/config.json is generated and temporary: it lives from here untilcebreus-release runs changeset version, which deletes it. Never stage it. If
it shows as untracked, that is not a stop — report that the repository should
add it to .gitignore to keep the window quiet.
git add .changeset/*.md
git diff --cached --name-onlyEvery staged path MUST end in .md under .changeset/. Otherwise unstage it,
or STOP. (git add .changeset/ would sweep in the config.)
This step executes — it does not propose. Uncommitted changesets are lost at the
next git checkout.
git commit -m $'docs(changeset): add release notes' -m $'- package-a: patch\n- package-b: minor'Subject is fixed: docs(changeset): add release notes, verbatim, every time.
No count, no version, no paraphrase. Body: one line per changeset,- <name>: <bump>, sorted by package then severity — a package split across two
severities gets two lines. No trailers, no attribution, no emoji.
A commit that fails is a STOP, not an obstacle. Never pass --no-verify,
never set core.hooksPath, never touch .git/config, never edit or move a
hook. A commit that skipped the repository's checks is worse than none, because
it looks finished. Report the error verbatim and stop.
What follows
cebreus-release runs changeset version, which turns the severity split into### Major / Minor / Patch Changes, then adds the finer #### Breaking changes
/ #### Features / #### Fixes / #### Improvements / #### Internal split
and writes the release summary. It does not rewrite your bullets: if the wording
is wrong, it is wrong here.
Attachments
import fs from 'node:fs';
export const READER_GROUPS_IN_ORDER = [
'breaking',
'feat',
'fix',
'improvement',
'internal',
];
export const GROUP_HEADING = {
breaking: 'Breaking changes',
feat: 'Features',
fix: 'Fixes',
improvement: 'Improvements',
internal: 'Internal',
};
export const GROUP_OF_CONVENTIONAL_TYPE = {
build: 'internal',
chore: 'internal',
ci: 'internal',
docs: 'internal',
feat: 'feat',
fix: 'fix',
perf: 'improvement',
refactor: 'improvement',
revert: 'fix',
style: 'internal',
test: 'internal',
};
export const UNRECOGNISED_TYPE_GROUP = 'internal';
export const SEVERITY_OF_GROUP = {
breaking: 'major',
feat: 'minor',
fix: 'patch',
improvement: 'patch',
internal: 'patch',
};
export const SEVERITIES_HIGHEST_FIRST = ['major', 'minor', 'patch'];
export function groupOfConventionalType(type) {
return GROUP_OF_CONVENTIONAL_TYPE[type] ?? UNRECOGNISED_TYPE_GROUP;
}
export function severityOfConventionalType(type) {
return SEVERITY_OF_GROUP[groupOfConventionalType(type)];
}
const PNPM_MEMBERS_BLOCK = /^packages:[ \t]*$/v;
const YAML_TOP_LEVEL_KEY = /^\S/v;
const YAML_LIST_ITEM = /^[ \t]+-[ \t]+['"]?([^'"#]+?)['"]?[ \t]*$/v;
function readGlobsFromPnpmMembersBlock(yaml) {
const globs = [];
let insideMembersBlock = false;
for (const line of yaml.split('\n')) {
if (PNPM_MEMBERS_BLOCK.test(line)) {
insideMembersBlock = true;
continue;
}
if (!insideMembersBlock) continue;
if (line.trim() === '' || line.trim().startsWith('#')) continue;
if (YAML_TOP_LEVEL_KEY.test(line)) break;
const item = YAML_LIST_ITEM.exec(line);
if (item) globs.push(item[1]);
}
return globs;
}
export function readDeclaredWorkspaceGlobs() {
if (fs.existsSync('pnpm-workspace.yaml')) {
const globs = readGlobsFromPnpmMembersBlock(
fs.readFileSync('pnpm-workspace.yaml', 'utf8'),
);
if (globs.length > 0) return globs;
}
if (fs.existsSync('package.json')) {
const rootManifest = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const declared = Array.isArray(rootManifest.workspaces)
? rootManifest.workspaces
: rootManifest.workspaces?.packages;
if (Array.isArray(declared) && declared.length > 0) return declared;
}
return [];
}
export function repositoryDeclaresWorkspaceMembers() {
return readDeclaredWorkspaceGlobs().length > 0;
}
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const SCRIPT_FOLDER = path.dirname(fileURLToPath(import.meta.url));
const TAXONOMY_FILE = 'changelog-taxonomy.mjs';
const FORMATTER_FILE = 'changelog-formatter.mjs';
const CONFIG_TEMPLATE_FILE = 'changeset-config.json';
const DEFAULT_REPO_WIDE_PACKAGE = 'packages/repo';
let taxonomy;
const RAW_CAPTURE_FOLDER = path.join('.temp', 'raw-changesets');
const AUDIT_FILE = path.join('.temp', 'changeset-capture.json');
const CHANGESET_FOLDER = '.changeset';
const GENERATED_CONFIG = path.join(CHANGESET_FOLDER, 'config.json');
const LOCKFILE_NAMES = new Set([
'bun.lockb',
'npm-shrinkwrap.json',
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
]);
const FILES_THAT_NEVER_IMPLY_A_PACKAGE = new Set(['.gitignore', '.DS_Store']);
const BUMP_PRECEDENCE = { major: 2, minor: 1, patch: 0 };
const PACKAGES_ABOVE_WHICH_A_COMMIT_IS_A_REPO_WIDE_SWEEP = 3;
const MINIMUM_BULLET_CEILING = 20;
const CEILING_AS_MULTIPLE_OF_FLOOR = 2;
const BASELINE_CANDIDATES_TO_CONSIDER = 30;
const LENGTH_ABOVE_WHICH_A_BULLET_LOOKS_HARD_WRAPPED = 55;
const GIT_OUTPUT_BUFFER_BYTES = 256 * 1024 * 1024;
const TOUCHES_NO_PACKAGE = '\u0000touches-no-package';
const SCOPELESS = '(none)';
const NO_DESCRIPTION = '(no description)';
const PACKAGE_AND_BUMP_SEPARATOR = '\u0000';
const COMMIT_SEPARATOR = '\u0001';
const MESSAGE_SEPARATOR = '\u0002';
const MESSAGE_END = '\u0003';
const CONVENTIONAL_SUBJECT = /^([a-zA-Z]+)(\(([^\)]*)\))?(!)?:[ \t]*(.*)$/v;
const BREAKING_CHANGE_TRAILER = /^BREAKING[ \-]CHANGE[: ]/mv;
const SIGN_OFF_OR_TOOLING_TRAILER =
/^(?:co-authored-by|signed-off-by|reviewed-by|refs?|closes?|fixes|see also|claude-session|generated with)\b/iv;
const BULLET_WITH_AUDIT_IDS_AND_PREFIX =
/^-[ \t]+((?:\[[0-9a-f]{7,}\][ \t]*)+)([a-zA-Z]+(?:\([^\)]*\))?:)[ \t]*\S/iv;
const AUDIT_ID = /[0-9a-f]{7,}/giv;
const BODY_BULLET = /^[\-*][ \t]+(\S.*)$/v;
const ENDS_A_SENTENCE = /[.!?:;\)]$/v;
const STARTS_LOWER_CASE = /^\p{Ll}/v;
const RUN_OF_WHITESPACE = /\s+/gv;
const FRONTMATTER_AND_BODY = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/v;
const DECLARED_PACKAGE_AND_BUMP = /"([^"]+)":[ \t]*(major|minor|patch)/v;
function abort(message) {
throw new Error(message);
}
function resolveCompanionFile(fileName, explicitPath, flagName) {
if (explicitPath) {
if (!fs.existsSync(explicitPath)) {
abort(
`${flagName} points at a file that does not exist: ${explicitPath}`,
);
}
return path.resolve(explicitPath);
}
const besideThisScript = path.join(SCRIPT_FOLDER, fileName);
if (fs.existsSync(besideThisScript)) return besideThisScript;
const skillsFolder = path.dirname(SCRIPT_FOLDER);
const inSiblingSkills = (
fs.existsSync(skillsFolder)
? fs
.readdirSync(skillsFolder, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(skillsFolder, entry.name, fileName))
: []
)
.filter((candidate) => fs.existsSync(candidate))
.toSorted();
if (inSiblingSkills.length > 0) return inSiblingSkills[0];
abort(
`cannot find ${fileName}: looked beside this script and in every sibling skill folder under ${skillsFolder}. Copy it next to this script, or pass ${flagName} <path>.`,
);
}
function runGit(args) {
return execFileSync('git', args, {
encoding: 'utf8',
maxBuffer: GIT_OUTPUT_BUFFER_BYTES,
});
}
function runGitTreatingFailureAsAbsence(args) {
try {
return execFileSync('git', args, {
encoding: 'utf8',
maxBuffer: GIT_OUTPUT_BUFFER_BYTES,
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
return null;
}
}
function expandGlobsToDirectories(globs) {
const included = [];
const excluded = [];
for (const glob of globs) {
(glob.startsWith('!') ? excluded : included).push(glob.replace(/^!/v, ''));
}
const directories = new Set();
for (const glob of included) {
if (glob.endsWith('/*')) {
const parent = glob.slice(0, -2);
if (!fs.existsSync(parent)) continue;
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
if (entry.isDirectory()) directories.add(`${parent}/${entry.name}`);
}
} else if (glob.includes('*')) {
abort(`unsupported workspace glob: ${glob}`);
} else {
directories.add(glob.replace(/\/$/v, ''));
}
}
for (const glob of excluded) {
if (glob.endsWith('/*')) {
const parentPrefix = glob.slice(0, -1);
for (const directory of [...directories]) {
if (directory.startsWith(parentPrefix)) directories.delete(directory);
}
} else {
directories.delete(glob.replace(/\/$/v, ''));
}
}
return [...directories].toSorted();
}
function readManifestOrNull(directory) {
const manifest = path.join(directory, 'package.json');
if (!fs.existsSync(manifest)) return null;
return JSON.parse(fs.readFileSync(manifest, 'utf8'));
}
function readWorkspaceMembersLongestPathFirst() {
const members = [];
for (const dir of expandGlobsToDirectories(
taxonomy.readDeclaredWorkspaceGlobs(),
)) {
const manifest = readManifestOrNull(dir);
if (!manifest?.name) continue;
members.push({ dir, name: manifest.name, version: manifest.version ?? '' });
}
return members.toSorted((a, b) => b.dir.length - a.dir.length);
}
function commitsThatDeletedAChangeset() {
const output = runGitTreatingFailureAsAbsence([
'log',
'-n',
String(BASELINE_CANDIDATES_TO_CONSIDER),
'--format=%H',
'--diff-filter=D',
'--',
'.changeset/*.md',
':(exclude).changeset/README.md',
]);
return (output ?? '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '');
}
function commitAlsoRewroteAChangelog(hash) {
return runGit(['show', '--name-only', '--format=', hash])
.split('\n')
.some((line) => line.trim().endsWith('CHANGELOG.md'));
}
function findLastReleaseCommit() {
const candidates = commitsThatDeletedAChangeset();
const lastRelease = candidates.find((hash) =>
commitAlsoRewroteAChangelog(hash),
);
if (!lastRelease && candidates.length > 0) {
abort(
`found ${candidates.length} commit(s) deleting a changeset but none also touched a CHANGELOG.md; pass --baseline <sha> explicitly. Newest candidate: ${candidates[0]}`,
);
}
return lastRelease ?? null;
}
function readCommitsInRange(range) {
const args = [
'log',
'--reverse',
'--no-merges',
'--name-only',
`--format=${COMMIT_SEPARATOR}%H %h${MESSAGE_SEPARATOR}%B${MESSAGE_END}`,
];
if (range) args.push(range);
const commits = [];
for (const record of runGit(args).split(COMMIT_SEPARATOR).slice(1)) {
const [hashes, afterHashes = ''] = record.split(MESSAGE_SEPARATOR);
const messageEnd = afterHashes.indexOf(MESSAGE_END);
const message =
messageEnd === -1 ? afterHashes : afterHashes.slice(0, messageEnd);
const pathList = messageEnd === -1 ? '' : afterHashes.slice(messageEnd + 1);
const [hash, short] = hashes.trim().split(' ');
commits.push({
files: pathList
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== ''),
hash,
message,
short,
});
}
return commits;
}
function splitSubjectFromBody(message) {
const lines = message.split('\n');
const subjectLine = lines.findIndex((line) => line.trim() !== '');
if (subjectLine === -1) return { body: '', subject: '' };
return {
body: lines
.slice(subjectLine + 1)
.join('\n')
.trim(),
subject: lines[subjectLine].trim(),
};
}
function parseConventionalSubject(subject) {
let remaining = subject;
let typeForcedByFixup;
if (remaining.startsWith('fixup! ')) {
remaining = remaining.slice('fixup! '.length).trimStart();
typeForcedByFixup = 'fix';
}
const parsed = CONVENTIONAL_SUBJECT.exec(remaining);
if (!parsed) return null;
const [, writtenType, , scope = '', breakingMarker, description] = parsed;
let type = typeForcedByFixup ?? writtenType.toLowerCase();
if (type === 'style') type = 'chore';
return {
breaking: breakingMarker === '!',
description: description.trim(),
prefix: scope ? `${type}(${scope}):` : `${type}:`,
scope,
type,
};
}
function looksLikeContinuationOfPreviousBullet(previous, candidate) {
return (
previous !== undefined &&
previous.kind === 'bullet' &&
candidate.kind === 'bullet' &&
previous.text.length >= LENGTH_ABOVE_WHICH_A_BULLET_LOOKS_HARD_WRAPPED &&
!ENDS_A_SENTENCE.test(previous.text) &&
STARTS_LOWER_CASE.test(candidate.text) &&
!CONVENTIONAL_SUBJECT.test(candidate.text)
);
}
function readBulletsRepairingWrappedLines(parsed, body) {
const items = [];
let openItem;
for (const line of body.split('\n')) {
const trimmed = line.trim();
if (trimmed === '' || SIGN_OFF_OR_TOOLING_TRAILER.test(trimmed)) {
openItem = undefined;
continue;
}
const bullet = BODY_BULLET.exec(trimmed);
if (bullet) {
openItem = { kind: 'bullet', lines: [bullet[1]] };
items.push(openItem);
continue;
}
if (openItem) {
openItem.lines.push(trimmed);
continue;
}
openItem = { kind: 'prose', lines: [trimmed] };
items.push(openItem);
}
const repaired = [];
let stitchedLines = 0;
for (const item of items) {
const candidate = {
kind: item.kind,
text: item.lines.join(' ').replaceAll(RUN_OF_WHITESPACE, ' ').trim(),
};
const previous = repaired.at(-1);
if (looksLikeContinuationOfPreviousBullet(previous, candidate)) {
previous.text = `${previous.text} ${candidate.text}`;
stitchedLines += 1;
continue;
}
repaired.push(candidate);
}
const bullets = [];
for (const text of [
parsed.description,
...repaired.map((item) => item.text),
]) {
const trimmed = String(text).trim();
if (trimmed !== '' && !bullets.includes(trimmed)) bullets.push(trimmed);
}
return {
bullets: bullets.length > 0 ? bullets : [NO_DESCRIPTION],
stitchedLines,
};
}
function severityOfCommit(parsed, body) {
if (parsed.breaking || BREAKING_CHANGE_TRAILER.test(body)) return 'major';
return parsed.type === 'feat' ? 'minor' : 'patch';
}
function readerGroupOfCommit(parsed, severity) {
if (parsed.breaking || severity === 'major') return 'breaking';
return (
taxonomy.GROUP_OF_CONVENTIONAL_TYPE[parsed.type] ??
taxonomy.UNRECOGNISED_TYPE_GROUP
);
}
function fileNeverImpliesAPackage(file) {
const name = path.basename(file);
return LOCKFILE_NAMES.has(name) || FILES_THAT_NEVER_IMPLY_A_PACKAGE.has(name);
}
function packagesTouchedByPaths(files, members) {
const touched = new Set();
for (const file of files) {
if (fileNeverImpliesAPackage(file)) continue;
const member = members.find(
(candidate) =>
file === candidate.dir || file.startsWith(`${candidate.dir}/`),
);
touched.add(member ? member.dir : TOUCHES_NO_PACKAGE);
}
if (touched.size === 0) touched.add(TOUCHES_NO_PACKAGE);
if (touched.size > 1) touched.delete(TOUCHES_NO_PACKAGE);
return [...touched];
}
function packageNamedByScope(scope, members) {
if (!scope) return null;
return (
members.find(
(member) =>
path.basename(member.dir) === scope ||
member.name === scope ||
member.name.split('/').at(-1) === scope,
) ?? null
);
}
function compareSemver(left, right) {
const parts = (version) =>
String(version)
.split('-')[0]
.split('.')
.map((part) => Number.parseInt(part, 10) || 0);
const a = parts(left);
const b = parts(right);
for (let position = 0; position < 3; position += 1) {
if (a[position] !== b[position]) return a[position] - b[position];
}
return 0;
}
function assertNoPackageVersionWentBackwards(members, baseline) {
if (!baseline) return;
const regressions = [];
for (const member of members) {
const atBaseline = runGitTreatingFailureAsAbsence([
'show',
`${baseline}:${member.dir}/package.json`,
]);
if (!atBaseline) continue;
const before = JSON.parse(atBaseline).version;
if (!before || !member.version) continue;
if (compareSemver(member.version, before) < 0) {
regressions.push(`${member.name}: ${before} -> ${member.version}`);
}
}
if (regressions.length > 0) {
abort(`version regression since baseline:\n ${regressions.join('\n ')}`);
}
}
function listWrittenChangesets() {
if (!fs.existsSync(CHANGESET_FOLDER)) return [];
return fs
.readdirSync(CHANGESET_FOLDER)
.filter((entry) => entry.endsWith('.md') && entry !== 'README.md')
.map((entry) => path.join(CHANGESET_FOLDER, entry));
}
function assertNoPendingChangesets() {
const pending = listWrittenChangesets();
if (pending.length > 0) {
abort(
`.changeset already holds pending changesets; release them or remove them first:\n ${pending.join('\n ')}`,
);
}
}
function writeOrRepairChangesetsConfig(configTemplate, formatter) {
fs.mkdirSync(CHANGESET_FOLDER, { recursive: true });
if (!fs.existsSync(formatter)) {
abort(`changelog formatter not found: ${formatter}`);
}
const relativeToChangesetFolder = path
.relative(CHANGESET_FOLDER, path.resolve(formatter))
.split(path.sep)
.join('/');
const changelog = relativeToChangesetFolder.startsWith('.')
? relativeToChangesetFolder
: `./${relativeToChangesetFolder}`;
if (fs.existsSync(GENERATED_CONFIG)) {
const existing = JSON.parse(fs.readFileSync(GENERATED_CONFIG, 'utf8'));
const formatterItPointsAt = path.resolve(
CHANGESET_FOLDER,
existing.changelog ?? '',
);
if (
existing.changelog === changelog &&
fs.existsSync(formatterItPointsAt)
) {
return { state: 'existing', target: GENERATED_CONFIG };
}
existing.changelog = changelog;
fs.writeFileSync(
GENERATED_CONFIG,
`${JSON.stringify(existing, undefined, 2)}\n`,
'utf8',
);
return { state: 'repaired', target: GENERATED_CONFIG };
}
if (!fs.existsSync(configTemplate)) {
abort(`changesets config template not found: ${configTemplate}`);
}
const config = JSON.parse(fs.readFileSync(configTemplate, 'utf8'));
config.changelog = changelog;
fs.writeFileSync(
GENERATED_CONFIG,
`${JSON.stringify(config, undefined, 2)}\n`,
'utf8',
);
return { state: 'created', target: GENERATED_CONFIG };
}
function slugifyPackageName(name) {
return name
.replace(/^@/v, '')
.replaceAll('/', '-')
.replaceAll(/[^\w.\-]+/gv, '-')
.replaceAll(/-{2,}/gv, '-')
.replaceAll(/^-|-$/gv, '')
.toLowerCase();
}
function parseCommandLine(argv) {
const options = { repoWidePackage: DEFAULT_REPO_WIDE_PACKAGE };
const optionTakingValue = new Map([
['--baseline', 'baseline'],
['--config', 'configTemplate'],
['--formatter', 'formatter'],
['--max-bullets', 'maxBullets'],
['--repo-package', 'repoWidePackage'],
['--taxonomy', 'taxonomy'],
]);
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--dry-run') {
options.dryRun = true;
} else if (argument === '--verify') {
options.verify = true;
} else if (argument === '--allow-nonconventional') {
options.allowNonConventional = true;
} else if (optionTakingValue.has(argument)) {
index += 1;
if (argv[index] === undefined) abort(`missing value for ${argument}`);
options[optionTakingValue.get(argument)] =
argument === '--max-bullets'
? Number.parseInt(argv[index], 10)
: argv[index];
} else {
abort(`unknown argument: ${argument}`);
}
}
return options;
}
function resolveRepoWidePackage(options, members, isWorkspace) {
if (!isWorkspace) {
const rootManifest = readManifestOrNull('.');
if (!rootManifest?.name) {
abort('single-package mode needs a name in package.json');
}
return { dir: '.', name: rootManifest.name };
}
const manifest = readManifestOrNull(options.repoWidePackage);
if (!manifest?.name) {
abort(
`repo-level package not found at ${options.repoWidePackage}. Create it, or pass --repo-package <workspace-path> naming the package that collects repo-wide changes.`,
);
}
if (!members.some((member) => member.dir === options.repoWidePackage)) {
abort(
`${options.repoWidePackage} is not a workspace member; changeset version would reject it`,
);
}
return { dir: options.repoWidePackage, name: manifest.name };
}
function fileBulletsUnderPackage(bulletsByPackage, packageName, bullet, lines) {
const entry = bulletsByPackage.get(packageName) ?? {
bullets: [],
bump: 'patch',
};
if (BUMP_PRECEDENCE[bullet.severity] > BUMP_PRECEDENCE[entry.bump]) {
entry.bump = bullet.severity;
}
for (const text of lines) {
if (!entry.bullets.some((existing) => existing.text === text)) {
entry.bullets.push({
group: bullet.group,
hash: bullet.hash,
scope: bullet.scope || SCOPELESS,
text,
type: bullet.type,
});
}
}
bulletsByPackage.set(packageName, entry);
}
function captureNonConventionalCommit(
commit,
subject,
repoWidePackage,
bulletsByPackage,
commitIndex,
) {
commitIndex.push({
bullets: 1,
bump: 'patch',
hash: commit.short,
prefix: 'chore:',
scopes: ['TOUCHES_NO_PACKAGE'],
subject,
});
fileBulletsUnderPackage(
bulletsByPackage,
repoWidePackage.name,
{
group: 'internal',
hash: commit.short,
scope: SCOPELESS,
severity: 'patch',
type: 'chore',
},
[`- [${commit.short}] chore: ${subject}`],
);
}
function captureCommits(commits, members, repoWidePackage, options) {
const bulletsByPackage = new Map();
const commitIndex = [];
const withoutConventionalSubject = [];
for (const commit of commits) {
const { body, subject } = splitSubjectFromBody(commit.message);
const parsed = parseConventionalSubject(subject);
if (!parsed) {
if (!options.allowNonConventional) {
withoutConventionalSubject.push(`${commit.short} ${subject}`);
continue;
}
captureNonConventionalCommit(
commit,
subject,
repoWidePackage,
bulletsByPackage,
commitIndex,
);
continue;
}
const severity = severityOfCommit(parsed, body);
const group = readerGroupOfCommit(parsed, severity);
const { bullets, stitchedLines } = readBulletsRepairingWrappedLines(
parsed,
body,
);
const touchedPackages = packagesTouchedByPaths(commit.files, members);
const asBulletLines = (texts) =>
texts.map((text) => `- [${commit.short}] ${parsed.prefix} ${text}`);
const subjectOnly = [parsed.description || NO_DESCRIPTION];
const scopeOwner = packageNamedByScope(parsed.scope, members);
const scopeOwnerWasTouched =
Boolean(scopeOwner) && touchedPackages.includes(scopeOwner.dir);
const isRepoWideSweep =
!scopeOwnerWasTouched &&
touchedPackages.length >
PACKAGES_ABOVE_WHICH_A_COMMIT_IS_A_REPO_WIDE_SWEEP;
const bullet = {
group,
hash: commit.short,
scope: parsed.scope,
severity,
type: parsed.type,
};
for (const touched of touchedPackages) {
const packageName =
touched === TOUCHES_NO_PACKAGE
? repoWidePackage.name
: members.find((member) => member.dir === touched).name;
const getsFullDetail = scopeOwnerWasTouched
? packageName === scopeOwner.name
: !isRepoWideSweep;
fileBulletsUnderPackage(
bulletsByPackage,
packageName,
bullet,
asBulletLines(getsFullDetail ? bullets : subjectOnly),
);
}
if (isRepoWideSweep) {
fileBulletsUnderPackage(
bulletsByPackage,
repoWidePackage.name,
bullet,
asBulletLines(bullets),
);
}
commitIndex.push({
attribution: scopeOwnerWasTouched
? `scope:${scopeOwner.name}`
: isRepoWideSweep
? 'sweep'
: 'all',
bullets: bullets.length,
bump: severity,
group,
...(stitchedLines > 0 ? { stitchedLines } : {}),
hash: commit.short,
prefix: parsed.prefix,
scopes: touchedPackages.map((touched) =>
touched === TOUCHES_NO_PACKAGE ? 'TOUCHES_NO_PACKAGE' : touched,
),
subject,
});
}
if (withoutConventionalSubject.length > 0) {
abort(
`commits without a conventional-commit subject:\n ${withoutConventionalSubject.join('\n ')}\nFix the history, or re-run with --allow-nonconventional to file them as repo-level chores.`,
);
}
return { bulletsByPackage, commitIndex };
}
function renderRawCaptureFile(packageName, bump, bullets) {
const sections = [];
for (const group of taxonomy.READER_GROUPS_IN_ORDER) {
const lines = bullets
.filter((bullet) => bullet.group === group)
.map((bullet) => bullet.text);
if (lines.length > 0) {
sections.push(
`### ${taxonomy.GROUP_HEADING[group]}\n${lines.join('\n')}`,
);
}
}
return `---\n"${packageName}": ${bump}\n---\n\n${sections.join('\n\n')}\n`;
}
function smallestBulletCountTheMergeRulesAllow(bullets) {
const namedScopes = new Set();
const typesAmongScopelessCommits = new Set();
for (const bullet of bullets) {
if (bullet.scope === SCOPELESS) {
typesAmongScopelessCommits.add(bullet.type);
} else {
namedScopes.add(bullet.scope);
}
}
return namedScopes.size + typesAmongScopelessCommits.size;
}
function planOneChangesetPerPackagePerSeverity(
bulletsByPackage,
timestamp,
ceilingOverride,
) {
const plan = [];
for (const packageName of [...bulletsByPackage.keys()].toSorted()) {
const entry = bulletsByPackage.get(packageName);
for (const bump of taxonomy.SEVERITIES_HIGHEST_FIRST) {
const bullets = entry.bullets.filter(
(bullet) => taxonomy.SEVERITY_OF_GROUP[bullet.group] === bump,
);
if (bullets.length === 0) continue;
const slug = slugifyPackageName(packageName);
const floor = smallestBulletCountTheMergeRulesAllow(bullets);
plan.push({
bullets,
bump,
floor,
groups: taxonomy.READER_GROUPS_IN_ORDER.filter((group) =>
bullets.some((bullet) => bullet.group === group),
),
hashes: [...new Set(bullets.map((bullet) => bullet.hash))],
maxBullets:
ceilingOverride ??
Math.max(
MINIMUM_BULLET_CEILING,
CEILING_AS_MULTIPLE_OF_FLOOR * floor,
),
name: packageName,
raw: path.join(RAW_CAPTURE_FOLDER, `${slug}-${bump}.md`),
rawBullets: bullets.length,
target: `${CHANGESET_FOLDER}/${slug}-${bump}-${timestamp}.md`,
});
}
}
return plan;
}
function readWrittenChangeset(file, problems) {
const text = fs.readFileSync(file, 'utf8');
const split = FRONTMATTER_AND_BODY.exec(text);
if (!split) {
problems.push(`${file}: missing or malformed frontmatter`);
return null;
}
const [, frontmatter, body] = split;
const declared = DECLARED_PACKAGE_AND_BUMP.exec(frontmatter);
if (!declared) {
problems.push(`${file}: frontmatter must name one package and one bump`);
return null;
}
return { body, bump: declared[2], name: declared[1] };
}
function checkBullet(file, bump, line, capturedPrefixes, problems) {
const bullet = BULLET_WITH_AUDIT_IDS_AND_PREFIX.exec(line);
if (!bullet) {
problems.push(
`${file}: bullet does not match "- [hash] type(scope): text": ${line}`,
);
return null;
}
const citedHashes = (bullet[1].match(AUDIT_ID) ?? []).map((id) =>
id.toLowerCase(),
);
const writtenPrefix = bullet[2];
const prefixesOfCitedCommits = citedHashes
.map((hash) => capturedPrefixes.get(hash))
.filter((prefix) => prefix !== undefined);
if (
prefixesOfCitedCommits.length > 0 &&
!prefixesOfCitedCommits.includes(writtenPrefix)
) {
problems.push(
`${file}: prefix "${writtenPrefix}" belongs to no cited commit (captured: ${[...new Set(prefixesOfCitedCommits)].join(', ')}): ${line.slice(0, 90)}`,
);
return citedHashes;
}
const type = writtenPrefix.split('(')[0].replace(':', '').toLowerCase();
const severityItBelongsIn =
taxonomy.SEVERITY_OF_GROUP[
taxonomy.GROUP_OF_CONVENTIONAL_TYPE[type] ??
taxonomy.UNRECOGNISED_TYPE_GROUP
];
if (severityItBelongsIn !== bump) {
problems.push(
`${file}: "${writtenPrefix}" belongs in the ${severityItBelongsIn} changeset, not the ${bump} one: ${line.slice(0, 90)}`,
);
}
return citedHashes;
}
function verifyWrittenChangesets(options) {
if (!fs.existsSync(AUDIT_FILE)) {
abort(`${AUDIT_FILE} not found - run the capture before verifying`);
}
const audit = JSON.parse(fs.readFileSync(AUDIT_FILE, 'utf8'));
const capturedPrefixes = new Map(
audit.commits.map((entry) => [entry.hash.toLowerCase(), entry.prefix]),
);
const files = listWrittenChangesets();
if (files.length === 0) abort('no changesets found in .changeset/');
const problems = [];
const writtenByPackageAndBump = new Map();
for (const file of files) {
const changeset = readWrittenChangeset(file, problems);
if (!changeset) continue;
const key = `${changeset.name}${PACKAGE_AND_BUMP_SEPARATOR}${changeset.bump}`;
const written = writtenByPackageAndBump.get(key) ?? {
bullets: 0,
hashes: new Set(),
};
writtenByPackageAndBump.set(key, written);
for (const line of changeset.body.split('\n')) {
const trimmed = line.trim();
if (trimmed === '') continue;
const citedHashes = checkBullet(
file,
changeset.bump,
trimmed,
capturedPrefixes,
problems,
);
if (!citedHashes) continue;
written.bullets += 1;
for (const hash of citedHashes) written.hashes.add(hash);
}
}
const plannedKeys = new Set();
for (const planned of audit.changesets) {
const key = `${planned.name}${PACKAGE_AND_BUMP_SEPARATOR}${planned.bump}`;
plannedKeys.add(key);
const written = writtenByPackageAndBump.get(key);
if (written === undefined) {
problems.push(
`missing changeset: ${planned.name} (${planned.bump}) - expected ${planned.target}`,
);
continue;
}
for (const hash of planned.hashes) {
if (!written.hashes.has(hash.toLowerCase())) {
const commit = audit.commits.find((entry) => entry.hash === hash);
problems.push(
`${planned.name} (${planned.bump}): commit not cited by any bullet: ${hash} ${commit?.subject ?? ''}`,
);
}
}
const hashesThisChangesetMayCite = new Set(
planned.hashes.map((hash) => hash.toLowerCase()),
);
for (const hash of written.hashes) {
if (!hashesThisChangesetMayCite.has(hash)) {
problems.push(
`${planned.name} (${planned.bump}): bullet cites ${hash}, which belongs to a different changeset`,
);
}
}
const ceiling = options.maxBullets ?? planned.maxBullets;
if (written.bullets > ceiling) {
problems.push(
`${planned.name} (${planned.bump}): ${written.bullets} bullets exceeds the cap of ${ceiling} (${planned.rawBullets} raw, floor ${planned.floor}) - merge further`,
);
}
}
for (const key of writtenByPackageAndBump.keys()) {
if (!plannedKeys.has(key)) {
const [name, bump] = key.split(PACKAGE_AND_BUMP_SEPARATOR);
problems.push(`changeset was never captured: ${name} (${bump})`);
}
}
if (problems.length > 0) {
abort(`verification failed:\n ${problems.join('\n ')}`);
}
process.stdout.write(
`${JSON.stringify(
{
changesetFiles: files.length,
commitsCited: audit.commits.length,
density: audit.changesets.map((planned) => ({
bullets: writtenByPackageAndBump.get(
`${planned.name}${PACKAGE_AND_BUMP_SEPARATOR}${planned.bump}`,
).bullets,
cap: options.maxBullets ?? planned.maxBullets,
changeset: `${planned.name} (${planned.bump})`,
raw: planned.rawBullets,
})),
perPackageCoverage: 'complete',
verified: true,
},
undefined,
2,
)}\n`,
);
}
function writeCapture(plan, commitIndex, baseline, timestamp, options) {
const config = writeOrRepairChangesetsConfig(
resolveCompanionFile(
CONFIG_TEMPLATE_FILE,
options.configTemplate,
'--config',
),
resolveCompanionFile(FORMATTER_FILE, options.formatter, '--formatter'),
);
fs.mkdirSync(RAW_CAPTURE_FOLDER, { recursive: true });
for (const planned of plan) {
fs.writeFileSync(
planned.raw,
renderRawCaptureFile(planned.name, planned.bump, planned.bullets),
'utf8',
);
}
fs.writeFileSync(
AUDIT_FILE,
`${JSON.stringify(
{
baseline,
changesets: plan.map(({ bullets, ...planned }) => planned),
commits: commitIndex,
timestamp,
},
undefined,
2,
)}\n`,
'utf8',
);
return config;
}
async function main() {
const options = parseCommandLine(process.argv.slice(2));
process.chdir(runGit(['rev-parse', '--show-toplevel']).trim());
const taxonomyFile = resolveCompanionFile(
TAXONOMY_FILE,
options.taxonomy,
'--taxonomy',
);
taxonomy = await import(pathToFileURL(taxonomyFile).href);
if (options.verify) {
verifyWrittenChangesets(options);
return;
}
const members = readWorkspaceMembersLongestPathFirst();
const isWorkspace = members.length > 0;
const repoWidePackage = resolveRepoWidePackage(options, members, isWorkspace);
assertNoPendingChangesets();
const baseline = options.baseline
? runGit(['rev-parse', options.baseline]).trim()
: findLastReleaseCommit();
assertNoPackageVersionWentBackwards(members, baseline);
const commits = readCommitsInRange(
baseline ? `${baseline}..HEAD` : undefined,
);
if (commits.length === 0) {
process.stdout.write(
`${JSON.stringify({ baseline, commitsCaptured: 0, note: 'nothing to capture' }, undefined, 2)}\n`,
);
return;
}
const { bulletsByPackage, commitIndex } = captureCommits(
commits,
members,
repoWidePackage,
options,
);
const timestamp = Math.floor(Date.now() / 1000);
const plan = planOneChangesetPerPackagePerSeverity(
bulletsByPackage,
timestamp,
options.maxBullets,
);
const config = options.dryRun
? { state: 'not written', target: GENERATED_CONFIG }
: writeCapture(plan, commitIndex, baseline, timestamp, options);
process.stdout.write(
`${JSON.stringify(
{
audit: AUDIT_FILE,
baseline: baseline ?? '(full history)',
bulletsTotal: commitIndex.reduce(
(total, entry) => total + entry.bullets,
0,
),
changesets: plan.map((planned) => ({
bump: planned.bump,
groups: planned.groups,
maxBullets: planned.maxBullets,
package: planned.name,
raw: planned.raw,
rawBullets: planned.rawBullets,
scopeFloor: planned.floor,
target: planned.target,
})),
commitsCaptured: commitIndex.length,
config: `${config.target} (${config.state})`,
dryRun: Boolean(options.dryRun),
mode: isWorkspace ? 'workspace' : 'single',
},
undefined,
2,
)}\n`,
);
}
try {
await main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
process.exitCode = 1;
}
# Reference — what the script does
Read this when a report surprises you, when `--verify` fails for a reason the
message does not fully explain, or when you must justify the output. Quote from
here; do not re-derive.
## Modes
`workspace` when `pnpm-workspace.yaml` has a `packages:` block, or
`package.json` has a non-empty `workspaces` field. `single` otherwise — a
`pnpm-workspace.yaml` carrying only `allowBuilds` or `overrides` declares no
members, so the file's presence proves nothing.
In `workspace` mode there is one changeset per package per severity and the
conventional scope is expected to name a package. In `single` mode there is one
changeset per severity for the root package and the scope names a subsystem.
This is why `cebreus-release` drops the scope from a monorepo changelog and
keeps it as a bullet lead-in in a single-package one.
## Baseline
Commits that deleted a `.changeset/*.md` (ignoring `README.md`) are walked
newest first; the first that also rewrote a `CHANGELOG.md` wins. That pairing is
what `changeset version` does and housekeeping commits do not. If candidates
exist but none pairs, the script aborts and names the newest. Override with
`--baseline <sha>`.
## Membership and bump: paths, never the subject
A package gets a changeset because its code changed. Members are expanded from
`pnpm-workspace.yaml` (or `workspaces`), honouring `!` exclusions, so a commit
touching an excluded directory lands in the repo-level package instead of
crashing. Lockfiles, `.gitignore` and `.DS_Store` are ignored when mapping. A
commit touching no member is repo-level; a commit touching members *and* loose
files belongs to those members only.
Deliberate: if the scope decided membership, a commit scoped to one package that
also changed another would ship changed code with no version bump.
## Attribution: the scope, never the paths
Paths say what a commit touched; they cannot say which package its body is
*about*.
- Scope names a package the commit touched: that package gets the full bullets,
every other touched package gets the subject line only.
- Scope names no package and the commit touched more than three packages: a
repo-wide sweep. Every touched package gets the subject line only, and the
full detail is filed once under the repo-level package.
- Otherwise: every touched package gets the full bullets.
Nothing is lost in any branch — the detail always lands somewhere, and the
subject is always true of every package it reaches.
## Bump and severity split
`!` in the subject or a `BREAKING CHANGE:` trailer gives `major`; `feat` gives
`minor`; everything else `patch`. No keyword heuristics, no version-based
special cases.
One changeset per (package × severity): `major` holds the breaking group,
`minor` the features, `patch` the fixes, improvements and internal work. The
released version is the highest bump among a package's changesets —
`changeset version` works that out; you never state it.
The split exists because `changeset version` groups the changelog by the bump
each changeset declares. Writing them separately buys correct
`### Major / Minor / Patch Changes` sections with no post-processing, and makes
it structurally impossible to bury a feature inside a patch bullet.
## Density ceiling
`scopeFloor` = distinct named scopes + distinct types among the scope-less
commits. `maxBullets` = twice that, never below 20. Always above the floor, so
the merge rules can never be mutually unsatisfiable. Override with
`--max-bullets <n>`.
## Type normalisation
Two subjects are rewritten before anything else sees them: `style` becomes
`chore`, and a `fixup! ` prefix becomes `fix`. The capture records the rewritten
prefix, so a `style(ui):` commit appears as `chore(ui):` in the raw file. The
raw file is the authority on a prefix, not `git log` — `--verify` checks against
the capture.
## Groups
`feat` → Features. `fix`, `revert` → Fixes. `perf`, `refactor` → Improvements.
`docs`, `test`, `chore`, `ci`, `build`, `style` and any unknown type → Internal.
Anything breaking → Breaking changes, first.
## Raw bullets
One per commit subject, one per body bullet (nested ones flattened), one per
body prose paragraph. Sign-off and tooling trailers and exact duplicates are
dropped; nothing else is.
Wrapped lines are repaired first: an indented continuation line rejoins its
bullet, and a long bullet that does not end a sentence absorbs a following
bullet that starts lower-case and carries no conventional prefix. This undoes an
editor re-prefixing every wrapped line with `- `, which otherwise leaves
fragments like `refactor(build): eleven` that cannot become a sentence without
inventing one. Repaired commits carry `stitchedLines` in
`.temp/changeset-capture.json`; if a bullet reads as though two claims were
welded together, look there.
## Gates
Capture aborts on: pending changesets already in `.changeset/`; a baseline with
no changelog change; a package version lower than at baseline; non-conventional
commit subjects; an unsupported workspace glob; a repo-level package that is not
a workspace member.
`--verify` fails on: a captured commit cited by no bullet; a missing changeset;
a bullet citing a commit that belongs to a different changeset; a bullet whose
type does not match its file's bump; a bullet whose prefix belongs to none of
the commits it cites; a bullet not matching `- [hash] type(scope): text`; a file
over its ceiling; a changeset that was never captured.
## Flags
| Flag | Purpose |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `--verify` | Check `.changeset/` against the capture. Writes nothing. |
| `--dry-run` | Report only; writes no raw capture. |
| `--baseline <sha>` | Override baseline detection. |
| `--max-bullets <n>` | Override the density ceiling for every changeset. |
| `--repo-package <path>` | Workspace mode only: the package collecting repo-wide changes. Default `packages/repo`. In single mode the root package plays that role. |
| `--allow-nonconventional` | File non-conventional commits as `chore:` bullets under the repo-level package (workspace) or root package (single), instead of aborting. |
| `--config <path>` | Changesets config template. |
| `--formatter <path>` | Changelog formatter. |
| `--taxonomy <path>` | Shared group and severity tables. |
## Companion files
`changelog-taxonomy.mjs` holds the group and severity tables and the workspace
detection that both scripts must agree on. `changelog-formatter.mjs` and
`changeset-config.json` live with the release skill. None of them is found by a
hardcoded folder name: each script looks beside itself first, then through every
sibling skill folder, so renaming a skill folder changes nothing. A missing file
aborts with the name it wanted and the flag that overrides it — `--taxonomy`,
`--formatter`, `--config`. Dropping a copy next to the script also works, which
keeps either skill runnable on its own.
## Artefacts
| Path | Life |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `.temp/raw-changesets/<slug>-<bump>.md` | Machine record, grouped. Gitignored. Input to step 3, evidence for step 4. |
| `.temp/changeset-capture.json` | Per-commit index and per-changeset expectation. Gitignored. Read by `--verify`. |
| `.changeset/<slug>-<bump>-<ts>.md` | What you write, and commit. `changeset version` consumes and deletes them. |
| `.changeset/config.json` | Generated from the template, with the formatter path computed. Never committed. Deleted by `cebreus-release` after versioning. |
## Worked example
Raw capture — `.temp/raw-changesets/acme-logger-minor.md`:
```md
---
"@acme/logger": minor
---
### Features
- [14b0b03] feat(logger): persist browser logs in development
- [14b0b03] feat(logger): Introduce Vite plugin for `browser-error.log` persistence.
- [14b0b03] feat(logger): Add `level` option to configure minimum log level.
- [e4f5a6b] feat(logger): add a JSON output mode
```
What you write — `.changeset/acme-logger-minor-1787690413.md`:
```md
---
"@acme/logger": minor
---
- [14b0b03] [e4f5a6b] feat(logger): Browser logs are now written to disk during development, at a configurable level and optionally as JSON.
```
Four raw bullets, two commits, one sentence, both hashes carried.
## Downstream
`changelog-formatter.mjs` runs inside `changeset version`, per line, and is lossless:
it strips the list marker, the run of audit ids and backticks, collapses
whitespace, and emits `- <type>(<scope>): <text>`. It never drops a bullet — a
dropped bullet is a change that silently never reaches the changelog and cannot
be recovered. Dependency bumps collapse to one `- Updated dependencies.` per
section.
`regroup-changelog.mjs` runs after, over the newest `## <version>` section only.
The `### Major / Minor / Patch Changes` headings come from the severity split
and stay; beneath each it adds `#### Features` / `#### Fixes` /
`#### Improvements` / `#### Internal` and strips the prefix. A severity block
with a single group gets no inner heading. Unknown or missing type → `Internal`,
last, still published.