Release
How to use
Use this when you are ready to release a new version.
Prompt
Release
This skill does not write release notes. The wording was written once, incebreus-generate-missing-changesets, from the raw commit capture. Here it is
only moved and grouped. If a bullet reads badly, the fix belongs in the
changeset: STOP and go back a step. The only prose written here is the release
summary blockquote.
Binding rules (fail-closed)
- Any gate fails: STOP and report the exact reason. Never fix forward,
reset, stage or commit without explicit user approval. - Scope: never modify anything outside
CHANGELOG.md,package.json,pnpm-lock.yamland.changeset/. Reject the write before executing it. - History: everything above and below the newest
## <version>section is
immutable. Never delete, reorder or merge version sections. - Content: never reword, merge, split or delete a bullet.
regroup-changelog.mjsmoves them; you do not. - Never run
changeset init— it overwrites the config and drops the
custom changelog formatter.
1. Preflight
1.1 git status --porcelain MUST be empty. Otherwise STOP and list each
unclean path.
1.2 pnpm and node MUST exist. If not found, retry with the platform's
usual package-manager path prepended to PATH (on macOS, /opt/homebrew/bin).
Still missing: STOP.
1.3 At least one pending changeset MUST exist:
find .changeset -maxdepth 1 -name '*.md' ! -name README.mdNone: STOP and run cebreus-generate-missing-changesets first. Never hand-write
a changeset here.
1.4 .changeset/config.json MUST exist — it is generated, never committed,
and step 2 deletes it. If it is missing, runcebreus-generate-missing-changesets with --dry-run once to regenerate it.
1.5 Baseline (read-only, to .temp/). Record HEAD, every package version,
the line count of every existing CHANGELOG.md, and the full list of pending
changeset files. This is the reference for every post-flight gate.
2. Version
pnpm exec changeset versionIf the CLI is missing, run pnpm install and retry. Never usenpx -y @changesets/cli: it fetches an unpinned version over the network at
release time, which can differ from the lockfile and from the config schema this
repository targets.
Non-zero exit, or no file changes: STOP.
Versioning was the last step that needed the config, so remove it — leaving it
behind puts an untracked file in the working tree of every repository that does
not gitignore it:
rm -f .changeset/config.json3. Regroup
The changesets are split one per severity, so changeset version has already
produced correct ### Major Changes / ### Minor Changes / ### Patch Changes
subsections. Those stay. What the CLI cannot do is the finer split, because the
conventional type lives inside the bullet.
node .agents/skills/cebreus-release/regroup-changelog.mjsIt shares changelog-taxonomy.mjs with the capture script so the two cannot
disagree about which type belongs in which group. It finds that file beside
itself or in any sibling skill folder; --taxonomy <path> overrides.
With no arguments it takes every changelog git diff reports as modified,
rewrites only the newest ## <version> section of each, and leaves the rest
untouched. Beneath each severity heading it adds #### Breaking changes,#### Features, #### Fixes, #### Improvements, #### Internal, in that
order, and strips the prefix that has become a heading. A severity block holding
a single group gets no inner heading. In a workspace repository the scope is
dropped (the changelog is already per package); in a single one it leads the
bullet as - **i18n:** …. Unknown or missing type goes to Internal. Nothing
is dropped, and a second run is a no-op.
Gate: the report MUST cover every file git diff --name-only -- '*CHANGELOG.md'
lists. Any skipped: no bullets in newest section means changeset version
produced an empty release section — STOP.
4. Release summary
Directly under each new ## <version> heading, insert a > blockquote of one
to three sentences: what this release changes and why it matters, consequence
first. Plain British English, no hashes, no paths, no claim the bullets below do
not support. Never touch an older version's blockquote.
This is the only text you write.
5. Post-flight gates
Version. No package version may be lower than its baseline. Otherwise
STOP and report the exact before and after strings.Changeset. Every baseline changeset file MUST be gone. Any survivor other
thanREADME.md: STOP and report the paths.Changelog coverage.
changeset versionbumpspackage.jsonand writesCHANGELOG.mdin separate steps, and exits 0 even when the second fails — a
broken prettier config in one package silently loses that package's whole
release section. Prove every bump reached a changelog:for manifest in $(git diff --name-only -- '*package.json'); do dir=$(dirname "$manifest") version=$(grep -m1 '"version"' "$manifest" | cut -d'"' -f4) grep -q "^## ${version}\$" "$dir/CHANGELOG.md" 2>/dev/null \ || echo "MISSING: $dir -> $version" doneAny output: STOP and report. Never hand-write the missing section — fix the
cause and re-run from a clean tree.History. Every pre-existing version heading MUST still be present and
unmodified. Otherwise STOP and report it.Scope.
git diff --name-only— any path outside rule 2: STOP and report
each before doing anything else.
6. Commit
git add -u .changeset/
git add package.json pnpm-lock.yaml CHANGELOG.md
git add packages/*/package.json packages/*/CHANGELOG.md 2>/dev/null || true
git add sites/*/package.json sites/*/CHANGELOG.md 2>/dev/null || true
git add tools/*/package.json tools/*/CHANGELOG.md 2>/dev/null || true
git add apps/*/package.json apps/*/CHANGELOG.md 2>/dev/null || truegit add -u stages the changeset deletions without adding anything untracked
that happens to sit in .changeset/.
Verify with git diff --cached --name-only and unstage anything out of scope;
if it cannot be cleanly unstaged, STOP.
git commit -m $'chore(release): version packages' -m $'- package-a@1.0.1\n- package-b@2.0.0'Subject is fixed: chore(release): version packages, verbatim, every time.
No version in the subject — the body carries it. Body: one line per updated
package, - <pkg>@<version>, sorted alphabetically. No trailers, no
attribution, no emoji.
Do NOT use --amend. Do NOT retry a failed commit without explicit approval.
A commit that fails is a STOP. Never pass --no-verify, never setcore.hooksPath, never touch .git/config, never edit or move a hook. A
release commit that skipped the repository's checks is worse than none. Report
the error verbatim and stop.
7. Report
Mode (workspace / single), commit hash, updated packages and versions,
modified changelogs, final git status --porcelain.
Why these two subjects are fixed, and how they sit against.agents/skills/cebreus-commit/SKILL.md: docs and chore are allowed types;
both subjects are imperative, lower-case, ≤60 characters, no full stop. docs(changeset):
is what the changesets CLI itself uses; "Version Packages" is what the officialchangesets/action names its commit. The one deviation: cebreus-commit says a
scope names a workspace directory or repo, while changeset and release
name pipeline stages. That is a closed vocabulary of two, not an invitation to
invent more.
Attachments
const LIST_MARKER = /^[\-*][ \t]+/v;
const RUN_OF_AUDIT_IDS = /^(?:\[[0-9a-f]{7,}\][ \t]*)+/iv;
const RUN_OF_WHITESPACE = /\s+/gv;
const LINE_BREAK = /\r?\n/v;
const DEPENDENCY_BUMP_LINE = '- Updated dependencies.\n';
const WORKSPACE_ROOTS = new Set(['packages', 'sites', 'tools', 'apps']);
const MAX_FILE_EXTENSION_LENGTH = 5;
const PUNCTUATION_AROUND_TOKEN = /[\(\),:;]/gv;
const FILE_EXTENSION = /^[a-z0-9]+$/iv;
// A bare `name.ext` stays: `Node.js` and `index.html` read as prose more often
// than as a file, and dropping the bullet would delete release notes silently.
// Requiring a slash also removes the need for an extension list to maintain.
function isFilePath(token) {
if (token.includes('://')) {
return false;
}
const withoutPunctuation = token.replaceAll(PUNCTUATION_AROUND_TOKEN, '');
if (!withoutPunctuation.includes('/')) {
return false;
}
if (WORKSPACE_ROOTS.has(withoutPunctuation.split('/')[0])) {
return true;
}
const lastSegment = withoutPunctuation.split('/').at(-1) ?? '';
if (!lastSegment.includes('.')) {
return false;
}
const extension = lastSegment.split('.').at(-1) ?? '';
return (
extension.length > 0 &&
extension.length <= MAX_FILE_EXTENSION_LENGTH &&
FILE_EXTENSION.test(extension)
);
}
function containsFilePath(text) {
return text.split(' ').some((token) => isFilePath(token));
}
function normaliseKeepingTypeScopePrefix(line) {
const withoutMarkers = String(line ?? '')
.trim()
.replace(LIST_MARKER, '')
.replace(RUN_OF_AUDIT_IDS, '')
.replaceAll('`', '')
.replaceAll(RUN_OF_WHITESPACE, ' ')
.trim();
// A bullet naming a file path is an implementation note, not a release note.
return containsFilePath(withoutMarkers) ? '' : withoutMarkers;
}
/**
* @param {{summary?: string}} changeset - The changeset being released.
* @returns {Promise<string>} Flat bullets, or an empty string.
*/
export async function getReleaseLine(changeset) {
if (typeof changeset?.summary !== 'string') {
throw new TypeError(
`changelog-formatter: changeset.summary must be a string, got ${typeof changeset?.summary}`,
);
}
const publishable = changeset.summary
.split(LINE_BREAK)
.map((line) => normaliseKeepingTypeScopePrefix(line))
.filter(Boolean);
if (publishable.length === 0) return '';
return `${publishable.map((bullet) => `- ${bullet}`).join('\n')}\n`;
}
/**
* @param {unknown} _changesets - Unused; kept for the changesets call signature.
* @param {unknown[]} dependenciesUpdated - The updated dependencies.
* @returns {Promise<string>} One collapsed bullet, or an empty string.
*/
export async function getDependencyReleaseLine(
_changesets,
dependenciesUpdated,
) {
if (!Array.isArray(dependenciesUpdated)) {
throw new TypeError(
`changelog-formatter: dependenciesUpdated must be an array, got ${typeof dependenciesUpdated}`,
);
}
return dependenciesUpdated.length === 0 ? '' : DEPENDENCY_BUMP_LINE;
}
{
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
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';
let taxonomy;
let GROUP_HEADINGS_ALREADY_WRITTEN;
const VERSION_HEADING = /^##[ \t]+\S/v;
const SEVERITY_HEADING = /^###[ \t]+(.*)$/v;
const GROUP_HEADING_LINE = /^####[ \t]+(.*)$/v;
const BULLET = /^-[ \t]+(.*)$/v;
const TYPE_SCOPE_PREFIXED_BULLET =
/^(?:\*\*)?([a-zA-Z]+)(?:\(([^\)]*)\))?(?:\*\*)?!?:[ \t]*(.*)$/v;
const TRAILING_BLANK_LINES = /\n{3,}$/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 changelogsModifiedSinceHead() {
return execFileSync('git', ['diff', '--name-only', '--', '*CHANGELOG.md'], {
encoding: 'utf8',
})
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '');
}
function boundsOfNewestVersionSection(lines) {
const start = lines.findIndex((line) => VERSION_HEADING.test(line));
if (start === -1) return null;
const offsetOfNextVersion = lines
.slice(start + 1)
.findIndex((line) => VERSION_HEADING.test(line));
return {
end:
offsetOfNextVersion === -1
? lines.length
: start + 1 + offsetOfNextVersion,
start,
};
}
function readGroupScopeAndText(bulletText) {
const prefixed = TYPE_SCOPE_PREFIXED_BULLET.exec(bulletText);
if (!prefixed) {
return {
group: taxonomy.UNRECOGNISED_TYPE_GROUP,
scope: null,
text: bulletText,
};
}
const [, rawType, scope = null, textAfterPrefix] = prefixed;
const type = rawType.toLowerCase();
const group = taxonomy.GROUP_OF_CONVENTIONAL_TYPE[type];
if (group === undefined) {
return {
group: taxonomy.UNRECOGNISED_TYPE_GROUP,
scope: null,
text: bulletText,
};
}
return { group, scope: scope || null, text: textAfterPrefix.trim(), type };
}
function splitIntoSeverityBlocks(sectionBody) {
const blocks = [];
let openBlock = { bullets: [], heading: null, prose: [] };
blocks.push(openBlock);
for (const line of sectionBody) {
if (GROUP_HEADING_LINE.test(line)) continue;
if (SEVERITY_HEADING.test(line)) {
openBlock = { bullets: [], heading: line, prose: [] };
blocks.push(openBlock);
continue;
}
const bullet = BULLET.exec(line);
if (bullet) {
openBlock.bullets.push(readGroupScopeAndText(bullet[1].trim()));
continue;
}
if (line.trim() !== '') openBlock.prose.push(line);
}
return blocks.filter(
(block) =>
block.heading !== null ||
block.bullets.length > 0 ||
block.prose.length > 0,
);
}
function renderBlockGroupedByType(bullets, leadWithScope) {
const asLine = (bullet) =>
leadWithScope && bullet.scope
? `- **${bullet.scope}:** ${bullet.text}`
: `- ${bullet.text}`;
const groupsPresent = taxonomy.READER_GROUPS_IN_ORDER.filter((group) =>
bullets.some((bullet) => bullet.group === group),
);
if (groupsPresent.length <= 1) return bullets.map((bullet) => asLine(bullet));
const lines = [];
for (const group of groupsPresent) {
lines.push(
`#### ${taxonomy.GROUP_HEADING[group]}`,
'',
...bullets
.filter((bullet) => bullet.group === group)
.map((bullet) => asLine(bullet)),
'',
);
}
return lines;
}
function regroupNewestSection(file, leadWithScope) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
const section = boundsOfNewestVersionSection(lines);
if (!section) abort(`${file}: no version heading found`);
const sectionBody = lines.slice(section.start + 1, section.end);
const alreadyRegrouped = sectionBody.some((line) =>
GROUP_HEADINGS_ALREADY_WRITTEN.has(
GROUP_HEADING_LINE.exec(line)?.[1]?.trim(),
),
);
if (alreadyRegrouped) return { file, skipped: 'already regrouped' };
const blocks = splitIntoSeverityBlocks(sectionBody);
const bulletsMoved = blocks.reduce(
(total, block) => total + block.bullets.length,
0,
);
if (bulletsMoved === 0) {
return { bullets: 0, file, skipped: 'no bullets in newest section' };
}
const rebuiltSection = [lines[section.start], ''];
for (const block of blocks) {
if (block.heading) rebuiltSection.push(block.heading, '');
if (block.prose.length > 0) rebuiltSection.push(...block.prose, '');
if (block.bullets.length === 0) continue;
rebuiltSection.push(
...renderBlockGroupedByType(block.bullets, leadWithScope),
);
if (rebuiltSection.at(-1) !== '') rebuiltSection.push('');
}
fs.writeFileSync(
file,
[
...lines.slice(0, section.start),
...rebuiltSection,
...lines.slice(section.end),
]
.join('\n')
.replace(TRAILING_BLANK_LINES, '\n'),
'utf8',
);
return { bullets: bulletsMoved, file };
}
async function main() {
const argv = process.argv.slice(2);
const taxonomyFlag = argv.indexOf('--taxonomy');
const explicitFiles =
taxonomyFlag === -1
? argv
: [...argv.slice(0, taxonomyFlag), ...argv.slice(taxonomyFlag + 2)];
taxonomy = await import(
pathToFileURL(
resolveCompanionFile(
TAXONOMY_FILE,
taxonomyFlag === -1 ? undefined : argv[taxonomyFlag + 1],
'--taxonomy',
),
).href
);
GROUP_HEADINGS_ALREADY_WRITTEN = new Set(
Object.values(taxonomy.GROUP_HEADING),
);
const targets =
explicitFiles.length > 0 ? explicitFiles : changelogsModifiedSinceHead();
if (targets.length === 0) abort('no modified CHANGELOG.md files');
const leadWithScope = !taxonomy.repositoryDeclaresWorkspaceMembers();
const results = targets.map((file) =>
regroupNewestSection(file, leadWithScope),
);
process.stdout.write(
`${JSON.stringify(
{ grouping: 'type', results, scopeShown: leadWithScope },
undefined,
2,
)}\n`,
);
}
try {
await main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
process.exitCode = 1;
}