diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9340aaf62..758dc0c72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,10 @@ jobs: - name: Test content-lint run: pnpm run content:lint:test + # Same reason: the release notes only run on a publish, too late to find out. + - name: Test release-notes + run: pnpm run release-notes:test + # A ratchet, not a floor: every file this pull request touched has to score # at least as well as the same file on the base branch, and introduce no # finding id it did not already carry. A fixed bar would fail a new page diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index c68c1eab5..a8e374ed2 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -4,13 +4,22 @@ on: pull_request_target: types: - opened + - reopened + - edited branches: - main +# One run per PR at a time. A queued run replaces an older queued one, which is fine: each run reads the PR's current state. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + jobs: add-pr-labels: name: Add PR labels - # One GitHub API call, no checkout and no build: a 1 vCPU runner is plenty. + # The type label sorts the PR in the release notes, so it follows the title, not the first draft of it. + if: github.event.action != 'edited' || github.event.changes.title.from != null + # A few GitHub API calls, no checkout and no build: a 1 vCPU runner is plenty. runs-on: ubuntu-slim timeout-minutes: 5 permissions: @@ -18,20 +27,15 @@ jobs: pull-requests: write steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} with: script: | - const labelsToAdd = [] - - const pullRequest = { - number: ${{ github.event.pull_request.number }}, - title: process.env.PULL_REQUEST_TITLE, - labelsNames: ${{ toJson(github.event.pull_request.labels.*.name) }} - } + // The payload is a snapshot from when the event fired; a later title edit may already be applied. + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: context.payload.pull_request.number }) + const { number, title } = pr + const current = pr.labels.map(l => l.name) - // Select label based on the type in PR title - const pullRequestTypeToLabelName = { + // Conventional Commits type → label from .github/release.yml + const typeLabels = { breaking: 'breaking', feat: 'feature', fix: 'bug', @@ -39,35 +43,28 @@ jobs: ci: 'ci', docs: 'documentation', enhancement: 'enhancement', - chore: 'dependencies', perf: 'performance', style: 'style', test: 'test', refactor: 'refactor', - revert: 'revert' + revert: 'revert', } - for (const [pullRequestType, labelName] of Object.entries( - pullRequestTypeToLabelName - )) { - if ( - pullRequest.title.startsWith(pullRequestType) && - !pullRequest.labelsNames.includes( - pullRequestTypeToLabelName[pullRequestType] - ) - ) { - labelsToAdd.push(labelName) - - break - } + const match = title.match(/^(\w+)(?:\(([^)]*)\))?(!)?:/) + const wanted = new Set() + if (match) { + const [, type, scope, bang] = match + if (typeLabels[type]) wanted.add(typeLabels[type]) + // Only dependency bumps are release-worthy chores; the others go to Other Changes. + if (type === 'chore' && scope === 'deps') wanted.add('dependencies') + if (bang) wanted.add('breaking') } - // Add selected labels - if (labelsToAdd.length > 0) { - await github.rest.issues.addLabels({ - issue_number: pullRequest.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: labelsToAdd - }) - } + // `breaking` can be set by hand on a PR whose title has no `!`, so it is never removed. + const managed = [...Object.values(typeLabels), 'dependencies'].filter(l => l !== 'breaking') + const stale = current.filter(l => managed.includes(l) && !wanted.has(l)) + const missing = [...wanted].filter(l => !current.includes(l)) + + const issue = { owner: context.repo.owner, repo: context.repo.repo, issue_number: number } + for (const name of stale) await github.rest.issues.removeLabel({ ...issue, name }) + if (missing.length > 0) await github.rest.issues.addLabels({ ...issue, labels: missing }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 775b9771a..788096ecc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,19 +48,14 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true - # createGithubReleases gives us one release per package, tagged pkg@version — that part - # we keep. Its *body* is the CHANGELOG section, which for a dependent package is a wall of - # "Updated dependencies [, , …]" and is empty for packages with no changeset - # of their own. `--generate-notes` is not the answer either: GitHub builds those from a - # commit range, and monorepo tags interleave, so each package would list everybody's PRs. - # So we rewrite each body from the PRs that actually touched that package's directory, - # grouped by the same categories as .github/release.yml. - name: Push tags if: steps.changesets.outputs.published == 'true' run: git push --tags - - name: Rewrite release notes + # Rebuilds each release body from that package's PRs and changesets, then moves Latest + # back to evlog. The header of scripts/release-notes/index.mjs says why. + - name: Rewrite release notes and mark evlog Latest if: steps.changesets.outputs.published == 'true' - run: node scripts/release-notes.mjs '${{ steps.changesets.outputs.publishedPackages }}' + run: node scripts/release-notes/index.mjs '${{ steps.changesets.outputs.publishedPackages }}' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index de84157e8..5cab4db31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ pnpm telemetry:cli # run this repo's CLI into that local dashboa pnpm cli:sandbox # disposable per-framework apps under .sandbox/ to test the CLI by hand (--reset restores them, --smoke runs the feature matrix) pnpm content:lint [path] # rank the written corpus by content findings (see scripts/content-lint/README.md) pnpm content:lint:test # the scanner's own tests, including its two calibration fixtures +pnpm release-notes:test # tests for scripts/release-notes, which rewrites GitHub release bodies and pins Latest to evlog ``` Publishing is automated: changesets + `.github/workflows/release.yml`. Never run `pnpm release` or `changeset publish` manually. diff --git a/package.json b/package.json index 9aa416c06..ba0ca9364 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "cli": "node packages/cli/bin/evlog.mjs", "content:lint": "node scripts/content-lint/index.mjs", "content:lint:test": "vitest run --root scripts/content-lint", + "release-notes:test": "vitest run --root scripts/release-notes", "cli:sandbox": "node scripts/cli-sandbox.mjs", "test:coverage": "pnpm --filter evlog run test:coverage", "test:coverage:open": "pnpm --filter evlog run test:coverage && open packages/evlog/coverage/index.html", diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs deleted file mode 100644 index a25e2e85c..000000000 --- a/scripts/release-notes.mjs +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Rewrite each published package's GitHub release body with GitHub-style notes: - * merged pull requests, grouped into the categories from `.github/release.yml`. - * - * ## Why this exists - * - * `createGithubReleases: true` gives changesets' own body — the CHANGELOG - * section. For a dependent package that reads: - * - * ### Patch Changes - * - Updated dependencies [[`f39ab30`](…), [`899464a`](…), [`d7f482a`](…), …] - * - evlog@2.23.0 - * - * ...which says nothing a reader can use, and packages with no changeset of - * their own get an empty release. - * - * The obvious alternative, `gh release create --generate-notes`, has the - * opposite problem: GitHub builds those notes from a commit *range*, and in a - * monorepo the per-package tags interleave. The range between two consecutive - * `@evlog/cli` tags sweeps up every core / telemetry / docs PR that landed in - * between, so each package's notes list everybody's work. - * - * So we build the list ourselves: take the commits in the range that actually - * touched the package's directory, resolve each to its pull request, and group - * them with the same label → category mapping GitHub uses. The output is - * indistinguishable from GitHub's, but correctly scoped. - * - * ## Usage - * - * node scripts/release-notes.mjs '[{"name":"evlog","version":"2.24.0"}]' - * - * Rehearse it locally before a tag exists: - * - * GITHUB_TOKEN=$(gh auth token) DRY_RUN=1 RELEASE_NOTES_REF=HEAD \ - * node scripts/release-notes.mjs '[{"name":"evlog","version":"2.24.0"}]' - * - * ## Env - * - * - `GITHUB_TOKEN` required, used for the PR lookups and `gh release edit` - * - `GITHUB_REPOSITORY` owner/repo (set by Actions; falls back to the git remote) - * - `DRY_RUN=1` print the notes instead of editing the releases - * - `RELEASE_NOTES_REF` end of the commit range (default: the release tag) - */ -import { execFileSync } from 'node:child_process' -import { readFileSync, readdirSync } from 'node:fs' -import { resolve } from 'node:path' - -const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim() - -function resolveRepo() { - if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY - const url = git('remote', 'get-url', 'origin') - const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/) - if (!match) throw new Error(`cannot derive owner/repo from remote "${url}"`) - return match[1] -} - -/** Map every workspace package name to its directory. */ -function packageDirs() { - const root = resolve(process.cwd(), 'packages') - const out = new Map() - for (const entry of readdirSync(root)) { - try { - const pkg = JSON.parse(readFileSync(resolve(root, entry, 'package.json'), 'utf8')) - if (pkg.name) out.set(pkg.name, `packages/${entry}`) - } catch { - // not a package directory - } - } - return out -} - -/** - * Read the category order out of `.github/release.yml` so the sections match - * what GitHub produces for a hand-generated release. Minimal parse — the file - * is a fixed shape we own, and this avoids a YAML dependency in CI. - */ -function releaseConfig() { - let text - try { - text = readFileSync(resolve(process.cwd(), '.github/release.yml'), 'utf8') - } catch { - return { categories: [], excludedLabels: [], excludedAuthors: [] } - } - - const categories = [] - const excludedLabels = [] - const excludedAuthors = [] - let section = null // 'exclude' | 'categories' - let excludeKey = null // 'labels' | 'authors' — they are separate lists - let current = null - - for (const line of text.split('\n')) { - if (/^\s{2}exclude:/.test(line)) { section = 'exclude'; excludeKey = null; continue } - if (/^\s{2}categories:/.test(line)) { section = 'categories'; continue } - if (section === 'exclude') { - if (/^\s*labels:/.test(line)) { excludeKey = 'labels'; continue } - if (/^\s*authors:/.test(line)) { excludeKey = 'authors'; continue } - } - - const title = line.match(/^\s*-\s*title:\s*(.+?)\s*$/) - if (title && section === 'categories') { - current = { title: title[1], labels: [] } - categories.push(current) - continue - } - const item = line.match(/^\s*-\s*(\S.*?)\s*$/) - if (item && !line.includes(':')) { - if (section === 'categories' && current) current.labels.push(item[1]) - else if (section === 'exclude' && excludeKey === 'labels') excludedLabels.push(item[1]) - else if (section === 'exclude' && excludeKey === 'authors') excludedAuthors.push(item[1]) - } - } - return { categories, excludedLabels, excludedAuthors } -} - -/** - * The tag this package was released under last time. - * - * Falls back to the newest `v*` tag from before the repo moved to per-package - * tags, so an early per-package release still gets a bounded range instead of - * the entire history. - */ -function previousTag(name, currentTag) { - const scoped = git('tag', '--list', `${name}@*`, '--sort=-creatordate') - .split('\n') - .filter(t => t && t !== currentTag) - if (scoped.length > 0) return scoped[0] - - const legacy = git('tag', '--list', 'v*', '--sort=-creatordate').split('\n').filter(Boolean) - return legacy[0] ?? null -} - -/** Commits in (from, to] that touched the package directory. */ -function commitsTouching(dir, from, to) { - const range = from ? `${from}..${to}` : to - const out = git('log', range, '--format=%H', '--no-merges', '--', dir) - return out ? out.split('\n').filter(Boolean) : [] -} - -async function pullsForCommit(repo, sha, token) { - const res = await fetch(`https://api.github.com/repos/${repo}/commits/${sha}/pulls`, { - headers: { - 'Accept': 'application/vnd.github+json', - 'Authorization': `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - }) - if (!res.ok) { - console.warn(`[release-notes] PR lookup failed for ${sha.slice(0, 8)}: ${res.status}`) - return [] - } - return await res.json() -} - -/** Render in GitHub's own shape, so the result reads like a generated release. */ -function renderNotes({ repo, pulls, previous, tag, config }) { - const line = pr => `* ${pr.title} by @${pr.author} in https://github.com/${repo}/pull/${pr.number}` - const remaining = new Set(pulls.map(p => p.number)) - const sections = [] - - for (const category of config.categories) { - const matched = pulls.filter(p => - remaining.has(p.number) && p.labels.some(l => category.labels.includes(l)), - ) - if (matched.length === 0) continue - for (const p of matched) remaining.delete(p.number) - sections.push(`### ${category.title}\n${matched.map(line).join('\n')}`) - } - - // GitHub files anything unlabelled under "Other Changes". - const others = pulls.filter(p => remaining.has(p.number)) - if (others.length > 0) sections.push(`### Other Changes 🔨\n${others.map(line).join('\n')}`) - - const body = sections.length > 0 - ? `## What's Changed\n${sections.join('\n')}` - : `## What's Changed\n_No pull requests touched this package in this release._` - - const compare = previous - ? `\n\n**Full Changelog**: https://github.com/${repo}/compare/${previous}...${tag}` - : '' - return body + compare -} - -async function main() { - const raw = process.argv[2] - if (!raw) throw new Error('usage: release-notes.mjs \'[{"name":"evlog","version":"1.0.0"}]\'') - - const published = JSON.parse(raw) - if (!Array.isArray(published) || published.length === 0) { - console.log('[release-notes] nothing published, skipping') - return - } - - const token = process.env.GITHUB_TOKEN - if (!token) throw new Error('GITHUB_TOKEN is required') - const repo = resolveRepo() - const dirs = packageDirs() - const config = releaseConfig() - const dryRun = process.env.DRY_RUN === '1' - - for (const { name, version } of published) { - const tag = `${name}@${version}` - const dir = dirs.get(name) - if (!dir) { - console.warn(`[release-notes] no directory for ${name}, skipping`) - continue - } - - const previous = previousTag(name, tag) - const head = process.env.RELEASE_NOTES_REF ?? tag - const shas = commitsTouching(dir, previous, head) - - // One PR can carry several commits; keep first-seen order (newest first). - const seen = new Map() - for (const sha of shas) { - for (const pr of await pullsForCommit(repo, sha, token)) { - if (!pr.merged_at || seen.has(pr.number)) continue - const author = pr.user?.login ?? 'unknown' - if (config.excludedAuthors.includes(author)) continue - const labels = (pr.labels ?? []).map(l => l.name) - if (labels.some(l => config.excludedLabels.includes(l))) continue - seen.set(pr.number, { - number: pr.number, - title: pr.title, - author, - labels, - }) - } - } - - const pulls = [...seen.values()] - const notes = renderNotes({ repo, pulls, previous, tag, config }) - console.log(`\n── ${tag} (${pulls.length} PR${pulls.length === 1 ? '' : 's'} since ${previous ?? 'repo start'})`) - - if (dryRun) { - console.log(notes) - continue - } - - execFileSync('gh', ['release', 'edit', tag, '--notes', notes], { - stdio: 'inherit', - env: { ...process.env, GH_TOKEN: token }, - }) - console.log(`[release-notes] updated ${tag}`) - } -} - -await main() diff --git a/scripts/release-notes/index.mjs b/scripts/release-notes/index.mjs new file mode 100644 index 000000000..a1191d668 --- /dev/null +++ b/scripts/release-notes/index.mjs @@ -0,0 +1,228 @@ +/** + * Rewrite each published package's GitHub release body, then pin the Latest + * badge to `evlog`. + * + * ## Why this exists + * + * `createGithubReleases: true` gives changesets' own body — the CHANGELOG + * section. For a dependent package that reads: + * + * ### Patch Changes + * - Updated dependencies [[`f39ab30`](…), [`899464a`](…), [`d7f482a`](…), …] + * - evlog@2.23.0 + * + * ...which says nothing a reader can use, and packages with no changeset of + * their own get an empty release. + * + * The obvious alternative, `gh release create --generate-notes`, has the + * opposite problem: GitHub builds those notes from a commit *range*, and in a + * monorepo the per-package tags interleave. The range between two consecutive + * `@evlog/cli` tags sweeps up every core / telemetry / docs PR that landed in + * between, so each package's notes list everybody's work. + * + * So we build the list ourselves: the PRs whose commits touched the package's + * directory since its previous tag, plus every PR a changeset in this version + * came from, grouped with the same label → category mapping GitHub uses. A PR + * that ships changesets is listed once per changeset, in the changeset's own + * words, so a PR that fixes two things shows two lines instead of one title. + * + * GitHub gives the Latest badge to the release created last, and changesets + * creates one per package, so a run that also publishes `@evlog/cli` would + * leave the CLI as Latest. The last step marks the newest stable `evlog` + * release Latest on every run. + * + * ## Usage + * + * node scripts/release-notes/index.mjs '[{"name":"evlog","version":"2.24.0"}]' + * + * Rehearse it without editing anything, on a version that is already tagged: + * + * GITHUB_TOKEN=$(gh auth token) DRY_RUN=1 \ + * node scripts/release-notes/index.mjs '[{"name":"evlog","version":"2.24.0"}]' + * + * Before the tag exists, check out the version packages PR branch and add + * `RELEASE_NOTES_REF=HEAD`. + * + * ## Env + * + * - `GITHUB_TOKEN` required, used for the PR lookups and `gh release edit` + * - `GITHUB_REPOSITORY` owner/repo (set by Actions; falls back to the git remote) + * - `DRY_RUN=1` print the notes instead of editing the releases + * - `RELEASE_NOTES_REF` end of the commit range (default: the release tag) + */ +import { execFileSync } from 'node:child_process' +import { readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { + changelogSection, + changesetEntries, + isExcluded, + latestStableTag, + parseReleaseConfig, + renderNotes, +} from './lib.mjs' + +const LATEST_PACKAGE = 'evlog' + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim() + +function resolveRepo() { + if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY + const url = git('remote', 'get-url', 'origin') + const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/) + if (!match) throw new Error(`cannot derive owner/repo from remote "${url}"`) + return match[1] +} + +/** Map every workspace package name to its directory. */ +function packageDirs() { + const root = resolve(process.cwd(), 'packages') + const out = new Map() + for (const entry of readdirSync(root)) { + try { + const pkg = JSON.parse(readFileSync(resolve(root, entry, 'package.json'), 'utf8')) + if (pkg.name) out.set(pkg.name, `packages/${entry}`) + } catch { + // not a package directory + } + } + return out +} + +/** + * The tag this package was released under last time. + * + * Falls back to the newest `v*` tag from before the repo moved to per-package + * tags, so an early per-package release still gets a bounded range instead of + * the entire history. + */ +function previousTag(name, currentTag, head) { + const scoped = git('tag', '--list', `${name}@*`, '--merged', head, '--sort=-creatordate') + .split('\n') + .filter(t => t && t !== currentTag) + if (scoped.length > 0) return scoped[0] + + const legacy = git('tag', '--list', 'v*', '--merged', head, '--sort=-creatordate').split('\n').filter(Boolean) + return legacy[0] ?? null +} + +/** Commits in (from, to] that touched the package directory. */ +function commitsTouching(dir, from, to) { + const range = from ? `${from}..${to}` : to + const out = git('log', range, '--format=%H', '--no-merges', '--', dir) + return out ? out.split('\n').filter(Boolean) : [] +} + +/** The CHANGELOG as of `ref`, so a rehearsal reads the same file the tag holds. */ +function changelogAt(ref, dir) { + return git('show', `${ref}:${dir}/CHANGELOG.md`) +} + +async function github(repo, path, token) { + const res = await fetch(`https://api.github.com/repos/${repo}/${path}`, { + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + // A missing line in a release is worse than a failed job someone can rerun. + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status} ${await res.text()}`) + return await res.json() +} + +const toPull = pr => ({ + number: pr.number, + title: pr.title, + author: pr.user?.login ?? 'unknown', + labels: (pr.labels ?? []).map(l => l.name), +}) + +async function collectPulls({ repo, token, config, shas, changesetPulls }) { + // One PR can carry several commits; keep first-seen order (newest first). + const seen = new Map() + const add = (pr) => { + if (!pr.merged_at || seen.has(pr.number)) return + const pull = toPull(pr) + if (!isExcluded(pull, config)) seen.set(pr.number, pull) + } + + for (const sha of shas) { + for (const pr of await github(repo, `commits/${sha}/pulls`, token)) add(pr) + } + // A changeset can bump a package from a PR that never touched its directory. + for (const number of changesetPulls) { + if (!seen.has(number)) add(await github(repo, `pulls/${number}`, token)) + } + return [...seen.values()] +} + +function ghRelease(args, token) { + execFileSync('gh', ['release', 'edit', ...args], { + stdio: 'inherit', + env: { ...process.env, GH_TOKEN: token }, + }) +} + +async function main() { + const raw = process.argv[2] + if (!raw) throw new Error('usage: release-notes/index.mjs \'[{"name":"evlog","version":"1.0.0"}]\'') + + const published = JSON.parse(raw) + if (!Array.isArray(published) || published.length === 0) { + console.log('[release-notes] nothing published, skipping') + return + } + + const token = process.env.GITHUB_TOKEN + if (!token) throw new Error('GITHUB_TOKEN is required') + const repo = resolveRepo() + const dirs = packageDirs() + const config = parseReleaseConfig(readFileSync(resolve(process.cwd(), '.github/release.yml'), 'utf8')) + const dryRun = process.env.DRY_RUN === '1' + + // Build every body before editing any, so a failed lookup leaves no release half-updated. + const releases = [] + for (const { name, version } of published) { + const tag = `${name}@${version}` + const dir = dirs.get(name) + if (!dir) throw new Error(`no directory for ${name}`) + + const head = process.env.RELEASE_NOTES_REF ?? tag + const previous = previousTag(name, tag, head) + const section = changelogSection(changelogAt(head, dir), version) + if (section === null) throw new Error(`${dir}/CHANGELOG.md has no ## ${version} section at ${head}`) + const entries = changesetEntries(section) + const pulls = await collectPulls({ + repo, + token, + config, + shas: commitsTouching(dir, previous, head), + changesetPulls: [...new Set(entries.map(e => e.pr).filter(Boolean))], + }) + const changelog = { path: `${dir}/CHANGELOG.md`, version } + const notes = renderNotes({ repo, pulls, entries, previous, tag, config, changelog }) + console.log(`\n── ${tag} (${pulls.length} PR${pulls.length === 1 ? '' : 's'} since ${previous ?? 'repo start'})`) + releases.push({ tag, notes }) + } + + for (const { tag, notes } of releases) { + if (dryRun) { + console.log(`\n${tag}\n${notes}`) + continue + } + ghRelease([tag, '--notes', notes], token) + console.log(`[release-notes] updated ${tag}`) + } + + const latest = latestStableTag(git('tag', '--list', `${LATEST_PACKAGE}@*`).split('\n'), LATEST_PACKAGE) + if (!latest) throw new Error(`no stable ${LATEST_PACKAGE} tag to mark Latest`) + if (dryRun) { + console.log(`\n[release-notes] would mark ${latest} as Latest`) + return + } + ghRelease([latest, '--latest'], token) + console.log(`[release-notes] marked ${latest} as Latest`) +} + +await main() diff --git a/scripts/release-notes/lib.mjs b/scripts/release-notes/lib.mjs new file mode 100644 index 000000000..93dc051cc --- /dev/null +++ b/scripts/release-notes/lib.mjs @@ -0,0 +1,208 @@ +/** + * Pure helpers for `index.mjs`: no git, no network, so they can be tested on + * fixtures. + */ + +/** + * Read the categories and exclusions out of `.github/release.yml`. Minimal + * parse: the file is a fixed shape we own, and this avoids a YAML dependency + * in CI. + */ +export function parseReleaseConfig(text) { + const categories = [] + const excludedLabels = [] + const excludedAuthors = [] + let section = null // 'exclude' | 'categories' + let excludeKey = null // 'labels' | 'authors' — they are separate lists + let current = null + + for (const line of text.split('\n')) { + if (/^\s{2}exclude:/.test(line)) { section = 'exclude'; excludeKey = null; continue } + if (/^\s{2}categories:/.test(line)) { section = 'categories'; continue } + if (section === 'exclude') { + if (/^\s*labels:/.test(line)) { excludeKey = 'labels'; continue } + if (/^\s*authors:/.test(line)) { excludeKey = 'authors'; continue } + } + + const title = line.match(/^\s*-\s*title:\s*(.+?)\s*$/) + if (title && section === 'categories') { + current = { title: title[1], labels: [] } + categories.push(current) + continue + } + const item = line.match(/^\s*-\s*(\S.*?)\s*$/) + if (item && !line.includes(':')) { + if (section === 'categories' && current) current.labels.push(item[1]) + else if (section === 'exclude' && excludeKey === 'labels') excludedLabels.push(item[1]) + else if (section === 'exclude' && excludeKey === 'authors') excludedAuthors.push(item[1]) + } + } + return { categories, excludedLabels, excludedAuthors } +} + +/** Whether a pull request is left out of the notes by `.github/release.yml`. */ +export function isExcluded(pr, config) { + return config.excludedAuthors.includes(pr.author) + || pr.labels.some(l => config.excludedLabels.includes(l)) +} + +const parseVersion = v => v.split('.').map(Number) + +/** + * The newest stable `@x.y.z` tag. Prereleases are skipped: GitHub never + * marks them Latest. + */ +export function latestStableTag(tags, name) { + const prefix = `${name}@` + const stable = tags + .filter(t => t.startsWith(prefix) && /^\d+\.\d+\.\d+$/.test(t.slice(prefix.length))) + .map(t => ({ tag: t, version: parseVersion(t.slice(prefix.length)) })) + stable.sort((a, b) => b.version[0] - a.version[0] || b.version[1] - a.version[1] || b.version[2] - a.version[2]) + return stable[0]?.tag ?? null +} + +/** The body of the `## ` section of a changesets CHANGELOG, or `null`. */ +export function changelogSection(changelog, version) { + const lines = changelog.split('\n') + const start = lines.findIndex(l => l.trim() === `## ${version}`) + if (start === -1) return null + const rest = lines.slice(start + 1) + const end = rest.findIndex(l => l.startsWith('## ')) + return (end === -1 ? rest : rest.slice(0, end)).join('\n').trim() +} + +/** + * Split a section into `### Minor Changes`-style groups of entries. An entry is + * a top-level `- ` bullet with every indented or blank line under it. + */ +function changelogGroups(section) { + const groups = [] + let group = null + let entry = null + for (const line of section.split('\n')) { + if (line.startsWith('### ')) { + group = { heading: line.slice(4).trim(), entries: [] } + groups.push(group) + entry = null + } + else if (line.startsWith('- ') && group) { + entry = [line] + group.entries.push(entry) + } + else if (entry) { + entry.push(line) + } + } + return groups.map(g => ({ heading: g.heading, entries: g.entries.map(e => e.join('\n').trimEnd()) })) +} + +const DEPENDENCY_ENTRY = /^- Updated dependencies\b/ +// `@changesets/changelog-github` prefixes each summary with the PR, the commit and the authors. +const ENTRY_PREFIX = /^- (?:\[#(\d+)\]\([^)]*\) )?(?:\[`[0-9a-f]+`\]\([^)]*\) )?(?:Thanks .*?! )?- / + +/** + * One entry per changeset in a section, in CHANGELOG order: the PR it came + * from (`null` without one), its bump and its summary. A PR with two + * changesets gives two entries. "Updated dependencies" entries are left out. + */ +export function changesetEntries(section) { + const out = [] + for (const { heading, entries } of changelogGroups(section)) { + const bump = heading.split(' ')[0].toLowerCase() + for (const entry of entries) { + if (DEPENDENCY_ENTRY.test(entry)) continue + const match = entry.match(ENTRY_PREFIX) + const summary = (match ? entry.slice(match[0].length) : entry.slice(2)) + .split('\n') + .map(l => l.replace(/^ {2}/, '')) + .join('\n') + .trim() + out.push({ pr: match?.[1] ? Number(match[1]) : null, bump, summary }) + } + } + return out +} + +/** + * The first sentence of a changeset summary, for a one-line release entry. + * A colon also ends it, since the summaries put the detail after one. A + * period or colon inside a code span, a period in "e.g." and "i.e.", and a + * leading label such as "Breaking:" do not. + */ +export function headline(summary) { + const text = summary.split(/\n\s*\n/)[0].replace(/^#+\s*/, '').replace(/\s+/g, ' ').trim() + let inCode = false + for (let i = 0; i < text.length; i++) { + if (text[i] === '`') inCode = !inCode + if (inCode || (text[i] !== '.' && text[i] !== ':') || (i + 1 < text.length && text[i + 1] !== ' ')) continue + if (text[i] === '.' && /\b(?:e\.g|i\.e)$/.test(text.slice(0, i))) continue + if (text[i] === ':' && !text.slice(0, i).includes(' ')) continue + return text.slice(0, i) + } + return text.replace(/\.$/, '') +} + +/** + * The release label a changeset entry is filed under. The bump says what the + * change is: a patch changeset in a `feat` PR is a fix, and a minor one in a + * `fix` PR is a feature. A patch keeps its PR's label when that is a release + * category narrower than a fix, such as `performance`; any other label, such + * as one set by hand, is ignored. + */ +function entryLabel(bump, prLabels, categoryLabels) { + if (bump === 'major') return 'breaking' + if (bump === 'minor') return 'feature' + return prLabels.find(l => categoryLabels.has(l) && !['breaking', 'feature', 'enhancement'].includes(l)) ?? 'bug' +} + +/** GitHub's heading anchor for a `## ` line. */ +const versionAnchor = version => version.toLowerCase().replace(/[^\w\- ]/g, '').replace(/ /g, '-') + +/** + * Render in GitHub's own shape, so the list reads like a generated release. + * Each changeset is its own line, worded by its summary; a PR without a + * changeset in this version is listed by its title. + */ +export function renderNotes({ repo, pulls, entries = [], previous, tag, config, changelog }) { + const items = [] + const byPull = new Map(pulls.map(p => [p.number, p])) + const categoryLabels = new Set(config.categories.flatMap(c => c.labels)) + for (const pull of pulls) { + const own = entries.filter(e => e.pr === pull.number) + if (own.length === 0) items.push({ text: pull.title, labels: pull.labels, pull }) + for (const e of own) items.push({ text: headline(e.summary), labels: [entryLabel(e.bump, pull.labels, categoryLabels)], pull }) + } + for (const e of entries.filter(e => !byPull.has(e.pr))) { + items.push({ text: headline(e.summary), labels: [entryLabel(e.bump, [], categoryLabels)], pull: null }) + } + + const line = ({ text, pull }) => pull + ? `* ${text} by @${pull.author} in https://github.com/${repo}/pull/${pull.number}` + : `* ${text}` + const remaining = new Set(items) + const sections = [] + + for (const category of config.categories) { + const matched = items.filter(i => remaining.has(i) && i.labels.some(l => category.labels.includes(l))) + if (matched.length === 0) continue + for (const i of matched) remaining.delete(i) + sections.push(`### ${category.title}\n${matched.map(line).join('\n')}`) + } + + // GitHub files anything unlabelled under "Other Changes". + const others = items.filter(i => remaining.has(i)) + if (others.length > 0) sections.push(`### Other Changes 🔨\n${others.map(line).join('\n')}`) + + const parts = [ + sections.length > 0 + ? `## What's Changed\n${sections.join('\n')}` + : `## What's Changed\n_No pull requests touched this package in this release._`, + ] + const footer = [] + if (changelog && entries.length > 0) { + footer.push(`**Changelog**: https://github.com/${repo}/blob/${tag}/${changelog.path}#${versionAnchor(changelog.version)}`) + } + if (previous) footer.push(`**Full Changelog**: https://github.com/${repo}/compare/${previous}...${tag}`) + if (footer.length > 0) parts.push(footer.join('\n')) + return parts.join('\n\n') +} diff --git a/scripts/release-notes/lib.test.mjs b/scripts/release-notes/lib.test.mjs new file mode 100644 index 000000000..a8f6854a3 --- /dev/null +++ b/scripts/release-notes/lib.test.mjs @@ -0,0 +1,182 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + changelogSection, + changesetEntries, + headline, + isExcluded, + latestStableTag, + parseReleaseConfig, + renderNotes, +} from './lib.mjs' + +// #816 ships two changesets from one PR, the case a PR-title list collapses into one line. +const changelog = `# evlog + +## 2.31.0 + +### Minor Changes + +- [#834](https://github.com/evloghq/evlog/pull/834) [\`b1880ab\`](https://github.com/evloghq/evlog/commit/b1880ab) Thanks [@HugoRCD](https://github.com/HugoRCD)! - \`log.fork()\` returns a promise that resolves once the child event has drained. Await it when a write must land. + +### Patch Changes + +- [#816](https://github.com/evloghq/evlog/pull/816) [\`db19fc2\`](https://github.com/evloghq/evlog/commit/db19fc2) Thanks [@HugoRCD](https://github.com/HugoRCD)! - Fix \`captureOutput\` sending evlog's own events to drains a second time: in JSON mode, each event came back as \`log.info\`. + +- [#816](https://github.com/evloghq/evlog/pull/816) [\`db19fc2\`](https://github.com/evloghq/evlog/commit/db19fc2) Thanks [@HugoRCD](https://github.com/HugoRCD)! - Fix every route returning 500 with \`Cannot find package 'evlog'\` in a Next.js app built with Turbopack. + + The factory is now bundled. + +- Updated dependencies [[\`f3613ee\`](https://github.com/evloghq/evlog/commit/f3613ee)]: + - @evlog/telemetry@0.3.5 + +## 2.30.1 + +### Patch Changes + +- [#773](https://github.com/evloghq/evlog/pull/773) [\`a053adb\`](https://github.com/evloghq/evlog/commit/a053adb) Thanks [@HugoRCD](https://github.com/HugoRCD)! - Older fix. +` + +const dependencyOnly = `## 0.3.4 + +### Patch Changes + +- Updated dependencies [[\`302fd0e\`](https://github.com/evloghq/evlog/commit/302fd0e)]: + - evlog@2.30.1 +` + +const config = parseReleaseConfig(readFileSync(resolve(import.meta.dirname, '../../.github/release.yml'), 'utf8')) + +describe('parseReleaseConfig', () => { + it('reads the categories in order and both exclusion lists', () => { + expect(config.categories[0]).toEqual({ title: 'Breaking Changes 💥', labels: ['breaking'] }) + expect(config.categories.map(c => c.labels[0])).toContain('bug') + expect(config.excludedLabels).toEqual(['ignore-for-release']) + expect(config.excludedAuthors).toContain('github-actions[bot]') + }) +}) + +describe('isExcluded', () => { + it('drops the release bot and ignore-for-release PRs', () => { + expect(isExcluded({ author: 'github-actions[bot]', labels: [] }, config)).toBe(true) + expect(isExcluded({ author: 'HugoRCD', labels: ['ignore-for-release'] }, config)).toBe(true) + expect(isExcluded({ author: 'HugoRCD', labels: ['feature'] }, config)).toBe(false) + }) +}) + +describe('latestStableTag', () => { + it('picks the highest stable version by number, not by string order', () => { + const tags = ['evlog@2.9.0', 'evlog@2.32.0', 'evlog@2.4.1', '@evlog/cli@9.0.0', 'v2.0.0'] + expect(latestStableTag(tags, 'evlog')).toBe('evlog@2.32.0') + }) + + it('skips prereleases', () => { + expect(latestStableTag(['evlog@2.32.0', 'evlog@3.0.0-beta.1'], 'evlog')).toBe('evlog@2.32.0') + }) + + it('returns null when the package has no stable tag', () => { + expect(latestStableTag(['evlog@3.0.0-beta.0', ''], 'evlog')).toBeNull() + }) +}) + +describe('changelogSection', () => { + it('stops at the next version heading', () => { + const section = changelogSection(changelog, '2.31.0') + expect(section).toMatch(/^### Minor Changes/) + expect(section).toContain('#816') + expect(section).not.toContain('#773') + }) + + it('returns null for a version that is not in the file', () => { + expect(changelogSection(changelog, '2.32.0')).toBeNull() + }) +}) + +describe('changesetEntries', () => { + it('gives one entry per changeset, two for a PR that ships two, without the dependency bumps', () => { + const entries = changesetEntries(changelogSection(changelog, '2.31.0')) + expect(entries.map(e => [e.pr, e.bump])).toEqual([[834, 'minor'], [816, 'patch'], [816, 'patch']]) + expect(entries[2].summary).toBe('Fix every route returning 500 with `Cannot find package \'evlog\'` in a Next.js app built with Turbopack.\n\nThe factory is now bundled.') + }) + + it('is empty for a dependency-only bump', () => { + expect(changesetEntries(changelogSection(dependencyOnly, '0.3.4'))).toEqual([]) + }) + + it('keeps a changeset that has no PR link', () => { + const section = '### Patch Changes\n\n- [`abc1234`](https://github.com/evloghq/evlog/commit/abc1234) Thanks [@a](https://github.com/a)! - Fix it.' + expect(changesetEntries(section)).toEqual([{ pr: null, bump: 'patch', summary: 'Fix it.' }]) + }) +}) + +describe('headline', () => { + it('ends at the first sentence or the colon before the detail', () => { + expect(headline('Redact secrets by key name. A new `secrets` built-in replaces them.')).toBe('Redact secrets by key name') + expect(headline('Fix `captureOutput` sending events twice: in JSON mode, each came back.')).toBe('Fix `captureOutput` sending events twice') + }) + + it('does not stop inside a code span, at e.g., or at a leading label', () => { + expect(headline('Read `evlog.config.ts` (or `.mjs`), e.g. in a workspace. More.')).toBe('Read `evlog.config.ts` (or `.mjs`), e.g. in a workspace') + expect(headline('Format `{ a: 1 }` in messages.')).toBe('Format `{ a: 1 }` in messages') + expect(headline('Breaking: `--global` is removed. Use `--local`.')).toBe('Breaking: `--global` is removed') + }) + + it('uses only the first paragraph and drops a heading marker', () => { + expect(headline('# fix(elysia): support Workers without enterWith\n\nCloudflare omits it.')).toBe('fix(elysia): support Workers without enterWith') + expect(headline('Add `fatal`\nand `trace` levels\n\nDetails.')).toBe('Add `fatal` and `trace` levels') + }) +}) + +describe('renderNotes', () => { + const repo = 'evloghq/evlog' + const entries = changesetEntries(changelogSection(changelog, '2.31.0')) + const pulls = [ + { number: 834, title: 'fix(core): return the child lifetime from log.fork()', author: 'HugoRCD', labels: ['bug'] }, + { number: 816, title: 'feat(next): bundle the instrumentation factory', author: 'HugoRCD', labels: ['feature'] }, + { number: 832, title: 'test: check entrypoint registrations', author: 'evlogai[bot]', labels: ['test'] }, + { number: 3, title: 'chore: c', author: 'a', labels: [] }, + ] + const changelogRef = { path: 'packages/evlog/CHANGELOG.md', version: '2.31.0' } + + it('lists each changeset in its own words under its bump, and other PRs by title under their label', () => { + const body = renderNotes({ repo, pulls, entries, previous: 'evlog@2.30.1', tag: 'evlog@2.31.0', config, changelog: changelogRef }) + expect(body).toBe([ + '## What\'s Changed', + '### Features 🚀', + '* `log.fork()` returns a promise that resolves once the child event has drained by @HugoRCD in https://github.com/evloghq/evlog/pull/834', + '### Bug Fixes 🐞', + '* Fix `captureOutput` sending evlog\'s own events to drains a second time by @HugoRCD in https://github.com/evloghq/evlog/pull/816', + '* Fix every route returning 500 with `Cannot find package \'evlog\'` in a Next.js app built with Turbopack by @HugoRCD in https://github.com/evloghq/evlog/pull/816', + '### Tests 🧪', + '* test: check entrypoint registrations by @evlogai[bot] in https://github.com/evloghq/evlog/pull/832', + '### Other Changes 🔨', + '* chore: c by @a in https://github.com/evloghq/evlog/pull/3', + '', + '**Changelog**: https://github.com/evloghq/evlog/blob/evlog@2.31.0/packages/evlog/CHANGELOG.md#2310', + '**Full Changelog**: https://github.com/evloghq/evlog/compare/evlog@2.30.1...evlog@2.31.0', + ].join('\n')) + }) + + it('files a patch changeset under its PR label when that is narrower than a fix', () => { + const perf = [{ number: 9, title: 'perf: x', author: 'a', labels: ['performance'] }] + const body = renderNotes({ repo, pulls: perf, entries: [{ pr: 9, bump: 'patch', summary: 'Speed up set().' }], previous: null, tag: 'evlog@1.0.0', config }) + expect(body).toContain('### Performance Improvements ⚡️\n* Speed up set() by @a') + }) + + it('keeps a patch changeset under Bug Fixes when its PR carries a label that is not a category', () => { + const pr = [{ number: 9, title: 'fix: x', author: 'a', labels: ['needs-backport', 'bug'] }] + const body = renderNotes({ repo, pulls: pr, entries: [{ pr: 9, bump: 'patch', summary: 'Fix x.' }], previous: null, tag: 'evlog@1.0.0', config }) + expect(body).toBe('## What\'s Changed\n### Bug Fixes 🐞\n* Fix x by @a in https://github.com/evloghq/evlog/pull/9') + }) + + it('lists a changeset without a PR, unlinked', () => { + const body = renderNotes({ repo, pulls: [], entries: [{ pr: null, bump: 'minor', summary: 'Add it.' }], previous: null, tag: 'evlog@1.0.0', config }) + expect(body).toBe('## What\'s Changed\n### Features 🚀\n* Add it') + }) + + it('says so when nothing touched the package', () => { + const body = renderNotes({ repo, pulls: [], previous: null, tag: 'evlog@1.0.0', config }) + expect(body).toBe('## What\'s Changed\n_No pull requests touched this package in this release._') + }) +})