Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions .agents/skills/minor-release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
name: minor-release
description: Write the changelog entry for a new minor or major Storybook release. Use when preparing a CHANGELOG.md entry for a X.Y.0 version.
allowed-tools: Bash, Read, Write, Edit
---

# Write Minor/Major Release Changelog

Assembles and writes a polished changelog entry for a Storybook minor or major (`X.Y.0`) release into `CHANGELOG.md`.

## Rules — read before doing anything

- **The two helper scripts are at `.agents/skills/minor-release/get-minor-changelog-summary.ts` and `.agents/skills/minor-release/write-minor-changelog-section.ts`. Do not search for them anywhere else in the repo. Do not look in `scripts/`, `package.json`, `AGENTS.md`, or elsewhere. Do not read the script source files. Just run them.**
- **Do not read `CHANGELOG.md` or `CHANGELOG.prerelease.md` before running the scripts.** The scripts handle version detection and entry collection automatically.
- **Do not read `CHANGELOG.md` for style guidance.** The style examples in Step 3 of this skill are the authoritative reference. Do not grep or read the changelog to infer format.
- Run all commands from the repository root.

## Step 1: Collect the changelog entries

Run the helper script to see all unique changelog entries from the prereleases, with patch-backported changes filtered out:

```bash
node .agents/skills/minor-release/get-minor-changelog-summary.ts [version]
```

- Omit `[version]` to auto-detect from the most recent prerelease in `CHANGELOG.prerelease.md`
- Pass `--verbose` to see which prerelease versions were found and how many patch PRs were filtered
- The output is a plain list of `- Entry text` lines, one per change

Read the output carefully — you will use these entries in Step 2 to choose highlights.

## Step 2: Select highlights

From the entries collected in Step 1, identify the **4–8 most significant changes** to feature as highlights. If fewer than 4 significant changes exist, include all available significant changes rather than padding the list with minor items. Good candidates:

- Major new features or capabilities
- New framework/ecosystem support (new renderer, builder, or major version support)
- Prominent DX or performance improvements
- Significant experimental features worth calling out

Exclude from highlights (they still belong in the full list):

- Bug fixes
- Maintenance, cleanup, dependency updates
- Minor improvements or internal refactors
- Reverts

## Step 3: Compose the highlights text

Based on the entries from Step 2, write the highlights section — the text that goes between the `## X.Y.0` heading and the full entry list. This is what you will pass to the write script. Follow the `Writing style` rules below.

### Writing style

The highlights text should contain only these parts, in order:

- An optional tagline line in the form `> _..._`
- One intro sentence
- One or more highlight bullet lists

Do not include the `## X.Y.0` heading — the script adds that.

The default format is:

```text
> _Tagline phrase_

Storybook X.Y [intro sentence]:

- 🔣 Highlight one: brief description
- 🔣 Highlight two: brief description
- 🔣 Highlight three: brief description
```

For releases with 7-8 highlights, a second group of bullets with its own intro sentence is fine (see 10.1.0 example).

**Style rules:**
- The tagline is wrapped in `> _italics_`. It is a short (5–10 word) noun phrase summarising the release theme — no verbs, no "we".
- Each highlight bullet: relevant emoji, feature/area name, brief description. Terse and specific.
- The intro sentence uses `Storybook X.Y contains hundreds of fixes and improvements including:` for most releases. For a major version, the intro is more impactful (see 10.0.0 example).

### Examples

**10.3.0** — a typical release with a single highlight group:

```text
> _Improved developer experience, AI-assisting tools, and broader ecosystem support_

Storybook 10.3 contains hundreds of fixes and improvements including:

- 🤖 Storybook MCP: Agentic component dev, docs, and test (Preview release for React)
- ⚡ Vite 8 support
- ▲ Next.js 16.2 support
- 📝 ESLint 10 support
- 〰️ Addon Pseudo-States: Tailwind v4 support
- 🔧 Addon-Vitest: Simplified configuration - no more setup files required
- ♿ Numerous accessibility improvements across the UI
```

Comment thread
JReinhold marked this conversation as resolved.
**10.1.0** — a release with multiple highlight groups:

```text
> _Easier to setup, more accessible to use_

Storybook 10.1 focuses on two key improvements: installation and accessibility:

- ♿ UI overhaul to fix hundreds of a11y issues
- 🧑‍💻 CLI overhaul for faster, more reliable install
- ✅ Checklist-based onboarding guide for new users

The release also contains compatibility fixes for:

- 🅰️ Angular 21 support
- 🦀 RSbuild install support in CLI
- ⚡️ Preact support for Vitest addon

Finally, it contains two highly-requested experimental features:

- 📋 Component manifest for Storybook MCP
- ⚛️ Improved JSX code snippets for React
```

**10.0.0** — a major release with a more impactful intro (no `>` tagline, two-sentence intro):

```text
Storybook 10 contains one breaking change: it's ESM-only. This simplifies our distribution and reduces install size by 29% while simultaneously unminifying dist code for easier debugging.
It also includes features to level up your UI development, documentation, and testing workflows:

- 🧩 Module automocking for easier testing
- 🏭 Typesafe CSF factories Preview for React
- 💫 UI editing and sharing optimizations
- 🏷️ Tag filtering exclusion and configuration for sidebar management
- 🔀 Next 16, Vitest 4, Svelte async components, and more!
```

## Step 4: Write the changelog entry

Pass the highlights text to the write script via stdin. The script will call the summary script internally, combine the full entry list with your highlights, and write the complete section to `CHANGELOG.md` — inserting at the top or overwriting any existing section for this version.

```bash
node .agents/skills/minor-release/write-minor-changelog-section.ts [version] << 'HIGHLIGHTS'
> _Your tagline here_

Storybook X.Y contains hundreds of fixes and improvements including:

- 🔣 Highlight one
- 🔣 Highlight two
HIGHLIGHTS
```

Use `--dry-run` to preview the composed section before writing:

```bash
node .agents/skills/minor-release/write-minor-changelog-section.ts --dry-run << 'HIGHLIGHTS'
...
HIGHLIGHTS
```
206 changes: 206 additions & 0 deletions .agents/skills/minor-release/get-minor-changelog-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { extname } from 'node:path';
import { fileURLToPath } from 'node:url';

import { Command } from 'commander';
import picocolors from 'picocolors';
import semver from 'semver';

const PRERELEASE_CHANGELOG_PATH = fileURLToPath(
new URL('../../../CHANGELOG.prerelease.md', import.meta.url)
);
const STABLE_CHANGELOG_PATH = fileURLToPath(new URL('../../../CHANGELOG.md', import.meta.url));

const program = new Command();

program
.name('get-minor-changelog-summary')
.description(
'Collects all changelog entries from prereleases of a minor/major version, filtering out changes that were already shipped in patch releases of the previous minor. If no version is given, the stable version of the most recent prerelease is used.'
)
.arguments('[version]')
.option('-V, --verbose', 'Enable verbose logging', false);

/**
* Parse a markdown changelog file into a map of version string -> section body.
* Sections are delineated by `## <version>` headings.
*/
function parseChangelogSections(content: string): Map<string, string> {
const sections = new Map<string, string>();
const parts = content.split(/^## /m);
Comment thread
JReinhold marked this conversation as resolved.
for (const part of parts) {
if (!part.trim()) {
continue;
}
const newlineIdx = part.indexOf('\n');
const version = newlineIdx === -1 ? part.trim() : part.slice(0, newlineIdx).trim();
const body = newlineIdx === -1 ? '' : part.slice(newlineIdx + 1);
sections.set(version, body);
}
Comment thread
JReinhold marked this conversation as resolved.
return sections;
}

/** Extract all PR numbers referenced in a changelog section body. */
function extractPRNumbers(content: string): Set<number> {
const prNumbers = new Set<number>();
const prRegex = /\[#(\d+)\]\(https:\/\/github\.com\//g;
let match;
while ((match = prRegex.exec(content)) !== null) {
prNumbers.add(parseInt(match[1], 10));
}
return prNumbers;
}

/** Extract individual changelog list items (lines starting with `- `) from a section body. */
function extractEntries(content: string): string[] {
return content
.split('\n')
.filter((line) => line.startsWith('- '))
.map((line) => line.trim());
}

export const getMinorChangelogSummary = async (args: { version?: string; verbose?: boolean }) => {
const { verbose } = args;

const log = (...msg: unknown[]) => {
if (verbose) {
console.error(...msg);
}
};

const [prereleaseChangelog, stableChangelog] = await Promise.all([
readFile(PRERELEASE_CHANGELOG_PATH, 'utf-8'),
readFile(STABLE_CHANGELOG_PATH, 'utf-8'),
]);

const preSections = parseChangelogSections(prereleaseChangelog);
const stableSections = parseChangelogSections(stableChangelog);

let version = args.version;
if (!version) {
const firstPrerelease = [...preSections.keys()][0];
if (!firstPrerelease) {
throw new Error(
`No prerelease entries found in ${picocolors.green(PRERELEASE_CHANGELOG_PATH)}`
);
}
const p = semver.parse(firstPrerelease);
if (!p) {
throw new Error(
`Could not parse version from first prerelease entry: ${picocolors.red(firstPrerelease)}`
);
}
version = `${p.major}.${p.minor}.0`;
log(
`🔍 No version specified, inferred ${picocolors.blue(version)} from most recent prerelease ${picocolors.green(firstPrerelease)}`
);
}
Comment thread
JReinhold marked this conversation as resolved.

const parsed = semver.parse(version);
if (!parsed || parsed.patch !== 0 || parsed.prerelease.length > 0) {
throw new Error(
`Version must be a stable minor or major release (e.g. 10.4.0), got: ${picocolors.red(version)}`
);
}

const { major, minor } = parsed;

// Collect all prerelease versions for the target (e.g. 10.4.0-alpha.*, 10.4.0-beta.*, 10.4.0-rc.*)
const prereleaseVersionPrefix = `${major}.${minor}.0-`;
const prereleaseVersions = [...preSections.keys()]
.filter((v) => v.startsWith(prereleaseVersionPrefix))
.sort((a, b) => semver.compare(a, b));

if (prereleaseVersions.length === 0) {
throw new Error(
`No prerelease versions found for ${picocolors.blue(version)} in ${picocolors.green(PRERELEASE_CHANGELOG_PATH)}`
);
}

log(
`🔍 Found ${picocolors.blue(prereleaseVersions.length)} prerelease version(s): ${prereleaseVersions.join(', ')}`
);

// Collect PR numbers that already shipped in patch releases of the previous minor (e.g. 10.3.1+)
const patchPRNumbers = new Set<number>();

if (minor > 0) {
const prevMinor = minor - 1;
const patchVersions = [...stableSections.keys()].filter((v) => {
const p = semver.parse(v);
return p && p.major === major && p.minor === prevMinor && p.patch > 0;
});

log(
`🔍 Patch versions to filter out: ${picocolors.yellow(patchVersions.join(', ') || 'none')}`
);

for (const pv of patchVersions) {
const body = stableSections.get(pv)!;
for (const pr of extractPRNumbers(body)) {
patchPRNumbers.add(pr);
}
}

log(
`🔍 Found ${picocolors.yellow(patchPRNumbers.size)} PR(s) already shipped in patch releases — these will be filtered out`
);
} else {
log(`ℹ️ Version ${picocolors.blue(version)} is a major release, no patches to filter`);
}

// Aggregate changelog entries from all prereleases, deduplicating by PR number
const entriesByPR = new Map<number, string>();
const directCommitEntries: string[] = [];

for (const preVersion of prereleaseVersions) {
const body = preSections.get(preVersion)!;
const entries = extractEntries(body);

for (const entry of entries) {
const prMatch = entry.match(/\[#(\d+)\]/);
if (prMatch) {
const prNum = parseInt(prMatch[1], 10);
if (!patchPRNumbers.has(prNum) && !entriesByPR.has(prNum)) {
entriesByPR.set(prNum, entry);
}
} else if (!directCommitEntries.includes(entry)) {
directCommitEntries.push(entry);
}
}
}

const allEntries = [...entriesByPR.values(), ...directCommitEntries].sort();
const text = allEntries.join('\n');

log(`\n✅ Generated changelog summary (${picocolors.blue(allEntries.length)} entries):\n`);

return { text, entryCount: allEntries.length, resolvedVersion: version };
};

function esMain(url: string): boolean {
if (!url || !process.argv[1]) {
return false;
}
const require = createRequire(url);
const scriptPath = require.resolve(process.argv[1]);
const modulePath = fileURLToPath(url);
const extension = extname(scriptPath);
return extension
? modulePath === scriptPath
: modulePath.slice(0, -extname(modulePath).length) === scriptPath;
}

if (esMain(import.meta.url)) {
const parsed = program.parse();
const [version] = parsed.args;
if (version && !semver.valid(version)) {
console.error(
`🚨 Invalid argument, expected a semver version e.g. ${picocolors.green('10.4.0')}, got: ${picocolors.red(version)}`
);
process.exit(1);
}
const { text } = await getMinorChangelogSummary({ version, verbose: parsed.opts().verbose });
console.log(text);
}
Loading
Loading