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
38 changes: 32 additions & 6 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
208 changes: 208 additions & 0 deletions scripts/cut-changelog.mjs
Original file line number Diff line number Diff line change
@@ -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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept hyphens in valid prerelease versions

When workflow_dispatch supplies a custom version such as 6.3.0-rc-1, the build and npm publishing steps accept that valid SemVer prerelease, but this narrower regex rejects it in the subsequent create-release job. Because changelog cutting runs after publishing, the workflow then stops before creating the release commit, tag, and GitHub release, leaving already-published packages stranded; validate with npm's SemVer parser rather than this regex.

AGENTS.md reference: AGENTS.md:L42-L42

Useful? React with 👍 / 👎.

console.error('usage: cut-changelog.mjs --version <x.y.z> [--date <yyyy-mm-dd>] [--from-tag <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');
Loading