diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..d74f53adb6f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +## Checklist + +- [ ] 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/.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 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 diff --git a/AGENTS.md b/AGENTS.md index d8deaa2bc70..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.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** `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/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/CONTRIBUTING.md b/CONTRIBUTING.md index a304ee9ad61..df46576551d 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 `changes/.polish.md` fragment following [the changelog instructions](changes/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. 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`. @@ -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/changes/README.md b/changes/README.md new file mode 100644 index 00000000000..3f8501bc5ee --- /dev/null +++ b/changes/README.md @@ -0,0 +1,28 @@ +# 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 +``` + +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 | +| --- | --- | +| `breaking` | Breaking Change | +| `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, 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. 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. + +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 3033e101c62..4977792a40f 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,12 @@ "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", + "changelog:open": "node scripts/changelog.js open", + "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..25081bf7d46 --- /dev/null +++ b/scripts/changelog.js @@ -0,0 +1,285 @@ +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", + feature: "#### :rocket: New Feature", + fix: "#### :bug: Bug fix", + docs: "#### :memo: Documentation", + polish: "#### :nail_care: Polish", + internal: "#### :house: Internal", +}; + +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})*$`, +); + +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(); + 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 => + !/^- \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)`, + ); + } + 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, "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)); + for (const entry of entries) { + if (entry.name === "README.md") continue; + if (!entry.isFile()) { + throw new Error( + `changes/${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 = categoryNotes(fragments, category); + return `${heading}\n\n${notes.length ? `${notes.join("\n")}\n\n` : ""}`; + }) + .join(""); +} + +function versionHeadings(changelog) { + return [...changelog.matchAll(/^# (.+)$/gm)].filter( + match => match[1] !== "Changelog", + ); +} + +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 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)`, + ); + } + 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. + for (const [category, heading] of Object.entries(categories)) { + const notes = categoryNotes(fragments, category); + if (!notes.length) continue; + const index = lines.indexOf(heading); + if (index === -1 || lines.lastIndexOf(heading) !== index) { + throw new Error(`Expected exactly one ${heading} section`); + } + 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, + ); + } + lines[0] = `# ${version}`; + return ( + changelog.slice(0, first.index) + lines.join("\n") + changelog.slice(end) + ); +} + +// 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 ( + changelog.slice(0, index) + + `# ${version} (Unreleased)\n\n${Object.values(categories).join("\n\n")}\n\n` + + changelog.slice(index) + ); +} + +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 => + file.status === "A" && + /^changes\/(?!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", "open"].includes(command)) { + throw new Error( + "Usage: node scripts/changelog.js check|preview|release|open", + ); + } + const fragments = await readFragments(root); + if (command === "check") { + if (process.env.GITHUB_EVENT_NAME === "pull_request") { + 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] }); + } + checkPullRequest(files, pullRequest); + } + return; + } + if (command === "preview") { + process.stdout.write(renderFragments(fragments)); + return; + } + const { version, repository } = JSON.parse( + await fs.readFile(path.join(root, "package.json"), "utf8"), + ); + const target = path.join(root, "CHANGELOG.md"); + const changelog = await fs.readFile(target, "utf8"); + if (command === "open") { + await fs.writeFile(target, openSection(changelog, version)); + return; + } + 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)); + } +} + +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..28a48383582 --- /dev/null +++ b/scripts/changelog.test.js @@ -0,0 +1,228 @@ +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, + checkPullRequest, + openSection, + parseFragment, + placeholderLink, + 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.throws( + () => + parseFragment("recovery.fix.md", `- Fix recovery. ${placeholderLink}`), + /replace the placeholder/, + ); + 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("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/); + // 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]), + /Expected first version heading/, + ); +}); + +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("checks pull request fragments and CHANGELOG.md edits", () => { + const pullRequest = (login, labels = []) => ({ + user: { login }, + labels: labels.map(name => ({ name })), + }); + const someone = pullRequest("someone"); + 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: "changes/existing.fix.md" }], + [{ status: "A", name: "changes/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("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.ok( + prepareRelease(opened, "13.0.1", [ + parseFragment("later.feature.md", note), + ]).includes(`# 13.0.1\n\n${categories.breaking}`), + ); + 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 => { + 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, "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, "changes", 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, "changes"))).includes("z.fix.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, "changes")), ["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); +}); diff --git a/scripts/releasePlan.js b/scripts/releasePlan.js index ad20058281a..058d69f88c8 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 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, "changes")); + } 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 to add them to ${plan.version} in a PR labelled changelog:release, merge it, and publish again.`, + ); + } } async function main() { diff --git a/scripts/releasePlan.test.js b/scripts/releasePlan.test.js index a5af03d6a28..18b01d168c2 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, "changes")); + await fs.writeFile(path.join(root, "changes", "README.md"), ""); + assert.deepEqual(await findPendingFragments(root), []); + await fs.writeFile(path.join(root, "changes", "late.fix.md"), ""); + assert.deepEqual(await findPendingFragments(root), ["late.fix.md"]); +});