From 8e1c7943dd655e9fae55a312c80dcb9e5544d263 Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Wed, 16 Sep 2026 14:38:51 -0500 Subject: [PATCH 1/3] Gather commits no release contains under Unreleased The section at the top of the changelog was only written when the version in package.json had no tag, which is true at release time because release-it bumps the version before it tags. After the release it is false, so pull requests merged between one release and the next were left out of the changelog with nothing said about them. They are now gathered under an Unreleased heading, which has no version for the prefix to belong to, and appears only when the branch has something no tag contains. Co-Authored-By: Claude Opus 5 --- README.md | 1 + lib/src/Gren.js | 58 ++++++++++++++++++++++++-------- test/Gren.membership.spec.js | 64 +++++++++++++++++++++++++++++++----- 3 files changed, 101 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c08d4db0..eafc7381 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ This works across release lines. For example, with 7.x on `master` and 6.x on a Merge one release line into another with a merge commit. A squash merge hides the commits, and their pull requests, that it brings in. The version being prepared (from `package.json`) gets a section for the commits on `--head` that no tag contains yet. +Once that version is tagged those commits are its own, and anything the branch gains afterwards is gathered under an `Unreleased` heading until the next version is prepared. Without `--tags`, `gren` writes that section and the latest tag `--head` contains, so on a maintenance branch it works on that line's latest release rather than on the highest version in the repository. `--tags=..` puts everything since the old tag in the new tag's section, taking in the releases between them. diff --git a/lib/src/Gren.js b/lib/src/Gren.js index 787b742e..94f571d9 100644 --- a/lib/src/Gren.js +++ b/lib/src/Gren.js @@ -35,6 +35,9 @@ const TAGS_LIMIT = 30; // The data sources whose releases are built from the commits each release contains. const MEMBERSHIP_DATA_SOURCES = ["prs", "prs-with-issues"]; +// The heading for commits that no release contains yet. +const UNRELEASED = "Unreleased"; + /** * Get the semver version a tag or release name stands for * @@ -1914,17 +1917,24 @@ class Gren { } return entries - .map(({ release, frozen, verbatim }) => - frozen - ? verbatim !== undefined && { + .map(({ release, frozen, verbatim }) => { + if (frozen) { + return ( + verbatim !== undefined && { id: release.id, release: release.name, name: this.options.prefix + release.name, published_at: release.date, verbatim, } - : blocks.shift(), - ) + ); + } + + const block = blocks.shift(); + + // There is no version for the prefix to belong to in an unreleased section. + return release.version ? block : { ...block, name: release.name }; + }) .filter(Boolean); } @@ -2016,23 +2026,30 @@ class Gren { const releases = []; const unreleasedVersion = parseVersion(version); - if (head && unreleasedVersion && !tags.some((tag) => tag.version === unreleasedVersion)) { + if (head) { if (!git.refExists(head)) { throw chalk.red(`\nThe branch "${head}" is not in the local repository.`); } const merged = git.tagsMergedInto(head); - const newer = tags.find( - (tag) => merged.has(tag.name) && semver.gte(tag.version, unreleasedVersion), - ); - - if (newer) { + const taken = unreleasedVersion && tags.some((tag) => tag.version === unreleasedVersion); + // A version equal to a tag is the ordinary state between releases. A version behind one + // is a package.json somebody forgot to bump, which is worth saying out loud. + const behind = + unreleasedVersion && + !taken && + tags.find((tag) => merged.has(tag.name) && semver.gt(tag.version, unreleasedVersion)); + + if (behind) { console.warn( chalk.yellow( - `\nSkipping the unreleased ${version} section: ${head} already contains ${newer.name}.`, + `\nThe version being prepared, ${version}, is older than ${behind.name}, which ` + + `${head} already contains. Commits no tag holds are listed as ${UNRELEASED}.`, ), ); - } else { + } + + if (unreleasedVersion && !taken && !behind) { releases.push({ id: null, name: version, @@ -2040,6 +2057,21 @@ class Gren { ref: head, date: new Date().toISOString(), }); + } else { + // The version being prepared has been released already, so anything the branch has that + // no tag contains belongs to a release that does not have a name yet. + const excludeRefs = tags.map(({ ref }) => ref); + + if (git.releaseCommits(head, excludeRefs).length) { + releases.push({ + id: null, + name: UNRELEASED, + version: null, + ref: head, + date: new Date().toISOString(), + excludeRefs, + }); + } } } diff --git a/test/Gren.membership.spec.js b/test/Gren.membership.spec.js index 55e9f6b6..8ef92fe0 100644 --- a/test/Gren.membership.spec.js +++ b/test/Gren.membership.spec.js @@ -412,14 +412,57 @@ describe("Gren release membership", () => { assert.deepEqual(numbers(byRelease(blocks)["v1.2.0"]), [6, 5, 4, 3]); }); - it("Should skip an unreleased version the branch has already passed", async () => { + it("Should gather commits no tag contains under an unreleased heading", async () => { + // 2.0.0 is released, and master carries c12 beyond every tag. + const blocks = await createGren({ version: "2.0.0" })._getReleaseBlocks(); + const unreleased = byRelease(blocks)["Unreleased"]; + + assert.deepEqual( + blocks.map(({ release }) => release), + ["Unreleased", "v2.0.0", "v1.2.1", "v1.2.0", "v1.1.1", "v1.1.0", "v1.0.0"], + ); + assert.deepEqual(numbers(unreleased), [9]); + assert.equal( + unreleased.name, + "Unreleased", + "The prefix belongs to a version, and it has none", + ); + }); + + it("Should leave out the unreleased heading when the branch is level with a tag", async () => { + const blocks = await createGren({ head: "1.x", version: "1.2.1" })._getReleaseBlocks(); + + assert.deepEqual( + blocks.map(({ release }) => release), + ["v2.0.0", "v1.2.1", "v1.2.0", "v1.1.1", "v1.1.0", "v1.0.0"], + "Every tag still has a section; 1.x simply has nothing beyond them", + ); + }); + + it("Should still gather them when the branch has passed the version being prepared", async () => { + const blocks = await createGren({ version: "1.1.5" })._getReleaseBlocks(); + + assert.include( + blocks.map(({ release }) => release), + "Unreleased", + "The work is there whatever package.json says", + ); + }); + + it("Should say when the version being prepared is older than the branch", async () => { const blocks = await createGren({ version: "1.1.5" })._getReleaseBlocks(); assert.notInclude( blocks.map(({ release }) => release), "1.1.5", ); - assert.include(warnings.join("\n"), "Skipping the unreleased 1.1.5 section"); + assert.include(warnings.join("\n"), "The version being prepared, 1.1.5, is older than"); + }); + + it("Should say nothing when the version being prepared is simply released", async () => { + await createGren({ version: "2.0.0" })._getReleaseBlocks(); + + assert.notInclude(warnings.join("\n"), "version being prepared"); }); it("Should generate only the tag given with --tags, or the latest tag", async () => { @@ -428,7 +471,8 @@ describe("Gren release membership", () => { assert.deepEqual( selected.map(({ release }) => release), - ["v1.1.1"], + ["Unreleased", "v1.1.1"], + "2.0.0 is released, so master's later commits have no version yet", ); assert.deepEqual( latest.map(({ release }) => release), @@ -523,16 +567,18 @@ describe("Gren release membership", () => { } }); - it("Should not ask about the branch when the version is already tagged", async () => { - // Nothing is read from the branch, so it does not matter where GitHub has it. + it("Should not ask about a branch it has nothing to read from", async () => { + // 1.x is level with v1.2.1, so nothing is read from it and where GitHub has it does not + // matter. const blocks = await createGren({ headSha: "0".repeat(40), - version: "2.0.0", + head: "1.x", + version: "1.2.1", })._getReleaseBlocks(); - assert.deepEqual( + assert.notInclude( blocks.map(({ release }) => release), - ["v2.0.0", "v1.2.1", "v1.2.0", "v1.1.1", "v1.1.0", "v1.0.0"], + "Unreleased", ); }); @@ -563,7 +609,7 @@ describe("Gren release membership", () => { try { // Every tag is here and points where GitHub says, but the commits between them are not. - await createGren({ tags: "v1.2.0", version: "1.2.0" })._getReleaseBlocks(); + await createGren({ tags: "v1.2.0", version: "1.2.0", head: "HEAD" })._getReleaseBlocks(); assert.fail("A shallow clone should stop the changelog"); } catch (error) { assert.include(String(error), "truncated history"); From 01ffdbe78b7191396ecee28e9eebe0918cfb9a32 Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Wed, 16 Sep 2026 14:43:09 -0500 Subject: [PATCH 2/3] Write the Unreleased heading only when it has something Whether a commit is beyond every tag and whether anything can be said about it are different questions. The heading was written for the first, so a commit whose pull request is still open produced a heading with "no changelog for this release" under it, which is noise: the commit is in the report either way. Co-Authored-By: Claude Opus 5 --- lib/src/Gren.js | 10 ++++++++-- test/Gren.membership.spec.js | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/src/Gren.js b/lib/src/Gren.js index 94f571d9..a36b01a5 100644 --- a/lib/src/Gren.js +++ b/lib/src/Gren.js @@ -1932,8 +1932,14 @@ class Gren { const block = blocks.shift(); - // There is no version for the prefix to belong to in an unreleased section. - return release.version ? block : { ...block, name: release.name }; + if (release.version) { + return block; + } + + // There is no version for the prefix to belong to in an unreleased section, and no + // reason to write the heading at all when the commits under it have nothing to say: + // they are in the report either way. + return block.body !== this._templateBody([]) && { ...block, name: release.name }; }) .filter(Boolean); } diff --git a/test/Gren.membership.spec.js b/test/Gren.membership.spec.js index 8ef92fe0..9027586e 100644 --- a/test/Gren.membership.spec.js +++ b/test/Gren.membership.spec.js @@ -429,6 +429,20 @@ describe("Gren release membership", () => { ); }); + it("Should leave out the unreleased heading when its commits have no pull request", async () => { + // c12 is beyond every tag, but without pull request 9 there is nothing to say about it. + const blocks = await createGren({ + version: "2.0.0", + overridePrs: (prs) => prs.filter(({ number }) => number !== 9), + })._getReleaseBlocks(); + + assert.notInclude( + blocks.map(({ release }) => release), + "Unreleased", + ); + assert.include(warnings.join("\n"), repo.sha.c12.slice(0, 10), "It is reported instead"); + }); + it("Should leave out the unreleased heading when the branch is level with a tag", async () => { const blocks = await createGren({ head: "1.x", version: "1.2.1" })._getReleaseBlocks(); From f6ce1b9aafb4cceb9291758ab96c92b2f25d5949 Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Wed, 16 Sep 2026 14:51:50 -0500 Subject: [PATCH 3/3] Take the unreleased heading and empty body from the templates Everything a reader sees is a template value, so that a project can word it, or translate it, as it likes. Two strings were written into the code instead: the Unreleased heading, and the sentence for a release with nothing to list. The second is why node-saml's configuration replaces that sentence inside its release template; it can set template.noChangelog instead. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- lib/src/Gren.js | 10 ++++------ lib/src/templates.js | 2 ++ test/Gren.membership.spec.js | 17 +++++++++++++++++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index eafc7381..fadec6ff 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ This works across release lines. For example, with 7.x on `master` and 6.x on a Merge one release line into another with a merge commit. A squash merge hides the commits, and their pull requests, that it brings in. The version being prepared (from `package.json`) gets a section for the commits on `--head` that no tag contains yet. -Once that version is tagged those commits are its own, and anything the branch gains afterwards is gathered under an `Unreleased` heading until the next version is prepared. +Once that version is tagged those commits are its own, and anything the branch gains afterwards is gathered under an `Unreleased` heading until the next version is prepared, which `template.unreleased` renames. Without `--tags`, `gren` writes that section and the latest tag `--head` contains, so on a maintenance branch it works on that line's latest release rather than on the highest version in the repository. `--tags=..` puts everything since the old tag in the new tag's section, taking in the releases between them. diff --git a/lib/src/Gren.js b/lib/src/Gren.js index a36b01a5..69286b4c 100644 --- a/lib/src/Gren.js +++ b/lib/src/Gren.js @@ -35,9 +35,6 @@ const TAGS_LIMIT = 30; // The data sources whose releases are built from the commits each release contains. const MEMBERSHIP_DATA_SOURCES = ["prs", "prs-with-issues"]; -// The heading for commits that no release contains yet. -const UNRELEASED = "Unreleased"; - /** * Get the semver version a tag or release name stands for * @@ -908,7 +905,7 @@ class Gren { return `${rangeBody}\n`; } - return "*No changelog for this release.*\n"; + return `${this.options.template.noChangelog}\n`; } /** @@ -2050,7 +2047,8 @@ class Gren { console.warn( chalk.yellow( `\nThe version being prepared, ${version}, is older than ${behind.name}, which ` + - `${head} already contains. Commits no tag holds are listed as ${UNRELEASED}.`, + `${head} already contains. Commits no tag holds are listed as ` + + `${this.options.template.unreleased}.`, ), ); } @@ -2071,7 +2069,7 @@ class Gren { if (git.releaseCommits(head, excludeRefs).length) { releases.push({ id: null, - name: UNRELEASED, + name: this.options.template.unreleased, version: null, ref: head, date: new Date().toISOString(), diff --git a/lib/src/templates.js b/lib/src/templates.js index e2ba28b1..2373c463 100644 --- a/lib/src/templates.js +++ b/lib/src/templates.js @@ -8,5 +8,7 @@ export default { group: "\n### {{heading}}\n", changelogTitle: "# Changelog\n\n", release: "## {{release}} ({{date}})\n\n{{body}}", + unreleased: "Unreleased", + noChangelog: "*No changelog for this release.*", releaseSeparator: "\n---\n\n", }; diff --git a/test/Gren.membership.spec.js b/test/Gren.membership.spec.js index 9027586e..83526103 100644 --- a/test/Gren.membership.spec.js +++ b/test/Gren.membership.spec.js @@ -429,6 +429,23 @@ describe("Gren release membership", () => { ); }); + it("Should take the unreleased heading and the empty body from the templates", async () => { + const gren = createGren({ + version: "2.0.0", + template: { issue: "{{text}} {{name}} {{url}}", unreleased: "Nog niet uitgebracht" }, + }); + const blocks = await gren._getReleaseBlocks(); + + assert.include( + blocks.map(({ release }) => release), + "Nog niet uitgebracht", + ); + assert.equal( + createGren({ template: { noChangelog: "_Niets te melden._" } })._templateBody([]), + "_Niets te melden._\n", + ); + }); + it("Should leave out the unreleased heading when its commits have no pull request", async () => { // c12 is beyond every tag, but without pull request 9 there is nothing to say about it. const blocks = await createGren({