// Renders GitHub Release notes for an aggregate release tag. // // A release commit carries every tag the release produced: the aggregate tag // (`release-beta/2026-08-26.5`) and one `name@version` tag per package that // `changeset publish` actually published. That is the whole manifest, recorded // by the release itself — this script reads it back rather than reconstructing it. // // Everything is read out of the tagged commit with `git show`, never from the // working tree, so notes for any past release can be rendered at any later time. // // Usage: // node scripts/release-notes.mjs print the notes // node scripts/release-notes.mjs --packages print `name@version` per line import { execFileSync } from 'node:child_process'; const AGGREGATE_PATTERN = /^release(-beta)?\/\d{4}-\d{2}-\d{2}\.\d+$/u; // GitHub rejects bodies over 125,000 characters. Leave room below that limit; // UTF-16 length also conservatively counts non-BMP characters twice. export const RELEASE_BODY_LIMIT = 120_000; export function fitReleaseNotes(header, sections) { const render = (bodies, notice = '') => `${header}${notice}\n\n${bodies.join('\n\n')}\n`; const bodies = sections.map(({ full }) => full); const full = render(bodies); if (full.length <= RELEASE_BODY_LIMIT) return full; const notice = '\n\n_Some long package entries are linked to their full changelogs to fit GitHub’s release body limit._'; // Replace whole sections, largest savings first, so Markdown stays intact and // the remaining entries keep their original order and complete content. const candidates = sections .map(({ full, compact }, index) => ({ index, compact, savings: full.length - compact.length, })) .filter(({ savings }) => savings > 0) .sort((left, right) => right.savings - left.savings); let length = full.length + notice.length; for (const { index, compact, savings } of candidates) { bodies[index] = compact; length -= savings; if (length <= RELEASE_BODY_LIMIT) return render(bodies, notice); } throw new Error( `Release notes exceed ${RELEASE_BODY_LIMIT} characters even with compact changelog links`, ); } function git(args) { return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }); } // `cwd` defaults to the process directory, which is what the release workflow wants. Tests override it to read a // throwaway repository built in a specific layout. function gitOrUndefined(args, { cwd } = {}) { try { return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], }); } catch { return undefined; } } export function parseAggregateTag(tag) { if (!AGGREGATE_PATTERN.test(tag)) { throw new Error(`Not an aggregate release tag: ${tag}`); } const [prefix, batch] = tag.split('/'); return { batch, channel: prefix === 'release-beta' ? 'beta' : 'stable' }; } // A package tag is `@`, and scoped names carry their own `@`. // Splitting on the last `@` is what keeps `@nocobase/create-app@0.1.0-beta.4` // from parsing as an empty name. export function parsePackageTag(tag) { const at = tag.lastIndexOf('@'); if (at <= 0) return undefined; const name = tag.slice(0, at); const version = tag.slice(at + 1); if (!name || !version || !/^\d/u.test(version)) return undefined; return { name, version }; } export function readPackagesFromTags(tags) { return tags .map((tag) => parsePackageTag(tag)) .filter(Boolean) .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0, ); } // Reads `## ` out of a CHANGELOG, stopping at the next `## ` heading. // Changesets writes exactly one such heading per released version. export function extractChangelogSection(changelog, version) { const lines = changelog.replaceAll('\r\n', '\n').split('\n'); const heading = `## ${version}`; const start = lines.findIndex((line) => line.trimEnd() === heading); if (start === -1) return undefined; let end = lines.length; for (let index = start + 1; index < lines.length; index += 1) { if (/^##\s/u.test(lines[index])) { end = index; break; } } return ( lines .slice(start + 1, end) .join('\n') .trim() || undefined ); } // Lists the directory of every published package in the tagged tree, as a path relative to `packages/`. // // Both layouts have to work. `packages/` was flat until the repository regrouped it into // `packages//`, so a tag cut before that move points at a tree whose manifests sit one level deep, // while a tag cut after it points at a tree two levels deep. Release notes are rendered from whichever commit the tag // names, including tags from before the move, and a layout mismatch fails silently: `resolveDirectories` just returns // an empty map and every package renders as "no changelog entry". Reading the tree itself rather than assuming a depth // keeps both eras — and any later regrouping — working. // // `-r` walks the whole tree, so it also reports manifests nested inside a package: `app-host` ships five of them under // `fixtures/app-dist/`. A nested manifest is never a published package, and keeping it would let it overwrite the entry // for the package that contains it, because `resolveDirectories` keys by name and the last writer wins. Whether that // happens at all comes down to which path git sorts last, which is far too subtle to rely on, so descendants are // dropped here instead. export function findPackageDirectories(commit, { cwd } = {}) { const listing = gitOrUndefined( ['ls-tree', '-r', '--name-only', `${commit}:packages`], { cwd }, ); if (listing === undefined) { throw new Error( `Cannot read packages/ from ${commit} — the tagged tree could not be listed`, ); } const directories = listing .split('\n') .filter((entry) => entry.endsWith('/package.json')) .map((entry) => entry.slice(0, -'/package.json'.length)) .sort(); // Sorting puts a parent directly before everything nested under it, so one pass over the sorted list is enough. const roots = []; for (const directory of directories) { const parent = roots.at(-1); if (parent !== undefined && directory.startsWith(`${parent}/`)) continue; roots.push(directory); } return roots; } // Maps published package names back to their directory in the tagged tree, so // the changelog can be read from the same commit the release was cut from. export function resolveDirectories(commit, directories, { cwd } = {}) { const byName = new Map(); for (const directory of directories) { const raw = gitOrUndefined( ['show', `${commit}:packages/${directory}/package.json`], { cwd }, ); if (!raw) continue; try { const { name } = JSON.parse(raw); if (name) byName.set(name, directory); } catch { // A package.json that does not parse cannot be matched to a tag; the // package simply renders without changelog details. } } return byName; } // Changesets can repeat a commit once per dependency change. Collapse each // generated run into one summary, keeping every distinct commit and leaving // the following dependency/version list attached to that summary. export function deduplicateDependencyUpdates(section) { const output = []; const commits = new Set(); let fence; const flush = () => { if (commits.size === 0) return; output.push(`- Updated dependencies ${[...commits].join(', ')}`); commits.clear(); }; for (const line of section.split('\n')) { const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line); if (fence) { if ( marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && marker[2].trim() === '' ) { fence = undefined; } output.push(line); continue; } if (marker) fence = marker[1]; const update = /^- Updated dependencies (\[[\da-f]+\])$/u.exec(line); if (update) { commits.add(update[1]); } else { flush(); output.push(line); } } flush(); return output.join('\n'); } export function renderReleaseNotes(tag) { const { batch, channel } = parseAggregateTag(tag); // The aggregate tag and the release branch share a name while a release is in // flight, so a bare ref is ambiguous — resolve through refs/tags/ explicitly. const commit = git(['rev-parse', `refs/tags/${tag}^{commit}`]).trim(); const tagsAtCommit = git(['tag', '--points-at', commit]) .split('\n') .filter(Boolean); const packages = readPackagesFromTags(tagsAtCommit); if (packages.length === 0) { throw new Error( `No package tags point at ${commit} — nothing was published for ${tag}`, ); } const directories = resolveDirectories( commit, findPackageDirectories(commit), ); // Resolving nothing while package tags exist means the tree was read but no manifest matched a published name — a // layout or tooling change the lookup no longer understands. Rendering would still "succeed", emitting a full set of // "no changelog entry" placeholders that reads like a release with no changes, so fail instead of publishing that. if (directories.size === 0) { throw new Error( `No package directories in ${commit} matched the ${packages.length} package tags for ${tag}`, ); } const label = channel === 'beta' ? 'Beta release' : 'Stable release'; const lines = [ `${label} \`${batch}\` published ${packages.length} package${packages.length === 1 ? '' : 's'}.`, '', '| Package | Version |', '| --- | --- |', ...packages.map((pkg) => `| \`${pkg.name}\` | \`${pkg.version}\` |`), ]; const sections = packages.map((pkg) => { const directory = directories.get(pkg.name); const changelog = directory ? gitOrUndefined(['show', `${commit}:packages/${directory}/CHANGELOG.md`]) : undefined; const section = changelog ? extractChangelogSection(changelog, pkg.version) : undefined; const heading = `## \`${pkg.name}@${pkg.version}\``; const full = `${heading}\n\n${section ? deduplicateDependencyUpdates(section) : '_No changelog entry was recorded for this version._'}`; const changelogUrl = directory ? `https://github.com/nocobase/nocobase/blob/${commit}/packages/${directory}/CHANGELOG.md#${pkg.version.replaceAll('.', '')}` : undefined; return { full, compact: changelogUrl ? `${heading}\n\n[Full changelog for this version](${changelogUrl})` : full, }; }); return fitReleaseNotes(lines.join('\n'), sections); } export function formatPackageList(tag) { const commit = git(['rev-parse', `refs/tags/${tag}^{commit}`]).trim(); const packages = readPackagesFromTags( git(['tag', '--points-at', commit]).split('\n').filter(Boolean), ); return packages.map((pkg) => `${pkg.name}@${pkg.version}`).join('\n'); } const isMain = process.argv[1]?.endsWith('release-notes.mjs'); if (isMain) { const [tag, ...flags] = process.argv.slice(2); try { if (!tag) throw new Error('Usage: release-notes.mjs '); const output = flags.includes('--packages') ? `${formatPackageList(tag)}\n` : renderReleaseNotes(tag); process.stdout.write(output); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; } }