Files
pholio/tools/release/Release.java
T
chris.giteaandClaude Opus 5.5 06e396b153 feat(credits): render the credits from a single markdown file
src/main/resources/userguide/credits.md now lists every shipped library
with its license (read from the dependencies' POMs), the recognition models
and the map and place data. CreditsView, opened from the ? in Settings,
renders it through the new MarkdownPage helper (per-language loading, links
opened in the browser), which the search help popup now shares, and the
release's distrib step copies it to distrib/CREDITS.md in the same commit
as the release note.

MarkdownToBBCode learns [text](url) links, inside bold too. URLs are quoted
in the generated BBCode: AtlantaFX's parser reads a tag ending in "/]" as
self-closing, so a URL with a trailing slash broke the whole page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 15:27:51 -04:00

353 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* The steps of the Maven {@code release} profile ({@code ./mvnw -Prelease validate}) that Maven cannot do
* itself. Run as a single-file source program — {@code java tools/release/Release.java <command> …} — so it
* needs nothing but the JDK, and never becomes part of the application.
*
* <p>Versions follow {@code YYYY.M.N}: the year and month (no leading zero) the release is cut in, then a
* build number that restarts at 0 every month. Maven reads the POM version once, at startup, which is why
* the profile calls {@code bump} first and only then starts a second, fresh Maven for the actual build.
*
* <p>Commands — each re-reads the version from the POM, so they share no state beyond that file:
* <ul>
* <li>{@code bump <pom> [allowDirty]} — refuses a dirty working tree (the release tag must describe the
* 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 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}),
* its SHA-256 and the note itself as assets, through the {@code gh} CLI;
* <li>{@code tag <pom>} — commits the POM alone and tags that commit {@code v<version>}, locally only;
* <li>{@code distrib <pom> <distribDir>} — commits that release note in the {@code distrib/} worktree,
* together with {@code CREDITS.md} refreshed from {@code src/main/resources/userguide/credits.md} (the
* one source of the credits, also shown in the application), and pushes (to {@code Imag-In/Pholio}'s
* {@code main}, per that worktree's upstream);
* <li>{@code next <current> <yyyy-mm>} — prints the version that would follow {@code current}; the pure
* rule, for tests;
* <li>{@code render-notes <version> <yyyy-mm-dd> <previousTag|->} — prints the note {@link #renderNotes}
* builds from the commit subjects on standard input, one per line; for tests.
* </ul>
*/
public final class Release {
private static final Pattern RELEASE_VERSION = Pattern.compile("(\\d{4})\\.(\\d{1,2})\\.(\\d+)");
private static final Pattern VERSION_ELEMENT = Pattern.compile("<version>([^<]+)</version>");
/** {@code type(scope)!: description} — scope and {@code !} (breaking change) optional. */
private static final Pattern CONVENTIONAL = Pattern.compile("^([a-zA-Z]+)(?:\\(([^)]+)\\))?(!)?:\\s+(.+)$");
/** How far back the very first release note looks, with no previous version tag to stop at. */
static final int FIRST_RELEASE_COMMITS = 30;
private static final String GENERAL_SCOPE = "general";
/** Section order and heading for each Conventional Commit type; anything else comes last, as is. */
private static final Map<String, String> TYPE_HEADINGS = orderedHeadings();
private static Map<String, String> orderedHeadings() {
Map<String, String> headings = new LinkedHashMap<>();
headings.put("feat", "✨ Features");
headings.put("fix", "🐛 Bug fixes");
headings.put("perf", "⚡ Performance");
headings.put("refactor", "♻️ Refactoring");
headings.put("docs", "📝 Documentation");
headings.put("doc", "📝 Documentation");
headings.put("style", "💄 Style");
headings.put("test", "✅ Tests");
headings.put("build", "📦 Build");
headings.put("ci", "👷 Continuous integration");
headings.put("chore", "🔧 Chores");
headings.put("revert", "⏪ Reverts");
return headings;
}
private Release() {
}
public static void main(String[] args) throws Exception {
if (args.length == 0) {
throw usage();
}
switch (args[0]) {
case "next" -> {
require(args, 3);
System.out.println(next(args[1], YearMonth.parse(args[2])));
}
case "bump" -> {
require(args, 2);
bump(Path.of(args[1]), args.length > 2 && Boolean.parseBoolean(args[2]));
}
case "notes" -> {
require(args, 3);
notes(Path.of(args[1]), Path.of(args[2]));
}
case "publish" -> {
require(args, 5);
publish(Path.of(args[1]), Path.of(args[2]), args[3], Path.of(args[4]));
}
case "tag" -> {
require(args, 2);
tag(Path.of(args[1]));
}
case "distrib" -> {
require(args, 3);
distrib(Path.of(args[1]), Path.of(args[2]));
}
case "render-notes" -> {
require(args, 4);
List<String> subjects = new String(System.in.readAllBytes(), StandardCharsets.UTF_8).lines().toList();
System.out.print(renderNotes(args[1], LocalDate.parse(args[2]), "-".equals(args[3]) ? null : args[3], subjects));
}
default -> throw usage();
}
}
/**
* {@code current}'s successor in {@code now}: the next build number within the same month, or build 0 of
* {@code now}'s month for anything else — another month, another year, or a version that is not
* {@code YYYY.M.N} at all (such as the {@code 1.0.0-SNAPSHOT} this scheme replaced).
*/
static String next(String current, YearMonth now) {
Matcher matcher = RELEASE_VERSION.matcher(current.strip());
if (matcher.matches()
&& Integer.parseInt(matcher.group(1)) == now.getYear()
&& Integer.parseInt(matcher.group(2)) == now.getMonthValue()) {
return now.getYear() + "." + now.getMonthValue() + "." + (Long.parseLong(matcher.group(3)) + 1);
}
return now.getYear() + "." + now.getMonthValue() + ".0";
}
private static void bump(Path pom, boolean allowDirty) throws IOException, InterruptedException {
if (!allowDirty) {
String status = run(List.of("git", "status", "--porcelain", "--untracked-files=no"), pom.toAbsolutePath().getParent(), true);
if (!status.isBlank()) {
throw new IllegalStateException("Uncommitted changes — commit them first, or pass -Drelease.allowDirty=true:\n" + status);
}
}
String text = Files.readString(pom, StandardCharsets.UTF_8);
int start = projectVersionStart(text);
Matcher matcher = VERSION_ELEMENT.matcher(text);
matcher.find(start);
String current = matcher.group(1);
String next = next(current, YearMonth.now());
Files.writeString(pom, text.substring(0, matcher.start(1)) + next + text.substring(matcher.end(1)), StandardCharsets.UTF_8);
System.out.println("[release] version " + current + " -> " + next);
}
private static void notes(Path pom, Path notesDir) throws IOException, InterruptedException {
String version = projectVersion(pom);
Path dir = pom.toAbsolutePath().getParent();
String previousTag = previousVersionTag(dir);
List<String> command = new ArrayList<>(List.of("git", "log", "--no-merges", "--format=%s"));
if (previousTag != null) {
command.add(previousTag + "..HEAD");
} else {
command.add("-n");
command.add(String.valueOf(FIRST_RELEASE_COMMITS));
}
List<String> subjects = run(command, dir, true).lines().toList();
Files.createDirectories(notesDir);
Path note = notePath(notesDir, version);
Files.writeString(note, renderNotes(version, LocalDate.now(), previousTag, subjects), StandardCharsets.UTF_8);
System.out.println("[release] wrote " + note);
}
/**
* The most recent {@code v<YYYY.M.N>} tag reachable from {@code HEAD} — the previous release — or
* {@code null} before the first one.
*/
private static String previousVersionTag(Path dir) throws IOException, InterruptedException {
return run(List.of("git", "tag", "--merged", "HEAD", "--list", "v*", "--sort=-v:refname"), dir, true)
.lines()
.map(String::strip)
.filter(tag -> RELEASE_VERSION.matcher(tag.substring(1)).matches())
.findFirst()
.orElse(null);
}
private static Path notePath(Path notesDir, String version) {
return notesDir.resolve("release-note-" + version + ".md");
}
/**
* 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 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) {}
Map<String, Change> changes = new LinkedHashMap<>();
Map<String, Integer> counts = new LinkedHashMap<>();
for (String raw : subjects) {
String subject = raw.strip();
Matcher matcher = CONVENTIONAL.matcher(subject);
if (!matcher.matches()) {
continue;
}
counts.merge(subject, 1, Integer::sum);
String scope = matcher.group(2) == null ? GENERAL_SCOPE : matcher.group(2).strip().toLowerCase();
changes.putIfAbsent(subject, new Change(matcher.group(1).toLowerCase(), scope, matcher.group(4).strip(), matcher.group(3) != null));
}
Comparator<String> scopeOrder = Comparator.<String, Boolean>comparing(GENERAL_SCOPE::equals).thenComparing(Comparator.naturalOrder());
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<>(byType);
changes.forEach((subject, change) -> {
String line = (change.breaking() ? "💥 **BREAKING** " : "") + change.description()
+ (counts.get(subject) > 1 ? " _(×" + counts.get(subject) + ")_" : "");
// 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);
});
StringBuilder note = new StringBuilder("# 🚀 Pholio ").append(version).append("\n\n");
note.append("_").append(date).append(" · ").append(changes.size()).append(changes.size() == 1 ? " change" : " changes")
.append(previousTag != null ? " since " + previousTag : " (last " + FIRST_RELEASE_COMMITS + " commits)").append("_\n");
if (grouped.isEmpty()) {
note.append("\nNo notable change.\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"));
});
});
return note.toString();
}
private static void publish(Path pom, Path jar, String repository, Path notesDir)
throws IOException, InterruptedException, NoSuchAlgorithmException {
String version = projectVersion(pom);
String tag = "v" + version;
Path asset = jar.resolveSibling("pholio-" + version + ".jar");
Files.copy(jar, asset, StandardCopyOption.REPLACE_EXISTING);
Path checksum = asset.resolveSibling(asset.getFileName() + ".sha256");
Files.writeString(checksum, sha256(asset) + " " + asset.getFileName() + "\n", StandardCharsets.UTF_8);
Path note = notePath(notesDir, version);
if (!Files.isRegularFile(note)) {
throw new IllegalStateException("No release note at " + note + " — the notes step must run first");
}
List<String> command = new ArrayList<>(List.of("gh", "release", "create", tag, asset.toString(), checksum.toString(), note.toString(),
"--repo", repository, "--target", "main",
"--title", "Pholio " + version, "--notes-file", note.toString()));
run(command, pom.toAbsolutePath().getParent(), false);
System.out.println("[release] published " + tag + " to " + repository);
}
private static void tag(Path pom) throws IOException, InterruptedException {
String version = projectVersion(pom);
Path dir = pom.toAbsolutePath().getParent();
// `git commit -- <path>` commits that file alone, whatever else happens to be staged.
run(List.of("git", "commit", "-m", "chore(release): " + version, "--", pom.getFileName().toString()), dir, false);
run(List.of("git", "tag", "v" + version), dir, false);
System.out.println("[release] committed and tagged v" + version + " locally (nothing pushed)");
}
private static void distrib(Path pom, Path distribDir) throws IOException, InterruptedException {
String version = projectVersion(pom);
if (!Files.exists(distribDir.resolve(".git"))) {
throw new IllegalStateException(distribDir + " is not the distrib worktree (git worktree add --orphan -b distrib distrib)");
}
String note = distribDir.relativize(notePath(distribDir.resolve("release_note"), version)).toString();
String credits = "CREDITS.md";
Files.copy(pom.toAbsolutePath().getParent().resolve("src/main/resources/userguide/credits.md"),
distribDir.resolve(credits), StandardCopyOption.REPLACE_EXISTING);
run(List.of("git", "add", "--", note, credits), distribDir, false);
// Like tag: those files only, whatever else is pending in the worktree (an unchanged CREDITS.md
// simply adds nothing to the commit).
run(List.of("git", "commit", "-m", "docs(release): notes for " + version, "--", note, credits), distribDir, false);
run(List.of("git", "push"), distribDir, false);
System.out.println("[release] committed and pushed " + note + " from " + distribDir);
}
private static String projectVersion(Path pom) throws IOException {
String text = Files.readString(pom, StandardCharsets.UTF_8);
Matcher matcher = VERSION_ELEMENT.matcher(text);
if (!matcher.find(projectVersionStart(text))) {
throw new IllegalStateException("No project <version> in " + pom);
}
return matcher.group(1).strip();
}
/**
* Where to look for the project's own {@code <version>}: after {@code </parent>}, whose own
* {@code <version>} is the Spring Boot parent's, never this project's.
*/
private static int projectVersionStart(String pom) {
int parentEnd = pom.indexOf("</parent>");
return parentEnd < 0 ? pom.indexOf("<project") : parentEnd;
}
private static String sha256(Path file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (var in = Files.newInputStream(file)) {
byte[] buffer = new byte[1 << 16];
for (int read; (read = in.read(buffer)) > 0; ) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
/**
* Runs {@code command} in {@code dir}, failing on a non-zero exit; {@code capture} returns its output
* instead of streaming it to this process's own console.
*/
private static String run(List<String> command, Path dir, boolean capture) throws IOException, InterruptedException {
ProcessBuilder builder = new ProcessBuilder(command).directory(dir.toFile());
if (capture) {
builder.redirectErrorStream(true);
} else {
builder.inheritIO();
}
Process process = builder.start();
String output = capture ? new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8) : "";
int exit = process.waitFor();
if (exit != 0) {
throw new IllegalStateException(String.join(" ", command) + " failed with exit code " + exit + "\n" + output);
}
return output;
}
private static void require(String[] args, int count) {
if (args.length < count) {
throw usage();
}
}
private static IllegalArgumentException usage() {
return new IllegalArgumentException(
"usage: Release next <current> <yyyy-mm> | bump <pom> [allowDirty] | notes <pom> <notesDir>"
+ " | publish <pom> <jar> <repo> <notesDir> | tag <pom> | distrib <pom> <distribDir>"
+ " | render-notes <version> <yyyy-mm-dd> <previousTag|->");
}
}