From f4b73acee9e33ced5ce542caa05f9ddae7fc554f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:24:00 +0000 Subject: [PATCH] ci: cut changelogs as part of the release commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish workflow bumped versions and committed, but nothing moved the curated [Unreleased] block, so 6.0.4 through 6.2.0 shipped to npm with no heading in any changelog and their entries piled up as "pending". Add scripts/cut-changelog.mjs and run it in create-release before the release commit. It moves the curated block under the released version, restores a bare [Unreleased], and sweeps the root changelog plus every lockstep packages/*/CHANGELOG.md. Notes: - Curated entries are authoritative; commit subjects are a fallback for the root changelog only when its block is empty, so a package with nothing pending gets no heading instead of duplicated noise. - packages/sdk-rust is excluded — it ships on its own crates.io version line. New package changelogs are swept by default. - Prereleases leave the block pending. - A pending level higher than the actual bump warns rather than fails; the step runs after npm publish, so it must never break a release. - The rebase-onto-main retry loop now resolves a changelog conflict (a PR curating [Unreleased] mid-release) by taking main's copy and re-cutting it, instead of failing a release that is already published. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TRUCraRPGnM6CP59qoxyd6 --- .github/workflows/publish-npm.yml | 38 +++++- AGENTS.md | 2 +- scripts/cut-changelog.mjs | 208 ++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 scripts/cut-changelog.mjs diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index aa6f6c02..3a920b4d 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -293,6 +293,17 @@ jobs: name: build-output path: . + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.22.3" + + # Move the curated [Unreleased] entries under the version being released + # and restore a bare [Unreleased], so a published version always has a + # heading. Prereleases are left pending. + - name: Cut changelogs + run: node scripts/cut-changelog.mjs --version "${{ needs.build.outputs.new_version }}" + - name: Commit and tag run: | git config user.name "GitHub Actions" @@ -301,20 +312,35 @@ jobs: NEW_VERSION="${{ needs.build.outputs.new_version }}" git add package.json package-lock.json packages/*/package.json + git add CHANGELOG.md packages/*/CHANGELOG.md if ! git diff --staged --quiet; then git commit -m "chore(release): v${NEW_VERSION}" # main can advance during the (multi-minute) build — e.g. a # concurrent SDK publish — so rebase onto the latest main and retry - # to avoid non-fast-forward rejections. The release commit only - # touches version metadata, so it rebases cleanly onto another - # release's commit. + # to avoid non-fast-forward rejections. Version metadata rebases + # cleanly; a changelog can conflict when a PR curated [Unreleased] + # during the build, so take main's copy and re-cut it rather than + # failing a release whose packages are already published. pushed=false for attempt in 1 2 3 4 5; do git fetch origin main if ! git rebase origin/main; then - git rebase --abort - echo "::error::rebase onto origin/main failed (unexpected conflict)" - exit 1 + conflicted=$(git diff --name-only --diff-filter=U) + if [ -z "$conflicted" ] || echo "$conflicted" | grep -qvE '(^|/)CHANGELOG\.md$'; then + git rebase --abort + echo "::error::rebase onto origin/main failed (unexpected conflict)" + exit 1 + fi + echo "changelog conflict on ${conflicted}; re-cutting against main" + # During a rebase --ours is the upstream side (origin/main). + echo "$conflicted" | xargs git checkout --ours -- + node scripts/cut-changelog.mjs --version "$NEW_VERSION" + echo "$conflicted" | xargs git add -- + if ! GIT_EDITOR=true git rebase --continue; then + git rebase --abort + echo "::error::failed to resolve changelog conflict during rebase" + exit 1 + fi fi if git push origin HEAD:main; then pushed=true diff --git a/AGENTS.md b/AGENTS.md index 190c52b3..be26b30a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Relaycast is headless Slack for agents: channels, threads, DMs, reactions, files - Curate the unreleased section in `CHANGELOG.md` for cross-package or user-facing release notes. - An empty post-release changelog starts at `[Unreleased]`. The first pending user-visible change must set the heading to `[Unreleased - Patch]`, `[Unreleased - Minor]`, or `[Unreleased - Major]` according to its SemVer impact. - The pending release level is monotonic: `Patch < Minor < Major`. Raise the heading when a higher-impact change arrives; never lower it for a later lower-impact change, and leave it unchanged for another change at the same level. -- When a release is cut, move the pending entries under the released version and restore an empty `[Unreleased]` heading with no release level. +- When a release is cut, the publish workflow runs `scripts/cut-changelog.mjs`, which moves the pending entries under the released version and restores an empty `[Unreleased]` heading with no release level. Do not hand-cut a release; curate `[Unreleased]` and let the release commit do it. (The Rust SDK changelog is excluded — it ships on its own crates.io version line.) - Add package-level API and migration detail to the relevant `packages/*/CHANGELOG.md` when one exists. - Apply the same release-level heading rules to any package changelog that receives a pending entry. - Keep entries concise and impact-first: one short bullet per user-visible change. diff --git a/scripts/cut-changelog.mjs b/scripts/cut-changelog.mjs new file mode 100644 index 00000000..8e5fba38 --- /dev/null +++ b/scripts/cut-changelog.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +/** + * Cut the curated `[Unreleased]` block of every lockstep changelog into a + * released heading, then restore a bare `[Unreleased]`. + * + * Curated entries are authoritative (see AGENTS.md "Changelog"). Commit + * subjects are only a fallback for the root changelog when its pending block + * is empty; a package changelog with nothing pending is left untouched, so the + * release simply gets no heading there. + * + * Usage: + * node scripts/cut-changelog.mjs --version 6.3.0 + * [--date 2026-07-25] [--from-tag v6.2.0] + * [--dry-run] + */ + +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); + +function flag(name) { + const index = args.indexOf(`--${name}`); + return index === -1 ? undefined : args[index + 1]; +} + +const version = flag('version'); +if (!version || !/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(version)) { + console.error('usage: cut-changelog.mjs --version [--date ] [--from-tag ] [--dry-run]'); + process.exit(1); +} + +// Prereleases publish off the same bump but do not close a release line, so the +// pending entries stay pending until the stable version ships. +if (version.includes('-')) { + console.log(`prerelease v${version}: leaving [Unreleased] in place`); + process.exit(0); +} + +const date = flag('date') ?? new Date().toISOString().slice(0, 10); +if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + console.error(`invalid --date: ${date}`); + process.exit(1); +} + +// The Rust SDK is published to crates.io on its own version line, so an npm +// release never closes its pending block. Every other package changelog is +// versioned in lockstep with the npm packages (the Swift SDK ships off the +// repo's release tag), including ones added later. +const EXCLUDED_PACKAGES = new Set(['sdk-rust']); + +const UNRELEASED = /^## \[Unreleased(?: - (?:Patch|Minor|Major))?\][ \t]*\n([\s\S]*?)(?=^## \[|(?![\s\S]))/m; + +const SECTION_BY_TYPE = new Map([ + ['feat', 'Added'], + ['fix', 'Fixed'], + ['perf', 'Changed'], + ['revert', 'Changed'], + ['deprecate', 'Deprecated'], + ['deprecated', 'Deprecated'], + ['remove', 'Removed'], + ['removed', 'Removed'], + ['security', 'Security'], +]); + +const SECTION_ORDER = [ + 'Breaking Changes', + 'Added', + 'Changed', + 'Deprecated', + 'Removed', + 'Fixed', + 'Security', +]; + +function warn(message) { + console.warn(process.env.GITHUB_ACTIONS ? `::warning::${message}` : `warning: ${message}`); +} + +function git(command) { + return execSync(command, { encoding: 'utf-8' }).trim(); +} + +function lastStableTag() { + const explicit = flag('from-tag'); + if (explicit) return explicit; + const tags = git('git tag -l --sort=-v:refname') + .split('\n') + .map(tag => tag.trim()) + .filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)); + return tags[0]; +} + +function levelOf(fromVersion, toVersion) { + const from = fromVersion.split('.').map(Number); + const to = toVersion.split('.').map(Number); + if (to[0] !== from[0]) return 'Major'; + if (to[1] !== from[1]) return 'Minor'; + return 'Patch'; +} + +/** Commit subjects since `fromTag`, grouped into Keep a Changelog sections. */ +function fallbackBody(fromTag) { + if (!fromTag) return ''; + const subjects = git(`git log ${fromTag}..HEAD --no-merges --pretty=format:%s`) + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + + const sections = new Map(SECTION_ORDER.map(section => [section, []])); + + for (const subject of subjects) { + const parsed = subject.match(/^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/i); + if (!parsed) continue; + const [, typeRaw, scope = '', bang, titleRaw] = parsed; + const type = typeRaw.toLowerCase(); + if (type === 'chore' && scope.toLowerCase() === 'release') continue; + + const section = bang ? 'Breaking Changes' : SECTION_BY_TYPE.get(type); + // Everything else (chore/docs/ci/test/build/style/refactor) is not part of + // the release narrative. + if (!section) continue; + + const title = titleRaw + .replace(/\s*\(#\d+[^)]*\)/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!title) continue; + + const entry = `${title.charAt(0).toUpperCase()}${title.slice(1)}`; + const entries = sections.get(section); + if (!entries.includes(entry)) entries.push(entry); + } + + const lines = []; + for (const section of SECTION_ORDER) { + const entries = sections.get(section); + if (entries.length === 0) continue; + lines.push(`### ${section}`, ''); + for (const entry of entries) lines.push(`- ${entry}`); + lines.push(''); + } + return lines.join('\n').trimEnd(); +} + +/** + * Replace the pending block in `file` with a released heading. + * @returns the pending release level when the file was cut, otherwise null. + */ +function cut(file, { fallback = '' } = {}) { + const changelog = readFileSync(file, 'utf-8'); + const match = changelog.match(UNRELEASED); + if (!match) { + warn(`${file}: no [Unreleased] heading, skipped`); + return null; + } + + const curated = match[1].trim(); + const body = curated || fallback; + if (!body) { + console.log(`${file}: nothing pending, left unchanged`); + return null; + } + + const start = match.index; + const end = start + match[0].length; + const updated = + changelog.slice(0, start) + + `## [Unreleased]\n\n## [${version}] - ${date}\n\n${body}\n\n` + + changelog.slice(end); + + if (!dryRun) writeFileSync(file, updated); + console.log(`${file}: cut [${version}] (${curated ? 'curated' : 'from commit subjects'})`); + return match[0].match(/\[Unreleased - (Patch|Minor|Major)\]/)?.[1] ?? null; +} + +const fromTag = lastStableTag(); +if (!fromTag) warn('no previous stable tag found; commit-subject fallback disabled'); +else console.log(`cutting v${version} (${date}), pending since ${fromTag}`); + +const packageChangelogs = readdirSync('packages', { withFileTypes: true }) + .filter(entry => entry.isDirectory() && !EXCLUDED_PACKAGES.has(entry.name)) + .map(entry => path.join('packages', entry.name, 'CHANGELOG.md')) + .filter(existsSync) + .sort(); + +const levels = []; +for (const file of ['CHANGELOG.md', ...packageChangelogs]) { + const level = cut(file, { fallback: file === 'CHANGELOG.md' ? fallbackBody(fromTag) : '' }); + if (level) levels.push({ file, level }); +} + +// The pending heading records the SemVer impact of what is being released; a +// release smaller than that impact is a mis-bump worth surfacing (after the +// fact — this runs post-publish, so it never fails the release). +if (fromTag) { + const actual = levelOf(fromTag.replace(/^v/, ''), version); + const rank = { Patch: 0, Minor: 1, Major: 2 }; + for (const { file, level } of levels) { + if (rank[level] > rank[actual]) { + warn(`${file} pending entries are marked ${level} but v${version} is a ${actual} bump`); + } + } +} + +if (dryRun) console.log('dry run: no files written');