feat(release): group the release note by type, then by scope

Sections are now the commit types (features, fixes, ...) with one sub-section
per scope, marked with a label icon instead of the puzzle piece.
ReleaseNotesTest, AGENTS.md and the release profile's comment follow the new
order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
This commit is contained in:
2026-09-30 13:25:51 -04:00
co-authored by Claude Opus 5.5
parent fb0232b63c
commit 223a49f77d
4 changed files with 22 additions and 23 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ Native installers with a bundled JRE are produced by jDeploy, driven from two fi
* **Releasing.** `./mvnw -Prelease validate` (profile `release` in `pom.xml`, steps in `tools/release/Release.java`):
bumps the version to `YYYY.M.N` (year and month without leading zero; `N` restarts at 0 each month), runs a
fresh `./mvnw verify`, writes `distrib/release_note/release-note-<version>.md` (Conventional Commits since
the previous `v<version>` tag — last 30 commits for the first release — grouped by scope then type,
the previous `v<version>` tag — last 30 commits for the first release — grouped by type then scope,
duplicate first lines merged), creates GitHub release `v<version>` in `Imag-In/Pholio` described by that
note, with `pholio-<version>.jar`, its `.sha256` and the note as assets (via `gh`), commits `pom.xml` and
tags `v<version>` locally, then commits and pushes the note from `distrib/`. Needs a clean working tree (or
+2 -2
View File
@@ -787,8 +787,8 @@
so this run - not the one executing the profile - is the one that sees the new
version (manifest Implementation-Version, `pholio -V`).
3. notes - distrib/release_note/release-note-<version>.md: the Conventional Commits since the
previous v<version> tag (last 30 commits for the first release), grouped by scope
then type, duplicate first lines merged.
previous v<version> tag (last 30 commits for the first release), grouped by type
then scope, duplicate first lines merged.
4. publish - GitHub release v<version> in ${release.github.repo}, described by that note, with
pholio-<version>.jar, its .sha256 and the note as assets, through the gh CLI (must
be logged in). Only the tag and the assets go there: that repository's main branch
@@ -19,7 +19,7 @@ class ReleaseNotesTest {
private static final Path SCRIPT = Path.of("tools", "release", "Release.java");
@Test
void groupsConventionalCommitsByScopeThenTypeAndMergesDuplicates() throws Exception {
void groupsConventionalCommitsByTypeThenScopeAndMergesDuplicates() throws Exception {
String note = render("v2026.9.0", List.of(
"feat(gallery): add a justified grid",
"fix(gallery): keep the selection on resize",
@@ -38,13 +38,12 @@ class ReleaseNotesTest {
.contains("- keep the selection on resize _(×2)_");
softly.assertThat(countOf(note, "add a justified grid")).isEqualTo(1);
softly.assertThat(note).as("non-conventional subjects dropped").doesNotContain("Merge branch").doesNotContain("wip");
softly.assertThat(note.indexOf("## 🧩 gallery")).as("scopes alphabetical")
.isLessThan(note.indexOf("## 🧩 search"));
softly.assertThat(note.indexOf("## 🧩 search")).as("unscoped last")
.isLessThan(note.indexOf("## 🧩 general"));
softly.assertThat(note.indexOf("### ✨ Features")).as("feat before fix within a scope")
.isLessThan(note.indexOf("### 🐛 Bug fixes"));
softly.assertThat(note).contains("### 📝 Documentation").contains("- 💥 **BREAKING** drop the legacy launcher");
softly.assertThat(note.indexOf("## ✨ Features")).as("types first, feat before fix")
.isLessThan(note.indexOf("## 🐛 Bug fixes"));
softly.assertThat(note.indexOf("## 🐛 Bug fixes")).as("then fix before refactor")
.isLessThan(note.indexOf("## ♻️ Refactoring"));
softly.assertThat(note).as("scopes under their type").contains("## 📝 Documentation\n\n### 🏷️ search\n")
.contains("## ♻️ Refactoring\n\n### 🏷️ general\n\n- 💥 **BREAKING** drop the legacy launcher");
softly.assertAll();
}
+12 -12
View File
@@ -32,7 +32,7 @@ import java.util.regex.Pattern;
* source that was built) unless {@code allowDirty} is {@code true}, then rewrites the project version;
* <li>{@code notes <pom> <notesDir>} — writes {@code release-note-<version>.md} into {@code notesDir}:
* every Conventional Commit since the previous {@code v<version>} tag (the last
* {@value #FIRST_RELEASE_COMMITS} commits when there is none yet), grouped by scope then by type — see
* {@value #FIRST_RELEASE_COMMITS} commits when there is none yet), grouped by type then by scope — see
* {@link #renderNotes};
* <li>{@code publish <pom> <jar> <repo> <notesDir>} — creates GitHub release {@code v<version>} in
* {@code repo}, described by that release note, with the jar (renamed {@code pholio-<version>.jar}),
@@ -190,9 +190,9 @@ public final class Release {
/**
* The Markdown release note for {@code subjects} (commit first lines, newest first). Only Conventional
* Commits count; the rest are dropped. Commits sharing the exact same first line are merged into one
* entry, suffixed with how many there were. Entries are grouped by scope — alphabetically, unscoped
* ones last under {@value #GENERAL_SCOPE} — then by type in {@link #TYPE_HEADINGS} order, unknown types
* last; a breaking change ({@code !}) is flagged as such.
* entry, suffixed with how many there were. Entries are grouped by type — in {@link #TYPE_HEADINGS}
* order, unknown types last — then by scope, alphabetically with unscoped ones last under
* {@value #GENERAL_SCOPE}; a breaking change ({@code !}) is flagged as such.
*/
static String renderNotes(String version, LocalDate date, String previousTag, List<String> subjects) {
record Change(String type, String scope, String description, boolean breaking) {}
@@ -214,13 +214,13 @@ public final class Release {
List<String> typeOrder = List.copyOf(TYPE_HEADINGS.keySet());
Comparator<String> byType = Comparator.<String>comparingInt(type -> typeOrder.contains(type) ? typeOrder.indexOf(type) : typeOrder.size())
.thenComparing(Comparator.naturalOrder());
Map<String, Map<String, List<String>>> grouped = new TreeMap<>(scopeOrder);
Map<String, Map<String, List<String>>> grouped = new TreeMap<>(byType);
changes.forEach((subject, change) -> {
String line = (change.breaking() ? "💥 **BREAKING** " : "") + change.description()
+ (counts.get(subject) > 1 ? " _(×" + counts.get(subject) + ")_" : "");
grouped.computeIfAbsent(change.scope(), scope -> new TreeMap<>(byType))
// doc and docs share one heading, so one section.
.computeIfAbsent("doc".equals(change.type()) ? "docs" : change.type(), type -> new ArrayList<>())
// doc and docs share one heading, so one section.
grouped.computeIfAbsent("doc".equals(change.type()) ? "docs" : change.type(), type -> new TreeMap<>(scopeOrder))
.computeIfAbsent(change.scope(), scope -> new ArrayList<>())
.add(line);
});
@@ -230,10 +230,10 @@ public final class Release {
if (grouped.isEmpty()) {
note.append("\nNo notable change.\n");
}
grouped.forEach((scope, byTypeLines) -> {
note.append("\n## 🧩 ").append(scope).append("\n");
byTypeLines.forEach((type, lines) -> {
note.append("\n### ").append(TYPE_HEADINGS.getOrDefault(type, "🔹 " + type)).append("\n\n");
grouped.forEach((type, byScopeLines) -> {
note.append("\n## ").append(TYPE_HEADINGS.getOrDefault(type, "🔹 " + type)).append("\n");
byScopeLines.forEach((scope, lines) -> {
note.append("\n### 🏷️ ").append(scope).append("\n\n");
lines.forEach(line -> note.append("- ").append(line).append("\n"));
});
});