diff --git a/AGENTS.md b/AGENTS.md index 0d263b0..4af4552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-.md` (Conventional Commits since - the previous `v` tag — last 30 commits for the first release — grouped by scope then type, + the previous `v` tag — last 30 commits for the first release — grouped by type then scope, duplicate first lines merged), creates GitHub release `v` in `Imag-In/Pholio` described by that note, with `pholio-.jar`, its `.sha256` and the note as assets (via `gh`), commits `pom.xml` and tags `v` locally, then commits and pushes the note from `distrib/`. Needs a clean working tree (or diff --git a/pom.xml b/pom.xml index 161379c..adb65ba 100644 --- a/pom.xml +++ b/pom.xml @@ -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-.md: the Conventional Commits since the - previous v tag (last 30 commits for the first release), grouped by scope - then type, duplicate first lines merged. + previous v tag (last 30 commits for the first release), grouped by type + then scope, duplicate first lines merged. 4. publish - GitHub release v in ${release.github.repo}, described by that note, with pholio-.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 diff --git a/src/test/java/org/icroco/pholio/release/ReleaseNotesTest.java b/src/test/java/org/icroco/pholio/release/ReleaseNotesTest.java index 6bbbf8e..c49fa09 100644 --- a/src/test/java/org/icroco/pholio/release/ReleaseNotesTest.java +++ b/src/test/java/org/icroco/pholio/release/ReleaseNotesTest.java @@ -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(); } diff --git a/tools/release/Release.java b/tools/release/Release.java index b8c955a..7519e36 100644 --- a/tools/release/Release.java +++ b/tools/release/Release.java @@ -32,7 +32,7 @@ import java.util.regex.Pattern; * source that was built) unless {@code allowDirty} is {@code true}, then rewrites the project version; *
  • {@code notes } — writes {@code release-note-.md} into {@code notesDir}: * every Conventional Commit since the previous {@code v} 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}; *
  • {@code publish } — creates GitHub release {@code v} in * {@code repo}, described by that release note, with the jar (renamed {@code pholio-.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 subjects) { record Change(String type, String scope, String description, boolean breaking) {} @@ -214,13 +214,13 @@ public final class Release { List typeOrder = List.copyOf(TYPE_HEADINGS.keySet()); Comparator byType = Comparator.comparingInt(type -> typeOrder.contains(type) ? typeOrder.indexOf(type) : typeOrder.size()) .thenComparing(Comparator.naturalOrder()); - Map>> grouped = new TreeMap<>(scopeOrder); + Map>> 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")); }); });