From bc3703bc55671569709155fdf986920cfd1ef6dc Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 5 Oct 2026 17:02:42 +0200 Subject: [PATCH 01/10] Add changelog fragments Signed-off-by: Christoph Knittel --- .github/pull_request_template.md | 6 ++ .github/workflows/ci.yml | 16 +++++ AGENTS.md | 2 +- CONTRIBUTING.md | 8 ++- changelog/README.md | 27 +++++++ package.json | 8 ++- scripts/changelog.js | 116 +++++++++++++++++++++++++++++++ scripts/changelog.test.js | 65 +++++++++++++++++ 8 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 changelog/README.md create mode 100644 scripts/changelog.js create mode 100644 scripts/changelog.test.js diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..179254b0998 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,6 @@ +## Description + +## Validation + +- [ ] Added a changelog fragment in `changelog/..md` with the PR link, or explained why a maintainer should apply `changelog:skip`. +- [ ] Ran the relevant tests. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a4888b9307..f1b55ccac2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [master, 11.0_release] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] branches: [master, 11.0_release] # Release builds are started by publish.yml after a maintainer indicates @@ -29,6 +30,21 @@ env: RUSTFLAGS: "-Dwarnings" jobs: + changelog: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: node --test scripts/changelog.test.js + - run: node scripts/changelog.js check + build-compiler: outputs: api-docs-artifact-id: ${{ steps.upload-api-docs.outputs.artifact-id }} diff --git a/AGENTS.md b/AGENTS.md index d8deaa2bc70..494d2222d6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ on `master`. CI runs on every PR in the stack. ## Testing and changelog -**Add a `CHANGELOG.md` entry** for any user-facing change (bug fix, feature, or breaking change). Put it under the matching section of the current `(Unreleased)` version and end the line with the PR link. See [CONTRIBUTING.md](CONTRIBUTING.md). PRs are expected to include one. +**Add a changelog fragment** in `changelog/..md` for any user-facing change (bug fix, feature, or breaking change). Use a unique descriptive filename and Markdown bullets ending with PR links. Categories: `breaking`, `compliance`, `feature`, `fix`, `docs`, `polish`, `internal`. Do not edit `CHANGELOG.md` in ordinary PRs. See [changelog/README.md](changelog/README.md). Run `yarn changelog:check`; changes without release notes need the maintainer-applied `changelog:skip` label. Release preparation PRs use `changelog:release`. For compiler or standard-library changes, run `make test`. For syntax changes, also run `make test-syntax`; use `make test-syntax-roundtrip` when parsing or diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a304ee9ad61..cd2f9d70b9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -510,7 +510,7 @@ Adding a new entry there requires re-running the analysis tests. Follow these st (If a `make` command fails, consider using the [DevContainer](#b-devcontainer).) -Finally, add a line to [CHANGELOG.md](CHANGELOG.md), using the `#### :nail_care: Polish` section. +Finally, add a `changelog/.polish.md` fragment following [the changelog instructions](changelog/README.md). ## Code structure @@ -572,7 +572,7 @@ The repository's `npm-release` GitHub environment should have required reviewers To build a new version and release it on NPM, follow these steps: 1. Verify that the version number is already set correctly for the release. (It should have been incremented after releasing the previous version.) -1. Create a PR to update `CHANGELOG.md`, removing the "(Unreleased)" for the version to be released. +1. Run `yarn changelog:preview` to review pending notes, then `yarn changelog:release` to collect fragments into `CHANGELOG.md`, remove "(Unreleased)", and delete consumed fragments. Create a PR containing these changes, review the combined release notes, and apply the `changelog:release` label. Existing unreleased entries are preserved. 1. Once that PR is merged and built successfully, run the `Publish` workflow from the GitHub Actions tab. Use the "Run workflow" branch dropdown to select the release branch (normally `master`, or the relevant maintenance branch) and indicate whether this is the current stable release. The version is read from that branch's `package.json`, and the npm tag is derived automatically: - A current stable release is published as `latest`. - A stable maintenance release for an older major is published as `latest-`, for example `latest-12`. @@ -618,3 +618,7 @@ To participate under these terms, all that you must do is include a line like th Signed-Off-By: Random J. Developer You must use your real name (sorry, no pseudonyms, and no anonymous contributions). + +## Changelog fragments + +For user-facing changes, add a fragment following [changelog/README.md](changelog/README.md). Ordinary PRs do not edit `CHANGELOG.md`. diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 00000000000..55945d6dd99 --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,27 @@ +# Changelog fragments + +Add a unique `..md` file for each change that needs release notes. For example, `fix-watcher-recovery.fix.md`: + +```markdown +- Recover blocked dependents after a failed build. https://github.com/rescript-lang/rescript/pull/8667 +``` + +Use descriptive filenames before a PR number exists, then add the actual PR link before CI passes. Each file may contain multiple bullets in the same category. Indent wrapped lines by two spaces; each bullet ends with one or more PR links. + +| Category | Section | +| --- | --- | +| `breaking` | Breaking Change | +| `compliance` | Spec Compliance | +| `feature` | New Feature | +| `fix` | Bug fix | +| `docs` | Documentation | +| `polish` | Polish | +| `internal` | Internal | + +`yarn changelog:check` validates all fragments. CI requires a newly added fragment relative to the PR merge base. Maintainers may apply `changelog:skip` when notes are unnecessary, or `changelog:release` for release preparation. These labels exempt the requirement, but never fragment validation. Reviewers decide whether the chosen category is appropriate. + +`yarn changelog:preview` prints pending fragments grouped by category and sorted by filename without changing files. It does not include entries already in `CHANGELOG.md`. + +During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments to the existing sections, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Running it again fails because the version has already been finalized. Releases with no fragments can still finalize existing notes. + +Only release preparation and opening the next unreleased section should edit `CHANGELOG.md`. Keep fragments on the branch containing the associated change; backports should include their fragments if they need notes in the maintenance release. diff --git a/package.json b/package.json index 3033e101c62..9fa2a61934c 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "Jaap Frolich (https://github.com/jfrolich)", "Matthias Le Brun (https://github.com/bloodyowl)", "Patrick Ecker (https://github.com/ryyppy)", - "Paul Tsnobiladzé (https://github.com/tsnobip)", + "Paul Tsnobiladz\u00e9 (https://github.com/tsnobip)", "Shulhi Sapli (https://github.com/shulhi)", "Woonki Moon (https://github.com/mununki)" ], @@ -56,7 +56,11 @@ "check:all": "biome check .", "format": "biome check --no-errors-on-unmatched . --fix", "typecheck": "tsc", - "apidocs:generate": "yarn workspace @utils/scripts apidocs:generate" + "apidocs:generate": "yarn workspace @utils/scripts apidocs:generate", + "changelog:check": "node scripts/changelog.js check", + "changelog:preview": "node scripts/changelog.js preview", + "changelog:release": "node scripts/changelog.js release", + "test-changelog": "node --test scripts/changelog.test.js" }, "files": [ "CHANGELOG.md", diff --git a/scripts/changelog.js b/scripts/changelog.js new file mode 100644 index 00000000000..3f5208f28c4 --- /dev/null +++ b/scripts/changelog.js @@ -0,0 +1,116 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const categories = { + breaking: "#### :boom: Breaking Change", + compliance: "#### :eyeglasses: Spec Compliance", + feature: "#### :rocket: New Feature", + fix: "#### :bug: Bug fix", + docs: "#### :memo: Documentation", + polish: "#### :nail_care: Polish", + internal: "#### :house: Internal", +}; + +export function parseFragment(name, content) { + const match = /^([a-z0-9][a-z0-9-]*)\.([a-z]+)\.md$/.exec(name); + if (!match || !Object.hasOwn(categories, match[2])) { + throw new Error( + `${name}: expected ..md; allowed categories: ${Object.keys(categories).join(", ")}`, + ); + } + const text = content.trim(); + const bullets = text.split(/\n(?=- )/); + const prLinks = /https:\/\/github\.com\/rescript-lang\/rescript(?:-compiler)?\/pull\/[1-9]\d*(?:\s+https:\/\/github\.com\/rescript-lang\/rescript(?:-compiler)?\/pull\/[1-9]\d*)*$/; + const invalid = bullets.some(bullet => + !/^- \S/.test(bullet) || + !prLinks.test(bullet) || + bullet.split("\n").slice(1).some(line => line && !/^ {2}\S/.test(line)), + ); + if (!text || invalid) { + throw new Error( + `${name}: expected Markdown bullets ending with PR links (indent continuation lines by two spaces)`, + ); + } + return { name, category: match[2], text }; +} + +export async function readFragments(root) { + const directory = path.join(root, "changelog"); + const entries = await fs.readdir(directory, { withFileTypes: true }); + const fragments = []; + for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { + if (entry.name === "README.md") continue; + if (!entry.isFile()) throw new Error(`changelog/${entry.name}: expected a regular fragment file`); + fragments.push(parseFragment(entry.name, await fs.readFile(path.join(directory, entry.name), "utf8"))); + } + return fragments; +} + +export function renderFragments(fragments) { + return Object.entries(categories).map(([category, heading]) => { + const notes = fragments.filter(fragment => fragment.category === category).map(fragment => fragment.text); + return `${heading}\n\n${notes.length ? `${notes.join("\n")}\n\n` : ""}`; + }).join(""); +} + +export function prepareRelease(changelog, version, fragments) { + const headings = [...changelog.matchAll(/^# (.+)$/gm)].filter(match => match[1] !== "Changelog"); + const first = headings[0]; + if (!first || first[1] !== `${version} (Unreleased)`) { + throw new Error(`Expected first version heading to be ${version} (Unreleased)`); + } + const end = headings[1]?.index ?? changelog.length; + let section = changelog.slice(first.index, end); + // Keep existing unreleased entries during migration, and preserve history verbatim. + for (const [category, heading] of Object.entries(categories)) { + const notes = fragments.filter(fragment => fragment.category === category).map(fragment => fragment.text); + if (!notes.length) continue; + if (section.split("\n").filter(line => line === heading).length !== 1) { + throw new Error(`Expected exactly one ${heading} section`); + } + section = section.replace(`${heading}\n`, `${heading}\n\n${notes.join("\n")}\n`); + } + section = section.replace(`# ${version} (Unreleased)`, `# ${version}`); + return changelog.slice(0, first.index) + section + changelog.slice(end); +} + +export function checkRequirement(files, exempt = false) { + if (exempt) return; + if (!files.some(file => file.status === "A" && /^changelog\/(?!README\.md$)[^/]+\.md$/.test(file.name))) { + throw new Error("Add a changelog fragment, or ask a maintainer for the changelog:skip label. Release PRs use changelog:release."); + } +} + +export async function run(command, root = process.cwd()) { + if (!["check", "preview", "release"].includes(command)) throw new Error("Usage: node scripts/changelog.js check|preview|release"); + const fragments = await readFragments(root); + if (command === "check") { + if (process.env.GITHUB_EVENT_NAME === "pull_request") { + const event = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8")); + const labels = event.pull_request.labels.map(label => label.name); + const diff = execFileSync("git", ["diff", "--no-renames", "--name-status", "-z", `${event.pull_request.base.sha}...HEAD`], { cwd: root, encoding: "utf8" }).split("\0"); + const files = []; + for (let i = 0; i + 1 < diff.length; i += 2) files.push({ status: diff[i], name: diff[i + 1] }); + checkRequirement(files, labels.includes("changelog:skip") || labels.includes("changelog:release")); + } + return; + } + if (command === "preview") { + process.stdout.write(renderFragments(fragments)); + return; + } + const { version } = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")); + const target = path.join(root, "CHANGELOG.md"); + const output = prepareRelease(await fs.readFile(target, "utf8"), version, fragments); + await fs.writeFile(target, output); + for (const fragment of fragments) await fs.unlink(path.join(root, "changelog", fragment.name)); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + run(process.argv[2]).catch(error => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/changelog.test.js b/scripts/changelog.test.js new file mode 100644 index 00000000000..e57e8b142e8 --- /dev/null +++ b/scripts/changelog.test.js @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { categories, checkRequirement, parseFragment, prepareRelease, readFragments, renderFragments, run } from "./changelog.js"; + +const note = "- Fix recovery. https://github.com/rescript-lang/rescript/pull/8667"; +const historical = "# 12.0.0\n\nOld notes.\n"; +const changelog = `# Changelog\n\n# 13.0.0 (Unreleased)\n\n${Object.values(categories).join("\n\n")}\n\n${historical}`; + +test("validates category names and bullet content", () => { + assert.equal(parseFragment("recovery.fix.md", note).category, "fix"); + for (const [name, content] of [["recovery.bugfix.md", note], ["recovery.md", note], ["recovery.fix.md", ""], ["recovery.fix.md", "- Missing link"], ["recovery.fix.md", `Heading\n${note}`]]) { + assert.throws(() => parseFragment(name, content)); + } + assert.equal(parseFragment("recovery.fix.md", `${note}\n${note}`).text, `${note}\n${note}`); +}); + +test("preserves existing notes and historical releases", () => { + const input = changelog.replace(`${categories.fix}\n`, `${categories.fix}\n\n- Existing fix.\n`); + const output = prepareRelease(input, "13.0.0", [parseFragment("recovery.fix.md", note)]); + assert.ok(output.includes(note)); + assert.ok(output.includes("- Existing fix.")); + assert.ok(output.endsWith(historical)); + assert.ok(output.includes("# 13.0.0\n")); + assert.throws(() => prepareRelease(output, "13.0.0", [])); + assert.throws(() => prepareRelease(input, "14.0.0", [])); +}); + +test("requires newly added fragments, with explicit exemptions", () => { + assert.throws(() => checkRequirement([{ status: "M", name: "changelog/existing.fix.md" }])); + assert.throws(() => checkRequirement([{ status: "A", name: "changelog/README.md" }])); + checkRequirement([{ status: "A", name: "changelog/new.fix.md" }]); + checkRequirement([], true); +}); + +test("validates before mutation, sorts fragments, consumes only on release", async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "rescript-changelog-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + await fs.mkdir(path.join(root, "changelog")); + await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ version: "13.0.0" })); + await fs.writeFile(path.join(root, "CHANGELOG.md"), changelog); + const write = (name, text) => fs.writeFile(path.join(root, "changelog", name), text); + await write("README.md", "Instructions"); + await write("z.fix.md", note); + await write("a.fix.md", note.replace("recovery", "formatting")); + await write("bad.unknown.md", note); + await assert.rejects(run("release", root)); + assert.equal(await fs.readFile(path.join(root, "CHANGELOG.md"), "utf8"), changelog); + assert.ok((await fs.readdir(path.join(root, "changelog"))).includes("z.fix.md")); + await fs.unlink(path.join(root, "changelog", "bad.unknown.md")); + assert.deepEqual((await readFragments(root)).map(fragment => fragment.name), ["a.fix.md", "z.fix.md"]); + await run("release", root); + assert.deepEqual(await fs.readdir(path.join(root, "changelog")), ["README.md"]); + await assert.rejects(run("release", root)); +}); + +test("preview groups categories without consuming fragments", () => { + const fragments = [parseFragment("recovery.fix.md", note)]; + const preview = renderFragments(fragments); + assert.ok(preview.includes(`${categories.fix}\n\n${note}`)); + assert.ok(preview.indexOf(categories.breaking) < preview.indexOf(categories.fix)); + assert.equal(fragments.length, 1); +}); From 9b35ebd835b8433ed43b670b37960ed152cbfe62 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 9 Oct 2026 14:22:23 +0000 Subject: [PATCH 02/10] Fix changelog fragment review findings Move the check to its own workflow, exempt Dependabot, insert notes newest PR first without splitting lists, and refuse to publish with leftover fragments. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christoph Knittel --- .github/workflows/changelog.yml | 32 ++++++ .github/workflows/ci.yml | 16 --- AGENTS.md | 2 +- CONTRIBUTING.md | 4 - changelog/README.md | 6 +- package.json | 2 +- scripts/changelog.js | 166 +++++++++++++++++++++++++------- scripts/changelog.test.js | 131 ++++++++++++++++++++++--- scripts/releasePlan.js | 21 ++++ scripts/releasePlan.test.js | 20 +++- 10 files changed, 326 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/changelog.yml diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 00000000000..74c065b266b --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,32 @@ +name: Changelog + +on: + push: + branches: [master, 11.0_release] + + # Label events rerun only this check, so applying changelog:skip or + # changelog:release does not restart the main CI. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + branches: [master, 11.0_release] + +concurrency: + group: changelog-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + changelog: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: node --test scripts/changelog.test.js + - run: node scripts/changelog.js check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1b55ccac2f..2a4888b9307 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,6 @@ on: branches: [master, 11.0_release] pull_request: - types: [opened, synchronize, reopened, labeled, unlabeled] branches: [master, 11.0_release] # Release builds are started by publish.yml after a maintainer indicates @@ -30,21 +29,6 @@ env: RUSTFLAGS: "-Dwarnings" jobs: - changelog: - runs-on: ubuntu-24.04 - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - uses: actions/setup-node@v6 - with: - node-version: 22 - - run: node --test scripts/changelog.test.js - - run: node scripts/changelog.js check - build-compiler: outputs: api-docs-artifact-id: ${{ steps.upload-api-docs.outputs.artifact-id }} diff --git a/AGENTS.md b/AGENTS.md index 494d2222d6e..868b687b758 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ on `master`. CI runs on every PR in the stack. ## Testing and changelog -**Add a changelog fragment** in `changelog/..md` for any user-facing change (bug fix, feature, or breaking change). Use a unique descriptive filename and Markdown bullets ending with PR links. Categories: `breaking`, `compliance`, `feature`, `fix`, `docs`, `polish`, `internal`. Do not edit `CHANGELOG.md` in ordinary PRs. See [changelog/README.md](changelog/README.md). Run `yarn changelog:check`; changes without release notes need the maintainer-applied `changelog:skip` label. Release preparation PRs use `changelog:release`. +**Add a changelog fragment** `changelog/..md` for any user-facing change (bug fix, feature, or breaking change) instead of editing `CHANGELOG.md`; see [changelog/README.md](changelog/README.md) for categories and format. Validate with `yarn changelog:check`. For compiler or standard-library changes, run `make test`. For syntax changes, also run `make test-syntax`; use `make test-syntax-roundtrip` when parsing or diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd2f9d70b9b..af59ff473a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -618,7 +618,3 @@ To participate under these terms, all that you must do is include a line like th Signed-Off-By: Random J. Developer You must use your real name (sorry, no pseudonyms, and no anonymous contributions). - -## Changelog fragments - -For user-facing changes, add a fragment following [changelog/README.md](changelog/README.md). Ordinary PRs do not edit `CHANGELOG.md`. diff --git a/changelog/README.md b/changelog/README.md index 55945d6dd99..77cc9b0616e 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -18,10 +18,10 @@ Use descriptive filenames before a PR number exists, then add the actual PR link | `polish` | Polish | | `internal` | Internal | -`yarn changelog:check` validates all fragments. CI requires a newly added fragment relative to the PR merge base. Maintainers may apply `changelog:skip` when notes are unnecessary, or `changelog:release` for release preparation. These labels exempt the requirement, but never fragment validation. Reviewers decide whether the chosen category is appropriate. +`yarn changelog:check` validates all fragments. CI requires a newly added fragment relative to the PR merge base. Maintainers may apply `changelog:skip` when notes are unnecessary, or `changelog:release` for release preparation. These labels, like Dependabot PRs, are exempt from the requirement, but never from fragment validation. Reviewers decide whether the chosen category is appropriate. -`yarn changelog:preview` prints pending fragments grouped by category and sorted by filename without changing files. It does not include entries already in `CHANGELOG.md`. +`yarn changelog:preview` prints pending fragments grouped by category, newest PR first, without changing files. It does not include entries already in `CHANGELOG.md`. -During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments to the existing sections, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Running it again fails because the version has already been finalized. Releases with no fragments can still finalize existing notes. +During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Running it again fails because the version has already been finalized. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changelog/`. Only release preparation and opening the next unreleased section should edit `CHANGELOG.md`. Keep fragments on the branch containing the associated change; backports should include their fragments if they need notes in the maintenance release. diff --git a/package.json b/package.json index 9fa2a61934c..3594a41e9d7 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "Jaap Frolich (https://github.com/jfrolich)", "Matthias Le Brun (https://github.com/bloodyowl)", "Patrick Ecker (https://github.com/ryyppy)", - "Paul Tsnobiladz\u00e9 (https://github.com/tsnobip)", + "Paul Tsnobiladzé (https://github.com/tsnobip)", "Shulhi Sapli (https://github.com/shulhi)", "Woonki Moon (https://github.com/mununki)" ], diff --git a/scripts/changelog.js b/scripts/changelog.js index 3f5208f28c4..2a5e2f842e7 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -13,6 +13,12 @@ export const categories = { internal: "#### :house: Internal", }; +const prLink = + /https:\/\/github\.com\/rescript-lang\/rescript(?:-compiler)?\/pull\/([1-9]\d*)/; +const trailingPrLinks = new RegExp( + `${prLink.source}(?:\\s+${prLink.source})*$`, +); + export function parseFragment(name, content) { const match = /^([a-z0-9][a-z0-9-]*)\.([a-z]+)\.md$/.exec(name); if (!match || !Object.hasOwn(categories, match[2])) { @@ -22,78 +28,161 @@ export function parseFragment(name, content) { } const text = content.trim(); const bullets = text.split(/\n(?=- )/); - const prLinks = /https:\/\/github\.com\/rescript-lang\/rescript(?:-compiler)?\/pull\/[1-9]\d*(?:\s+https:\/\/github\.com\/rescript-lang\/rescript(?:-compiler)?\/pull\/[1-9]\d*)*$/; - const invalid = bullets.some(bullet => - !/^- \S/.test(bullet) || - !prLinks.test(bullet) || - bullet.split("\n").slice(1).some(line => line && !/^ {2}\S/.test(line)), + const invalid = bullets.some( + bullet => + !/^- \S/.test(bullet) || + !trailingPrLinks.test(bullet) || + bullet + .split("\n") + .slice(1) + .some(line => line && !/^ {2}\S/.test(line)), ); if (!text || invalid) { throw new Error( `${name}: expected Markdown bullets ending with PR links (indent continuation lines by two spaces)`, ); } - return { name, category: match[2], text }; + const pr = Math.max( + ...[...text.matchAll(new RegExp(prLink, "g"))].map(link => Number(link[1])), + ); + return { name, category: match[2], text, pr }; } export async function readFragments(root) { const directory = path.join(root, "changelog"); const entries = await fs.readdir(directory, { withFileTypes: true }); const fragments = []; - for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of entries) { if (entry.name === "README.md") continue; - if (!entry.isFile()) throw new Error(`changelog/${entry.name}: expected a regular fragment file`); - fragments.push(parseFragment(entry.name, await fs.readFile(path.join(directory, entry.name), "utf8"))); + if (!entry.isFile()) { + throw new Error( + `changelog/${entry.name}: expected a regular fragment file`, + ); + } + fragments.push( + parseFragment( + entry.name, + await fs.readFile(path.join(directory, entry.name), "utf8"), + ), + ); } return fragments; } +// Newest PR first, matching the order of existing CHANGELOG.md entries. +function categoryNotes(fragments, category) { + return fragments + .filter(fragment => fragment.category === category) + .sort( + (a, b) => b.pr - a.pr || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0), + ) + .map(fragment => fragment.text); +} + export function renderFragments(fragments) { - return Object.entries(categories).map(([category, heading]) => { - const notes = fragments.filter(fragment => fragment.category === category).map(fragment => fragment.text); - return `${heading}\n\n${notes.length ? `${notes.join("\n")}\n\n` : ""}`; - }).join(""); + return Object.entries(categories) + .map(([category, heading]) => { + const notes = categoryNotes(fragments, category); + return `${heading}\n\n${notes.length ? `${notes.join("\n")}\n\n` : ""}`; + }) + .join(""); } export function prepareRelease(changelog, version, fragments) { - const headings = [...changelog.matchAll(/^# (.+)$/gm)].filter(match => match[1] !== "Changelog"); + const headings = [...changelog.matchAll(/^# (.+)$/gm)].filter( + match => match[1] !== "Changelog", + ); const first = headings[0]; if (!first || first[1] !== `${version} (Unreleased)`) { - throw new Error(`Expected first version heading to be ${version} (Unreleased)`); + throw new Error( + `Expected first version heading to be ${version} (Unreleased)`, + ); } const end = headings[1]?.index ?? changelog.length; - let section = changelog.slice(first.index, end); + const lines = changelog.slice(first.index, end).split("\n"); // Keep existing unreleased entries during migration, and preserve history verbatim. for (const [category, heading] of Object.entries(categories)) { - const notes = fragments.filter(fragment => fragment.category === category).map(fragment => fragment.text); + const notes = categoryNotes(fragments, category); if (!notes.length) continue; - if (section.split("\n").filter(line => line === heading).length !== 1) { + const index = lines.indexOf(heading); + if (index === -1 || lines.lastIndexOf(heading) !== index) { throw new Error(`Expected exactly one ${heading} section`); } - section = section.replace(`${heading}\n`, `${heading}\n\n${notes.join("\n")}\n`); + let next = index + 1; + while (next < lines.length && lines[next] === "") next++; + // New notes go above existing entries in the same list; otherwise keep the + // blank lines that separated the heading from what follows. + const blanks = lines.slice(index + 1, next); + const after = lines[next]?.startsWith("- ") + ? [] + : blanks.length + ? blanks + : [""]; + lines.splice( + index + 1, + blanks.length, + "", + ...notes.join("\n").split("\n"), + ...after, + ); } - section = section.replace(`# ${version} (Unreleased)`, `# ${version}`); - return changelog.slice(0, first.index) + section + changelog.slice(end); + lines[0] = `# ${version}`; + return ( + changelog.slice(0, first.index) + lines.join("\n") + changelog.slice(end) + ); +} + +export function isExempt(pullRequest) { + const labels = pullRequest.labels.map(label => label.name); + return ( + labels.includes("changelog:skip") || + labels.includes("changelog:release") || + pullRequest.user.login === "dependabot[bot]" + ); } export function checkRequirement(files, exempt = false) { if (exempt) return; - if (!files.some(file => file.status === "A" && /^changelog\/(?!README\.md$)[^/]+\.md$/.test(file.name))) { - throw new Error("Add a changelog fragment, or ask a maintainer for the changelog:skip label. Release PRs use changelog:release."); + if ( + !files.some( + file => + file.status === "A" && + /^changelog\/(?!README\.md$)[^/]+\.md$/.test(file.name), + ) + ) { + throw new Error( + "Add a changelog fragment, or ask a maintainer for the changelog:skip label. Release PRs use changelog:release.", + ); } } export async function run(command, root = process.cwd()) { - if (!["check", "preview", "release"].includes(command)) throw new Error("Usage: node scripts/changelog.js check|preview|release"); + if (!["check", "preview", "release"].includes(command)) { + throw new Error("Usage: node scripts/changelog.js check|preview|release"); + } const fragments = await readFragments(root); if (command === "check") { if (process.env.GITHUB_EVENT_NAME === "pull_request") { - const event = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8")); - const labels = event.pull_request.labels.map(label => label.name); - const diff = execFileSync("git", ["diff", "--no-renames", "--name-status", "-z", `${event.pull_request.base.sha}...HEAD`], { cwd: root, encoding: "utf8" }).split("\0"); + const { pull_request: pullRequest } = JSON.parse( + await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8"), + ); + const diff = execFileSync( + "git", + [ + "diff", + "--no-renames", + "--name-status", + "-z", + `${pullRequest.base.sha}...HEAD`, + ], + { cwd: root, encoding: "utf8" }, + ).split("\0"); const files = []; - for (let i = 0; i + 1 < diff.length; i += 2) files.push({ status: diff[i], name: diff[i + 1] }); - checkRequirement(files, labels.includes("changelog:skip") || labels.includes("changelog:release")); + for (let i = 0; i + 1 < diff.length; i += 2) { + files.push({ status: diff[i], name: diff[i + 1] }); + } + checkRequirement(files, isExempt(pullRequest)); } return; } @@ -101,14 +190,25 @@ export async function run(command, root = process.cwd()) { process.stdout.write(renderFragments(fragments)); return; } - const { version } = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")); + const { version } = JSON.parse( + await fs.readFile(path.join(root, "package.json"), "utf8"), + ); const target = path.join(root, "CHANGELOG.md"); - const output = prepareRelease(await fs.readFile(target, "utf8"), version, fragments); + const output = prepareRelease( + await fs.readFile(target, "utf8"), + version, + fragments, + ); await fs.writeFile(target, output); - for (const fragment of fragments) await fs.unlink(path.join(root, "changelog", fragment.name)); + for (const fragment of fragments) { + await fs.unlink(path.join(root, "changelog", fragment.name)); + } } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { run(process.argv[2]).catch(error => { console.error(error.message); process.exitCode = 1; diff --git a/scripts/changelog.test.js b/scripts/changelog.test.js index e57e8b142e8..32173df00f1 100644 --- a/scripts/changelog.test.js +++ b/scripts/changelog.test.js @@ -3,23 +3,47 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { categories, checkRequirement, parseFragment, prepareRelease, readFragments, renderFragments, run } from "./changelog.js"; +import { + categories, + checkRequirement, + isExempt, + parseFragment, + prepareRelease, + readFragments, + renderFragments, + run, +} from "./changelog.js"; -const note = "- Fix recovery. https://github.com/rescript-lang/rescript/pull/8667"; +const note = + "- Fix recovery. https://github.com/rescript-lang/rescript/pull/8667"; const historical = "# 12.0.0\n\nOld notes.\n"; const changelog = `# Changelog\n\n# 13.0.0 (Unreleased)\n\n${Object.values(categories).join("\n\n")}\n\n${historical}`; test("validates category names and bullet content", () => { assert.equal(parseFragment("recovery.fix.md", note).category, "fix"); - for (const [name, content] of [["recovery.bugfix.md", note], ["recovery.md", note], ["recovery.fix.md", ""], ["recovery.fix.md", "- Missing link"], ["recovery.fix.md", `Heading\n${note}`]]) { + for (const [name, content] of [ + ["recovery.bugfix.md", note], + ["recovery.md", note], + ["recovery.fix.md", ""], + ["recovery.fix.md", "- Missing link"], + ["recovery.fix.md", `Heading\n${note}`], + ]) { assert.throws(() => parseFragment(name, content)); } - assert.equal(parseFragment("recovery.fix.md", `${note}\n${note}`).text, `${note}\n${note}`); + assert.equal( + parseFragment("recovery.fix.md", `${note}\n${note}`).text, + `${note}\n${note}`, + ); }); test("preserves existing notes and historical releases", () => { - const input = changelog.replace(`${categories.fix}\n`, `${categories.fix}\n\n- Existing fix.\n`); - const output = prepareRelease(input, "13.0.0", [parseFragment("recovery.fix.md", note)]); + const input = changelog.replace( + `${categories.fix}\n`, + `${categories.fix}\n\n- Existing fix.\n`, + ); + const output = prepareRelease(input, "13.0.0", [ + parseFragment("recovery.fix.md", note), + ]); assert.ok(output.includes(note)); assert.ok(output.includes("- Existing fix.")); assert.ok(output.endsWith(historical)); @@ -28,9 +52,70 @@ test("preserves existing notes and historical releases", () => { assert.throws(() => prepareRelease(input, "14.0.0", [])); }); +test("orders notes newest PR first above existing entries", () => { + const input = changelog.replace( + `${categories.fix}\n`, + `${categories.fix}\n\n- Existing fix.\n`, + ); + const older = parseFragment( + "a.fix.md", + "- Older. https://github.com/rescript-lang/rescript/pull/10", + ); + const newer = parseFragment( + "b.fix.md", + "- Newer. https://github.com/rescript-lang/rescript/pull/9 https://github.com/rescript-lang/rescript/pull/20", + ); + const output = prepareRelease(input, "13.0.0", [older, newer]); + assert.ok( + output.includes( + `${categories.fix}\n\n${newer.text}\n${older.text}\n- Existing fix.\n\n${categories.docs}`, + ), + ); + assert.ok( + renderFragments([older, newer]).includes(`${newer.text}\n${older.text}`), + ); +}); + +test("keeps section spacing when a section is empty or last", () => { + const output = prepareRelease(changelog, "13.0.0", [ + parseFragment("a.fix.md", note), + parseFragment("b.internal.md", note), + ]); + assert.ok( + output.includes(`${categories.fix}\n\n${note}\n\n${categories.docs}`), + ); + assert.ok( + output.endsWith(`${categories.internal}\n\n${note}\n\n${historical}`), + ); +}); + +test("inserts notes containing replacement patterns verbatim", () => { + const text = + "- Handle `$$`, `$&`, `$'` and `$\u0060`. https://github.com/rescript-lang/rescript/pull/1"; + const output = prepareRelease(changelog, "13.0.0", [ + parseFragment("dollar.fix.md", text), + ]); + assert.ok(output.includes(`${categories.fix}\n\n${text}\n`)); +}); + +test("exempts labelled and Dependabot pull requests", () => { + const pullRequest = (login, labels = []) => ({ + user: { login }, + labels: labels.map(name => ({ name })), + }); + assert.equal(isExempt(pullRequest("someone")), false); + assert.equal(isExempt(pullRequest("someone", ["changelog:skip"])), true); + assert.equal(isExempt(pullRequest("someone", ["changelog:release"])), true); + assert.equal(isExempt(pullRequest("dependabot[bot]")), true); +}); + test("requires newly added fragments, with explicit exemptions", () => { - assert.throws(() => checkRequirement([{ status: "M", name: "changelog/existing.fix.md" }])); - assert.throws(() => checkRequirement([{ status: "A", name: "changelog/README.md" }])); + assert.throws(() => + checkRequirement([{ status: "M", name: "changelog/existing.fix.md" }]), + ); + assert.throws(() => + checkRequirement([{ status: "A", name: "changelog/README.md" }]), + ); checkRequirement([{ status: "A", name: "changelog/new.fix.md" }]); checkRequirement([], true); }); @@ -39,20 +124,34 @@ test("validates before mutation, sorts fragments, consumes only on release", asy const root = await fs.mkdtemp(path.join(os.tmpdir(), "rescript-changelog-")); t.after(() => fs.rm(root, { recursive: true, force: true })); await fs.mkdir(path.join(root, "changelog")); - await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ version: "13.0.0" })); + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ version: "13.0.0" }), + ); await fs.writeFile(path.join(root, "CHANGELOG.md"), changelog); - const write = (name, text) => fs.writeFile(path.join(root, "changelog", name), text); + const write = (name, text) => + fs.writeFile(path.join(root, "changelog", name), text); await write("README.md", "Instructions"); await write("z.fix.md", note); await write("a.fix.md", note.replace("recovery", "formatting")); await write("bad.unknown.md", note); await assert.rejects(run("release", root)); - assert.equal(await fs.readFile(path.join(root, "CHANGELOG.md"), "utf8"), changelog); - assert.ok((await fs.readdir(path.join(root, "changelog"))).includes("z.fix.md")); + assert.equal( + await fs.readFile(path.join(root, "CHANGELOG.md"), "utf8"), + changelog, + ); + assert.ok( + (await fs.readdir(path.join(root, "changelog"))).includes("z.fix.md"), + ); await fs.unlink(path.join(root, "changelog", "bad.unknown.md")); - assert.deepEqual((await readFragments(root)).map(fragment => fragment.name), ["a.fix.md", "z.fix.md"]); + assert.deepEqual( + (await readFragments(root)).map(fragment => fragment.name), + ["a.fix.md", "z.fix.md"], + ); await run("release", root); - assert.deepEqual(await fs.readdir(path.join(root, "changelog")), ["README.md"]); + assert.deepEqual(await fs.readdir(path.join(root, "changelog")), [ + "README.md", + ]); await assert.rejects(run("release", root)); }); @@ -60,6 +159,8 @@ test("preview groups categories without consuming fragments", () => { const fragments = [parseFragment("recovery.fix.md", note)]; const preview = renderFragments(fragments); assert.ok(preview.includes(`${categories.fix}\n\n${note}`)); - assert.ok(preview.indexOf(categories.breaking) < preview.indexOf(categories.fix)); + assert.ok( + preview.indexOf(categories.breaking) < preview.indexOf(categories.fix), + ); assert.equal(fragments.length, 1); }); diff --git a/scripts/releasePlan.js b/scripts/releasePlan.js index ad20058281a..6c06589d020 100644 --- a/scripts/releasePlan.js +++ b/scripts/releasePlan.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from "node:fs/promises"; +import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; import semver from "semver"; @@ -48,6 +49,19 @@ export function findFirstReleasedVersion(changelog) { return null; } +// Fragments left in changelog/ were merged after release preparation, so +// publishing would ship their changes without release notes. +export async function findPendingFragments(root = ".") { + let names; + try { + names = await fs.readdir(path.join(root, "changelog")); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + return names.filter(name => name !== "README.md").sort(); +} + export async function validateReleaseFiles(plan) { const packageJson = JSON.parse(await fs.readFile("package.json", "utf8")); if (packageJson.version !== plan.version) { @@ -63,6 +77,13 @@ export async function validateReleaseFiles(plan) { `The first released changelog version is ${firstReleasedVersion ?? "missing"}, expected ${plan.version}.`, ); } + + const pendingFragments = await findPendingFragments(); + if (pendingFragments.length > 0) { + throw new Error( + `Unreleased changelog fragments remain: ${pendingFragments.join(", ")}. Run yarn changelog:release in a release PR first.`, + ); + } } async function main() { diff --git a/scripts/releasePlan.test.js b/scripts/releasePlan.test.js index a5af03d6a28..76e7abd64eb 100644 --- a/scripts/releasePlan.test.js +++ b/scripts/releasePlan.test.js @@ -1,6 +1,13 @@ import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; -import { createReleasePlan, findFirstReleasedVersion } from "./releasePlan.js"; +import { + createReleasePlan, + findFirstReleasedVersion, + findPendingFragments, +} from "./releasePlan.js"; test("plans a maintenance release", () => { assert.deepEqual(createReleasePlan("12.3.0"), { @@ -62,3 +69,14 @@ test("ignores unreleased changelog headings", () => { `; assert.equal(findFirstReleasedVersion(changelog), "13.0.0-alpha.5"); }); + +test("finds changelog fragments left unreleased", async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "rescript-release-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + assert.deepEqual(await findPendingFragments(root), []); + await fs.mkdir(path.join(root, "changelog")); + await fs.writeFile(path.join(root, "changelog", "README.md"), ""); + assert.deepEqual(await findPendingFragments(root), []); + await fs.writeFile(path.join(root, "changelog", "late.fix.md"), ""); + assert.deepEqual(await findPendingFragments(root), ["late.fix.md"]); +}); From fdb5b5071f2c66673e805180eb3dbdfcbea247a7 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 9 Oct 2026 14:43:13 +0000 Subject: [PATCH 03/10] Run release plan tests in CI Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christoph Knittel --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a4888b9307..a9c8bd48a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,9 @@ jobs: - name: Version Check run: yarn constraints + - name: Test release plan + run: yarn test-release-plan + - name: Run tests run: node scripts/test.js -all From cfcb7ff8cd51300ced20e17ed0a48a8aaba65d66 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 9 Oct 2026 14:49:00 +0000 Subject: [PATCH 04/10] Guard CHANGELOG.md edits and add changelog:open Reject CHANGELOG.md edits without changelog:release, explain the placeholder link and draft PR flow, and open the next unreleased section by command. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christoph Knittel --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- changelog/README.md | 8 +++-- package.json | 1 + scripts/changelog.js | 73 +++++++++++++++++++++++++++++---------- scripts/changelog.test.js | 64 ++++++++++++++++++++++++++-------- 6 files changed, 113 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 868b687b758..70f51313334 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ on `master`. CI runs on every PR in the stack. ## Testing and changelog -**Add a changelog fragment** `changelog/..md` for any user-facing change (bug fix, feature, or breaking change) instead of editing `CHANGELOG.md`; see [changelog/README.md](changelog/README.md) for categories and format. Validate with `yarn changelog:check`. +**Add a changelog fragment** `changelog/..md` for any user-facing change (bug fix, feature, or breaking change) instead of editing `CHANGELOG.md`; see [changelog/README.md](changelog/README.md) for categories and format. Validate with `yarn changelog:check`. Since the PR number is not known yet, open the PR as a draft with the placeholder link `https://github.com/rescript-lang/rescript/pull/XXXX`, then replace it with the real link, amend, push again, and mark the PR ready for review (`gh pr ready`). For compiler or standard-library changes, run `make test`. For syntax changes, also run `make test-syntax`; use `make test-syntax-roundtrip` when parsing or diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af59ff473a3..db18f766bde 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -585,7 +585,7 @@ To build a new version and release it on NPM, follow these steps: 1. Create a PR with the following changes to prepare for development of the next version: - Increment the `EXPECTED_VERSION` number in `yarn.config.cjs` for the next version. - Run `yarn constraints --fix` to take that version number over into other files. - - Update `CHANGELOG.md` and add an entry for the next version, e.g., "10.0.0-beta.2 (Unreleased)" + - Run `yarn changelog:open` to add the empty "(Unreleased)" section for the next version to `CHANGELOG.md`, and apply the `changelog:release` label to the PR. ## Debugging issues from CI builds diff --git a/changelog/README.md b/changelog/README.md index 77cc9b0616e..1d2b119aa31 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -6,7 +6,7 @@ Add a unique `..md` file for each change that needs relea - Recover blocked dependents after a failed build. https://github.com/rescript-lang/rescript/pull/8667 ``` -Use descriptive filenames before a PR number exists, then add the actual PR link before CI passes. Each file may contain multiple bullets in the same category. Indent wrapped lines by two spaces; each bullet ends with one or more PR links. +The PR number is only known once the PR exists, so open it as a draft with the placeholder link `https://github.com/rescript-lang/rescript/pull/XXXX`, replace the placeholder with the real link, amend and push again, then mark the PR ready for review. The changelog check fails while the placeholder remains. Each file may contain multiple bullets in the same category. Indent wrapped lines by two spaces; each bullet ends with one or more PR links. | Category | Section | | --- | --- | @@ -18,10 +18,12 @@ Use descriptive filenames before a PR number exists, then add the actual PR link | `polish` | Polish | | `internal` | Internal | -`yarn changelog:check` validates all fragments. CI requires a newly added fragment relative to the PR merge base. Maintainers may apply `changelog:skip` when notes are unnecessary, or `changelog:release` for release preparation. These labels, like Dependabot PRs, are exempt from the requirement, but never from fragment validation. Reviewers decide whether the chosen category is appropriate. +`yarn changelog:check` validates all fragments. CI requires a newly added fragment relative to the PR merge base, and rejects edits to `CHANGELOG.md` unless the PR has the `changelog:release` label. Maintainers may apply `changelog:skip` when notes are unnecessary, or `changelog:release` for release preparation. These labels, like Dependabot PRs, are exempt from the requirement, but never from fragment validation. Reviewers decide whether the chosen category is appropriate. `yarn changelog:preview` prints pending fragments grouped by category, newest PR first, without changing files. It does not include entries already in `CHANGELOG.md`. During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Running it again fails because the version has already been finalized. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changelog/`. -Only release preparation and opening the next unreleased section should edit `CHANGELOG.md`. Keep fragments on the branch containing the associated change; backports should include their fragments if they need notes in the maintenance release. +After publishing, run `yarn changelog:open` once `package.json` has the next version. It adds an empty unreleased section with every category heading. + +Only release preparation and opening the next unreleased section should edit `CHANGELOG.md`, in PRs labelled `changelog:release`. Keep fragments on the branch containing the associated change; backports should include their fragments if they need notes in the maintenance release. diff --git a/package.json b/package.json index 3594a41e9d7..4977792a40f 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "changelog:check": "node scripts/changelog.js check", "changelog:preview": "node scripts/changelog.js preview", "changelog:release": "node scripts/changelog.js release", + "changelog:open": "node scripts/changelog.js open", "test-changelog": "node --test scripts/changelog.test.js" }, "files": [ diff --git a/scripts/changelog.js b/scripts/changelog.js index 2a5e2f842e7..d33e21adf01 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -15,6 +15,8 @@ export const categories = { const prLink = /https:\/\/github\.com\/rescript-lang\/rescript(?:-compiler)?\/pull\/([1-9]\d*)/; +export const placeholderLink = + "https://github.com/rescript-lang/rescript/pull/XXXX"; const trailingPrLinks = new RegExp( `${prLink.source}(?:\\s+${prLink.source})*$`, ); @@ -27,6 +29,11 @@ export function parseFragment(name, content) { ); } const text = content.trim(); + if (text.includes(placeholderLink)) { + throw new Error( + `${name}: replace the placeholder ${placeholderLink} with the PR link`, + ); + } const bullets = text.split(/\n(?=- )/); const invalid = bullets.some( bullet => @@ -89,10 +96,14 @@ export function renderFragments(fragments) { .join(""); } -export function prepareRelease(changelog, version, fragments) { - const headings = [...changelog.matchAll(/^# (.+)$/gm)].filter( +function versionHeadings(changelog) { + return [...changelog.matchAll(/^# (.+)$/gm)].filter( match => match[1] !== "Changelog", ); +} + +export function prepareRelease(changelog, version, fragments) { + const headings = versionHeadings(changelog); const first = headings[0]; if (!first || first[1] !== `${version} (Unreleased)`) { throw new Error( @@ -133,17 +144,41 @@ export function prepareRelease(changelog, version, fragments) { ); } -export function isExempt(pullRequest) { - const labels = pullRequest.labels.map(label => label.name); +// Adds an empty unreleased section with every category heading, so that the +// next release can insert fragments into it. +export function openSection(changelog, version) { + const [first] = versionHeadings(changelog); + if (first?.[1].endsWith(" (Unreleased)")) { + throw new Error(`${first[1]} must be released first`); + } + if (first?.[1] === version) { + throw new Error(`${version} has already been released`); + } + const index = first?.index ?? changelog.length; return ( - labels.includes("changelog:skip") || - labels.includes("changelog:release") || - pullRequest.user.login === "dependabot[bot]" + changelog.slice(0, index) + + `# ${version} (Unreleased)\n\n${Object.values(categories).join("\n\n")}\n\n` + + changelog.slice(index) ); } -export function checkRequirement(files, exempt = false) { - if (exempt) return; +export function checkPullRequest(files, pullRequest) { + const labels = pullRequest.labels.map(label => label.name); + if ( + files.some(file => file.name === "CHANGELOG.md") && + !labels.includes("changelog:release") + ) { + throw new Error( + "Add a changelog fragment instead of editing CHANGELOG.md. Release PRs use the changelog:release label.", + ); + } + if ( + labels.includes("changelog:skip") || + labels.includes("changelog:release") || + pullRequest.user.login === "dependabot[bot]" + ) { + return; + } if ( !files.some( file => @@ -158,8 +193,10 @@ export function checkRequirement(files, exempt = false) { } export async function run(command, root = process.cwd()) { - if (!["check", "preview", "release"].includes(command)) { - throw new Error("Usage: node scripts/changelog.js check|preview|release"); + if (!["check", "preview", "release", "open"].includes(command)) { + throw new Error( + "Usage: node scripts/changelog.js check|preview|release|open", + ); } const fragments = await readFragments(root); if (command === "check") { @@ -182,7 +219,7 @@ export async function run(command, root = process.cwd()) { for (let i = 0; i + 1 < diff.length; i += 2) { files.push({ status: diff[i], name: diff[i + 1] }); } - checkRequirement(files, isExempt(pullRequest)); + checkPullRequest(files, pullRequest); } return; } @@ -194,12 +231,12 @@ export async function run(command, root = process.cwd()) { await fs.readFile(path.join(root, "package.json"), "utf8"), ); const target = path.join(root, "CHANGELOG.md"); - const output = prepareRelease( - await fs.readFile(target, "utf8"), - version, - fragments, - ); - await fs.writeFile(target, output); + const changelog = await fs.readFile(target, "utf8"); + if (command === "open") { + await fs.writeFile(target, openSection(changelog, version)); + return; + } + await fs.writeFile(target, prepareRelease(changelog, version, fragments)); for (const fragment of fragments) { await fs.unlink(path.join(root, "changelog", fragment.name)); } diff --git a/scripts/changelog.test.js b/scripts/changelog.test.js index 32173df00f1..95c2c901a8e 100644 --- a/scripts/changelog.test.js +++ b/scripts/changelog.test.js @@ -5,9 +5,10 @@ import path from "node:path"; import test from "node:test"; import { categories, - checkRequirement, - isExempt, + checkPullRequest, + openSection, parseFragment, + placeholderLink, prepareRelease, readFragments, renderFragments, @@ -30,6 +31,11 @@ test("validates category names and bullet content", () => { ]) { assert.throws(() => parseFragment(name, content)); } + assert.throws( + () => + parseFragment("recovery.fix.md", `- Fix recovery. ${placeholderLink}`), + /replace the placeholder/, + ); assert.equal( parseFragment("recovery.fix.md", `${note}\n${note}`).text, `${note}\n${note}`, @@ -98,26 +104,56 @@ test("inserts notes containing replacement patterns verbatim", () => { assert.ok(output.includes(`${categories.fix}\n\n${text}\n`)); }); -test("exempts labelled and Dependabot pull requests", () => { +test("checks pull request fragments and CHANGELOG.md edits", () => { const pullRequest = (login, labels = []) => ({ user: { login }, labels: labels.map(name => ({ name })), }); - assert.equal(isExempt(pullRequest("someone")), false); - assert.equal(isExempt(pullRequest("someone", ["changelog:skip"])), true); - assert.equal(isExempt(pullRequest("someone", ["changelog:release"])), true); - assert.equal(isExempt(pullRequest("dependabot[bot]")), true); + const someone = pullRequest("someone"); + const fragment = { status: "A", name: "changelog/new.fix.md" }; + const changelogEdit = { status: "M", name: "CHANGELOG.md" }; + checkPullRequest([fragment], someone); + for (const files of [ + [], + [{ status: "M", name: "changelog/existing.fix.md" }], + [{ status: "A", name: "changelog/README.md" }], + [fragment, changelogEdit], + ]) { + assert.throws(() => checkPullRequest(files, someone)); + } + checkPullRequest([], pullRequest("someone", ["changelog:skip"])); + checkPullRequest([], pullRequest("dependabot[bot]")); + for (const author of [ + pullRequest("someone", ["changelog:skip"]), + pullRequest("dependabot[bot]"), + ]) { + assert.throws(() => checkPullRequest([changelogEdit], author), /CHANGELOG/); + } + checkPullRequest( + [changelogEdit], + pullRequest("someone", ["changelog:release"]), + ); }); -test("requires newly added fragments, with explicit exemptions", () => { - assert.throws(() => - checkRequirement([{ status: "M", name: "changelog/existing.fix.md" }]), +test("opens the next unreleased section for the following release", () => { + const released = prepareRelease(changelog, "13.0.0", [ + parseFragment("recovery.fix.md", note), + ]); + const opened = openSection(released, "13.0.1"); + assert.equal( + opened, + released.replace( + "# 13.0.0\n", + `# 13.0.1 (Unreleased)\n\n${Object.values(categories).join("\n\n")}\n\n# 13.0.0\n`, + ), ); - assert.throws(() => - checkRequirement([{ status: "A", name: "changelog/README.md" }]), + assert.ok( + prepareRelease(opened, "13.0.1", [ + parseFragment("later.feature.md", note), + ]).includes(`# 13.0.1\n\n${categories.breaking}`), ); - checkRequirement([{ status: "A", name: "changelog/new.fix.md" }]); - checkRequirement([], true); + assert.throws(() => openSection(opened, "13.0.2"), /must be released/); + assert.throws(() => openSection(released, "13.0.0"), /already been released/); }); test("validates before mutation, sorts fragments, consumes only on release", async t => { From b5e6df195fedcd9826c100c46737cc3bd1c535be Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 9 Oct 2026 18:57:39 +0000 Subject: [PATCH 05/10] Turn the PR template into a short checklist Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christoph Knittel --- .github/pull_request_template.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 179254b0998..1ae93cf9877 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,5 @@ -## Description +## Checklist -## Validation - -- [ ] Added a changelog fragment in `changelog/..md` with the PR link, or explained why a maintainer should apply `changelog:skip`. -- [ ] Ran the relevant tests. +- [ ] Added a changelog fragment (see `changelog/README.md`), or no release notes are needed and a maintainer should apply `changelog:skip`. +- [ ] Added tests for the fix or feature. +- [ ] Signed off every commit for the DCO (`git commit -s`). From 6a5a479ec3b96de06a355ee22c944742f466fc95 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 9 Oct 2026 19:18:50 +0000 Subject: [PATCH 06/10] Drop the unused Spec Compliance category It was used once, in 10.1.0-rc.1. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christoph Knittel --- CHANGELOG.md | 3 --- changelog/README.md | 1 - scripts/changelog.js | 1 - 3 files changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 997f809d328..2f74fab2666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ > **Tags:** > > - :boom: [Breaking Change] -> - :eyeglasses: [Spec Compliance] > - :rocket: [New Feature] > - :bug: [Bug Fix] > - :memo: [Documentation] @@ -18,8 +17,6 @@ - Remove the call-site `@inlined` attribute, which was parsed but never affected code generation. It is now reported as a misplaced attribute (warning 53). https://github.com/rescript-lang/rescript/pull/8734 - Remove `@deriving(abstract)` and `@deriving(jsConverter)`. Use record types (with optional fields, mutable fields and `@as` renaming) and polymorphic variants directly instead. https://github.com/rescript-lang/rescript/pull/8729 -#### :eyeglasses: Spec Compliance - #### :rocket: New Feature #### :bug: Bug fix diff --git a/changelog/README.md b/changelog/README.md index 1d2b119aa31..03031d32e76 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -11,7 +11,6 @@ The PR number is only known once the PR exists, so open it as a draft with the p | Category | Section | | --- | --- | | `breaking` | Breaking Change | -| `compliance` | Spec Compliance | | `feature` | New Feature | | `fix` | Bug fix | | `docs` | Documentation | diff --git a/scripts/changelog.js b/scripts/changelog.js index d33e21adf01..91cdf4c3913 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"; export const categories = { breaking: "#### :boom: Breaking Change", - compliance: "#### :eyeglasses: Spec Compliance", feature: "#### :rocket: New Feature", fix: "#### :bug: Bug fix", docs: "#### :memo: Documentation", From d91af0f1a51187ecdac4a0fad05dbbf48e7409de Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 9 Oct 2026 19:46:32 +0000 Subject: [PATCH 07/10] Move changelog fragments to changes/ Yarn always packs a root changelog directory, which would ship fragments in the npm package and break the artifact list check. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christoph Knittel --- .github/pull_request_template.md | 2 +- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- {changelog => changes}/README.md | 2 +- scripts/changelog.js | 8 ++++---- scripts/changelog.test.js | 18 ++++++++---------- scripts/releasePlan.js | 4 ++-- scripts/releasePlan.test.js | 6 +++--- 8 files changed, 21 insertions(+), 23 deletions(-) rename {changelog => changes}/README.md (97%) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1ae93cf9877..d74f53adb6f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ ## Checklist -- [ ] Added a changelog fragment (see `changelog/README.md`), or no release notes are needed and a maintainer should apply `changelog:skip`. +- [ ] Added a changelog fragment (see `changes/README.md`), or no release notes are needed and a maintainer should apply `changelog:skip`. - [ ] Added tests for the fix or feature. - [ ] Signed off every commit for the DCO (`git commit -s`). diff --git a/AGENTS.md b/AGENTS.md index 70f51313334..3206fa69d06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ on `master`. CI runs on every PR in the stack. ## Testing and changelog -**Add a changelog fragment** `changelog/..md` for any user-facing change (bug fix, feature, or breaking change) instead of editing `CHANGELOG.md`; see [changelog/README.md](changelog/README.md) for categories and format. Validate with `yarn changelog:check`. Since the PR number is not known yet, open the PR as a draft with the placeholder link `https://github.com/rescript-lang/rescript/pull/XXXX`, then replace it with the real link, amend, push again, and mark the PR ready for review (`gh pr ready`). +**Add a changelog fragment** `changes/..md` for any user-facing change (bug fix, feature, or breaking change) instead of editing `CHANGELOG.md`; see [changes/README.md](changes/README.md) for categories and format. Validate with `yarn changelog:check`. Since the PR number is not known yet, open the PR as a draft with the placeholder link `https://github.com/rescript-lang/rescript/pull/XXXX`, then replace it with the real link, amend, push again, and mark the PR ready for review (`gh pr ready`). For compiler or standard-library changes, run `make test`. For syntax changes, also run `make test-syntax`; use `make test-syntax-roundtrip` when parsing or diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db18f766bde..8a3d959f603 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -510,7 +510,7 @@ Adding a new entry there requires re-running the analysis tests. Follow these st (If a `make` command fails, consider using the [DevContainer](#b-devcontainer).) -Finally, add a `changelog/.polish.md` fragment following [the changelog instructions](changelog/README.md). +Finally, add a `changes/.polish.md` fragment following [the changelog instructions](changes/README.md). ## Code structure diff --git a/changelog/README.md b/changes/README.md similarity index 97% rename from changelog/README.md rename to changes/README.md index 03031d32e76..b52379667c8 100644 --- a/changelog/README.md +++ b/changes/README.md @@ -21,7 +21,7 @@ The PR number is only known once the PR exists, so open it as a draft with the p `yarn changelog:preview` prints pending fragments grouped by category, newest PR first, without changing files. It does not include entries already in `CHANGELOG.md`. -During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Running it again fails because the version has already been finalized. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changelog/`. +During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Running it again fails because the version has already been finalized. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. After publishing, run `yarn changelog:open` once `package.json` has the next version. It adds an empty unreleased section with every category heading. diff --git a/scripts/changelog.js b/scripts/changelog.js index 91cdf4c3913..f24828ec503 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -55,7 +55,7 @@ export function parseFragment(name, content) { } export async function readFragments(root) { - const directory = path.join(root, "changelog"); + const directory = path.join(root, "changes"); const entries = await fs.readdir(directory, { withFileTypes: true }); const fragments = []; entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); @@ -63,7 +63,7 @@ export async function readFragments(root) { if (entry.name === "README.md") continue; if (!entry.isFile()) { throw new Error( - `changelog/${entry.name}: expected a regular fragment file`, + `changes/${entry.name}: expected a regular fragment file`, ); } fragments.push( @@ -182,7 +182,7 @@ export function checkPullRequest(files, pullRequest) { !files.some( file => file.status === "A" && - /^changelog\/(?!README\.md$)[^/]+\.md$/.test(file.name), + /^changes\/(?!README\.md$)[^/]+\.md$/.test(file.name), ) ) { throw new Error( @@ -237,7 +237,7 @@ export async function run(command, root = process.cwd()) { } await fs.writeFile(target, prepareRelease(changelog, version, fragments)); for (const fragment of fragments) { - await fs.unlink(path.join(root, "changelog", fragment.name)); + await fs.unlink(path.join(root, "changes", fragment.name)); } } diff --git a/scripts/changelog.test.js b/scripts/changelog.test.js index 95c2c901a8e..d17fe36d5f5 100644 --- a/scripts/changelog.test.js +++ b/scripts/changelog.test.js @@ -110,13 +110,13 @@ test("checks pull request fragments and CHANGELOG.md edits", () => { labels: labels.map(name => ({ name })), }); const someone = pullRequest("someone"); - const fragment = { status: "A", name: "changelog/new.fix.md" }; + const fragment = { status: "A", name: "changes/new.fix.md" }; const changelogEdit = { status: "M", name: "CHANGELOG.md" }; checkPullRequest([fragment], someone); for (const files of [ [], - [{ status: "M", name: "changelog/existing.fix.md" }], - [{ status: "A", name: "changelog/README.md" }], + [{ status: "M", name: "changes/existing.fix.md" }], + [{ status: "A", name: "changes/README.md" }], [fragment, changelogEdit], ]) { assert.throws(() => checkPullRequest(files, someone)); @@ -159,14 +159,14 @@ test("opens the next unreleased section for the following release", () => { test("validates before mutation, sorts fragments, consumes only on release", async t => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "rescript-changelog-")); t.after(() => fs.rm(root, { recursive: true, force: true })); - await fs.mkdir(path.join(root, "changelog")); + await fs.mkdir(path.join(root, "changes")); await fs.writeFile( path.join(root, "package.json"), JSON.stringify({ version: "13.0.0" }), ); await fs.writeFile(path.join(root, "CHANGELOG.md"), changelog); const write = (name, text) => - fs.writeFile(path.join(root, "changelog", name), text); + fs.writeFile(path.join(root, "changes", name), text); await write("README.md", "Instructions"); await write("z.fix.md", note); await write("a.fix.md", note.replace("recovery", "formatting")); @@ -177,17 +177,15 @@ test("validates before mutation, sorts fragments, consumes only on release", asy changelog, ); assert.ok( - (await fs.readdir(path.join(root, "changelog"))).includes("z.fix.md"), + (await fs.readdir(path.join(root, "changes"))).includes("z.fix.md"), ); - await fs.unlink(path.join(root, "changelog", "bad.unknown.md")); + await fs.unlink(path.join(root, "changes", "bad.unknown.md")); assert.deepEqual( (await readFragments(root)).map(fragment => fragment.name), ["a.fix.md", "z.fix.md"], ); await run("release", root); - assert.deepEqual(await fs.readdir(path.join(root, "changelog")), [ - "README.md", - ]); + assert.deepEqual(await fs.readdir(path.join(root, "changes")), ["README.md"]); await assert.rejects(run("release", root)); }); diff --git a/scripts/releasePlan.js b/scripts/releasePlan.js index 6c06589d020..d48a67bcef1 100644 --- a/scripts/releasePlan.js +++ b/scripts/releasePlan.js @@ -49,12 +49,12 @@ export function findFirstReleasedVersion(changelog) { return null; } -// Fragments left in changelog/ were merged after release preparation, so +// Fragments left in changes/ were merged after release preparation, so // publishing would ship their changes without release notes. export async function findPendingFragments(root = ".") { let names; try { - names = await fs.readdir(path.join(root, "changelog")); + names = await fs.readdir(path.join(root, "changes")); } catch (error) { if (error.code === "ENOENT") return []; throw error; diff --git a/scripts/releasePlan.test.js b/scripts/releasePlan.test.js index 76e7abd64eb..18b01d168c2 100644 --- a/scripts/releasePlan.test.js +++ b/scripts/releasePlan.test.js @@ -74,9 +74,9 @@ test("finds changelog fragments left unreleased", async t => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "rescript-release-")); t.after(() => fs.rm(root, { recursive: true, force: true })); assert.deepEqual(await findPendingFragments(root), []); - await fs.mkdir(path.join(root, "changelog")); - await fs.writeFile(path.join(root, "changelog", "README.md"), ""); + await fs.mkdir(path.join(root, "changes")); + await fs.writeFile(path.join(root, "changes", "README.md"), ""); assert.deepEqual(await findPendingFragments(root), []); - await fs.writeFile(path.join(root, "changelog", "late.fix.md"), ""); + await fs.writeFile(path.join(root, "changes", "late.fix.md"), ""); assert.deepEqual(await findPendingFragments(root), ["late.fix.md"]); }); From 09f63877d285563cb7f645dba0fcb327b94fe17d Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 12 Oct 2026 07:59:25 +0000 Subject: [PATCH 08/10] Collect late fragments into a finalized version Publish blocks on fragments merged after release preparation; rerunning changelog:release now adds them instead of failing on the finalized heading. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XJG88tCTovUPzEf2nxUPtq Signed-off-by: Christoph Knittel --- CONTRIBUTING.md | 2 +- changes/README.md | 2 +- scripts/changelog.js | 8 +++++++- scripts/changelog.test.js | 23 +++++++++++++++++++++++ scripts/releasePlan.js | 2 +- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a3d959f603..7053c16833d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -572,7 +572,7 @@ The repository's `npm-release` GitHub environment should have required reviewers To build a new version and release it on NPM, follow these steps: 1. Verify that the version number is already set correctly for the release. (It should have been incremented after releasing the previous version.) -1. Run `yarn changelog:preview` to review pending notes, then `yarn changelog:release` to collect fragments into `CHANGELOG.md`, remove "(Unreleased)", and delete consumed fragments. Create a PR containing these changes, review the combined release notes, and apply the `changelog:release` label. Existing unreleased entries are preserved. +1. Run `yarn changelog:preview` to review pending notes, then `yarn changelog:release` to collect fragments into `CHANGELOG.md`, remove "(Unreleased)", and delete consumed fragments. Create a PR containing these changes, review the combined release notes, and apply the `changelog:release` label. Existing unreleased entries are preserved. If more fragments are merged before publishing, the `Publish` workflow refuses to run; repeat this step to add them to the same version. 1. Once that PR is merged and built successfully, run the `Publish` workflow from the GitHub Actions tab. Use the "Run workflow" branch dropdown to select the release branch (normally `master`, or the relevant maintenance branch) and indicate whether this is the current stable release. The version is read from that branch's `package.json`, and the npm tag is derived automatically: - A current stable release is published as `latest`. - A stable maintenance release for an older major is published as `latest-`, for example `latest-12`. diff --git a/changes/README.md b/changes/README.md index b52379667c8..f219aed74db 100644 --- a/changes/README.md +++ b/changes/README.md @@ -21,7 +21,7 @@ The PR number is only known once the PR exists, so open it as a draft with the p `yarn changelog:preview` prints pending fragments grouped by category, newest PR first, without changing files. It does not include entries already in `CHANGELOG.md`. -During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Running it again fails because the version has already been finalized. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. +During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. If fragments are merged after the release PR but before publishing, run `yarn changelog:release` again: until `package.json` moves on to the next version, it adds them to the finalized version. With no fragments left, running it again fails. After publishing, run `yarn changelog:open` once `package.json` has the next version. It adds an empty unreleased section with every category heading. diff --git a/scripts/changelog.js b/scripts/changelog.js index f24828ec503..05b25e0fed8 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -104,11 +104,17 @@ function versionHeadings(changelog) { export function prepareRelease(changelog, version, fragments) { const headings = versionHeadings(changelog); const first = headings[0]; - if (!first || first[1] !== `${version} (Unreleased)`) { + // A version finalized by an earlier run but not yet published (package.json + // has not moved on) can still collect fragments merged in the meantime. + const finalized = first?.[1] === version; + if (!first || (first[1] !== `${version} (Unreleased)` && !finalized)) { throw new Error( `Expected first version heading to be ${version} (Unreleased)`, ); } + if (finalized && !fragments.length) { + throw new Error(`${version} is already finalized and no fragments remain`); + } const end = headings[1]?.index ?? changelog.length; const lines = changelog.slice(first.index, end).split("\n"); // Keep existing unreleased entries during migration, and preserve history verbatim. diff --git a/scripts/changelog.test.js b/scripts/changelog.test.js index d17fe36d5f5..3563357713f 100644 --- a/scripts/changelog.test.js +++ b/scripts/changelog.test.js @@ -58,6 +58,29 @@ test("preserves existing notes and historical releases", () => { assert.throws(() => prepareRelease(input, "14.0.0", [])); }); +test("collects late fragments into a finalized, unpublished version", () => { + const released = prepareRelease(changelog, "13.0.0", [ + parseFragment("early.fix.md", note), + ]); + const late = parseFragment( + "late.fix.md", + "- Late fix. https://github.com/rescript-lang/rescript/pull/9000", + ); + const output = prepareRelease(released, "13.0.0", [late]); + assert.ok( + output.includes(`# 13.0.0\n\n${categories.breaking}`), + "keeps the finalized heading", + ); + assert.ok(output.includes(`${categories.fix}\n\n${late.text}\n${note}\n`)); + assert.ok(output.endsWith(historical)); + assert.throws(() => prepareRelease(released, "13.0.0", []), /finalized/); + // Once the next version is opened, late fragments belong to it instead. + assert.throws( + () => prepareRelease(openSection(released, "13.0.1"), "13.0.0", [late]), + /Expected first version heading/, + ); +}); + test("orders notes newest PR first above existing entries", () => { const input = changelog.replace( `${categories.fix}\n`, diff --git a/scripts/releasePlan.js b/scripts/releasePlan.js index d48a67bcef1..058d69f88c8 100644 --- a/scripts/releasePlan.js +++ b/scripts/releasePlan.js @@ -81,7 +81,7 @@ export async function validateReleaseFiles(plan) { const pendingFragments = await findPendingFragments(); if (pendingFragments.length > 0) { throw new Error( - `Unreleased changelog fragments remain: ${pendingFragments.join(", ")}. Run yarn changelog:release in a release PR first.`, + `Unreleased changelog fragments remain: ${pendingFragments.join(", ")}. Run yarn changelog:release to add them to ${plan.version} in a PR labelled changelog:release, merge it, and publish again.`, ); } } From 344c25857b5ba74c74c4386d313cc35425a79d11 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 12 Oct 2026 08:16:43 +0000 Subject: [PATCH 09/10] Keep late fragments out of tagged versions Publish tags vX before publishing, so a tagged version is treated as published and its notes are no longer extended. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XJG88tCTovUPzEf2nxUPtq Signed-off-by: Christoph Knittel --- changes/README.md | 2 +- scripts/changelog.js | 39 +++++++++++++++++++++++++++++++++------ scripts/changelog.test.js | 5 +++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/changes/README.md b/changes/README.md index f219aed74db..ce8561bfbca 100644 --- a/changes/README.md +++ b/changes/README.md @@ -21,7 +21,7 @@ The PR number is only known once the PR exists, so open it as a draft with the p `yarn changelog:preview` prints pending fragments grouped by category, newest PR first, without changing files. It does not include entries already in `CHANGELOG.md`. -During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. If fragments are merged after the release PR but before publishing, run `yarn changelog:release` again: until `package.json` moves on to the next version, it adds them to the finalized version. With no fragments left, running it again fails. +During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. If fragments are merged after the release PR but before publishing, run `yarn changelog:release` again: as long as Publish has not created the version's `v` tag, it adds them to the finalized version. Once the tag exists, it refuses, and the fragments wait for the next version after `yarn changelog:open`. With no fragments left, running it again fails. After publishing, run `yarn changelog:open` once `package.json` has the next version. It adds an empty unreleased section with every category heading. diff --git a/scripts/changelog.js b/scripts/changelog.js index 05b25e0fed8..25081bf7d46 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -101,12 +101,27 @@ function versionHeadings(changelog) { ); } -export function prepareRelease(changelog, version, fragments) { +export function isFinalized(changelog, version) { + return versionHeadings(changelog)[0]?.[1] === version; +} + +// Publish pushes the vX tag before publishing and refuses to move it, so a +// tagged version can no longer take more notes. +export function releaseTagExists(repositoryUrl, version) { + const url = repositoryUrl.replace(/^git\+/, ""); + return ( + execFileSync("git", ["ls-remote", "--tags", url, `refs/tags/v${version}`], { + encoding: "utf8", + }).trim() !== "" + ); +} + +export function prepareRelease(changelog, version, fragments, { tagged } = {}) { const headings = versionHeadings(changelog); const first = headings[0]; - // A version finalized by an earlier run but not yet published (package.json - // has not moved on) can still collect fragments merged in the meantime. - const finalized = first?.[1] === version; + // A version finalized by an earlier run but not yet published can still + // collect fragments merged in the meantime. + const finalized = isFinalized(changelog, version); if (!first || (first[1] !== `${version} (Unreleased)` && !finalized)) { throw new Error( `Expected first version heading to be ${version} (Unreleased)`, @@ -115,6 +130,11 @@ export function prepareRelease(changelog, version, fragments) { if (finalized && !fragments.length) { throw new Error(`${version} is already finalized and no fragments remain`); } + if (finalized && tagged) { + throw new Error( + `${version} has already been tagged for publishing; run yarn changelog:open for the next version first`, + ); + } const end = headings[1]?.index ?? changelog.length; const lines = changelog.slice(first.index, end).split("\n"); // Keep existing unreleased entries during migration, and preserve history verbatim. @@ -232,7 +252,7 @@ export async function run(command, root = process.cwd()) { process.stdout.write(renderFragments(fragments)); return; } - const { version } = JSON.parse( + const { version, repository } = JSON.parse( await fs.readFile(path.join(root, "package.json"), "utf8"), ); const target = path.join(root, "CHANGELOG.md"); @@ -241,7 +261,14 @@ export async function run(command, root = process.cwd()) { await fs.writeFile(target, openSection(changelog, version)); return; } - await fs.writeFile(target, prepareRelease(changelog, version, fragments)); + const tagged = + fragments.length > 0 && + isFinalized(changelog, version) && + releaseTagExists(repository.url, version); + await fs.writeFile( + target, + prepareRelease(changelog, version, fragments, { tagged }), + ); for (const fragment of fragments) { await fs.unlink(path.join(root, "changes", fragment.name)); } diff --git a/scripts/changelog.test.js b/scripts/changelog.test.js index 3563357713f..28a48383582 100644 --- a/scripts/changelog.test.js +++ b/scripts/changelog.test.js @@ -74,6 +74,11 @@ test("collects late fragments into a finalized, unpublished version", () => { assert.ok(output.includes(`${categories.fix}\n\n${late.text}\n${note}\n`)); assert.ok(output.endsWith(historical)); assert.throws(() => prepareRelease(released, "13.0.0", []), /finalized/); + // Publish has tagged the version, so its notes are frozen. + assert.throws( + () => prepareRelease(released, "13.0.0", [late], { tagged: true }), + /already been tagged/, + ); // Once the next version is opened, late fragments belong to it instead. assert.throws( () => prepareRelease(openSection(released, "13.0.1"), "13.0.0", [late]), From 184c38aac3f36c58882e2fd40891d7f8db98e65f Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 12 Oct 2026 08:31:35 +0000 Subject: [PATCH 10/10] Document not collecting notes during a Publish run Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XJG88tCTovUPzEf2nxUPtq Signed-off-by: Christoph Knittel --- CONTRIBUTING.md | 2 +- changes/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7053c16833d..df46576551d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -572,7 +572,7 @@ The repository's `npm-release` GitHub environment should have required reviewers To build a new version and release it on NPM, follow these steps: 1. Verify that the version number is already set correctly for the release. (It should have been incremented after releasing the previous version.) -1. Run `yarn changelog:preview` to review pending notes, then `yarn changelog:release` to collect fragments into `CHANGELOG.md`, remove "(Unreleased)", and delete consumed fragments. Create a PR containing these changes, review the combined release notes, and apply the `changelog:release` label. Existing unreleased entries are preserved. If more fragments are merged before publishing, the `Publish` workflow refuses to run; repeat this step to add them to the same version. +1. Run `yarn changelog:preview` to review pending notes, then `yarn changelog:release` to collect fragments into `CHANGELOG.md`, remove "(Unreleased)", and delete consumed fragments. Create a PR containing these changes, review the combined release notes, and apply the `changelog:release` label. Existing unreleased entries are preserved. If more fragments are merged before publishing, the `Publish` workflow refuses to run; repeat this step to add them to the same version. Don't run `yarn changelog:release` while a `Publish` run is in progress: it could add notes for changes that the run is not publishing. 1. Once that PR is merged and built successfully, run the `Publish` workflow from the GitHub Actions tab. Use the "Run workflow" branch dropdown to select the release branch (normally `master`, or the relevant maintenance branch) and indicate whether this is the current stable release. The version is read from that branch's `package.json`, and the npm tag is derived automatically: - A current stable release is published as `latest`. - A stable maintenance release for an older major is published as `latest-`, for example `latest-12`. diff --git a/changes/README.md b/changes/README.md index ce8561bfbca..3f8501bc5ee 100644 --- a/changes/README.md +++ b/changes/README.md @@ -21,7 +21,7 @@ The PR number is only known once the PR exists, so open it as a draft with the p `yarn changelog:preview` prints pending fragments grouped by category, newest PR first, without changing files. It does not include entries already in `CHANGELOG.md`. -During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. If fragments are merged after the release PR but before publishing, run `yarn changelog:release` again: as long as Publish has not created the version's `v` tag, it adds them to the finalized version. Once the tag exists, it refuses, and the fragments wait for the next version after `yarn changelog:open`. With no fragments left, running it again fails. +During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. If fragments are merged after the release PR but before publishing, run `yarn changelog:release` again: as long as Publish has not created the version's `v` tag, it adds them to the finalized version. Once the tag exists, it refuses, and the fragments wait for the next version after `yarn changelog:open`. With no fragments left, running it again fails. Don't run it while a Publish run is in progress, because the tag only appears once that run gets to tagging, and the notes could then describe changes the run is not publishing. After publishing, run `yarn changelog:open` once `package.json` has the next version. It adds an empty unreleased section with every category heading.