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
353 lines
18 KiB
Java
353 lines
18 KiB
Java
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|->");
|
||
}
|
||
}
|