diff --git a/.agents/skills/minor-release/SKILL.md b/.agents/skills/minor-release/SKILL.md new file mode 100644 index 000000000000..a8f23eb7025c --- /dev/null +++ b/.agents/skills/minor-release/SKILL.md @@ -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 +``` + +**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 +``` diff --git a/.agents/skills/minor-release/get-minor-changelog-summary.ts b/.agents/skills/minor-release/get-minor-changelog-summary.ts new file mode 100644 index 000000000000..86a5599904fe --- /dev/null +++ b/.agents/skills/minor-release/get-minor-changelog-summary.ts @@ -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 `## ` headings. + */ +function parseChangelogSections(content: string): Map { + const sections = new Map(); + const parts = content.split(/^## /m); + 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); + } + return sections; +} + +/** Extract all PR numbers referenced in a changelog section body. */ +function extractPRNumbers(content: string): Set { + const prNumbers = new Set(); + 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)}` + ); + } + + 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(); + + 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(); + 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); +} diff --git a/.agents/skills/minor-release/write-minor-changelog-section.ts b/.agents/skills/minor-release/write-minor-changelog-section.ts new file mode 100644 index 000000000000..5e6f39fe3558 --- /dev/null +++ b/.agents/skills/minor-release/write-minor-changelog-section.ts @@ -0,0 +1,156 @@ +import { readFile, writeFile } 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'; + +import { getMinorChangelogSummary } from './get-minor-changelog-summary.ts'; + +const CHANGELOG_PATH = fileURLToPath(new URL('../../../CHANGELOG.md', import.meta.url)); + +const program = new Command(); + +program + .name('write-minor-changelog-section') + .description( + 'Writes a minor/major release section to CHANGELOG.md. Reads the highlights text (tagline + intro + bullet points) from stdin, collects the full entry list automatically, and assembles + writes the complete section.' + ) + .arguments('[version]') + .option('--verbose', 'Enable verbose logging', false) + .option('-d, --dry-run', 'Print the composed section instead of writing to CHANGELOG.md', false); + +/** Read all of stdin, or return empty string if stdin is a TTY (interactive). */ +async function readStdin(): Promise { + if (process.stdin.isTTY) { + return ''; + } + const chunks: Array = []; + for await (const chunk of process.stdin) { + if (typeof chunk === 'string' || Buffer.isBuffer(chunk)) { + chunks.push(chunk.toString()); + } + } + return chunks.join('').trim(); +} + +/** + * Insert or replace the X.Y.0 section in the changelog content. + * - If a `## X.Y.0` heading already exists, the entire section is replaced. + * - Otherwise the new section is prepended to the top of the file. + */ +function insertOrReplaceSection(content: string, version: string, section: string): string { + const heading = `## ${version}`; + + // Section may be at the very start of the file, or after a newline + let startIdx: number; + if (content.startsWith(heading + '\n')) { + startIdx = 0; + } else { + const idx = content.indexOf('\n' + heading + '\n'); + startIdx = idx === -1 ? -1 : idx + 1; // +1 to skip the leading \n + } + + if (startIdx !== -1) { + // Find where this section ends (the next `## ` heading) + const afterHeading = startIdx + heading.length + 1; + const nextSectionIdx = content.indexOf('\n## ', afterHeading); + + if (nextSectionIdx !== -1) { + return content.slice(0, startIdx) + section + '\n' + content.slice(nextSectionIdx + 1); + } else { + return content.slice(0, startIdx) + section; + } + } else { + // No existing section — prepend to the top + return section + '\n' + content; + } +} + +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 opts = program.parse(); + const [version] = opts.args; + const { verbose, dryRun } = opts.opts(); + + const log = (...msg: unknown[]) => { + if (verbose) { + console.error(...msg); + } + }; + + 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); + } + + (async () => { + // 1. Read highlights from stdin + const highlights = await readStdin(); + if (!highlights) { + const invokedScriptPath = + process.argv[1] ?? '.agents/skills/minor-release/write-minor-changelog-section.ts'; + console.error( + `🚨 No highlights provided via stdin.\n\nUsage:\n echo "highlights text" | node ${invokedScriptPath} [version]\n\nThe highlights text should contain the tagline, intro sentence, and bullet points — everything except the ## heading and the full entry list.` + ); + process.exit(1); + } + + // 2. Collect the full entry list (also resolves the version if not provided) + const { text: entries, resolvedVersion } = await getMinorChangelogSummary({ + version, + verbose, + }); + + log(`\n📝 Composing changelog section for ${picocolors.blue(resolvedVersion)}...`); + + // 3. Assemble the full section + const section = [ + `## ${resolvedVersion}`, + '', + highlights, + '', + '
', + 'List of all updates', + '', + entries, + '', + '
', + '', + ].join('\n'); + + if (dryRun) { + console.log(section); + return; + } + + // 4. Read CHANGELOG.md, insert/replace the section, write back + const existing = await readFile(CHANGELOG_PATH, 'utf-8'); + const updated = insertOrReplaceSection(existing, resolvedVersion, section); + + await writeFile(CHANGELOG_PATH, updated, 'utf-8'); + log(`✅ Written to ${picocolors.green(CHANGELOG_PATH)}`); + console.error( + `✅ CHANGELOG.md updated for ${picocolors.blue(resolvedVersion)} (${picocolors.yellow(entries.split('\n').length)} entries)` + ); + })().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a83b724791b..884e765a3d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ ## 10.3.0 -_> Improved developer experience, AI-assisting tools, and broader ecosystem support_ +> _Improved developer experience, AI-assisting tools, and broader ecosystem support_ Storybook 10.3 contains hundreds of fixes and improvements including: