From c3bf3c78974ec3416f5fe4a286dfa6f5377ac03a Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 11:43:41 +0200 Subject: [PATCH 01/25] Add design spec for decoupling lib/gui versioning --- ...8-16-decouple-lib-gui-versioning-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-decouple-lib-gui-versioning-design.md diff --git a/docs/superpowers/specs/2026-08-16-decouple-lib-gui-versioning-design.md b/docs/superpowers/specs/2026-08-16-decouple-lib-gui-versioning-design.md new file mode 100644 index 0000000..2aa7daa --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-decouple-lib-gui-versioning-design.md @@ -0,0 +1,154 @@ +# Decouple `lib` and `gui` versioning — Design + +**Status:** Approved by user, ready for implementation planning. + +## Motivation + +The parent aggregator, `md2pdf` (lib), and `MarkdownToPdf` (gui) currently share one version +number via the `revision` property, and `release.sh` releases all three together in one pass: +it deploys `md2pdf` to Maven Central, tags the single shared version, and creates one GitHub +release combining lib's javadoc jar with gui's platform zips. + +This forces every gui-only change (e.g. a packaging fix, an icon asset, a `build-mas.sh` +compatibility fix with no `lib` changes at all) through a Maven Central deploy of an unchanged +library — Central deploys are irreversible and this is unnecessary churn. `gui/pom.xml` already +has an indirection (`${revision}`) suggesting decoupling was +anticipated but never finished. + +**Goal:** `lib` and `gui` release independently, on independent version numbers, with `gui` +releases never touching Maven Central. + +## Constraint: Maven's `` element + +Maven only resolves a small reserved set of property names — `revision`, `sha1`, `changelist` — +when used inside a `` element; this is a Maven core restriction for "CI-friendly +versions," not just a `flatten-maven-plugin` convention. An arbitrary property name like +`${gui.version}` placed directly in a `` tag will NOT resolve — Maven leaves it as a +literal unresolved string in the effective model. + +This rules out simply introducing ``/`` properties and using them +inside `` elements directly. + +## Chosen mechanism: per-module `revision` override + +Keep the single aggregator/reactor build (no CI restructuring, no splitting into separate Maven +root projects). `gui/pom.xml` overrides the reserved `revision` property in its own +`` block, shadowing the parent's value only for gui's own `` resolution: + +```xml + + + 0.2.0 + +``` + +```xml + + + 0.2.1 + 0.2.0 + +``` + +`lib/pom.xml` needs no change — it continues to inherit `revision` from the parent, which +becomes, by convention, "lib's version." + +A single `mvn install` still reactor-builds both modules exactly as today; Maven resolves the +inter-module dependency locally because `md2pdf.version` is kept in lockstep with lib's actual +`` by whoever bumps versions at release time (see `release.sh` below) — it is never +allowed to drift from lib's real version, since that would break local reactor resolution and +force a remote (Central) lookup instead. + +flatten-maven-plugin's `resolveCiFriendliesOnly` mode operates per-module on the effective model +being built, so it correctly flattens gui's overridden `revision` into gui's own +installed/deployed POM, independent of lib's. + +## `release.sh` becomes two commands, not one flag + +Drop the `--module` flag idea — two positional invocations instead: + +``` +./release.sh lib +./release.sh gui +``` + +Both share the existing preconditions (clean tree, on `main`, `gh`/`mvn` available and +authenticated), plus one new one that applies regardless of which module is being released: +`gui/pom.xml`'s `md2pdf.version` must equal lib's actual current `revision` exactly — a bump to +lib's version with no matching update to `gui/pom.xml` breaks local reactor resolution (Maven +would try to resolve the stale coordinate remotely and fail, since an unreleased lib bump isn't +on Central yet). `release.sh` checks this and dies with a clear message before doing anything +else, rather than let it surface as an opaque Maven resolution error mid-build. + +From there the two flows diverge: + +### `./release.sh lib` + +- `$VERSION` read via the existing root-level `mvn ... evaluate -Dexpression=revision` + (unchanged — this already reads lib's version, since lib inherits `revision` unmodified). +- Tag: `md2pdf-v$VERSION` (was: bare `v$VERSION`). +- Release notes: composed from `release.md` + `lib/release.md` only. The parent's version now + always equals lib's, so gating `release.md`'s heading on lib releases keeps the "shared + build/tooling" changelog meaningfully aligned with the version it's attached to. +- Assets: `md2pdf-$VERSION-sources.jar` and `md2pdf-$VERSION-javadoc.jar`. Both are already + produced by `lib/pom.xml`'s unconditional `maven-source-plugin`/`maven-javadoc-plugin` + bindings (`attach-sources`/`attach-javadocs`) — CI currently builds and uploads only the + javadoc jar as a distinct artifact; add an equivalent upload step for the sources jar. Central + itself already requires both for a valid deploy, so nothing changes about what gets published + there — this only adds the sources jar as a downloadable GitHub release asset, matching what + Central already requires and lib already builds. +- Maven Central deploy: unchanged (`mvn -Prelease -pl lib -am clean site deploy`), including the + existing "already on Central" idempotency check and `--skip-deploy` recovery path. + +### `./release.sh gui` + +- `$VERSION` read via `mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate + -Dexpression=revision -DforceStdout` — scoped to the `gui` module so it picks up gui's + overridden `revision`, not the parent's. +- Tag: `MarkdownToPdf-v$VERSION` (was: bare `v$VERSION`). +- Release notes: composed from `gui/release.md` only. +- Assets: the existing four — `md2pdf-$VERSION-linux-x64.zip`, `md2pdf-$VERSION-macos-aarch64.zip`, + `md2pdf-$VERSION-windows-x64.zip`, `md2pdf-$VERSION-no-jdk.zip`. (Filenames keep their existing + `md2pdf-` prefix — that's a separate, pre-existing artifact-naming convention, not something + this change touches. Only the *version number* inside those filenames now comes from gui's own + `revision`.) +- **No Maven Central deploy step at all** — this is the entire point. + +## CI (`ci.yml`) changes + +The `build` job's "Read the project version" step currently evaluates `revision` unscoped (at +the repo root), which today happens to equal both lib's and gui's version. Once they diverge, +this step must produce **two** values: + +- `APP_VERSION` — evaluated scoped to `gui` (`-pl gui`) — used to name the packaged app zips and + the no-jdk archive, exactly as `APP_VERSION` is used today, just correctly scoped now. +- A second value for lib's version — used to name the javadoc jar (already does this today via + the same variable, so this must be split out) and the new sources jar upload step. + +Both the existing "Build the javadoc jar" step and the new sources-jar upload step name their +artifacts from lib's version, not gui's. + +## `docs/release-process.md` rewrite + +Replace the single unified process description with two independent flows (mirroring the two +subsections above), and drop the "all three changelogs need a heading for this version" gate in +favor of: bump only the changelog(s) for the module you're releasing (`lib/release.md` for a lib +release, `gui/release.md` for a gui release; `release.md` alongside `lib/release.md` since it's +gated on lib's version). + +## Knock-on effect: `MarkdownToPdf-dist` + +`build-mas.sh --tag` already accepts an arbitrary git ref string — no code change is needed +there. Going forward, callers pass gui's tag (`MarkdownToPdf-vX.Y.Z`), not the old bare +`vX.Y.Z`. Only the script's usage comment/examples need updating to reflect the new tag name. + +## Out of scope + +- Splitting `lib`/`gui` into fully independent top-level Maven projects (no shared parent POM). + Rejected: more Maven-native, but requires restructuring CI to build and locally install `lib` + before building `gui`, for no benefit this project currently needs — the reactor-build + approach above achieves independent versioning without that restructuring. +- Renaming the `md2pdf-*` artifact/zip filename prefix to something gui-specific. Unrelated, + pre-existing convention; not touched by this change. From 57e858caa034d86bf815e3b50dbb565753bd061d Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 17:32:33 +0200 Subject: [PATCH 02/25] gui: declare its own literal , independent of lib's revision --- gui/pom.xml | 8 +++++++- pom.xml | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/gui/pom.xml b/gui/pom.xml index 6b2d74f..6af71ec 100644 --- a/gui/pom.xml +++ b/gui/pom.xml @@ -10,6 +10,12 @@ ${revision} MarkdownToPdf + + 0.2.0 jar MarkdownToPdf Editor @@ -187,7 +193,7 @@ - + diff --git a/pom.xml b/pom.xml index bd8b870..1f4c6f4 100644 --- a/pom.xml +++ b/pom.xml @@ -22,7 +22,8 @@ 21 21 UTF-8 - + 0.2.0 25.0.4 From 26241b789107521528b612185f3f1bd3faa923bf Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 17:35:47 +0200 Subject: [PATCH 03/25] gui/MarkdownToPdf.xml: point the launcher dependency at gui's own version --- gui/MarkdownToPdf.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gui/MarkdownToPdf.xml b/gui/MarkdownToPdf.xml index fbcec32..46f16ef 100644 --- a/gui/MarkdownToPdf.xml +++ b/gui/MarkdownToPdf.xml @@ -33,7 +33,11 @@ se.alipsa MarkdownToPdf - ${project.version} + + 0.2.0 org.openjfx From 30356d351b8c387b7d1e3d60f447eb0869c15f69 Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 17:38:05 +0200 Subject: [PATCH 04/25] docs: describe gui's independent versioning in CLAUDE.md --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0821c76..f85e622 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ SpotBugs runs at `verify` with `effort=Max`, `threshold=Medium`. False positives ## Module structure -This is a Maven multi-module project with `${revision}` CI-friendly versioning (resolved by flatten-maven-plugin). +This is a Maven multi-module project. `lib` uses `${revision}` CI-friendly versioning (resolved by flatten-maven-plugin); `gui` has its own independent ``, see below. ### `lib` — the engine library (`se.alipsa:md2pdf`) @@ -81,4 +81,4 @@ bundle their own Java 25 runtime, so end users do not need a JDK. - **Zero new Maven dependencies** for anything in the `gui` model layer or `lib` core. The CSS round-trip parser in `StyleProfile.fromCss()` is intentionally hand-written for this reason. - **No `--add-exports`/`--add-opens` config needed** — Spotless 3.x handles Google Java Format's module requirements automatically. - Build-generated files such as `.flattened-pom.xml` and `dependency-reduced-pom.xml` are ignored by `.gitignore`; do not commit them. -- The `${revision}` property in the root POM controls the version for all modules. Bump it in `pom.xml` only; flatten-maven-plugin propagates it. `gui/MarkdownToPdf.xml` inherits the root parent, so its launcher dependency follows `${revision}` as well. +- The `${revision}` property in the root POM controls `lib`'s version (and the parent's). `gui` has its own independent `` in `gui/pom.xml`, bumped separately at gui release time. A gui release requires bumping **two** files in lockstep: `gui/pom.xml`'s `` and the dependency `` in `gui/MarkdownToPdf.xml` (`release.sh gui` checks they match). `gui/MarkdownToPdf.xml`'s own `` reference still follows `${revision}` (i.e. lib's version) since that file is unchanged by gui's version override — only its dependency on the built `MarkdownToPdf` artifact needs gui's version directly. From 5997e10da1b0fc80dc6b6bce0426d6d17a13e41a Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 17:44:34 +0200 Subject: [PATCH 05/25] UpdateChecker: filter releases by MarkdownToPdf-v tag prefix instead of releases/latest --- .../md2pdf/gui/update/UpdateChecker.java | 71 +++++++--- .../gui/update/UpdateCheckerHttpTest.java | 22 +-- .../md2pdf/gui/update/UpdateCheckerTest.java | 125 +++++++++++++----- 3 files changed, 162 insertions(+), 56 deletions(-) diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java index 1cb2347..f07b751 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java @@ -25,14 +25,22 @@ public UpdateChecker() {} /** System property that overrides the GitHub API URL, for manual QA against a local fixture. */ public static final String API_URL_PROPERTY = "md2pdf.update.apiUrl"; + /** + * gui releases are tagged {@code MarkdownToPdf-v}; lib releases share the same + * repository and are tagged {@code md2pdf-v}. {@code releases/latest} is repo-wide, so a + * lib-only release published after a gui release would shadow it and ship no platform zips at all + * — the releases list must be filtered to this prefix instead. + */ + private static final String TAG_PREFIX = "MarkdownToPdf-v"; + private static final String DEFAULT_API_URL = - "https://api.github.com/repos/Alipsa/MarkdownToPdf/releases/latest"; + "https://api.github.com/repos/Alipsa/MarkdownToPdf/releases?per_page=100"; private static final Logger LOGGER = LogManager.getLogger(UpdateChecker.class); /** - * Fetches the latest GitHub release and returns update info if it is newer than {@code - * currentVersion} and ships an asset for this platform. + * Fetches recent GitHub releases and returns update info if the latest release tagged {@code + * MarkdownToPdf-v*} is newer than {@code currentVersion} and ships an asset for this platform. * * @param currentVersion the version currently running * @return update information when a newer platform release is available @@ -65,18 +73,20 @@ public Optional checkForUpdate(String currentVersion) throws UpdateC } /** - * Pure evaluation of a GitHub releases/latest JSON response against the currently running version - * and platform. No network access — used directly by tests. + * Pure evaluation of a {@code GET /releases} JSON array response against the currently running + * version and platform. No network access — used directly by tests. * - *

Only the platform's own release asset and the release page URL are required; the {@code - * SHA256SUMS} checksum asset is captured when present but not required, since nothing in this - * check-only feature downloads or verifies it yet (that lands with the self-apply follow-up) — - * requiring it here would silently suppress every notification against a release that predates - * checksum publishing, such as the current {@code v0.1.0}. + *

The response is a top-level array of releases, not a single release: {@code releases/latest} + * is repo-wide and would let an unrelated {@code lib} release (tagged {@code md2pdf-v*}) shadow + * the actual latest {@code gui} release, or return no platform zips at all. This scans every + * entry, keeps only tags starting with {@link #TAG_PREFIX}, and picks the highest version among + * matches via {@link VersionComparator} — not "the first match" — because GitHub sorts {@code + * /releases} by the tagged commit's date, not publish time, so a gui release cut from an older + * commit is not guaranteed to sort above a newer lib release. * * @param currentVersion the version currently running * @param platform the platform whose release asset should be selected - * @param responseJson the GitHub Releases API response + * @param responseJson the {@code GET /releases} JSON array response * @return update information when a newer matching release is available */ public static Optional parseAndEvaluate( @@ -85,12 +95,13 @@ public static Optional parseAndEvaluate( LOGGER.info("Skipping update check: no release archive for this platform."); return Optional.empty(); } - String tagName = GitHubReleaseJson.extractTagName(responseJson); - if (tagName == null) { - LOGGER.info("Skipping update check: release response had no tag_name."); + String releaseJson = selectLatestGuiRelease(responseJson); + if (releaseJson == null) { + LOGGER.warn("Skipping update check: no {}* release found in the fetched page.", TAG_PREFIX); return Optional.empty(); } - String latestVersion = tagName.startsWith("v") ? tagName.substring(1) : tagName; + String tagName = GitHubReleaseJson.extractTagName(releaseJson); + String latestVersion = tagName.substring(TAG_PREFIX.length()); if (!VersionComparator.isNewer(latestVersion, currentVersion)) { LOGGER.info( "No update available: latest release {} is not newer than the running {}.", @@ -103,13 +114,13 @@ public static Optional parseAndEvaluate( // response — nothing stops a future field reorder from handing back the uploader's profile // URL instead. A release page URL always contains "/releases/"; a profile URL never does, so // this converts a reorder from silently opening the wrong page into a skipped notification. - String htmlUrl = GitHubReleaseJson.extractHtmlUrl(responseJson); + String htmlUrl = GitHubReleaseJson.extractHtmlUrl(releaseJson); if (htmlUrl == null || !htmlUrl.contains("/releases/")) { LOGGER.info( "Skipping update check: release {} had no usable html_url ({}).", tagName, htmlUrl); return Optional.empty(); } - List assets = GitHubReleaseJson.extractAssets(responseJson); + List assets = GitHubReleaseJson.extractAssets(releaseJson); String expectedAssetName = "md2pdf-" + latestVersion + platform.assetSuffix(); String downloadUrl = findAssetUrl(assets, expectedAssetName); if (downloadUrl == null) { @@ -125,6 +136,32 @@ public static Optional parseAndEvaluate( latestVersion, tagName, expectedAssetName, downloadUrl, checksumsUrl, htmlUrl)); } + /** + * Scans a {@code GET /releases} JSON array and returns the JSON object of the release with the + * highest version among those tagged {@link #TAG_PREFIX}, or {@code null} if none match. + */ + private static String selectLatestGuiRelease(String responseJson) { + int openBracket = responseJson.indexOf('['); + if (openBracket < 0) { + return null; + } + String arrayBody = GitHubReleaseJson.extractBracketedRegion(responseJson, openBracket); + String bestJson = null; + String bestVersion = null; + for (String candidate : GitHubReleaseJson.splitTopLevelObjects(arrayBody)) { + String tagName = GitHubReleaseJson.extractTagName(candidate); + if (tagName == null || !tagName.startsWith(TAG_PREFIX)) { + continue; + } + String candidateVersion = tagName.substring(TAG_PREFIX.length()); + if (bestVersion == null || VersionComparator.isNewer(candidateVersion, bestVersion)) { + bestJson = candidate; + bestVersion = candidateVersion; + } + } + return bestJson; + } + private static String findAssetUrl(List assets, String name) { for (GitHubReleaseJson.Asset asset : assets) { if (name.equals(asset.name())) { diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java index fa5ec3c..7353474 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java @@ -36,9 +36,11 @@ public class UpdateCheckerHttpTest { void startServer() throws IOException { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.start(); + // HttpServer context matching is path-only; the query string does not need to be part + // of the registered context path below. System.setProperty( UpdateChecker.API_URL_PROPERTY, - "http://127.0.0.1:" + server.getAddress().getPort() + "/releases/latest"); + "http://127.0.0.1:" + server.getAddress().getPort() + "/releases?per_page=100"); } @AfterEach @@ -51,7 +53,7 @@ void stopServer() { private void respond(int status, String body) { server.createContext( - "/releases/latest", + "/releases", exchange -> { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); // sendResponseHeaders' responseLength contract: 0 means chunked with unspecified @@ -94,13 +96,15 @@ void wellFormedNewerReleaseIsReturned() throws UpdateCheckException { respond( 200, """ - { - "tag_name": "v99.0.0", - "html_url": "https://github.com/Alipsa/MarkdownToPdf/releases/tag/v99.0.0", - "assets": [ - {"name": "%s", "browser_download_url": "https://example.com/%s"} - ] - } + [ + { + "tag_name": "MarkdownToPdf-v99.0.0", + "html_url": "https://github.com/Alipsa/MarkdownToPdf/releases/tag/MarkdownToPdf-v99.0.0", + "assets": [ + {"name": "%s", "browser_download_url": "https://example.com/%s"} + ] + } + ] """ .formatted(assetName, assetName)); diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java index 360352c..cd72712 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java @@ -36,64 +36,73 @@ private static String asset(String name, String url) { .strip(); } + // GET /releases returns a top-level array, not a single release — releases/latest is + // repo-wide and would let an unrelated lib release (tagged md2pdf-v*) shadow the actual + // latest gui release, or return no platform zips at all. + private static String releasesArray(String... releaseJsons) { + return "[" + String.join(",", releaseJsons) + "]"; + } + @Test void updateAvailableWithMatchingAssetIsReturned() { - String json = + String release = releaseJson( - "v0.1.2", + "MarkdownToPdf-v0.1.2", asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip"), asset("SHA256SUMS", "https://example.com/SHA256SUMS")); Optional result = - UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, json); + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); assertTrue(result.isPresent()); UpdateInfo info = result.get(); assertEquals("0.1.2", info.latestVersion()); - assertEquals("v0.1.2", info.tagName()); + assertEquals("MarkdownToPdf-v0.1.2", info.tagName()); assertEquals("md2pdf-0.1.2-linux-x64.zip", info.assetName()); assertEquals("https://example.com/md2pdf-0.1.2-linux-x64.zip", info.downloadUrl()); assertEquals("https://example.com/SHA256SUMS", info.checksumsUrl()); assertEquals( - "https://github.com/Alipsa/MarkdownToPdf/releases/tag/v0.1.2", info.releaseHtmlUrl()); + "https://github.com/Alipsa/MarkdownToPdf/releases/tag/MarkdownToPdf-v0.1.2", + info.releaseHtmlUrl()); } @Test void alreadyLatestVersionReturnsEmpty() { - String json = + String release = releaseJson( - "v0.1.1", + "MarkdownToPdf-v0.1.1", asset("md2pdf-0.1.1-linux-x64.zip", "https://example.com/md2pdf-0.1.1-linux-x64.zip"), asset("SHA256SUMS", "https://example.com/SHA256SUMS")); - assertTrue(UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, json).isEmpty()); + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) + .isEmpty()); } @Test void updateAvailableButNoAssetForThisPlatformReturnsEmpty() { - String json = + String release = releaseJson( - "v0.1.2", + "MarkdownToPdf-v0.1.2", asset( "md2pdf-0.1.2-macos-aarch64.zip", "https://example.com/md2pdf-0.1.2-macos-aarch64.zip"), asset("SHA256SUMS", "https://example.com/SHA256SUMS")); - assertTrue(UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, json).isEmpty()); + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) + .isEmpty()); } @Test void missingChecksumsAssetStillReturnsUpdate() { - // The current latest release (v0.1.0) ships no platform zip and no SHA256SUMS — a release - // that adds the platform zip before (or without) SHA256SUMS must still be able to notify. - // Verifying the checksum is the self-apply follow-up's concern, not this check-only PR's. - String json = + String release = releaseJson( - "v0.1.2", + "MarkdownToPdf-v0.1.2", asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); Optional result = - UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, json); + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); assertTrue(result.isPresent()); assertNull(result.get().checksumsUrl()); @@ -101,10 +110,10 @@ void missingChecksumsAssetStillReturnsUpdate() { @Test void missingHtmlUrlReturnsEmpty() { - String json = + String release = """ { - "tag_name": "v0.1.2", + "tag_name": "MarkdownToPdf-v0.1.2", "assets": [%s] } """ @@ -113,19 +122,17 @@ void missingHtmlUrlReturnsEmpty() { "md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); - assertTrue(UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, json).isEmpty()); + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) + .isEmpty()); } @Test void authorProfileShapedHtmlUrlReturnsEmpty() { - // Guards extractScalarBeforeAssets's reliance on GitHub's field ordering: if a future - // response ever put an author/uploader object (which also carries an "html_url") before the - // release's own field, this must be treated the same as a missing html_url, not silently - // surfaced as the release page. - String json = + String release = """ { - "tag_name": "v0.1.2", + "tag_name": "MarkdownToPdf-v0.1.2", "html_url": "https://github.com/someuser", "assets": [%s] } @@ -135,17 +142,75 @@ void authorProfileShapedHtmlUrlReturnsEmpty() { "md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); - assertTrue(UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, json).isEmpty()); + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) + .isEmpty()); } @Test void unsupportedPlatformReturnsEmpty() { - String json = + String release = releaseJson( - "v0.1.2", + "MarkdownToPdf-v0.1.2", asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip"), asset("SHA256SUMS", "https://example.com/SHA256SUMS")); - assertTrue(UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.UNSUPPORTED, json).isEmpty()); + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.UNSUPPORTED, releasesArray(release)) + .isEmpty()); + } + + @Test + void libReleaseInTheArrayIsIgnored() { + // A newer lib release (md2pdf-v*) must never shadow the actual latest gui release. + String libRelease = + releaseJson( + "md2pdf-v9.9.9", + asset("md2pdf-9.9.9-sources.jar", "https://example.com/md2pdf-9.9.9-sources.jar")); + String guiRelease = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); + + Optional result = + UpdateChecker.parseAndEvaluate( + "0.1.1", UpdatePlatform.LINUX_X64, releasesArray(libRelease, guiRelease)); + + assertTrue(result.isPresent()); + assertEquals("0.1.2", result.get().latestVersion()); + } + + @Test + void picksHighestVersionAmongMatchingPrefixRegardlessOfArrayOrder() { + // GitHub sorts /releases by the tagged commit's date, not publish time, so a gui release + // cut from an older commit is not guaranteed to sort above a newer one — "first match" is + // not a safe selection rule. The older release is placed first here on purpose. + String olderGuiRelease = + releaseJson( + "MarkdownToPdf-v0.1.1", + asset("md2pdf-0.1.1-linux-x64.zip", "https://example.com/md2pdf-0.1.1-linux-x64.zip")); + String newerGuiRelease = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); + + Optional result = + UpdateChecker.parseAndEvaluate( + "0.1.0", UpdatePlatform.LINUX_X64, releasesArray(olderGuiRelease, newerGuiRelease)); + + assertTrue(result.isPresent()); + assertEquals("0.1.2", result.get().latestVersion()); + } + + @Test + void noMatchingPrefixInArrayReturnsEmpty() { + String libRelease = + releaseJson( + "md2pdf-v9.9.9", + asset("md2pdf-9.9.9-sources.jar", "https://example.com/md2pdf-9.9.9-sources.jar")); + + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.0", UpdatePlatform.LINUX_X64, releasesArray(libRelease)) + .isEmpty()); } } From 375a1b910b67865ad11525e4a0fcc2d07ec9fb13 Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 17:50:38 +0200 Subject: [PATCH 06/25] release.sh: split lib/gui into independent release flows --- release.sh | 139 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 98 insertions(+), 41 deletions(-) diff --git a/release.sh b/release.sh index d7f936f..2e7bf40 100755 --- a/release.sh +++ b/release.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# Drives a MarkdownToPdf release. +# Drives a MarkdownToPdf release. lib and gui release independently, on independent +# version numbers; gui releases never touch Maven Central. # -# ./release.sh [--skip-deploy] +# ./release.sh lib [--skip-deploy] +# ./release.sh gui # # Builds nothing. Every release asset comes from the CI run for HEAD, so what ships is # byte-identical to what was tested. Runs on Linux or macOS. @@ -10,12 +12,31 @@ set -euo pipefail BASEDIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" > /dev/null 2>&1 && pwd )" cd "$BASEDIR" -SKIP_DEPLOY=0 -[ "${1:-}" = "--skip-deploy" ] && SKIP_DEPLOY=1 - die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } step() { printf '\n=== %s\n' "$*"; } +MODULE="${1:-}" +case "$MODULE" in + lib) + SKIP_DEPLOY=0 + case "${2:-}" in + "") ;; + --skip-deploy) SKIP_DEPLOY=1 ;; + *) die "unrecognized argument: ${2}. usage: ./release.sh lib [--skip-deploy]" ;; + esac + ;; + gui) + [ -z "${2:-}" ] \ + || die "gui has no Maven Central deploy step, so --skip-deploy does not apply. usage: ./release.sh gui" + ;; + "") + die "usage: ./release.sh lib [--skip-deploy] | ./release.sh gui" + ;; + *) + die "unrecognized module: $MODULE. usage: ./release.sh lib [--skip-deploy] | ./release.sh gui" + ;; +esac + # ── 1. preconditions ──────────────────────────────────────────────── step "Preconditions" @@ -37,22 +58,36 @@ gh auth status > /dev/null 2>&1 || die "gh is not authenticated" [ -z "$(git status --porcelain)" ] || die "working tree is not clean" [ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || die "not on main" -VERSION="$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" +if [ "$MODULE" = "lib" ]; then + VERSION="$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" + TAG="md2pdf-v$VERSION" +else + VERSION="$(mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout)" + TAG="MarkdownToPdf-v$VERSION" + # gui/MarkdownToPdf.xml duplicates gui's own version in a second, non-reactor file that + # nothing else builds, tests, or touches (only CLAUDE.md references it) — a stale value + # there is invisible until a developer runs `mvn -f gui/MarkdownToPdf.xml javafx:run`, + # possibly releases later. + LAUNCHER_VERSION="$(sed -n -e '/MarkdownToPdf<\/artifactId>/,/<\/dependency>/ s/.*\(.*\)<\/version>.*/\1/p' gui/MarkdownToPdf.xml)" + [ "$LAUNCHER_VERSION" = "$VERSION" ] \ + || die "gui/MarkdownToPdf.xml's dependency version ($LAUNCHER_VERSION) does not match gui/pom.xml's version ($VERSION) — bump both together before releasing" +fi case "$VERSION" in *-SNAPSHOT) die "refusing to release a snapshot version: $VERSION" ;; esac -TAG="v$VERSION" -echo "Releasing $VERSION" +echo "Releasing $MODULE $VERSION" git fetch --tags --quiet git rev-parse -q --verify "refs/tags/$TAG" > /dev/null && die "tag $TAG already exists locally" git ls-remote --exit-code --tags origin "$TAG" > /dev/null 2>&1 && die "tag $TAG already exists on the remote" git push --dry-run --quiet origin HEAD || die "git push would fail" -# The POM, not the directory: a directory listing can 200 on a partially-populated or -# stale path, and only the POM's presence means the version is actually published. -CENTRAL="https://repo1.maven.org/maven2/se/alipsa/md2pdf/$VERSION/md2pdf-$VERSION.pom" -if curl -sfI "$CENTRAL" > /dev/null; then - [ "$SKIP_DEPLOY" -eq 1 ] \ - || die "$VERSION is already on Maven Central. Maven Central cannot be overwritten; re-run with --skip-deploy to finish the rest of the release." +if [ "$MODULE" = "lib" ]; then + # The POM, not the directory: a directory listing can 200 on a partially-populated or + # stale path, and only the POM's presence means the version is actually published. + CENTRAL="https://repo1.maven.org/maven2/se/alipsa/md2pdf/$VERSION/md2pdf-$VERSION.pom" + if curl -sfI "$CENTRAL" > /dev/null; then + [ "$SKIP_DEPLOY" -eq 1 ] \ + || die "$VERSION is already on Maven Central. Maven Central cannot be overwritten; re-run with './release.sh lib --skip-deploy' to finish the rest of the release." + fi fi # ── 1b. release notes ─────────────────────────────────────────────── @@ -88,8 +123,6 @@ NOTES="$BASEDIR/.release-staging/release-notes-$VERSION.md" mkdir -p "$(dirname "$NOTES")" : > "$NOTES" -# One section per published artifact, named as the artifact is: an aggregator whose notes -# cover the shared build, and the two modules a user actually consumes. add_section() { local title="$1" file="$2" body body="$(extract_section "$file" "$VERSION")" @@ -97,9 +130,12 @@ add_section() { || die "$file has no section for $VERSION — bump its heading from -SNAPSHOT before releasing" printf '## %s\n\n%s\n\n' "$title" "$body" >> "$NOTES" } -add_section "md2pdf-parent — build and release tooling" release.md -add_section "md2pdf — library" lib/release.md -add_section "MarkdownToPdf — desktop application" gui/release.md +if [ "$MODULE" = "lib" ]; then + add_section "md2pdf-parent — build and release tooling" release.md + add_section "md2pdf — library" lib/release.md +else + add_section "MarkdownToPdf — desktop application" gui/release.md +fi cat "$NOTES" # ── 2. wait for CI ────────────────────────────────────────────────── @@ -123,13 +159,19 @@ STAGING="$BASEDIR/.release-staging/release-$VERSION" rm -rf "$STAGING" mkdir -p "$STAGING" -ASSETS=( - "md2pdf-$VERSION-linux-x64.zip" - "md2pdf-$VERSION-macos-aarch64.zip" - "md2pdf-$VERSION-windows-x64.zip" - "md2pdf-$VERSION-no-jdk.zip" - "md2pdf-$VERSION-javadoc.jar" -) +if [ "$MODULE" = "lib" ]; then + ASSETS=( + "md2pdf-$VERSION-sources.jar" + "md2pdf-$VERSION-javadoc.jar" + ) +else + ASSETS=( + "md2pdf-$VERSION-linux-x64.zip" + "md2pdf-$VERSION-macos-aarch64.zip" + "md2pdf-$VERSION-windows-x64.zip" + "md2pdf-$VERSION-no-jdk.zip" + ) +fi # One call per artifact. gh run download extracts multiple artifacts into separate # subdirectories and only flattens when a single artifact is named — and the artifact @@ -142,16 +184,21 @@ done # ── 4. sanity-check ───────────────────────────────────────────────── step "Checking the staged assets" +if [ "$MODULE" = "lib" ]; then + EXPECTED_COUNT=2 +else + EXPECTED_COUNT=4 +fi count="$(find "$STAGING" -maxdepth 1 -type f | wc -l | tr -d ' ')" -[ "$count" -eq 5 ] || die "expected exactly 5 files in $STAGING, found $count" +[ "$count" -eq "$EXPECTED_COUNT" ] || die "expected exactly $EXPECTED_COUNT files in $STAGING, found $count" # Per-asset floors, because the assets differ by three orders of magnitude: a platform zip -# carries a ~100 MB runtime, the no-jdk zip is ~15 MB, and the javadoc jar is ~130 KB. A -# single 1 MB floor would abort every release on the javadoc jar. +# carries a ~100 MB runtime, the no-jdk zip is ~15 MB, and the javadoc/sources jars are each +# tens of KB. A single 1 MB floor would abort every release on the small jars. asset_floor() { case "$1" in *-linux-x64.zip|*-macos-aarch64.zip|*-windows-x64.zip) echo 40000000 ;; # 40 MB *-no-jdk.zip) echo 5000000 ;; # 5 MB - *-javadoc.jar) echo 20000 ;; # 20 KB + *-javadoc.jar|*-sources.jar) echo 10000 ;; # 10 KB *) echo 1 ;; esac } @@ -162,13 +209,15 @@ for asset in "${ASSETS[@]}"; do floor="$(asset_floor "$asset")" [ "$size" -gt "$floor" ] || die "$asset is only $size bytes (expected more than $floor)" done -for label in linux-x64 macos-aarch64 windows-x64; do - unzip -l "$STAGING/md2pdf-$VERSION-$label.zip" | grep -F 'MarkdownToPdf' > /dev/null \ - || die "md2pdf-$VERSION-$label.zip has no MarkdownToPdf entry" -done -unzip -l "$STAGING/md2pdf-$VERSION-no-jdk.zip" | grep -F 'MarkdownToPdf.jar' > /dev/null \ - || die "the no-jdk zip has no MarkdownToPdf.jar" -echo " 5 assets OK" +if [ "$MODULE" = "gui" ]; then + for label in linux-x64 macos-aarch64 windows-x64; do + unzip -l "$STAGING/md2pdf-$VERSION-$label.zip" | grep -F 'MarkdownToPdf' > /dev/null \ + || die "md2pdf-$VERSION-$label.zip has no MarkdownToPdf entry" + done + unzip -l "$STAGING/md2pdf-$VERSION-no-jdk.zip" | grep -F 'MarkdownToPdf.jar' > /dev/null \ + || die "the no-jdk zip has no MarkdownToPdf.jar" +fi +echo " $EXPECTED_COUNT assets OK" # ── 5. checksums ──────────────────────────────────────────────────── step "Generating SHA256SUMS" @@ -179,7 +228,9 @@ step "Generating SHA256SUMS" cat "$STAGING/SHA256SUMS" # ── 6. Maven Central — the point of no return ─────────────────────── -if [ "$SKIP_DEPLOY" -eq 1 ]; then +if [ "$MODULE" = "gui" ]; then + step "No Maven Central deploy for gui — this is the entire point" +elif [ "$SKIP_DEPLOY" -eq 1 ]; then step "Skipping the Maven Central deploy (--skip-deploy)" else step "Publishing lib to Maven Central" @@ -197,11 +248,17 @@ git push origin "$TAG" # ── 8. GitHub release ─────────────────────────────────────────────── step "Creating the GitHub release" +FINAL_COUNT=$((EXPECTED_COUNT + 1)) count="$(find "$STAGING" -maxdepth 1 -type f | wc -l | tr -d ' ')" -[ "$count" -eq 6 ] || die "expected exactly 6 files in $STAGING, found $count" +[ "$count" -eq "$FINAL_COUNT" ] || die "expected exactly $FINAL_COUNT files in $STAGING, found $count" +if [ "$MODULE" = "lib" ]; then + TITLE="md2pdf $VERSION" +else + TITLE="MarkdownToPdf $VERSION" +fi # gh release create takes filenames or globs, never a directory. gh release create "$TAG" "$STAGING"/* \ - --title "MarkdownToPdf $VERSION" \ + --title "$TITLE" \ --notes-file "$NOTES" -printf '\nReleased %s\n' "$VERSION" +printf '\nReleased %s %s\n' "$MODULE" "$VERSION" From 9d44b54dcc885af51b49da85785e0271095f50a7 Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 17:56:56 +0200 Subject: [PATCH 07/25] install.sh, buildAndRun.sh: read gui's own version, not the shared revision --- gui/buildAndRun.sh | 2 +- install.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/buildAndRun.sh b/gui/buildAndRun.sh index 35ab869..627c40c 100755 --- a/gui/buildAndRun.sh +++ b/gui/buildAndRun.sh @@ -13,7 +13,7 @@ cd "$DIR/.." || exit 1 mvn install -DskipTests || exit 1 ./gui/createApp.sh linux || exit 1 -VERSION="$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" +VERSION="$(mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout)" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT unzip -q "gui/target/md2pdf-$VERSION-linux-x64.zip" -d "$WORK" diff --git a/install.sh b/install.sh index 4adc559..42ad1fc 100755 --- a/install.sh +++ b/install.sh @@ -24,7 +24,7 @@ fi mvn install -DskipTests ./gui/createApp.sh "$PLATFORM" -VERSION="$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" +VERSION="$(mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout)" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT unzip -q "gui/target/md2pdf-$VERSION-$LABEL.zip" -d "$WORK" From f081561c401051ab8d64bc0c6e6ef476ba1f075e Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 17:59:29 +0200 Subject: [PATCH 08/25] ci: split APP_VERSION/LIB_VERSION, upload the lib sources jar release.sh (rewritten in 375a1b9) expects a md2pdf-$VERSION-sources artifact from lib's release CI run, but ci.yml never built or uploaded one. It also only read a single shared "project version" via revision, which no longer reflects gui's now-independent version. Split the version-read step into APP_VERSION (gui's project.version) and LIB_VERSION (lib's revision), point the javadoc jar upload at LIB_VERSION, and add a sources-jar build/upload step modeled on the javadoc step so release.sh's lib release flow has an artifact to download. --- .github/workflows/ci.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 029f034..da82895 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,9 @@ jobs: run: sudo apt-get update && sudo apt-get install -y xvfb - name: Read the project version - run: echo "APP_VERSION=$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" >> "$GITHUB_ENV" + run: | + echo "APP_VERSION=$(mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout)" >> "$GITHUB_ENV" + echo "LIB_VERSION=$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" >> "$GITHUB_ENV" - run: mvn install -DskipTests @@ -155,8 +157,19 @@ jobs: - uses: actions/upload-artifact@v7 if: matrix.platform == 'linux' with: - name: md2pdf-${{ env.APP_VERSION }}-javadoc - path: lib/target/md2pdf-${{ env.APP_VERSION }}-javadoc.jar + name: md2pdf-${{ env.LIB_VERSION }}-javadoc + path: lib/target/md2pdf-${{ env.LIB_VERSION }}-javadoc.jar + if-no-files-found: error + + - name: Build the sources jar + if: matrix.platform == 'linux' + run: mvn -pl lib source:jar + + - uses: actions/upload-artifact@v7 + if: matrix.platform == 'linux' + with: + name: md2pdf-${{ env.LIB_VERSION }}-sources + path: lib/target/md2pdf-${{ env.LIB_VERSION }}-sources.jar if-no-files-found: error install-failures: From 249c7098ab4620140f9efd820bb4b988937e3cdd Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 18:03:06 +0200 Subject: [PATCH 09/25] docs: rewrite release-process.md for independent lib/gui releases - Replace single unified process with independent lib/gui flows - Add explicit version-bump instructions for each module - Document two-file gui lockstep requirement - Include stranded-install documentation requirement - Fix bare ./release.sh to specify both invocation forms (lib/gui) --- docs/release-process.md | 63 +++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/docs/release-process.md b/docs/release-process.md index ef0f00c..e872d56 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,27 +1,54 @@ # MarkdownToPdf release process -Run `./release.sh` from a clean `main` checkout. It downloads the artifacts from the green CI -run for `HEAD`, publishes the library to Maven Central, creates the version tag and opens the -GitHub release. Use `./release.sh --skip-deploy` only when Maven Central already received the -release and a later release step needs recovery; see the recovery instructions in -[`gui/readme.md`](../gui/readme.md). +`lib` and `gui` release independently, on independent version numbers. `gui` releases never +touch Maven Central. -## Before releasing +``` +./release.sh lib [--skip-deploy] +./release.sh gui +``` -Bump `` in the root `pom.xml`, then give **all three** changelogs a -`## ` section — [`release.md`](../release.md), [`lib/release.md`](../lib/release.md) -and [`gui/release.md`](../gui/release.md). Each file covers only its own artifact: -`md2pdf-parent` for the shared build, CI and release tooling, `md2pdf` for the library, and -`MarkdownToPdf` for the desktop application. +Run either from a clean `main` checkout. Both download the artifacts from the green CI run for +`HEAD`, create the version tag, and open the GitHub release; `./release.sh lib` additionally +publishes the library to Maven Central. Use `./release.sh lib --skip-deploy` only when Maven +Central already received the release and a later release step needs recovery; see +[`gui/readme.md`](../gui/readme.md) for lib's and gui's recovery instructions. -`release.sh` composes the GitHub release notes from those three sections, one per artifact, -and checks them in its preconditions — before anything is downloaded and long before the -irreversible Maven Central deploy. A module whose heading still reads `-SNAPSHOT` has no -section for the version being released, and the run aborts with: +## Before releasing a lib version + +Bump `` in the root `pom.xml`, then give **both** relevant changelogs a +`## ` section — [`release.md`](../release.md) and [`lib/release.md`](../lib/release.md). +`release.md` covers the shared build, CI and release tooling; `lib/release.md` covers the +library itself. + +## Before releasing a gui version + +Bump **two** files in lockstep: + +- `gui/pom.xml`'s own ``. +- The dependency `` in `gui/MarkdownToPdf.xml` — `release.sh gui` checks these match + and refuses to proceed otherwise, but the values still have to be written by hand in both + places. + +Then give [`gui/release.md`](../gui/release.md) a `## ` section. If this is the first +release under the new `MarkdownToPdf-v` tag scheme, that section must also say plainly +that installs predating this change will not detect this or any future update automatically (the +old `UpdateChecker` parses the new tag scheme incorrectly) and should be updated manually from +the GitHub releases page. + +## What `release.sh` checks + +`release.sh` composes the GitHub release notes from the relevant changelog section(s) above and +checks them in its preconditions — before anything is downloaded and long before the irreversible +Maven Central deploy (lib only). A module whose heading still reads `-SNAPSHOT` has no section for +the version being released, and the run aborts with: ERROR: gui/release.md has no section for 0.1.1 — bump its heading from -SNAPSHOT before releasing -Push the release commit and wait for its CI run to go green before running `./release.sh`; -the script releases the artifacts built by the run for `HEAD`, so a later commit means a -later CI run and a re-check. +For a gui release, it also checks that `gui/MarkdownToPdf.xml`'s dependency version matches +`gui/pom.xml`'s version, and dies with a clear message before doing anything else if they've +drifted. +Push the release commit and wait for its CI run to go green before running `./release.sh lib [--skip-deploy]` +or `./release.sh gui`, as appropriate; the script releases the artifacts built by the run for +`HEAD`, so a later commit means a later CI run and a re-check. From 84f457541812bb4661c3859190191341d6e7b404 Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 18:10:01 +0200 Subject: [PATCH 10/25] docs: note that GitHub's repo-wide Latest badge can point at a lib-only release --- README.md | 4 ++++ gui/readme.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 1254cff..015ea8c 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ The installer checks for these and reports what is missing, but cannot install t | `md2pdf--windows-x64.zip` | unzip, then double-click `md2pdf-install.cmd` | ~100 MB | | `md2pdf--no-jdk.zip` | unzip, then `java --enable-native-access=javafx.graphics,javafx.web,javafx.media -jar MarkdownToPdf.jar` (needs a JavaFX-bundled JDK 25+) | ~15 MB | +GitHub's repo-sidebar "Latest" badge is repo-wide and can point at a `lib`-only release (which +ships no application) when one is newer than the latest `gui` release. Download the newest +release tagged `MarkdownToPdf-v*` specifically, not whatever the sidebar highlights. + Verify a download against `SHA256SUMS` from the same release: Linux: sha256sum -c SHA256SUMS diff --git a/gui/readme.md b/gui/readme.md index e89636d..0291a0c 100644 --- a/gui/readme.md +++ b/gui/readme.md @@ -45,6 +45,10 @@ The installer checks for these and reports what is missing, but cannot install t | `md2pdf--windows-x64.zip` | unzip, then double-click `md2pdf-install.cmd` | | `md2pdf--no-jdk.zip` | unzip, then `java --enable-native-access=javafx.graphics,javafx.web,javafx.media -jar MarkdownToPdf.jar` | +GitHub's repo-sidebar "Latest" badge is repo-wide and can point at a `lib`-only release (which +ships no application) when one is newer than the latest `gui` release. Download the newest +release tagged `MarkdownToPdf-v*` specifically, not whatever the sidebar highlights. + The installer will: 1. Copy the application to the standard location: `~/.local/share/MarkdownToPdf` on Linux, From ded86c68da27ef11c71ad695a268bb469ab6714b Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 18:11:48 +0200 Subject: [PATCH 11/25] docs: split the recovery section into lib and gui blocks --- gui/readme.md | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/gui/readme.md b/gui/readme.md index 0291a0c..e78f91e 100644 --- a/gui/readme.md +++ b/gui/readme.md @@ -122,18 +122,37 @@ Double-click the `MarkdownToPdf` shortcut on the Desktop, or run: ## Recovery after a partial release -`release.sh` publishes to Maven Central in step 6, and that step cannot be undone or -repeated. Steps 7 and 8 — the tag and the GitHub release — are both reversible. +Both `./release.sh lib` and `./release.sh gui` can fail after the tag is pushed but before the +GitHub release is created (step 8) — a `gh` auth expiry, a network drop, or an asset upload error +partway through several ~100 MB zips all leave the tag pushed with no release to show for it. +Re-running the same command then dies in its own preconditions, since the tag now exists both +locally and on the remote. -If a release fails after the deploy: +### lib - git push --delete origin v - git tag -d v - gh release delete v --yes # only if a partial release was created - ./release.sh --skip-deploy +`./release.sh lib` additionally publishes to Maven Central in step 6, and that step cannot be +undone or repeated — steps 7 and 8 (the tag and the GitHub release) are both reversible on their +own. -`--skip-deploy` re-downloads the same CI artifacts and resumes from the tag. It works -from a clean checkout: nothing in steps 3-5 is built locally. +If a lib release fails after the deploy: + + git push --delete origin md2pdf-v + git tag -d md2pdf-v + gh release delete md2pdf-v --yes # only if a partial release was created + ./release.sh lib --skip-deploy + +`--skip-deploy` re-downloads the same CI artifacts and resumes from the tag. It works from a +clean checkout: nothing in steps 3-5 is built locally. + +### gui + +gui has no irreversible step — every stage is safe to redo, so recovery is just +delete-and-re-run, every time: + + git push --delete origin MarkdownToPdf-v + git tag -d MarkdownToPdf-v + gh release delete MarkdownToPdf-v --yes # only if a partial release was created + ./release.sh gui ## Style Profiles From 7cf2f7978964e2a0904110fa4d8bb6e4d99414a8 Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 18:16:48 +0200 Subject: [PATCH 12/25] docs: finalize versioning-decouple spec (6 review rounds) and add implementation plan The design spec went through six rounds of external review after the initial commit, fixing mechanism bugs, doc gaps, test gaps, CLI grammar, and recovery docs. This commits the final spec text alongside the implementation plan that the ten preceding commits on this branch execute task-by-task. --- ...ouple-lib-gui-versioning-implementation.md | 1535 +++++++++++++++++ ...8-16-decouple-lib-gui-versioning-design.md | 435 ++++- 2 files changed, 1934 insertions(+), 36 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-16-decouple-lib-gui-versioning-implementation.md diff --git a/docs/superpowers/plans/2026-08-16-decouple-lib-gui-versioning-implementation.md b/docs/superpowers/plans/2026-08-16-decouple-lib-gui-versioning-implementation.md new file mode 100644 index 0000000..35ca8da --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-decouple-lib-gui-versioning-implementation.md @@ -0,0 +1,1535 @@ +# Decouple `lib` and `gui` versioning — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let `lib` (`md2pdf`) and `gui` (`MarkdownToPdf`) release independently, on independent version numbers, with `gui` releases never touching Maven Central. + +**Architecture:** `gui/pom.xml` declares its own literal `` while its `${revision}` reference stays textually untouched (this exact combination is what keeps Maven's `relativePath` matching working — verified empirically, see spec). `release.sh` becomes a two-branch script (`lib` / `gui`) instead of one flow. `UpdateChecker` is rewritten to filter GitHub's release list by tag prefix instead of trusting the repo-wide `releases/latest` endpoint. Every script/workflow step that read the shared `revision` property to name or locate a `gui` artifact is repointed at `-Dexpression=project.version -pl gui`. + +**Tech Stack:** Java 25 (gui) / Java 21 (lib), Maven multi-module reactor with `flatten-maven-plugin` (`resolveCiFriendliesOnly`), Bash release/build scripts, GitHub Actions, JUnit 5. + +**Spec:** `docs/superpowers/specs/2026-08-16-decouple-lib-gui-versioning-design.md` — six rounds of review, all mechanisms in this plan verified empirically against that spec. Read it alongside this plan; the "why" for every step below lives there. + +## Global Constraints + +- Zero new Maven dependencies in the `gui` model layer or `lib` core (CLAUDE.md). +- `gui/pom.xml`'s `` element must stay the literal, untouched text `${revision}` — never a computed/overridden value or a literal version number. Changing this breaks `relativePath` matching (verified: `mvn install` fails with `Non-resolvable parent POM ... 'parent.relativePath' points at wrong local POM`). +- `gui` must never gain a Maven Central deploy step. `central-publishing-maven-plugin`'s `skipPublishing=true` in `gui/pom.xml` stays as-is. +- Every place that currently evaluates `-Dexpression=revision` to name or locate a **gui** artifact must switch to `-Dexpression=project.version` scoped `-pl gui` — scoping alone is not enough once gui stops overriding `revision`. +- All existing CI gates (`mvn verify`, `shellcheck`, `spotless:check`, `spotbugs:check`) must keep passing after every task. +- Run `mvn spotless:apply` before `mvn verify` on any touched Java file; never run `spotless:apply` and `verify` in the same command (CLAUDE.md). + +--- + +### Task 1: `gui` declares its own literal `` + +**Files:** +- Modify: `pom.xml:25` (stale comment) +- Modify: `gui/pom.xml:11-13` (add gui's own ``), `gui/pom.xml:190` (antrun `Implementation-Version` entry) + +**Interfaces:** +- Produces: gui's own resolvable version (`0.2.0` initially — same value as lib's current `revision`, since this task introduces the *mechanism*, not a version bump; the two diverge at the next gui-only release). Every later task that reads gui's version via `-Dexpression=project.version -pl gui` depends on this. + +- [ ] **Step 1: Fix the stale comment in the root POM** + +`pom.xml:25` currently reads: +```xml + + 0.2.0 +``` +This is now only true for a `lib` release. Change it to: +```xml + + 0.2.0 +``` + +- [ ] **Step 2: Add gui's own literal ``, leaving `` untouched** + +`gui/pom.xml:7-13` currently reads: +```xml + + se.alipsa + md2pdf-parent + ${revision} + + MarkdownToPdf + jar +``` +Change to: +```xml + + se.alipsa + md2pdf-parent + ${revision} + + MarkdownToPdf + + 0.2.0 + jar +``` + +Do **not** touch the `${revision}` property at `gui/pom.xml:24` — it already tracks lib's actual version automatically and needs no change. + +- [ ] **Step 3: Fix the antrun-generated `Implementation-Version`** + +`gui/pom.xml:189-190` currently reads: +```xml + + +``` +Change the entry to: +```xml + + +``` +This is not cosmetic: `MarkdownToPdf.java`'s `readCurrentVersion()` reads this same key from the bundled `MarkdownToPdf.properties` and feeds it to both the About dialog and `UpdateChecker`'s comparison baseline. Left as `${revision}`, the running app would report itself as lib's version instead of its own the moment the two diverge — while `maven-jar-plugin`'s own manifest entry (`gui/pom.xml:234`, unchanged) already correctly uses `${pom.version}`. Matching them here closes that gap. + +- [ ] **Step 4: Build and verify the mechanism** + +Run: +```bash +mvn -q install -DskipTests +``` +Expected: `BUILD SUCCESS` (no output on success since `-q`; check exit code with `echo $?` → `0`). + +Run: +```bash +mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout +``` +Expected output: `0.2.0` (lib's version, unchanged). + +Run: +```bash +mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout +``` +Expected output: `0.2.0` (gui's own version, now explicit rather than inherited — same value today, but resolved via gui's own `` element, not via the parent). + +Run: +```bash +grep -A2 "" ~/.m2/repository/se/alipsa/MarkdownToPdf/0.2.0/MarkdownToPdf-0.2.0.pom +``` +Expected: shows `...0.2.0` — the installed, flattened gui POM's parent reference resolves correctly, and its own coordinate (the file's version-numbered path, `0.2.0`) confirms gui's own `` resolved too. No `${revision}` left unresolved anywhere in the file. + +- [ ] **Step 5: Commit** + +```bash +git add pom.xml gui/pom.xml +git commit -m "gui: declare its own literal , independent of lib's revision" +``` + +--- + +### Task 2: `gui/MarkdownToPdf.xml` points directly at gui's own version + +**Files:** +- Modify: `gui/MarkdownToPdf.xml:35-37` + +**Interfaces:** +- Consumes: gui's own version from Task 1 (`0.2.0`). +- Produces: a second, independent place gui's version is written — `release.sh`'s new precondition (Task 5) checks this file's dependency version against `gui/pom.xml`'s. + +- [ ] **Step 1: Point the dependency version at gui's own literal version, not the parent's** + +`gui/MarkdownToPdf.xml:33-37` currently reads: +```xml + + se.alipsa + MarkdownToPdf + ${project.version} + +``` +`${project.version}` here resolves through this file's own `` inheritance (`gui/MarkdownToPdf.xml:22-27`, unchanged, still `${revision}`) — i.e. lib's version — while the gui artifact it depends on is now installed under gui's own literal version. Change to: +```xml + + se.alipsa + MarkdownToPdf + + 0.2.0 + +``` + +- [ ] **Step 2: Verify the dependency resolves** + +Run (requires Task 1's `mvn install` to already have installed `se.alipsa:MarkdownToPdf:0.2.0`): +```bash +mvn -q -f gui/MarkdownToPdf.xml org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout +``` +Expected output: `0.2.0` (this is the *launcher* POM's own version, inherited from the parent — confirms the file's model still builds correctly after the edit). + +Run: +```bash +mvn -q -f gui/MarkdownToPdf.xml dependency:resolve +``` +Expected: exits `0` with no `Could not resolve dependencies` error — confirms `se.alipsa:MarkdownToPdf:0.2.0` resolves from the local repo (installed by Task 1). + +- [ ] **Step 3: Commit** + +```bash +git add gui/MarkdownToPdf.xml +git commit -m "gui/MarkdownToPdf.xml: point the launcher dependency at gui's own version" +``` + +--- + +### Task 3: `CLAUDE.md` rewrite + +**Files:** +- Modify: `CLAUDE.md:43`, `CLAUDE.md:84` + +**Interfaces:** +- Consumes: the mechanism from Tasks 1-2 (this task only rewrites prose to describe it accurately). + +- [ ] **Step 1: Fix the now-half-true multi-module description** + +`CLAUDE.md:43` currently reads: +``` +This is a Maven multi-module project with `${revision}` CI-friendly versioning (resolved by flatten-maven-plugin). +``` +Change to: +``` +This is a Maven multi-module project. `lib` uses `${revision}` CI-friendly versioning (resolved by flatten-maven-plugin); `gui` has its own independent ``, see below. +``` + +- [ ] **Step 2: Rewrite all three sentences of the `${revision}` bullet** + +`CLAUDE.md:84` currently reads: +``` +- The `${revision}` property in the root POM controls the version for all modules. Bump it in `pom.xml` only; flatten-maven-plugin propagates it. `gui/MarkdownToPdf.xml` inherits the root parent, so its launcher dependency follows `${revision}` as well. +``` +All three sentences are wrong post-decoupling, not just the third. Change to: +``` +- The `${revision}` property in the root POM controls `lib`'s version (and the parent's). `gui` has its own independent `` in `gui/pom.xml`, bumped separately at gui release time. A gui release requires bumping **two** files in lockstep: `gui/pom.xml`'s `` and the dependency `` in `gui/MarkdownToPdf.xml` (`release.sh gui` checks they match). `gui/MarkdownToPdf.xml`'s own `` reference still follows `${revision}` (i.e. lib's version) since that file is unchanged by gui's version override — only its dependency on the built `MarkdownToPdf` artifact needs gui's version directly. +``` + +- [ ] **Step 3: Verify** + +Re-read `CLAUDE.md:40-46` and `CLAUDE.md:80-90` and confirm: no sentence still claims `revision` controls "all modules," no instruction says "bump `pom.xml` only" for a gui release, and the two-file lockstep is stated plainly. + +- [ ] **Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: describe gui's independent versioning in CLAUDE.md" +``` + +--- + +### Task 4: `UpdateChecker` learns the new tag scheme + +**Files:** +- Modify: `gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java` +- Test: `gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java` +- Test: `gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java` +- No change: `gui/src/main/java/se/alipsa/md2pdf/gui/update/GitHubReleaseJson.java`, `gui/src/test/java/test/alipsa/md2pdf/gui/update/GitHubReleaseJsonTest.java`, `gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java` — see Step 1's note on why. + +**Interfaces:** +- Consumes: `GitHubReleaseJson.extractBracketedRegion(String json, int openIndex)`, `GitHubReleaseJson.splitTopLevelObjects(String arrayBody)`, `GitHubReleaseJson.extractTagName(String json)`, `GitHubReleaseJson.extractHtmlUrl(String json)`, `GitHubReleaseJson.extractAssets(String json)` — all unchanged, all already public static. `VersionComparator.isNewer(String candidate, String current)` — unchanged. +- Produces: `UpdateChecker.parseAndEvaluate(String currentVersion, UpdatePlatform platform, String responseJson)` — same signature as before, but `responseJson` is now a **JSON array** (`GET /releases` shape), not a single release object. + +This is written test-first (TDD): the test files are rewritten to assert the *new* behavior first, confirmed failing against the *old* implementation, then `UpdateChecker.java` is rewritten to make them pass. + +- [ ] **Step 1: Rewrite `UpdateCheckerTest.java` to assert the new tag scheme and array shape** + +Design note: `GitHubReleaseJson`'s existing methods (`extractTagName`, `extractHtmlUrl`, `extractAssets`) operate on a **single** release JSON object and are reused unchanged, per-candidate, inside `UpdateChecker`'s new array-scanning logic — so `GitHubReleaseJsonTest.java` needs no changes; its fixtures already correctly exercise that single-release shape. The new array-splitting and prefix-filtering logic lives entirely in `UpdateChecker` (using `GitHubReleaseJson`'s already-public `extractBracketedRegion`/`splitTopLevelObjects` as building blocks), so its test coverage belongs in `UpdateCheckerTest`, which already exercises `UpdateChecker.parseAndEvaluate` directly with hand-built JSON. + +Replace the full contents of `gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java` with: + +```java +package test.alipsa.md2pdf.gui.update; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import se.alipsa.md2pdf.gui.update.UpdateChecker; +import se.alipsa.md2pdf.gui.update.UpdateInfo; +import se.alipsa.md2pdf.gui.update.UpdatePlatform; + +public class UpdateCheckerTest { + + private static String releaseJson(String tag, String... assetLines) { + StringBuilder assets = new StringBuilder(); + for (int i = 0; i < assetLines.length; i++) { + if (i > 0) { + assets.append(','); + } + assets.append(assetLines[i]); + } + return """ + { + "tag_name": "%s", + "html_url": "https://github.com/Alipsa/MarkdownToPdf/releases/tag/%s", + "assets": [%s] + } + """ + .formatted(tag, tag, assets); + } + + private static String asset(String name, String url) { + return """ + {"name": "%s", "browser_download_url": "%s"} + """ + .formatted(name, url) + .strip(); + } + + // GET /releases returns a top-level array, not a single release — releases/latest is + // repo-wide and would let an unrelated lib release (tagged md2pdf-v*) shadow the actual + // latest gui release, or return no platform zips at all. + private static String releasesArray(String... releaseJsons) { + return "[" + String.join(",", releaseJsons) + "]"; + } + + @Test + void updateAvailableWithMatchingAssetIsReturned() { + String release = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip"), + asset("SHA256SUMS", "https://example.com/SHA256SUMS")); + + Optional result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertTrue(result.isPresent()); + UpdateInfo info = result.get(); + assertEquals("0.1.2", info.latestVersion()); + assertEquals("MarkdownToPdf-v0.1.2", info.tagName()); + assertEquals("md2pdf-0.1.2-linux-x64.zip", info.assetName()); + assertEquals("https://example.com/md2pdf-0.1.2-linux-x64.zip", info.downloadUrl()); + assertEquals("https://example.com/SHA256SUMS", info.checksumsUrl()); + assertEquals( + "https://github.com/Alipsa/MarkdownToPdf/releases/tag/MarkdownToPdf-v0.1.2", + info.releaseHtmlUrl()); + } + + @Test + void alreadyLatestVersionReturnsEmpty() { + String release = + releaseJson( + "MarkdownToPdf-v0.1.1", + asset("md2pdf-0.1.1-linux-x64.zip", "https://example.com/md2pdf-0.1.1-linux-x64.zip"), + asset("SHA256SUMS", "https://example.com/SHA256SUMS")); + + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) + .isEmpty()); + } + + @Test + void updateAvailableButNoAssetForThisPlatformReturnsEmpty() { + String release = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset( + "md2pdf-0.1.2-macos-aarch64.zip", + "https://example.com/md2pdf-0.1.2-macos-aarch64.zip"), + asset("SHA256SUMS", "https://example.com/SHA256SUMS")); + + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) + .isEmpty()); + } + + @Test + void missingChecksumsAssetStillReturnsUpdate() { + String release = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); + + Optional result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertTrue(result.isPresent()); + assertNull(result.get().checksumsUrl()); + } + + @Test + void missingHtmlUrlReturnsEmpty() { + String release = + """ + { + "tag_name": "MarkdownToPdf-v0.1.2", + "assets": [%s] + } + """ + .formatted( + asset( + "md2pdf-0.1.2-linux-x64.zip", + "https://example.com/md2pdf-0.1.2-linux-x64.zip")); + + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) + .isEmpty()); + } + + @Test + void authorProfileShapedHtmlUrlReturnsEmpty() { + String release = + """ + { + "tag_name": "MarkdownToPdf-v0.1.2", + "html_url": "https://github.com/someuser", + "assets": [%s] + } + """ + .formatted( + asset( + "md2pdf-0.1.2-linux-x64.zip", + "https://example.com/md2pdf-0.1.2-linux-x64.zip")); + + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) + .isEmpty()); + } + + @Test + void unsupportedPlatformReturnsEmpty() { + String release = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip"), + asset("SHA256SUMS", "https://example.com/SHA256SUMS")); + + assertTrue( + UpdateChecker.parseAndEvaluate( + "0.1.1", UpdatePlatform.UNSUPPORTED, releasesArray(release)) + .isEmpty()); + } + + @Test + void libReleaseInTheArrayIsIgnored() { + // A newer lib release (md2pdf-v*) must never shadow the actual latest gui release. + String libRelease = + releaseJson( + "md2pdf-v9.9.9", + asset("md2pdf-9.9.9-sources.jar", "https://example.com/md2pdf-9.9.9-sources.jar")); + String guiRelease = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); + + Optional result = + UpdateChecker.parseAndEvaluate( + "0.1.1", UpdatePlatform.LINUX_X64, releasesArray(libRelease, guiRelease)); + + assertTrue(result.isPresent()); + assertEquals("0.1.2", result.get().latestVersion()); + } + + @Test + void picksHighestVersionAmongMatchingPrefixRegardlessOfArrayOrder() { + // GitHub sorts /releases by the tagged commit's date, not publish time, so a gui release + // cut from an older commit is not guaranteed to sort above a newer one — "first match" is + // not a safe selection rule. The older release is placed first here on purpose. + String olderGuiRelease = + releaseJson( + "MarkdownToPdf-v0.1.1", + asset("md2pdf-0.1.1-linux-x64.zip", "https://example.com/md2pdf-0.1.1-linux-x64.zip")); + String newerGuiRelease = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); + + Optional result = + UpdateChecker.parseAndEvaluate( + "0.1.0", UpdatePlatform.LINUX_X64, releasesArray(olderGuiRelease, newerGuiRelease)); + + assertTrue(result.isPresent()); + assertEquals("0.1.2", result.get().latestVersion()); + } + + @Test + void noMatchingPrefixInArrayReturnsEmpty() { + String libRelease = + releaseJson( + "md2pdf-v9.9.9", + asset("md2pdf-9.9.9-sources.jar", "https://example.com/md2pdf-9.9.9-sources.jar")); + + assertTrue( + UpdateChecker.parseAndEvaluate("0.1.0", UpdatePlatform.LINUX_X64, releasesArray(libRelease)) + .isEmpty()); + } +} +``` + +- [ ] **Step 2: Rewrite `UpdateCheckerHttpTest.java`'s stubbed path and fixture** + +Replace the full contents of `gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java` with: + +```java +package test.alipsa.md2pdf.gui.update; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; +import se.alipsa.md2pdf.gui.update.UpdateCheckException; +import se.alipsa.md2pdf.gui.update.UpdateChecker; +import se.alipsa.md2pdf.gui.update.UpdateInfo; +import se.alipsa.md2pdf.gui.update.UpdatePlatform; + +/** + * Exercises {@link UpdateChecker#checkForUpdate(String)} end-to-end against a real local HTTP + * server (JDK-bundled {@code com.sun.net.httpserver}, so this adds no dependency) via the {@link + * UpdateChecker#API_URL_PROPERTY} override seam — the same seam intended for manual QA against a + * fixture server. + * + *

{@code @Isolated}: this test mutates the {@code md2pdf.update.apiUrl} system property, which + * is global JVM state. Harmless with this project's default sequential test execution, but + * isolating it keeps that true even if parallel execution is ever enabled. + */ +@Isolated +public class UpdateCheckerHttpTest { + + private HttpServer server; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.start(); + // HttpServer context matching is path-only; the query string does not need to be part + // of the registered context path below. + System.setProperty( + UpdateChecker.API_URL_PROPERTY, + "http://127.0.0.1:" + server.getAddress().getPort() + "/releases?per_page=100"); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + System.clearProperty(UpdateChecker.API_URL_PROPERTY); + } + + private void respond(int status, String body) { + server.createContext( + "/releases", + exchange -> { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + // sendResponseHeaders' responseLength contract: 0 means chunked with unspecified + // length, -1 means no response body at all. A genuinely empty body must send -1, not + // 0, or the client is left waiting on a chunked stream that never starts. + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + if (bytes.length > 0) { + os.write(bytes); + } + } + }); + } + + @Test + void non200StatusThrowsUpdateCheckException() { + respond(500, "boom"); + UpdateChecker checker = new UpdateChecker(); + assertThrows(UpdateCheckException.class, () -> checker.checkForUpdate("0.1.0")); + } + + @Test + void emptyBodyReturnsEmptyWithoutThrowing() throws UpdateCheckException { + respond(200, ""); + UpdateChecker checker = new UpdateChecker(); + assertTrue(checker.checkForUpdate("0.1.0").isEmpty()); + } + + @Test + void malformedJsonReturnsEmptyWithoutThrowing() throws UpdateCheckException { + respond(200, "{not json at all"); + UpdateChecker checker = new UpdateChecker(); + assertTrue(checker.checkForUpdate("0.1.0").isEmpty()); + } + + @Test + void wellFormedNewerReleaseIsReturned() throws UpdateCheckException { + UpdatePlatform platform = UpdatePlatform.detectCurrent(); + String assetName = "md2pdf-99.0.0" + platform.assetSuffix(); + respond( + 200, + """ + [ + { + "tag_name": "MarkdownToPdf-v99.0.0", + "html_url": "https://github.com/Alipsa/MarkdownToPdf/releases/tag/MarkdownToPdf-v99.0.0", + "assets": [ + {"name": "%s", "browser_download_url": "https://example.com/%s"} + ] + } + ] + """ + .formatted(assetName, assetName)); + + Optional result = new UpdateChecker().checkForUpdate("0.1.0"); + + // Deterministic on every platform CI runs on: UNSUPPORTED never matches (no asset suffix to + // build a real file name from), every supported platform matches the asset built above. + assertEquals(platform != UpdatePlatform.UNSUPPORTED, result.isPresent()); + } +} +``` + +- [ ] **Step 3: Run the tests and confirm they fail against the old implementation** + +Run: +```bash +mvn -q -pl gui test -Dtest=UpdateCheckerTest,UpdateCheckerHttpTest 2>&1 | tail -60 +``` +Expected: multiple failures — e.g. `updateAvailableWithMatchingAssetIsReturned` fails because `parseAndEvaluate` still expects a single-object response and `extractTagName` on a top-level array (`[` first char) will not find `"tag_name"` before the first `"assets"` the way the old code assumes, and the old `tagName.startsWith("v")` strip logic mishandles `MarkdownToPdf-v...`. Confirm the failures are the *expected* new-behavior mismatches, not compile errors. + +- [ ] **Step 4: Rewrite `UpdateChecker.java`** + +Replace the full contents of `gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java` with: + +```java +package se.alipsa.md2pdf.gui.update; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Checks GitHub Releases for a MarkdownToPdf version newer than the one currently running. Uses + * only {@link HttpClient} and {@link GitHubReleaseJson}'s hand-written field extraction — no JSON + * or HTTP library dependency is added, per CLAUDE.md's zero-new-dependency constraint for the + * {@code gui} module. + */ +public class UpdateChecker { + + /** Creates an update checker. */ + public UpdateChecker() {} + + /** System property that overrides the GitHub API URL, for manual QA against a local fixture. */ + public static final String API_URL_PROPERTY = "md2pdf.update.apiUrl"; + + /** + * gui releases are tagged {@code MarkdownToPdf-v}; lib releases share the same + * repository and are tagged {@code md2pdf-v}. {@code releases/latest} is repo-wide, + * so a lib-only release published after a gui release would shadow it and ship no platform + * zips at all — the releases list must be filtered to this prefix instead. + */ + private static final String TAG_PREFIX = "MarkdownToPdf-v"; + + private static final String DEFAULT_API_URL = + "https://api.github.com/repos/Alipsa/MarkdownToPdf/releases?per_page=100"; + + private static final Logger LOGGER = LogManager.getLogger(UpdateChecker.class); + + /** + * Fetches recent GitHub releases and returns update info if the latest release tagged {@code + * MarkdownToPdf-v*} is newer than {@code currentVersion} and ships an asset for this platform. + * + * @param currentVersion the version currently running + * @return update information when a newer platform release is available + * @throws UpdateCheckException on any network, HTTP-status or interrupt failure + */ + public Optional checkForUpdate(String currentVersion) throws UpdateCheckException { + String apiUrl = System.getProperty(API_URL_PROPERTY, DEFAULT_API_URL); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(apiUrl)) + .header("Accept", "application/vnd.github+json") + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + HttpResponse response; + try (HttpClient httpClient = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()) { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException e) { + throw new UpdateCheckException("Failed to reach " + apiUrl, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new UpdateCheckException("Interrupted while checking for updates", e); + } + if (response.statusCode() != 200) { + throw new UpdateCheckException( + "GitHub returned HTTP " + response.statusCode() + " for " + apiUrl); + } + return parseAndEvaluate(currentVersion, UpdatePlatform.detectCurrent(), response.body()); + } + + /** + * Pure evaluation of a {@code GET /releases} JSON array response against the currently running + * version and platform. No network access — used directly by tests. + * + *

The response is a top-level array of releases, not a single release: {@code + * releases/latest} is repo-wide and would let an unrelated {@code lib} release (tagged {@code + * md2pdf-v*}) shadow the actual latest {@code gui} release, or return no platform zips at all. + * This scans every entry, keeps only tags starting with {@link #TAG_PREFIX}, and picks the + * highest version among matches via {@link VersionComparator} — not "the first match" — because + * GitHub sorts {@code /releases} by the tagged commit's date, not publish time, so a gui release + * cut from an older commit is not guaranteed to sort above a newer lib release. + * + * @param currentVersion the version currently running + * @param platform the platform whose release asset should be selected + * @param responseJson the {@code GET /releases} JSON array response + * @return update information when a newer matching release is available + */ + public static Optional parseAndEvaluate( + String currentVersion, UpdatePlatform platform, String responseJson) { + if (platform == UpdatePlatform.UNSUPPORTED) { + LOGGER.info("Skipping update check: no release archive for this platform."); + return Optional.empty(); + } + String releaseJson = selectLatestGuiRelease(responseJson); + if (releaseJson == null) { + LOGGER.warn("Skipping update check: no {}* release found in the fetched page.", TAG_PREFIX); + return Optional.empty(); + } + String tagName = GitHubReleaseJson.extractTagName(releaseJson); + String latestVersion = tagName.substring(TAG_PREFIX.length()); + if (!VersionComparator.isNewer(latestVersion, currentVersion)) { + LOGGER.info( + "No update available: latest release {} is not newer than the running {}.", + latestVersion, + currentVersion); + return Optional.empty(); + } + // extractHtmlUrl takes the first "html_url" before "assets", which is the release's own + // field only because it precedes the "assets" array in GitHub's current (but spec-unordered) + // response — nothing stops a future field reorder from handing back the uploader's profile + // URL instead. A release page URL always contains "/releases/"; a profile URL never does, so + // this converts a reorder from silently opening the wrong page into a skipped notification. + String htmlUrl = GitHubReleaseJson.extractHtmlUrl(releaseJson); + if (htmlUrl == null || !htmlUrl.contains("/releases/")) { + LOGGER.info( + "Skipping update check: release {} had no usable html_url ({}).", tagName, htmlUrl); + return Optional.empty(); + } + List assets = GitHubReleaseJson.extractAssets(releaseJson); + String expectedAssetName = "md2pdf-" + latestVersion + platform.assetSuffix(); + String downloadUrl = findAssetUrl(assets, expectedAssetName); + if (downloadUrl == null) { + LOGGER.info( + "Release {} is newer but ships no '{}' asset for this platform.", + tagName, + expectedAssetName); + return Optional.empty(); + } + String checksumsUrl = findAssetUrl(assets, "SHA256SUMS"); + return Optional.of( + new UpdateInfo( + latestVersion, tagName, expectedAssetName, downloadUrl, checksumsUrl, htmlUrl)); + } + + /** + * Scans a {@code GET /releases} JSON array and returns the JSON object of the release with the + * highest version among those tagged {@link #TAG_PREFIX}, or {@code null} if none match. + */ + private static String selectLatestGuiRelease(String responseJson) { + int openBracket = responseJson.indexOf('['); + if (openBracket < 0) { + return null; + } + String arrayBody = GitHubReleaseJson.extractBracketedRegion(responseJson, openBracket); + String bestJson = null; + String bestVersion = null; + for (String candidate : GitHubReleaseJson.splitTopLevelObjects(arrayBody)) { + String tagName = GitHubReleaseJson.extractTagName(candidate); + if (tagName == null || !tagName.startsWith(TAG_PREFIX)) { + continue; + } + String candidateVersion = tagName.substring(TAG_PREFIX.length()); + if (bestVersion == null || VersionComparator.isNewer(candidateVersion, bestVersion)) { + bestJson = candidate; + bestVersion = candidateVersion; + } + } + return bestJson; + } + + private static String findAssetUrl(List assets, String name) { + for (GitHubReleaseJson.Asset asset : assets) { + if (name.equals(asset.name())) { + return asset.browserDownloadUrl(); + } + } + return null; + } +} +``` + +- [ ] **Step 5: Run the tests and confirm they pass** + +Run: +```bash +mvn -q -pl gui test -Dtest=UpdateCheckerTest,UpdateCheckerHttpTest,GitHubReleaseJsonTest +``` +Expected: `BUILD SUCCESS`, all tests green, including the unmodified `GitHubReleaseJsonTest` (confirms the single-release extraction helpers still work unchanged). + +- [ ] **Step 6: Format and run the full gui test suite** + +```bash +mvn spotless:apply +mvn -pl gui verify +``` +Expected: `BUILD SUCCESS`. + +- [ ] **Step 7: Commit** + +```bash +git add gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java \ + gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java \ + gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java +git commit -m "UpdateChecker: filter releases by MarkdownToPdf-v tag prefix instead of releases/latest" +``` + +--- + +### Task 5: `release.sh` — module argument, `--skip-deploy` grammar, and both flows + +**Files:** +- Modify: `release.sh` (full rewrite) + +**Interfaces:** +- Consumes: gui's own version (Task 1, read via `-Dexpression=project.version -pl gui`), the `gui/MarkdownToPdf.xml` lockstep target (Task 2). +- Produces: the `./release.sh lib [--skip-deploy]` / `./release.sh gui` invocations that `docs/release-process.md` (Task 8) and `gui/readme.md` (Task 10) document. + +This script has no automated test harness — it drives real git tags, a real Maven Central deploy, and a real GitHub release. Verification here is: syntax/lint checks, actually running the argument-validation paths that `die` before touching git/gh/mvn state (safe, exercised below), and code-review tracing for the rest. + +- [ ] **Step 1: Replace `release.sh` in full** + +```bash +#!/usr/bin/env bash +# Drives a MarkdownToPdf release. lib and gui release independently, on independent +# version numbers; gui releases never touch Maven Central. +# +# ./release.sh lib [--skip-deploy] +# ./release.sh gui +# +# Builds nothing. Every release asset comes from the CI run for HEAD, so what ships is +# byte-identical to what was tested. Runs on Linux or macOS. +set -euo pipefail + +BASEDIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" > /dev/null 2>&1 && pwd )" +cd "$BASEDIR" + +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +step() { printf '\n=== %s\n' "$*"; } + +MODULE="${1:-}" +case "$MODULE" in + lib) + SKIP_DEPLOY=0 + case "${2:-}" in + "") ;; + --skip-deploy) SKIP_DEPLOY=1 ;; + *) die "unrecognized argument: ${2}. usage: ./release.sh lib [--skip-deploy]" ;; + esac + ;; + gui) + [ -z "${2:-}" ] \ + || die "gui has no Maven Central deploy step, so --skip-deploy does not apply. usage: ./release.sh gui" + ;; + "") + die "usage: ./release.sh lib [--skip-deploy] | ./release.sh gui" + ;; + *) + die "unrecognized module: $MODULE. usage: ./release.sh lib [--skip-deploy] | ./release.sh gui" + ;; +esac + +# ── 1. preconditions ──────────────────────────────────────────────── +step "Preconditions" + +# sha256sum is GNU coreutils and absent on macOS, which ships shasum. Resolved here +# rather than at the point of use so an unusable host fails before anything is +# downloaded — and long before the irreversible step. +if command -v sha256sum > /dev/null; then + sha256() { sha256sum "$@"; } +elif command -v shasum > /dev/null; then + sha256() { shasum -a 256 "$@"; } +else + die "need sha256sum or shasum" +fi + +command -v gh > /dev/null || die "gh is not installed" +command -v mvn > /dev/null || die "mvn is not installed" +gh auth status > /dev/null 2>&1 || die "gh is not authenticated" + +[ -z "$(git status --porcelain)" ] || die "working tree is not clean" +[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || die "not on main" + +if [ "$MODULE" = "lib" ]; then + VERSION="$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" + TAG="md2pdf-v$VERSION" +else + VERSION="$(mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout)" + TAG="MarkdownToPdf-v$VERSION" + # gui/MarkdownToPdf.xml duplicates gui's own version in a second, non-reactor file that + # nothing else builds, tests, or touches (only CLAUDE.md references it) — a stale value + # there is invisible until a developer runs `mvn -f gui/MarkdownToPdf.xml javafx:run`, + # possibly releases later. + LAUNCHER_VERSION="$(sed -n -e '/MarkdownToPdf<\/artifactId>/,/<\/dependency>/ s/.*\(.*\)<\/version>.*/\1/p' gui/MarkdownToPdf.xml)" + [ "$LAUNCHER_VERSION" = "$VERSION" ] \ + || die "gui/MarkdownToPdf.xml's dependency version ($LAUNCHER_VERSION) does not match gui/pom.xml's version ($VERSION) — bump both together before releasing" +fi +case "$VERSION" in *-SNAPSHOT) die "refusing to release a snapshot version: $VERSION" ;; esac +echo "Releasing $MODULE $VERSION" + +git fetch --tags --quiet +git rev-parse -q --verify "refs/tags/$TAG" > /dev/null && die "tag $TAG already exists locally" +git ls-remote --exit-code --tags origin "$TAG" > /dev/null 2>&1 && die "tag $TAG already exists on the remote" +git push --dry-run --quiet origin HEAD || die "git push would fail" + +if [ "$MODULE" = "lib" ]; then + # The POM, not the directory: a directory listing can 200 on a partially-populated or + # stale path, and only the POM's presence means the version is actually published. + CENTRAL="https://repo1.maven.org/maven2/se/alipsa/md2pdf/$VERSION/md2pdf-$VERSION.pom" + if curl -sfI "$CENTRAL" > /dev/null; then + [ "$SKIP_DEPLOY" -eq 1 ] \ + || die "$VERSION is already on Maven Central. Maven Central cannot be overwritten; re-run with './release.sh lib --skip-deploy' to finish the rest of the release." + fi +fi + +# ── 1b. release notes ─────────────────────────────────────────────── +# Composed here rather than at step 8: a module whose changelog still heads its section +# -SNAPSHOT has nothing to say about $VERSION, and that has to stop the release before the +# irreversible deploy — not surface as a published release page with a section missing. +step "Composing release notes" + +# All three changelogs head every version section "## ", optionally followed by a +# date, so a section runs from that heading to the next "## " one. Only "## " closes a +# section: a deeper "### " subheading inside one is part of it and is kept. Leading and +# trailing blank lines are dropped so the composed file has no ragged gaps between sections. +extract_section() { + awk -v ver="$2" ' + /^## / { + if (started) { exit } + heading = $0 + sub(/^##[[:space:]]+/, "", heading) + if (heading == ver || index(heading, ver " ") == 1) { started = 1 } + next + } + started { + if (NF == 0) { if (printed) pending++; next } + while (pending > 0) { print ""; pending-- } + print; printed = 1 + } + ' "$1" +} + +# Outside $STAGING on purpose: step 3 empties that directory, and step 8 uploads every file +# in it as a release asset. .release-staging itself is gitignored. +NOTES="$BASEDIR/.release-staging/release-notes-$VERSION.md" +mkdir -p "$(dirname "$NOTES")" +: > "$NOTES" + +add_section() { + local title="$1" file="$2" body + body="$(extract_section "$file" "$VERSION")" + [ -n "$body" ] \ + || die "$file has no section for $VERSION — bump its heading from -SNAPSHOT before releasing" + printf '## %s\n\n%s\n\n' "$title" "$body" >> "$NOTES" +} +if [ "$MODULE" = "lib" ]; then + add_section "md2pdf-parent — build and release tooling" release.md + add_section "md2pdf — library" lib/release.md +else + add_section "MarkdownToPdf — desktop application" gui/release.md +fi +cat "$NOTES" + +# ── 2. wait for CI ────────────────────────────────────────────────── +step "Waiting for CI" +SHA="$(git rev-parse HEAD)" +RUN_ID="$(gh run list --commit "$SHA" --workflow ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')" +[ -n "$RUN_ID" ] || die "no CI run for $SHA — push the commit and wait for CI to start" +# The whole run, not just the build jobs: verify, lint-scripts and install-failures are +# separate gates, and releasing past a failing test defeats the point of running it. +gh run watch "$RUN_ID" --exit-status || die "CI run $RUN_ID did not succeed" + +# ── 3. download the assets ────────────────────────────────────────── +step "Downloading release assets" +# Keep staging outside target/: the release deploy includes `clean`, and -am brings the +# aggregator parent into the reactor, so Maven clean removes every module's target tree. +# This directory is ignored so a failed post-deploy recovery does not dirty the checkout. +STAGING="$BASEDIR/.release-staging/release-$VERSION" +# Emptied, not reused: the --skip-deploy recovery re-runs this step over a directory a +# previous attempt already populated, and a stale file here would ship unhashed under a +# SHA256SUMS that appears to account for it. +rm -rf "$STAGING" +mkdir -p "$STAGING" + +if [ "$MODULE" = "lib" ]; then + ASSETS=( + "md2pdf-$VERSION-sources.jar" + "md2pdf-$VERSION-javadoc.jar" + ) +else + ASSETS=( + "md2pdf-$VERSION-linux-x64.zip" + "md2pdf-$VERSION-macos-aarch64.zip" + "md2pdf-$VERSION-windows-x64.zip" + "md2pdf-$VERSION-no-jdk.zip" + ) +fi + +# One call per artifact. gh run download extracts multiple artifacts into separate +# subdirectories and only flattens when a single artifact is named — and the artifact +# name is the file's basename, so this loop is just the list above. +for asset in "${ASSETS[@]}"; do + name="${asset%.*}" + echo " $asset" + gh run download "$RUN_ID" -n "$name" -D "$STAGING" || die "could not download artifact $name" +done + +# ── 4. sanity-check ───────────────────────────────────────────────── +step "Checking the staged assets" +if [ "$MODULE" = "lib" ]; then + EXPECTED_COUNT=2 +else + EXPECTED_COUNT=4 +fi +count="$(find "$STAGING" -maxdepth 1 -type f | wc -l | tr -d ' ')" +[ "$count" -eq "$EXPECTED_COUNT" ] || die "expected exactly $EXPECTED_COUNT files in $STAGING, found $count" +# Per-asset floors, because the assets differ by three orders of magnitude: a platform zip +# carries a ~100 MB runtime, the no-jdk zip is ~15 MB, and the javadoc/sources jars are each +# tens of KB. A single 1 MB floor would abort every release on the small jars. +asset_floor() { + case "$1" in + *-linux-x64.zip|*-macos-aarch64.zip|*-windows-x64.zip) echo 40000000 ;; # 40 MB + *-no-jdk.zip) echo 5000000 ;; # 5 MB + *-javadoc.jar|*-sources.jar) echo 10000 ;; # 10 KB + *) echo 1 ;; + esac +} +for asset in "${ASSETS[@]}"; do + f="$STAGING/$asset" + [ -f "$f" ] || die "missing: $asset" + size="$(wc -c < "$f" | tr -d ' ')" + floor="$(asset_floor "$asset")" + [ "$size" -gt "$floor" ] || die "$asset is only $size bytes (expected more than $floor)" +done +if [ "$MODULE" = "gui" ]; then + for label in linux-x64 macos-aarch64 windows-x64; do + unzip -l "$STAGING/md2pdf-$VERSION-$label.zip" | grep -F 'MarkdownToPdf' > /dev/null \ + || die "md2pdf-$VERSION-$label.zip has no MarkdownToPdf entry" + done + unzip -l "$STAGING/md2pdf-$VERSION-no-jdk.zip" | grep -F 'MarkdownToPdf.jar' > /dev/null \ + || die "the no-jdk zip has no MarkdownToPdf.jar" +fi +echo " $EXPECTED_COUNT assets OK" + +# ── 5. checksums ──────────────────────────────────────────────────── +step "Generating SHA256SUMS" +# Hashed by name from the staging directory so the file records bare basenames: a user +# who downloads the assets and SHA256SUMS into one directory can then run +# `sha256sum -c SHA256SUMS` with nothing else. +( cd "$STAGING" && sha256 "${ASSETS[@]}" > SHA256SUMS ) +cat "$STAGING/SHA256SUMS" + +# ── 6. Maven Central — the point of no return ─────────────────────── +if [ "$MODULE" = "gui" ]; then + step "No Maven Central deploy for gui — this is the entire point" +elif [ "$SKIP_DEPLOY" -eq 1 ]; then + step "Skipping the Maven Central deploy (--skip-deploy)" +else + step "Publishing lib to Maven Central" + # -pl lib -am, never a bare deploy: the release profile is on the aggregator parent and + # gui is a module, so an unqualified deploy would hand the GUI application to the + # central-publishing plugin. -am is required because the parent POM is itself a + # published artifact that lib resolves against. + mvn -Prelease -pl lib -am clean site deploy +fi + +# ── 7. tag ────────────────────────────────────────────────────────── +step "Tagging $TAG" +git tag -a "$TAG" -m "Release $VERSION" +git push origin "$TAG" + +# ── 8. GitHub release ─────────────────────────────────────────────── +step "Creating the GitHub release" +FINAL_COUNT=$((EXPECTED_COUNT + 1)) +count="$(find "$STAGING" -maxdepth 1 -type f | wc -l | tr -d ' ')" +[ "$count" -eq "$FINAL_COUNT" ] || die "expected exactly $FINAL_COUNT files in $STAGING, found $count" +if [ "$MODULE" = "lib" ]; then + TITLE="md2pdf $VERSION" +else + TITLE="MarkdownToPdf $VERSION" +fi +# gh release create takes filenames or globs, never a directory. +gh release create "$TAG" "$STAGING"/* \ + --title "$TITLE" \ + --notes-file "$NOTES" + +printf '\nReleased %s %s\n' "$MODULE" "$VERSION" +``` + +- [ ] **Step 2: Syntax and lint checks** + +```bash +bash -n release.sh +``` +Expected: no output, exit `0`. + +```bash +shellcheck release.sh +``` +Expected: no findings (this mirrors `ci.yml`'s `lint-scripts` job, which shellchecks this exact file — a failure here is a failure there). + +- [ ] **Step 3: Exercise the argument-validation paths that `die` before touching git/gh/mvn state** + +These are safe to run for real: the `case` block at the top of the script `die`s before the "Preconditions" step even begins, so none of these reach git status checks, `mvn`, or network calls. + +```bash +./release.sh; echo "exit=$?" +``` +Expected: `ERROR: usage: ./release.sh lib [--skip-deploy] | ./release.sh gui` on stderr, `exit=1`. + +```bash +./release.sh nonsense; echo "exit=$?" +``` +Expected: `ERROR: unrecognized module: nonsense. usage: ./release.sh lib [--skip-deploy] | ./release.sh gui`, `exit=1`. + +```bash +./release.sh gui --skip-deploy; echo "exit=$?" +``` +Expected: `ERROR: gui has no Maven Central deploy step, so --skip-deploy does not apply. usage: ./release.sh gui`, `exit=1`. + +```bash +./release.sh lib --bogus; echo "exit=$?" +``` +Expected: `ERROR: unrecognized argument: --bogus. usage: ./release.sh lib [--skip-deploy]`, `exit=1`. + +- [ ] **Step 4: Manual trace of the rest of the flow (cannot be safely executed — a real run tags, deploys, and opens a GitHub release)** + +Re-read the full script and confirm by inspection: +- `./release.sh lib` — tag `md2pdf-v$VERSION`, notes from `release.md` + `lib/release.md` only, assets are the two lib jars, Central deploy runs unless `--skip-deploy`, title `md2pdf $VERSION`. +- `./release.sh gui` — tag `MarkdownToPdf-v$VERSION`, notes from `gui/release.md` only, assets are the four platform zips, no Central deploy ever, title `MarkdownToPdf $VERSION`, and the `LAUNCHER_VERSION` precondition fires before anything else if `gui/MarkdownToPdf.xml`'s dependency version is out of sync with `gui/pom.xml`'s. + +- [ ] **Step 5: Commit** + +```bash +git add release.sh +git commit -m "release.sh: split lib/gui into independent release flows" +``` + +--- + +### Task 6: `install.sh` and `gui/buildAndRun.sh` read gui's own version + +**Files:** +- Modify: `install.sh:27` +- Modify: `gui/buildAndRun.sh:16` + +**Interfaces:** +- Consumes: gui's own version from Task 1. + +- [ ] **Step 1: Fix `install.sh`** + +`install.sh:27` currently reads: +```bash +VERSION="$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" +``` +Change to: +```bash +VERSION="$(mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout)" +``` +(`install.sh:30`'s `unzip -q "gui/target/md2pdf-$VERSION-$LABEL.zip" ...` needs no change — it already consumes `$VERSION` generically.) + +- [ ] **Step 2: Fix `gui/buildAndRun.sh`** + +`gui/buildAndRun.sh:16` currently reads: +```bash +VERSION="$(mvn -q org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=revision -DforceStdout)" +``` +Change to: +```bash +VERSION="$(mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout)" +``` + +- [ ] **Step 3: Syntax/lint checks** + +```bash +bash -n install.sh && bash -n gui/buildAndRun.sh +shellcheck install.sh gui/buildAndRun.sh +``` +Expected: no output beyond nothing, exit `0` for both. + +- [ ] **Step 4: Verify the evaluate call in isolation** + +```bash +mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout +``` +Expected output: `0.2.0` (matches Task 1's value; confirms both scripts now read the same value `gui/target/md2pdf--

Drafts and prereleases are skipped outright: GitHub's {@code draft}/{@code prerelease} flags + * mark a release as not a real, generally-available release, and offering one as an update would + * nag every user still on a genuinely released version. Candidates with an unparseable version + * (e.g. a malformed tag like {@code MarkdownToPdf-v0.4.0.RC1}) are skipped rather than allowed to + * seed {@code bestVersion}: {@link VersionComparator#isNewer} fails safe to {@code false} + * whenever either side fails to parse, so once an unparseable version became {@code bestVersion} + * every later, genuinely newer, well-formed candidate would also compare as "not newer" against + * it (because parsing {@code bestVersion} itself fails) and could never replace it — the + * malformed tag would win forever. */ private static String selectLatestGuiRelease(String responseJson) { - int openBracket = responseJson.indexOf('['); - if (openBracket < 0) { + String trimmed = responseJson.strip(); + if (!trimmed.startsWith("[")) { + LOGGER.warn( + "Response is not a JSON array (expected the GET /releases contract) — check the {} " + + "override if set.", + API_URL_PROPERTY); return null; } + int openBracket = responseJson.indexOf('['); String arrayBody = GitHubReleaseJson.extractBracketedRegion(responseJson, openBracket); String bestJson = null; String bestVersion = null; @@ -153,7 +170,14 @@ private static String selectLatestGuiRelease(String responseJson) { if (tagName == null || !tagName.startsWith(TAG_PREFIX)) { continue; } + if (GitHubReleaseJson.extractBooleanBeforeAssets(candidate, "draft") + || GitHubReleaseJson.extractBooleanBeforeAssets(candidate, "prerelease")) { + continue; + } String candidateVersion = tagName.substring(TAG_PREFIX.length()); + if (!VersionComparator.isParseable(candidateVersion)) { + continue; + } if (bestVersion == null || VersionComparator.isNewer(candidateVersion, bestVersion)) { bestJson = candidate; bestVersion = candidateVersion; diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java index c8054d1..9140917 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java @@ -36,6 +36,17 @@ public static boolean isNewer(String candidate, String current) { return currentHadSuffix && !candidateHadSuffix; } + /** + * Returns {@code true} if {@code version}'s dotted-numeric prefix can be parsed — i.e. {@link + * #isNewer} can meaningfully compare it against another version. + * + * @param version the version string to check + * @return {@code true} when {@code version} is parseable + */ + public static boolean isParseable(String version) { + return parse(version) != null; + } + private static int compareParts(int[] a, int[] b) { int length = Math.max(a.length, b.length); for (int i = 0; i < length; i++) { diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/GitHubReleaseJsonTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/GitHubReleaseJsonTest.java index b46f4a7..5b947d7 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/GitHubReleaseJsonTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/GitHubReleaseJsonTest.java @@ -74,4 +74,45 @@ void emptyAssetsArrayReturnsEmptyList() { String json = "{\"tag_name\": \"v0.1.2\", \"assets\": []}"; assertTrue(GitHubReleaseJson.extractAssets(json).isEmpty()); } + + @Test + void extractsBooleanFieldWhenTrue() { + String json = "{\"tag_name\": \"v0.1.2\", \"draft\": true, \"assets\": []}"; + assertTrue(GitHubReleaseJson.extractBooleanBeforeAssets(json, "draft")); + } + + @Test + void extractsBooleanFieldWhenFalse() { + String json = "{\"tag_name\": \"v0.1.2\", \"draft\": false, \"assets\": []}"; + assertFalse(GitHubReleaseJson.extractBooleanBeforeAssets(json, "draft")); + } + + @Test + void absentBooleanFieldDefaultsToFalseWithoutThrowing() { + String json = "{\"tag_name\": \"v0.1.2\", \"assets\": []}"; + assertFalse(GitHubReleaseJson.extractBooleanBeforeAssets(json, "draft")); + assertFalse(GitHubReleaseJson.extractBooleanBeforeAssets(json, "prerelease")); + } + + @Test + void booleanFieldNestedInsideAssetsIsNotConfusedWithReleaseLevelField() { + // The release itself has no "draft" field, but an asset's nested object does. That nested + // occurrence, which appears after "assets", must not be mistaken for the release's own flag. + String json = + """ + { + "tag_name": "v0.1.2", + "assets": [ + { + "name": "md2pdf-0.1.2-linux-x64.zip", + "uploader": { + "login": "someone", + "draft": true + } + } + ] + } + """; + assertFalse(GitHubReleaseJson.extractBooleanBeforeAssets(json, "draft")); + } } diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java index 7353474..7aca76a 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java @@ -7,14 +7,14 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; -import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Isolated; import se.alipsa.md2pdf.gui.update.UpdateCheckException; +import se.alipsa.md2pdf.gui.update.UpdateCheckOutcome; +import se.alipsa.md2pdf.gui.update.UpdateCheckResult; import se.alipsa.md2pdf.gui.update.UpdateChecker; -import se.alipsa.md2pdf.gui.update.UpdateInfo; import se.alipsa.md2pdf.gui.update.UpdatePlatform; /** @@ -76,17 +76,21 @@ void non200StatusThrowsUpdateCheckException() { } @Test - void emptyBodyReturnsEmptyWithoutThrowing() throws UpdateCheckException { + void emptyBodyIsIndeterminateWithoutThrowing() throws UpdateCheckException { respond(200, ""); UpdateChecker checker = new UpdateChecker(); - assertTrue(checker.checkForUpdate("0.1.0").isEmpty()); + UpdateCheckResult result = checker.checkForUpdate("0.1.0"); + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } @Test - void malformedJsonReturnsEmptyWithoutThrowing() throws UpdateCheckException { + void malformedJsonIsIndeterminateWithoutThrowing() throws UpdateCheckException { respond(200, "{not json at all"); UpdateChecker checker = new UpdateChecker(); - assertTrue(checker.checkForUpdate("0.1.0").isEmpty()); + UpdateCheckResult result = checker.checkForUpdate("0.1.0"); + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } @Test @@ -108,10 +112,15 @@ void wellFormedNewerReleaseIsReturned() throws UpdateCheckException { """ .formatted(assetName, assetName)); - Optional result = new UpdateChecker().checkForUpdate("0.1.0"); + UpdateCheckResult result = new UpdateChecker().checkForUpdate("0.1.0"); - // Deterministic on every platform CI runs on: UNSUPPORTED never matches (no asset suffix to - // build a real file name from), every supported platform matches the asset built above. - assertEquals(platform != UpdatePlatform.UNSUPPORTED, result.isPresent()); + // Deterministic on every platform CI runs on: UNSUPPORTED is INDETERMINATE (no asset suffix + // to build a real file name from), every supported platform matches the asset built above. + if (platform == UpdatePlatform.UNSUPPORTED) { + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + } else { + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertTrue(result.updateInfo().isPresent()); + } } } diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java index cd72712..ed81e91 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerTest.java @@ -2,15 +2,20 @@ import static org.junit.jupiter.api.Assertions.*; -import java.util.Optional; import org.junit.jupiter.api.Test; +import se.alipsa.md2pdf.gui.update.UpdateCheckOutcome; +import se.alipsa.md2pdf.gui.update.UpdateCheckResult; import se.alipsa.md2pdf.gui.update.UpdateChecker; -import se.alipsa.md2pdf.gui.update.UpdateInfo; import se.alipsa.md2pdf.gui.update.UpdatePlatform; public class UpdateCheckerTest { private static String releaseJson(String tag, String... assetLines) { + return releaseJson(tag, false, false, assetLines); + } + + private static String releaseJson( + String tag, boolean draft, boolean prerelease, String... assetLines) { StringBuilder assets = new StringBuilder(); for (int i = 0; i < assetLines.length; i++) { if (i > 0) { @@ -21,11 +26,13 @@ private static String releaseJson(String tag, String... assetLines) { return """ { "tag_name": "%s", + "draft": %s, + "prerelease": %s, "html_url": "https://github.com/Alipsa/MarkdownToPdf/releases/tag/%s", "assets": [%s] } """ - .formatted(tag, tag, assets); + .formatted(tag, draft, prerelease, tag, assets); } private static String asset(String name, String url) { @@ -51,11 +58,12 @@ void updateAvailableWithMatchingAssetIsReturned() { asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip"), asset("SHA256SUMS", "https://example.com/SHA256SUMS")); - Optional result = + UpdateCheckResult result = UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); - assertTrue(result.isPresent()); - UpdateInfo info = result.get(); + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertTrue(result.updateInfo().isPresent()); + var info = result.updateInfo().get(); assertEquals("0.1.2", info.latestVersion()); assertEquals("MarkdownToPdf-v0.1.2", info.tagName()); assertEquals("md2pdf-0.1.2-linux-x64.zip", info.assetName()); @@ -67,20 +75,22 @@ void updateAvailableWithMatchingAssetIsReturned() { } @Test - void alreadyLatestVersionReturnsEmpty() { + void alreadyLatestVersionIsUpToDate() { String release = releaseJson( "MarkdownToPdf-v0.1.1", asset("md2pdf-0.1.1-linux-x64.zip", "https://example.com/md2pdf-0.1.1-linux-x64.zip"), asset("SHA256SUMS", "https://example.com/SHA256SUMS")); - assertTrue( - UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) - .isEmpty()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.UP_TO_DATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } @Test - void updateAvailableButNoAssetForThisPlatformReturnsEmpty() { + void updateAvailableButNoAssetForThisPlatformIsIndeterminate() { String release = releaseJson( "MarkdownToPdf-v0.1.2", @@ -89,9 +99,11 @@ void updateAvailableButNoAssetForThisPlatformReturnsEmpty() { "https://example.com/md2pdf-0.1.2-macos-aarch64.zip"), asset("SHA256SUMS", "https://example.com/SHA256SUMS")); - assertTrue( - UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) - .isEmpty()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } @Test @@ -101,15 +113,16 @@ void missingChecksumsAssetStillReturnsUpdate() { "MarkdownToPdf-v0.1.2", asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); - Optional result = + UpdateCheckResult result = UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); - assertTrue(result.isPresent()); - assertNull(result.get().checksumsUrl()); + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertTrue(result.updateInfo().isPresent()); + assertNull(result.updateInfo().get().checksumsUrl()); } @Test - void missingHtmlUrlReturnsEmpty() { + void missingHtmlUrlIsIndeterminate() { String release = """ { @@ -122,13 +135,15 @@ void missingHtmlUrlReturnsEmpty() { "md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); - assertTrue( - UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) - .isEmpty()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } @Test - void authorProfileShapedHtmlUrlReturnsEmpty() { + void authorProfileShapedHtmlUrlIsIndeterminate() { String release = """ { @@ -142,22 +157,26 @@ void authorProfileShapedHtmlUrlReturnsEmpty() { "md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); - assertTrue( - UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)) - .isEmpty()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } @Test - void unsupportedPlatformReturnsEmpty() { + void unsupportedPlatformIsIndeterminate() { String release = releaseJson( "MarkdownToPdf-v0.1.2", asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip"), asset("SHA256SUMS", "https://example.com/SHA256SUMS")); - assertTrue( - UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.UNSUPPORTED, releasesArray(release)) - .isEmpty()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.UNSUPPORTED, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } @Test @@ -172,12 +191,12 @@ void libReleaseInTheArrayIsIgnored() { "MarkdownToPdf-v0.1.2", asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); - Optional result = + UpdateCheckResult result = UpdateChecker.parseAndEvaluate( "0.1.1", UpdatePlatform.LINUX_X64, releasesArray(libRelease, guiRelease)); - assertTrue(result.isPresent()); - assertEquals("0.1.2", result.get().latestVersion()); + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.1.2", result.updateInfo().get().latestVersion()); } @Test @@ -194,23 +213,129 @@ void picksHighestVersionAmongMatchingPrefixRegardlessOfArrayOrder() { "MarkdownToPdf-v0.1.2", asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); - Optional result = + UpdateCheckResult result = UpdateChecker.parseAndEvaluate( "0.1.0", UpdatePlatform.LINUX_X64, releasesArray(olderGuiRelease, newerGuiRelease)); - assertTrue(result.isPresent()); - assertEquals("0.1.2", result.get().latestVersion()); + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.1.2", result.updateInfo().get().latestVersion()); } @Test - void noMatchingPrefixInArrayReturnsEmpty() { + void noMatchingPrefixInArrayIsIndeterminate() { String libRelease = releaseJson( "md2pdf-v9.9.9", asset("md2pdf-9.9.9-sources.jar", "https://example.com/md2pdf-9.9.9-sources.jar")); - assertTrue( - UpdateChecker.parseAndEvaluate("0.1.0", UpdatePlatform.LINUX_X64, releasesArray(libRelease)) - .isEmpty()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate( + "0.1.0", UpdatePlatform.LINUX_X64, releasesArray(libRelease)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); + } + + // ── Finding 1: prereleases and drafts must never be advertised as updates ────────────── + + @Test + void draftReleaseIsSkippedInFavorOfOlderNonDraftRelease() { + String draftNewer = + releaseJson( + "MarkdownToPdf-v0.4.0", + true, + false, + asset("md2pdf-0.4.0-linux-x64.zip", "https://example.com/md2pdf-0.4.0-linux-x64.zip")); + String olderReal = + releaseJson( + "MarkdownToPdf-v0.2.0", + asset("md2pdf-0.2.0-linux-x64.zip", "https://example.com/md2pdf-0.2.0-linux-x64.zip")); + + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate( + "0.1.0", UpdatePlatform.LINUX_X64, releasesArray(draftNewer, olderReal)); + + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.2.0", result.updateInfo().get().latestVersion()); + } + + @Test + void prereleaseIsSkippedInFavorOfOlderNonPrereleaseRelease() { + String prereleaseNewer = + releaseJson( + "MarkdownToPdf-v0.4.0-rc1", + false, + true, + asset( + "md2pdf-0.4.0-rc1-linux-x64.zip", + "https://example.com/md2pdf-0.4.0-rc1-linux-x64.zip")); + String olderReal = + releaseJson( + "MarkdownToPdf-v0.2.0", + asset("md2pdf-0.2.0-linux-x64.zip", "https://example.com/md2pdf-0.2.0-linux-x64.zip")); + + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate( + "0.1.0", UpdatePlatform.LINUX_X64, releasesArray(prereleaseNewer, olderReal)); + + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.2.0", result.updateInfo().get().latestVersion()); + } + + @Test + void onlyDraftCandidateAvailableIsIndeterminate() { + String draftOnly = + releaseJson( + "MarkdownToPdf-v0.4.0", + true, + false, + asset("md2pdf-0.4.0-linux-x64.zip", "https://example.com/md2pdf-0.4.0-linux-x64.zip")); + + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.0", UpdatePlatform.LINUX_X64, releasesArray(draftOnly)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); + } + + // ── Finding 2: one unparseable tag must not permanently poison selection ──────────────── + + @Test + void unparseableFirstCandidateDoesNotPoisonSelectionOfLaterWellFormedNewerRelease() { + String unparseableFirst = + releaseJson( + "MarkdownToPdf-v0.4.0.RC1", + asset( + "md2pdf-0.4.0.RC1-linux-x64.zip", + "https://example.com/md2pdf-0.4.0.RC1-linux-x64.zip")); + String wellFormedNewer = + releaseJson( + "MarkdownToPdf-v0.4.0", + asset("md2pdf-0.4.0-linux-x64.zip", "https://example.com/md2pdf-0.4.0-linux-x64.zip")); + + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate( + "0.1.0", UpdatePlatform.LINUX_X64, releasesArray(unparseableFirst, wellFormedNewer)); + + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.4.0", result.updateInfo().get().latestVersion()); + } + + // ── Finding 3: a non-array top-level response must be diagnosable, not silently mis-scanned ─ + + @Test + void nonArrayTopLevelResponseIsIndeterminate() { + // A single release object (not the expected top-level array) whose only "[" is the nested + // assets array. Scanning from the first "[" would lock onto assets and find no tag_name. + String singleReleaseObject = + releaseJson( + "MarkdownToPdf-v0.1.2", + asset("md2pdf-0.1.2-linux-x64.zip", "https://example.com/md2pdf-0.1.2-linux-x64.zip")); + + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.0", UpdatePlatform.LINUX_X64, singleReleaseObject); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } } diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/VersionComparatorTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/VersionComparatorTest.java index 32f331b..cd0eb4d 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/VersionComparatorTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/VersionComparatorTest.java @@ -59,4 +59,21 @@ void nullOrBlankInputsNeverThrowAndNeverReportNewer() { assertFalse(VersionComparator.isNewer("0.1.2", null)); assertFalse(VersionComparator.isNewer("", "")); } + + @Test + void normalVersionIsParseable() { + assertTrue(VersionComparator.isParseable("0.1.1")); + } + + @Test + void unparseableVersionIsNotParseable() { + assertFalse(VersionComparator.isParseable("0.4.0.RC1")); + } + + @Test + void nullOrBlankVersionIsNotParseable() { + assertFalse(VersionComparator.isParseable(null)); + assertFalse(VersionComparator.isParseable("")); + assertFalse(VersionComparator.isParseable(" ")); + } } From 9a0bb908268b7aa66bbec53bce4fee36a87ce383 Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 19:53:10 +0200 Subject: [PATCH 17/25] Surface md2pdf library version in About dialog The About dialog now displays which version of the md2pdf library is bundled, helping users diagnose PDF-rendering bugs. Added Md2pdf-Version to the MarkdownToPdf.properties file and updated the About dialog to read and display it. Fixes review finding on decouple-lib-gui-versioning-spec branch. --- gui/pom.xml | 1 + gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/gui/pom.xml b/gui/pom.xml index 0d20feb..ad8ea14 100644 --- a/gui/pom.xml +++ b/gui/pom.xml @@ -195,6 +195,7 @@ + diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java b/gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java index 176e7d1..9cd6673 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java @@ -1369,6 +1369,7 @@ private void showAbout() { buildTime = dt; } } + String md2pdfVersion = props.getProperty("Md2pdf-Version", "unknown"); String batikVersion = props.getProperty("Batik-Version", "unknown"); String jsoupVersion = props.getProperty("Jsoup-Version", "unknown"); String openHtmlVersion = props.getProperty("Openhtmltopdf-Version", "unknown"); @@ -1379,7 +1380,9 @@ private void showAbout() { .append(version) .append("\nBuilt: ") .append(buildTime) - .append("\n\nOpenHTMLtoPDF version: ") + .append("\n\nmd2pdf library version: ") + .append(md2pdfVersion) + .append("\nOpenHTMLtoPDF version: ") .append(openHtmlVersion) .append("\nBatik version: ") .append(batikVersion) From c8e6aab4cdfb336602b33b06229b5a230a93e2ae Mon Sep 17 00:00:00 2001 From: pernyf Date: Sun, 16 Aug 2026 19:55:02 +0200 Subject: [PATCH 18/25] UpdateCheckResult: enforce the UpdateInfo-iff-UPDATE_AVAILABLE invariant The record's canonical constructor was public with no validation, so nothing but convention stopped a future caller from constructing an inconsistent result (e.g. UP_TO_DATE carrying an UpdateInfo). Add a compact constructor that rejects that shape, and a couple of tests enforcing it directly, per the final-review finding on this fix. Co-Authored-By: Claude Sonnet 5 --- .../md2pdf/gui/update/UpdateCheckResult.java | 11 ++++ .../gui/update/UpdateCheckResultTest.java | 63 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckResultTest.java diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateCheckResult.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateCheckResult.java index 90b9e9d..b8b6ef0 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateCheckResult.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateCheckResult.java @@ -12,6 +12,17 @@ */ public record UpdateCheckResult(UpdateCheckOutcome outcome, Optional updateInfo) { + public UpdateCheckResult { + boolean shouldHaveInfo = outcome == UpdateCheckOutcome.UPDATE_AVAILABLE; + if (updateInfo.isPresent() != shouldHaveInfo) { + throw new IllegalArgumentException( + "updateInfo must be present if and only if outcome is UPDATE_AVAILABLE, got outcome=" + + outcome + + " updateInfo=" + + updateInfo); + } + } + /** * Creates a result for a newer, usable release. * diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckResultTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckResultTest.java new file mode 100644 index 0000000..28d0881 --- /dev/null +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckResultTest.java @@ -0,0 +1,63 @@ +package test.alipsa.md2pdf.gui.update; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import se.alipsa.md2pdf.gui.update.UpdateCheckOutcome; +import se.alipsa.md2pdf.gui.update.UpdateCheckResult; +import se.alipsa.md2pdf.gui.update.UpdateInfo; + +class UpdateCheckResultTest { + + private static final UpdateInfo SOME_INFO = + new UpdateInfo( + "99.0.0", + "MarkdownToPdf-v99.0.0", + "md2pdf-99.0.0-linux-x64.zip", + "https://example.com/asset.zip", + "https://example.com/SHA256SUMS", + "https://github.com/Alipsa/MarkdownToPdf/releases/tag/MarkdownToPdf-v99.0.0"); + + @Test + void updateAvailableCarriesInfo() { + UpdateCheckResult result = UpdateCheckResult.updateAvailable(SOME_INFO); + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals(Optional.of(SOME_INFO), result.updateInfo()); + } + + @Test + void upToDateCarriesNoInfo() { + UpdateCheckResult result = UpdateCheckResult.upToDate(); + assertEquals(UpdateCheckOutcome.UP_TO_DATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); + } + + @Test + void indeterminateCarriesNoInfo() { + UpdateCheckResult result = UpdateCheckResult.indeterminate(); + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); + } + + @Test + void constructingUpdateAvailableWithoutInfoIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> new UpdateCheckResult(UpdateCheckOutcome.UPDATE_AVAILABLE, Optional.empty())); + } + + @Test + void constructingUpToDateWithInfoIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> new UpdateCheckResult(UpdateCheckOutcome.UP_TO_DATE, Optional.of(SOME_INFO))); + } + + @Test + void constructingIndeterminateWithInfoIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> new UpdateCheckResult(UpdateCheckOutcome.INDETERMINATE, Optional.of(SOME_INFO))); + } +} From 50d21b557e28b554bec70dc3acd8b087aee89146 Mon Sep 17 00:00:00 2001 From: per Date: Sun, 16 Aug 2026 20:49:47 +0200 Subject: [PATCH 19/25] Fix GUI release updater compatibility --- docs/release-process.md | 13 ++++++------- gui/readme.md | 6 +++++- release.sh | 24 ++++++++++++++++++++++-- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/release-process.md b/docs/release-process.md index 43e8c84..8c7e4dd 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -31,13 +31,12 @@ Bump **two** files in lockstep: and refuses to proceed otherwise, but the values still have to be written by hand in both places. -Then give [`gui/release.md`](../gui/release.md) a `## ` section. If this is the first -release under the new `MarkdownToPdf-v` tag scheme, that section must also say plainly -that installs predating this change will not detect this or any future update automatically (the -old `UpdateChecker` parses the new tag scheme incorrectly) and should be updated manually from -the GitHub releases page. (`0.2.0` was already released under the legacy bare `v0.2.0` tag — the -first gui release under the new `MarkdownToPdf-v` scheme must be a version bumped past -`0.2.0`.) +Then give [`gui/release.md`](../gui/release.md) a `## ` section. On the first gui release +under the new `MarkdownToPdf-v` tag scheme, `release.sh` also creates a bare +`v` compatibility release with the same assets. This lets installed 0.2.0-and-earlier +clients, which only understand the old tag scheme, update into the prefix-aware checker. (`0.2.0` +was already released under the legacy bare `v0.2.0` tag — the first gui release under the new +scheme must be a version bumped past `0.2.0`.) ## What `release.sh` checks diff --git a/gui/readme.md b/gui/readme.md index e78f91e..96e3e44 100644 --- a/gui/readme.md +++ b/gui/readme.md @@ -147,11 +147,15 @@ clean checkout: nothing in steps 3-5 is built locally. ### gui gui has no irreversible step — every stage is safe to redo, so recovery is just -delete-and-re-run, every time: +delete-and-re-run, every time. On the first gui release under the new tag scheme, also delete +the `v` compatibility tag and release if they were created: git push --delete origin MarkdownToPdf-v git tag -d MarkdownToPdf-v gh release delete MarkdownToPdf-v --yes # only if a partial release was created + git push --delete origin v # first new-style gui release only + git tag -d v # first new-style gui release only + gh release delete v --yes # first new-style gui release only ./release.sh gui ## Style Profiles diff --git a/release.sh b/release.sh index 3f95785..2ae853b 100755 --- a/release.sh +++ b/release.sh @@ -80,6 +80,15 @@ case "$VERSION" in *-SNAPSHOT) die "refusing to release a snapshot version: $VER echo "Releasing $MODULE $VERSION" git fetch --tags --quiet +# 0.2.0 and earlier check the repository-wide releases/latest endpoint and only understand bare +# v tags. Make the first new-style GUI release available under both tags so those +# installed clients can update into the prefix-aware checker. Create the compatibility release +# last below, making it releases/latest until a subsequent repo-wide release is published. +LEGACY_GUI_TAG="" +if [ "$MODULE" = "gui" ] \ + && ! git ls-remote --exit-code --tags origin "refs/tags/MarkdownToPdf-v*" > /dev/null 2>&1; then + LEGACY_GUI_TAG="v$VERSION" +fi git rev-parse -q --verify "refs/tags/$TAG" > /dev/null && die "tag $TAG already exists locally" git ls-remote --exit-code --tags origin "$TAG" > /dev/null 2>&1 && die "tag $TAG already exists on the remote" @@ -133,7 +142,7 @@ extract_section() { # Outside $STAGING on purpose: step 3 empties that directory, and step 8 uploads every file # in it as a release asset. .release-staging itself is gitignored. -NOTES="$BASEDIR/.release-staging/release-notes-$VERSION.md" +NOTES="$BASEDIR/.release-staging/release-notes-$MODULE-$VERSION.md" mkdir -p "$(dirname "$NOTES")" : > "$NOTES" @@ -166,7 +175,7 @@ step "Downloading release assets" # Keep staging outside target/: the release deploy includes `clean`, and -am brings the # aggregator parent into the reactor, so Maven clean removes every module's target tree. # This directory is ignored so a failed post-deploy recovery does not dirty the checkout. -STAGING="$BASEDIR/.release-staging/release-$VERSION" +STAGING="$BASEDIR/.release-staging/release-$MODULE-$VERSION" # Emptied, not reused: the --skip-deploy recovery re-runs this step over a directory a # previous attempt already populated, and a stale file here would ship unhashed under a # SHA256SUMS that appears to account for it. @@ -260,6 +269,11 @@ fi step "Tagging $TAG" git tag -a "$TAG" -m "Release $VERSION" git push origin "$TAG" +if [ -n "$LEGACY_GUI_TAG" ]; then + step "Tagging compatibility release $LEGACY_GUI_TAG" + git tag -a "$LEGACY_GUI_TAG" -m "Release $VERSION (legacy GUI updater compatibility)" + git push origin "$LEGACY_GUI_TAG" +fi # ── 8. GitHub release ─────────────────────────────────────────────── step "Creating the GitHub release" @@ -275,5 +289,11 @@ fi gh release create "$TAG" "$STAGING"/* \ --title "$TITLE" \ --notes-file "$NOTES" +if [ -n "$LEGACY_GUI_TAG" ]; then + step "Creating compatibility GitHub release" + gh release create "$LEGACY_GUI_TAG" "$STAGING"/* \ + --title "$TITLE (legacy updater compatibility)" \ + --notes-file "$NOTES" +fi printf '\nReleased %s %s\n' "$MODULE" "$VERSION" From 164fe0ff2f8b133677a7fef846ef573193418b3e Mon Sep 17 00:00:00 2001 From: per Date: Sun, 16 Aug 2026 21:22:02 +0200 Subject: [PATCH 20/25] Harden release transition and update pagination --- .../md2pdf/gui/update/UpdateChecker.java | 28 +++++++++++++- .../gui/update/UpdateCheckerHttpTest.java | 37 +++++++++++++++++++ release.sh | 30 ++++++++------- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java index cf5538c..f7c57b9 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; +import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; @@ -69,7 +70,11 @@ public UpdateCheckResult checkForUpdate(String currentVersion) throws UpdateChec throw new UpdateCheckException( "GitHub returned HTTP " + response.statusCode() + " for " + apiUrl); } - return parseAndEvaluate(currentVersion, UpdatePlatform.detectCurrent(), response.body()); + return parseAndEvaluate( + currentVersion, + UpdatePlatform.detectCurrent(), + response.body(), + hasNextPage(response.headers())); } /** @@ -92,6 +97,18 @@ public UpdateCheckResult checkForUpdate(String currentVersion) throws UpdateChec */ public static UpdateCheckResult parseAndEvaluate( String currentVersion, UpdatePlatform platform, String responseJson) { + return parseAndEvaluate(currentVersion, platform, responseJson, false); + } + + /** + * Evaluates one page of a {@code GET /releases} response. + * + *

When GitHub signals another page, an apparent up-to-date result is indeterminate: a newer + * GUI release may be on a page not yet inspected. A discovered newer release is still safe to + * offer, because it is newer regardless of what later pages contain. + */ + private static UpdateCheckResult parseAndEvaluate( + String currentVersion, UpdatePlatform platform, String responseJson, boolean hasNextPage) { if (platform == UpdatePlatform.UNSUPPORTED) { LOGGER.info("Skipping update check: no release archive for this platform."); return UpdateCheckResult.indeterminate(); @@ -104,6 +121,11 @@ public static UpdateCheckResult parseAndEvaluate( String tagName = GitHubReleaseJson.extractTagName(releaseJson); String latestVersion = tagName.substring(TAG_PREFIX.length()); if (!VersionComparator.isNewer(latestVersion, currentVersion)) { + if (hasNextPage) { + LOGGER.info( + "Skipping up-to-date result: another releases page may contain a newer GUI release."); + return UpdateCheckResult.indeterminate(); + } LOGGER.info( "No update available: latest release {} is not newer than the running {}.", latestVersion, @@ -137,6 +159,10 @@ public static UpdateCheckResult parseAndEvaluate( latestVersion, tagName, expectedAssetName, downloadUrl, checksumsUrl, htmlUrl)); } + private static boolean hasNextPage(HttpHeaders headers) { + return headers.allValues("Link").stream().anyMatch(link -> link.contains("rel=\"next\"")); + } + /** * Scans a {@code GET /releases} JSON array and returns the JSON object of the release with the * highest version among those tagged {@link #TAG_PREFIX}, skipping drafts, prereleases, and diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java index 7aca76a..64f34d6 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java @@ -52,10 +52,21 @@ void stopServer() { } private void respond(int status, String body) { + respond(status, body, false); + } + + private void respond(int status, String body, boolean hasNextPage) { server.createContext( "/releases", exchange -> { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + if (hasNextPage) { + exchange + .getResponseHeaders() + .add( + "Link", + "; rel=\"next\""); + } // sendResponseHeaders' responseLength contract: 0 means chunked with unspecified // length, -1 means no response body at all. A genuinely empty body must send -1, not // 0, or the client is left waiting on a chunked stream that never starts. @@ -123,4 +134,30 @@ void wellFormedNewerReleaseIsReturned() throws UpdateCheckException { assertTrue(result.updateInfo().isPresent()); } } + + @Test + void currentVersionOnAnIncompletePageIsIndeterminate() throws UpdateCheckException { + UpdatePlatform platform = UpdatePlatform.detectCurrent(); + String assetName = "md2pdf-0.1.0" + platform.assetSuffix(); + respond( + 200, + """ + [ + { + "tag_name": "MarkdownToPdf-v0.1.0", + "html_url": "https://github.com/Alipsa/MarkdownToPdf/releases/tag/MarkdownToPdf-v0.1.0", + "assets": [ + {"name": "%s", "browser_download_url": "https://example.com/%s"} + ] + } + ] + """ + .formatted(assetName, assetName), + true); + + UpdateCheckResult result = new UpdateChecker().checkForUpdate("0.1.0"); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); + } } diff --git a/release.sh b/release.sh index 2ae853b..ca75bf2 100755 --- a/release.sh +++ b/release.sh @@ -81,25 +81,29 @@ echo "Releasing $MODULE $VERSION" git fetch --tags --quiet # 0.2.0 and earlier check the repository-wide releases/latest endpoint and only understand bare -# v tags. Make the first new-style GUI release available under both tags so those -# installed clients can update into the prefix-aware checker. Create the compatibility release -# last below, making it releases/latest until a subsequent repo-wide release is published. +# v tags. Make the first new-style GUI release available under both tags so those +# installed clients can update into the prefix-aware checker. git fetch --tags above makes this +# an offline check: a network/auth failure cannot accidentally re-arm the one-shot release. LEGACY_GUI_TAG="" -if [ "$MODULE" = "gui" ] \ - && ! git ls-remote --exit-code --tags origin "refs/tags/MarkdownToPdf-v*" > /dev/null 2>&1; then +if [ "$MODULE" = "gui" ] && [ -z "$(git tag --list 'MarkdownToPdf-v*')" ]; then LEGACY_GUI_TAG="v$VERSION" fi git rev-parse -q --verify "refs/tags/$TAG" > /dev/null && die "tag $TAG already exists locally" git ls-remote --exit-code --tags origin "$TAG" > /dev/null 2>&1 && die "tag $TAG already exists on the remote" -# Transitional guard: this repo's pre-existing releases were tagged bare "v", -# before lib and gui split into their own tag schemes. It self-retires once every -# pre-split version has been superseded — no future version will ever collide with a -# legacy tag, only versions that were already released before this script existed. -git rev-parse -q --verify "refs/tags/v$VERSION" > /dev/null \ - && die "$VERSION was already released under the legacy tag v$VERSION — bump the version before releasing under the new $MODULE-specific tag scheme" -git ls-remote --exit-code --tags origin "v$VERSION" > /dev/null 2>&1 \ - && die "$VERSION was already released under the legacy tag v$VERSION — bump the version before releasing under the new $MODULE-specific tag scheme" +# Only these genuinely pre-split bare tags reserve a version for both independently-versioned +# modules. The first new-style GUI release deliberately creates its own bare compatibility tag, +# which must not prevent lib from later releasing the same version. +case "$VERSION" in + 0.1.0|0.1.1|0.2.0) + git rev-parse -q --verify "refs/tags/v$VERSION" > /dev/null \ + && die "$VERSION was already released under the pre-split tag v$VERSION — bump the version before releasing under the new $MODULE-specific tag scheme" + ;; +esac +if [ -n "$LEGACY_GUI_TAG" ]; then + git rev-parse -q --verify "refs/tags/$LEGACY_GUI_TAG" > /dev/null \ + && die "compatibility tag $LEGACY_GUI_TAG already exists — delete its tag and GitHub release before re-running the first new-style gui release" +fi git push --dry-run --quiet origin HEAD || die "git push would fail" From c780ae87ddd539cf010108a72bbdc754e2814a3d Mon Sep 17 00:00:00 2001 From: per Date: Sun, 16 Aug 2026 22:06:05 +0200 Subject: [PATCH 21/25] Complete release migration safeguards --- docs/release-process.md | 6 ++ .../md2pdf/gui/update/UpdateChecker.java | 101 +++++++++++------- .../gui/update/UpdateCheckerHttpTest.java | 51 +++++++-- release.sh | 12 ++- 4 files changed, 123 insertions(+), 47 deletions(-) diff --git a/docs/release-process.md b/docs/release-process.md index 8c7e4dd..eceb6a4 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -38,6 +38,12 @@ clients, which only understand the old tag scheme, update into the prefix-aware was already released under the legacy bare `v0.2.0` tag — the first gui release under the new scheme must be a version bumped past `0.2.0`.) +The legacy updater only looks at the repository-wide `releases/latest` endpoint, so this migration +window closes as soon as either module publishes another release. `release.sh` verifies that the +compatibility release initially wins GitHub's latest-release selection; after that, leave both lib +and gui releases quiet for at least seven days to give normally active legacy installs time to run +their daily update check. + ## What `release.sh` checks `release.sh` composes the GitHub release notes from the relevant changelog section(s) above and diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java index f7c57b9..81e2bff 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java @@ -7,7 +7,9 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -49,32 +51,16 @@ public UpdateChecker() {} */ public UpdateCheckResult checkForUpdate(String currentVersion) throws UpdateCheckException { String apiUrl = System.getProperty(API_URL_PROPERTY, DEFAULT_API_URL); - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(apiUrl)) - .header("Accept", "application/vnd.github+json") - .timeout(Duration.ofSeconds(10)) - .GET() - .build(); - HttpResponse response; try (HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()) { - response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return parseAndEvaluate( + currentVersion, UpdatePlatform.detectCurrent(), fetchAllReleasePages(httpClient, apiUrl)); } catch (IOException e) { throw new UpdateCheckException("Failed to reach " + apiUrl, e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new UpdateCheckException("Interrupted while checking for updates", e); } - if (response.statusCode() != 200) { - throw new UpdateCheckException( - "GitHub returned HTTP " + response.statusCode() + " for " + apiUrl); - } - return parseAndEvaluate( - currentVersion, - UpdatePlatform.detectCurrent(), - response.body(), - hasNextPage(response.headers())); } /** @@ -97,18 +83,6 @@ public UpdateCheckResult checkForUpdate(String currentVersion) throws UpdateChec */ public static UpdateCheckResult parseAndEvaluate( String currentVersion, UpdatePlatform platform, String responseJson) { - return parseAndEvaluate(currentVersion, platform, responseJson, false); - } - - /** - * Evaluates one page of a {@code GET /releases} response. - * - *

When GitHub signals another page, an apparent up-to-date result is indeterminate: a newer - * GUI release may be on a page not yet inspected. A discovered newer release is still safe to - * offer, because it is newer regardless of what later pages contain. - */ - private static UpdateCheckResult parseAndEvaluate( - String currentVersion, UpdatePlatform platform, String responseJson, boolean hasNextPage) { if (platform == UpdatePlatform.UNSUPPORTED) { LOGGER.info("Skipping update check: no release archive for this platform."); return UpdateCheckResult.indeterminate(); @@ -121,11 +95,6 @@ private static UpdateCheckResult parseAndEvaluate( String tagName = GitHubReleaseJson.extractTagName(releaseJson); String latestVersion = tagName.substring(TAG_PREFIX.length()); if (!VersionComparator.isNewer(latestVersion, currentVersion)) { - if (hasNextPage) { - LOGGER.info( - "Skipping up-to-date result: another releases page may contain a newer GUI release."); - return UpdateCheckResult.indeterminate(); - } LOGGER.info( "No update available: latest release {} is not newer than the running {}.", latestVersion, @@ -159,8 +128,66 @@ private static UpdateCheckResult parseAndEvaluate( latestVersion, tagName, expectedAssetName, downloadUrl, checksumsUrl, htmlUrl)); } - private static boolean hasNextPage(HttpHeaders headers) { - return headers.allValues("Link").stream().anyMatch(link -> link.contains("rel=\"next\"")); + /** Fetches and combines every page that GitHub links from a releases response. */ + private static String fetchAllReleasePages(HttpClient httpClient, String apiUrl) + throws IOException, InterruptedException, UpdateCheckException { + URI nextPage = URI.create(apiUrl); + Set fetchedPages = new HashSet<>(); + StringBuilder releases = new StringBuilder("["); + boolean firstPage = true; + while (nextPage != null) { + if (!fetchedPages.add(nextPage)) { + throw new UpdateCheckException( + "GitHub Releases pagination linked to a page already fetched"); + } + HttpRequest request = + HttpRequest.newBuilder() + .uri(nextPage) + .header("Accept", "application/vnd.github+json") + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new UpdateCheckException( + "GitHub returned HTTP " + response.statusCode() + " for " + nextPage); + } + String page = response.body().strip(); + if (!page.startsWith("[")) { + return response.body(); + } + String pageItems = GitHubReleaseJson.extractBracketedRegion(page, page.indexOf('[')).strip(); + if (!pageItems.isEmpty()) { + if (!firstPage) { + releases.append(','); + } + releases.append(pageItems); + firstPage = false; + } + nextPage = findNextPage(response.headers()); + } + return releases.append(']').toString(); + } + + private static URI findNextPage(HttpHeaders headers) throws UpdateCheckException { + for (String link : headers.allValues("Link")) { + for (String entry : link.split(",")) { + if (entry.contains("rel=\"next\"")) { + int open = entry.indexOf('<'); + int close = entry.indexOf('>', open + 1); + if (open < 0 || close < 0) { + throw new UpdateCheckException("GitHub returned a malformed next-page Link header"); + } + try { + return URI.create(entry.substring(open + 1, close)); + } catch (IllegalArgumentException e) { + throw new UpdateCheckException("GitHub returned an invalid next-page Link URL", e); + } + } + } + } + return null; } /** diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java index 64f34d6..d417876 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java @@ -79,6 +79,29 @@ private void respond(int status, String body, boolean hasNextPage) { }); } + private void respondTwoPages(String firstPage, String secondPage) { + server.createContext( + "/releases", + exchange -> { + boolean secondRequest = "page=2".equals(exchange.getRequestURI().getQuery()); + String body = secondRequest ? secondPage : firstPage; + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + if (!secondRequest) { + exchange + .getResponseHeaders() + .add( + "Link", + "; rel=\"next\""); + } + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + }); + } + @Test void non200StatusThrowsUpdateCheckException() { respond(500, "boom"); @@ -136,11 +159,10 @@ void wellFormedNewerReleaseIsReturned() throws UpdateCheckException { } @Test - void currentVersionOnAnIncompletePageIsIndeterminate() throws UpdateCheckException { + void nextPageIsFetchedBeforeSelectingTheLatestGuiRelease() throws UpdateCheckException { UpdatePlatform platform = UpdatePlatform.detectCurrent(); - String assetName = "md2pdf-0.1.0" + platform.assetSuffix(); - respond( - 200, + String assetName = "md2pdf-0.1.1" + platform.assetSuffix(); + respondTwoPages( """ [ { @@ -153,11 +175,26 @@ void currentVersionOnAnIncompletePageIsIndeterminate() throws UpdateCheckExcepti ] """ .formatted(assetName, assetName), - true); + """ + [ + { + "tag_name": "MarkdownToPdf-v0.1.1", + "html_url": "https://github.com/Alipsa/MarkdownToPdf/releases/tag/MarkdownToPdf-v0.1.1", + "assets": [ + {"name": "%s", "browser_download_url": "https://example.com/%s"} + ] + } + ] + """ + .formatted(assetName, assetName)); UpdateCheckResult result = new UpdateChecker().checkForUpdate("0.1.0"); - assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); - assertTrue(result.updateInfo().isEmpty()); + if (platform == UpdatePlatform.UNSUPPORTED) { + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + } else { + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.1.1", result.updateInfo().get().latestVersion()); + } } } diff --git a/release.sh b/release.sh index ca75bf2..88339e1 100755 --- a/release.sh +++ b/release.sh @@ -219,13 +219,13 @@ fi count="$(find "$STAGING" -maxdepth 1 -type f | wc -l | tr -d ' ')" [ "$count" -eq "$EXPECTED_COUNT" ] || die "expected exactly $EXPECTED_COUNT files in $STAGING, found $count" # Per-asset floors, because the assets differ by three orders of magnitude: a platform zip -# carries a ~100 MB runtime, the no-jdk zip is ~15 MB, and the javadoc/sources jars are each -# tens of KB. A single 1 MB floor would abort every release on the small jars. +# carries a ~100 MB runtime, the no-jdk zip is ~15 MB, the javadoc jar is ~130 KB, and the +# sources jar is only tens of KB. A single 1 MB floor would abort every release on the small jars. asset_floor() { case "$1" in *-linux-x64.zip|*-macos-aarch64.zip|*-windows-x64.zip) echo 40000000 ;; # 40 MB *-no-jdk.zip) echo 5000000 ;; # 5 MB - *-javadoc.jar) echo 10000 ;; # 10 KB + *-javadoc.jar) echo 20000 ;; # 20 KB *-sources.jar) echo 5000 ;; # 5 KB *) echo 1 ;; esac @@ -298,6 +298,12 @@ if [ -n "$LEGACY_GUI_TAG" ]; then gh release create "$LEGACY_GUI_TAG" "$STAGING"/* \ --title "$TITLE (legacy updater compatibility)" \ --notes-file "$NOTES" + # Old clients use the repository-wide releases/latest endpoint. Both release tags point to the + # same commit, so assert GitHub selected the compatibility release rather than relying on an + # undocumented tie-break in its latest-release selection. + LATEST_TAG="$(gh release view --json tagName --jq .tagName)" + [ "$LATEST_TAG" = "$LEGACY_GUI_TAG" ] \ + || die "GitHub releases/latest resolved to $LATEST_TAG, not compatibility release $LEGACY_GUI_TAG — do not publish another release until this is corrected" fi printf '\nReleased %s %s\n' "$MODULE" "$VERSION" From 9c37f885e093a647654ab789bb53ae30f5fa178c Mon Sep 17 00:00:00 2001 From: per Date: Sun, 16 Aug 2026 22:18:52 +0200 Subject: [PATCH 22/25] Release GUI 0.2.1 --- gui/MarkdownToPdf.xml | 2 +- gui/pom.xml | 2 +- gui/release.md | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/gui/MarkdownToPdf.xml b/gui/MarkdownToPdf.xml index 46f16ef..b096f69 100644 --- a/gui/MarkdownToPdf.xml +++ b/gui/MarkdownToPdf.xml @@ -37,7 +37,7 @@ builds, tests, or touches this file (only CLAUDE.md references it), so a stale value here is invisible until someone runs `mvn -f gui/MarkdownToPdf.xml javafx:run`. release.sh's gui flow checks this stays in sync. --> - 0.2.0 + 0.2.1 org.openjfx diff --git a/gui/pom.xml b/gui/pom.xml index ad8ea14..a7d9bfb 100644 --- a/gui/pom.xml +++ b/gui/pom.xml @@ -15,7 +15,7 @@ this). The above MUST stay the unresolved revision property reference, never a literal version — overriding revision here or hardcoding the parent reference both break the build (see the design spec's "Rejected" section). --> - 0.2.0 + 0.2.1 jar MarkdownToPdf Editor diff --git a/gui/release.md b/gui/release.md index 8450e87..5bd5b50 100644 --- a/gui/release.md +++ b/gui/release.md @@ -1,6 +1,14 @@ # MarkdownToPdf GUI Release History (Note, dates are in yyyy-MM-dd format) +## 0.2.1 (2026-08-16) +- GUI releases now have an independent version from the bundled md2pdf library; this release uses + md2pdf 0.2.0. The **About** dialog shows both versions. +- Made update checks reliable when the repository also has library releases: they select GUI + releases only, follow paginated release results, and safely ignore drafts, prereleases and + malformed release data. +- Added the required high-resolution macOS application icon for Mac App Store packaging. + ## 0.2.0 (2026-08-15) - Added a pluggable file-access layer for sandboxed distributions. Stored projects are retained when their files are temporarily inaccessible, and the application can ask to locate an From 08d259918d595bbddba1da8284bb8c1830ae4639 Mon Sep 17 00:00:00 2001 From: per Date: Sun, 16 Aug 2026 22:32:43 +0200 Subject: [PATCH 23/25] Bound update release pagination --- .../md2pdf/gui/update/UpdateChecker.java | 55 +++++++++++++++---- .../gui/update/UpdateCheckerHttpTest.java | 48 ++++++++++------ 2 files changed, 73 insertions(+), 30 deletions(-) diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java index 81e2bff..2aaf061 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java @@ -38,6 +38,8 @@ public UpdateChecker() {} private static final String DEFAULT_API_URL = "https://api.github.com/repos/Alipsa/MarkdownToPdf/releases?per_page=100"; + private static final int MAX_RELEASE_PAGES = 5; + private static final Logger LOGGER = LogManager.getLogger(UpdateChecker.class); /** @@ -50,11 +52,15 @@ public UpdateChecker() {} * @throws UpdateCheckException on any network, HTTP-status or interrupt failure */ public UpdateCheckResult checkForUpdate(String currentVersion) throws UpdateCheckException { + UpdatePlatform platform = UpdatePlatform.detectCurrent(); + if (platform == UpdatePlatform.UNSUPPORTED) { + LOGGER.info("Skipping update check: no release archive for this platform."); + return UpdateCheckResult.indeterminate(); + } String apiUrl = System.getProperty(API_URL_PROPERTY, DEFAULT_API_URL); try (HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()) { - return parseAndEvaluate( - currentVersion, UpdatePlatform.detectCurrent(), fetchAllReleasePages(httpClient, apiUrl)); + return parseAndEvaluate(currentVersion, platform, fetchAllReleasePages(httpClient, apiUrl)); } catch (IOException e) { throw new UpdateCheckException("Failed to reach " + apiUrl, e); } catch (InterruptedException e) { @@ -128,14 +134,23 @@ public static UpdateCheckResult parseAndEvaluate( latestVersion, tagName, expectedAssetName, downloadUrl, checksumsUrl, htmlUrl)); } - /** Fetches and combines every page that GitHub links from a releases response. */ + /** + * Fetches and combines up to {@link #MAX_RELEASE_PAGES} linked GitHub Releases response pages. + */ private static String fetchAllReleasePages(HttpClient httpClient, String apiUrl) throws IOException, InterruptedException, UpdateCheckException { - URI nextPage = URI.create(apiUrl); + URI nextPage = parseHttpUri(apiUrl, "release API URL"); + if (!nextPage.isAbsolute()) { + throw new UpdateCheckException("GitHub release API URL must be absolute"); + } Set fetchedPages = new HashSet<>(); StringBuilder releases = new StringBuilder("["); boolean firstPage = true; while (nextPage != null) { + if (fetchedPages.size() == MAX_RELEASE_PAGES) { + throw new UpdateCheckException( + "GitHub Releases pagination exceeded " + MAX_RELEASE_PAGES + " pages"); + } if (!fetchedPages.add(nextPage)) { throw new UpdateCheckException( "GitHub Releases pagination linked to a page already fetched"); @@ -155,7 +170,13 @@ private static String fetchAllReleasePages(HttpClient httpClient, String apiUrl) } String page = response.body().strip(); if (!page.startsWith("[")) { - return response.body(); + if (fetchedPages.size() == 1) { + return response.body(); + } + LOGGER.warn( + "Ignoring non-array response after {} valid GitHub Releases page(s).", + fetchedPages.size() - 1); + break; } String pageItems = GitHubReleaseJson.extractBracketedRegion(page, page.indexOf('[')).strip(); if (!pageItems.isEmpty()) { @@ -165,12 +186,13 @@ private static String fetchAllReleasePages(HttpClient httpClient, String apiUrl) releases.append(pageItems); firstPage = false; } - nextPage = findNextPage(response.headers()); + nextPage = findNextPage(response.headers(), nextPage); } return releases.append(']').toString(); } - private static URI findNextPage(HttpHeaders headers) throws UpdateCheckException { + private static URI findNextPage(HttpHeaders headers, URI currentPage) + throws UpdateCheckException { for (String link : headers.allValues("Link")) { for (String entry : link.split(",")) { if (entry.contains("rel=\"next\"")) { @@ -179,17 +201,26 @@ private static URI findNextPage(HttpHeaders headers) throws UpdateCheckException if (open < 0 || close < 0) { throw new UpdateCheckException("GitHub returned a malformed next-page Link header"); } - try { - return URI.create(entry.substring(open + 1, close)); - } catch (IllegalArgumentException e) { - throw new UpdateCheckException("GitHub returned an invalid next-page Link URL", e); - } + URI linkedPage = parseHttpUri(entry.substring(open + 1, close), "next-page Link URL"); + return currentPage.resolve(linkedPage); } } } return null; } + private static URI parseHttpUri(String value, String description) throws UpdateCheckException { + try { + URI uri = URI.create(value); + if (uri.isAbsolute() && !"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) { + throw new UpdateCheckException("GitHub returned a non-HTTP " + description); + } + return uri; + } catch (IllegalArgumentException e) { + throw new UpdateCheckException("GitHub returned an invalid " + description, e); + } + } + /** * Scans a {@code GET /releases} JSON array and returns the JSON object of the release with the * highest version among those tagged {@link #TAG_PREFIX}, skipping drafts, prereleases, and diff --git a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java index d417876..7d5828a 100644 --- a/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java +++ b/gui/src/test/java/test/alipsa/md2pdf/gui/update/UpdateCheckerHttpTest.java @@ -52,21 +52,10 @@ void stopServer() { } private void respond(int status, String body) { - respond(status, body, false); - } - - private void respond(int status, String body, boolean hasNextPage) { server.createContext( "/releases", exchange -> { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); - if (hasNextPage) { - exchange - .getResponseHeaders() - .add( - "Link", - "; rel=\"next\""); - } // sendResponseHeaders' responseLength contract: 0 means chunked with unspecified // length, -1 means no response body at all. A genuinely empty body must send -1, not // 0, or the client is left waiting on a chunked stream that never starts. @@ -87,13 +76,7 @@ private void respondTwoPages(String firstPage, String secondPage) { String body = secondRequest ? secondPage : firstPage; byte[] bytes = body.getBytes(StandardCharsets.UTF_8); if (!secondRequest) { - exchange - .getResponseHeaders() - .add( - "Link", - "; rel=\"next\""); + exchange.getResponseHeaders().add("Link", "; rel=\"next\""); } exchange.sendResponseHeaders(200, bytes.length); try (OutputStream os = exchange.getResponseBody()) { @@ -197,4 +180,33 @@ void nextPageIsFetchedBeforeSelectingTheLatestGuiRelease() throws UpdateCheckExc assertEquals("0.1.1", result.updateInfo().get().latestVersion()); } } + + @Test + void nonArrayLaterPageKeepsTheReleaseFoundOnEarlierPages() throws UpdateCheckException { + UpdatePlatform platform = UpdatePlatform.detectCurrent(); + String assetName = "md2pdf-0.1.1" + platform.assetSuffix(); + respondTwoPages( + """ + [ + { + "tag_name": "MarkdownToPdf-v0.1.1", + "html_url": "https://github.com/Alipsa/MarkdownToPdf/releases/tag/MarkdownToPdf-v0.1.1", + "assets": [ + {"name": "%s", "browser_download_url": "https://example.com/%s"} + ] + } + ] + """ + .formatted(assetName, assetName), + "GitHub is temporarily unavailable"); + + UpdateCheckResult result = new UpdateChecker().checkForUpdate("0.1.0"); + + if (platform == UpdatePlatform.UNSUPPORTED) { + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + } else { + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.1.1", result.updateInfo().get().latestVersion()); + } + } } From 0ef9f97c929a994713a2222a0797c15691aa68f5 Mon Sep 17 00:00:00 2001 From: per Date: Sun, 16 Aug 2026 23:03:49 +0200 Subject: [PATCH 24/25] Harden compatibility release checks --- docs/release-process.md | 4 +++- .../java/se/alipsa/md2pdf/gui/update/UpdateChecker.java | 2 +- .../se/alipsa/md2pdf/gui/update/VersionComparator.java | 8 ++++---- release.sh | 7 +++++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/release-process.md b/docs/release-process.md index eceb6a4..6afc3f2 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -42,7 +42,9 @@ The legacy updater only looks at the repository-wide `releases/latest` endpoint, window closes as soon as either module publishes another release. `release.sh` verifies that the compatibility release initially wins GitHub's latest-release selection; after that, leave both lib and gui releases quiet for at least seven days to give normally active legacy installs time to run -their daily update check. +their daily update check. Run the first new-style gui release in an exclusive release window: a +concurrent release can displace the compatibility release before the script verifies it, leaving +both tags and releases to clean up manually. ## What `release.sh` checks diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java index 2aaf061..dd7bc8b 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/update/UpdateChecker.java @@ -171,7 +171,7 @@ private static String fetchAllReleasePages(HttpClient httpClient, String apiUrl) String page = response.body().strip(); if (!page.startsWith("[")) { if (fetchedPages.size() == 1) { - return response.body(); + return page; } LOGGER.warn( "Ignoring non-array response after {} valid GitHub Releases page(s).", diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java index 9140917..032e456 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java @@ -12,10 +12,10 @@ private VersionComparator() {} /** * Returns {@code true} if {@code candidate} is a strictly newer release than {@code current}. A - * trailing {@code -SNAPSHOT}-style suffix is stripped from each side before the dotted numeric - * segments are compared; if the numeric segments are otherwise equal, the side that had a suffix - * stripped is treated as older (so a locally built {@code 0.1.1-SNAPSHOT} is reported as older - * than a released {@code 0.1.1}, never as ahead of it). + * trailing {@code -suffix} is treated as an opaque prerelease marker and stripped before the + * dotted numeric segments are compared. If the numeric segments are otherwise equal, a suffixed + * version is older than an unsuffixed release; two differently suffixed versions (for example + * {@code 0.2.1-beta} and {@code 0.2.1-SNAPSHOT}) compare as equal. * * @param candidate the version to test * @param current the currently running version diff --git a/release.sh b/release.sh index 88339e1..b61e41d 100755 --- a/release.sh +++ b/release.sh @@ -98,6 +98,12 @@ case "$VERSION" in 0.1.0|0.1.1|0.2.0) git rev-parse -q --verify "refs/tags/v$VERSION" > /dev/null \ && die "$VERSION was already released under the pre-split tag v$VERSION — bump the version before releasing under the new $MODULE-specific tag scheme" + if git ls-remote --exit-code --tags origin "v$VERSION" > /dev/null 2>&1; then + die "$VERSION was already released under the pre-split tag v$VERSION — bump the version before releasing under the new $MODULE-specific tag scheme" + else + status=$? + [ "$status" -eq 2 ] || die "could not check remote pre-split tag v$VERSION" + fi ;; esac if [ -n "$LEGACY_GUI_TAG" ]; then @@ -297,6 +303,7 @@ if [ -n "$LEGACY_GUI_TAG" ]; then step "Creating compatibility GitHub release" gh release create "$LEGACY_GUI_TAG" "$STAGING"/* \ --title "$TITLE (legacy updater compatibility)" \ + --latest \ --notes-file "$NOTES" # Old clients use the repository-wide releases/latest endpoint. Both release tags point to the # same commit, so assert GitHub selected the compatibility release rather than relying on an From 3b725ee770964ea75c6fb5be9175d7fef2c57a62 Mon Sep 17 00:00:00 2001 From: per Date: Sun, 16 Aug 2026 23:28:23 +0200 Subject: [PATCH 25/25] Avoid migration release latest window --- docs/release-process.md | 4 +++- release.sh | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/release-process.md b/docs/release-process.md index 6afc3f2..c7b9870 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -44,7 +44,9 @@ compatibility release initially wins GitHub's latest-release selection; after th and gui releases quiet for at least seven days to give normally active legacy installs time to run their daily update check. Run the first new-style gui release in an exclusive release window: a concurrent release can displace the compatibility release before the script verifies it, leaving -both tags and releases to clean up manually. +both tags and releases to clean up manually. This one-time compatibility flow requires a GitHub +CLI version that supports `gh release create --latest`; `release.sh` checks that capability before +publishing anything. ## What `release.sh` checks diff --git a/release.sh b/release.sh index b61e41d..7ab858e 100755 --- a/release.sh +++ b/release.sh @@ -88,6 +88,14 @@ LEGACY_GUI_TAG="" if [ "$MODULE" = "gui" ] && [ -z "$(git tag --list 'MarkdownToPdf-v*')" ]; then LEGACY_GUI_TAG="v$VERSION" fi +NEW_RELEASE_LATEST_OPTION=() +if [ -n "$LEGACY_GUI_TAG" ]; then + gh release create --help | grep -F -- '--latest' > /dev/null \ + || die "the first new-style gui release requires a gh version that supports gh release create --latest" + # Do not let an old updater observe the new-style tag during the short interval before the + # compatibility release below is created and explicitly marked latest. + NEW_RELEASE_LATEST_OPTION=(--latest=false) +fi git rev-parse -q --verify "refs/tags/$TAG" > /dev/null && die "tag $TAG already exists locally" git ls-remote --exit-code --tags origin "$TAG" > /dev/null 2>&1 && die "tag $TAG already exists on the remote" @@ -298,7 +306,8 @@ fi # gh release create takes filenames or globs, never a directory. gh release create "$TAG" "$STAGING"/* \ --title "$TITLE" \ - --notes-file "$NOTES" + --notes-file "$NOTES" \ + "${NEW_RELEASE_LATEST_OPTION[@]}" if [ -n "$LEGACY_GUI_TAG" ]; then step "Creating compatibility GitHub release" gh release create "$LEGACY_GUI_TAG" "$STAGING"/* \