diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 029f034..c135755 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,23 @@ jobs: zsh -n gui/src/main/assembly/mac/markdownToPdf zsh -n gui/src/main/assembly/mac/mkicns.zsh # md2pdf-install.cmd has no linter; its native packaging and overlap jobs exercise it. + - run: cat .github/versions.env >> "$GITHUB_ENV" + - uses: actions/setup-java@v5 + with: + java-version: ${{ env.LIBERICA_VERSION }} + distribution: liberica + java-package: jdk+fx + cache: maven + # release.sh's gui flow enforces this same lockstep at release time; checking it here + # too means drift is caught on every push/PR instead of sitting unnoticed until release. + - name: check gui/pom.xml and gui/MarkdownToPdf.xml stay in lockstep + run: | + POM_VERSION="$(mvn -q -pl gui org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout)" + LAUNCHER_VERSION="$(sed -n -e '/MarkdownToPdf<\/artifactId>/,/<\/dependency>/ s/.*\(.*\)<\/version>.*/\1/p' gui/MarkdownToPdf.xml)" + if [ "$POM_VERSION" != "$LAUNCHER_VERSION" ]; then + echo "gui/pom.xml's version ($POM_VERSION) does not match gui/MarkdownToPdf.xml's dependency version ($LAUNCHER_VERSION) — bump both together" + exit 1 + fi build: strategy: @@ -76,7 +93,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 +174,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: 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. 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/docs/release-process.md b/docs/release-process.md index ef0f00c..c7b9870 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,27 +1,66 @@ # 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. (`0.2.0` was already released under the legacy bare `v0.2.0` tag — the first +lib release under the new `md2pdf-v` scheme must be a version bumped past `0.2.0`.) + +## 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. 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`.) + +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. 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. 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 + +`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. 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--

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 - * @return update information when a newer matching release is available + * @param responseJson the {@code GET /releases} JSON array response + * @return the outcome of the check, and update information when a newer matching release is + * available */ - public static Optional parseAndEvaluate( + public static UpdateCheckResult 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(); + return UpdateCheckResult.indeterminate(); } - String tagName = GitHubReleaseJson.extractTagName(responseJson); - if (tagName == null) { - LOGGER.info("Skipping update check: release response had no tag_name."); - 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 UpdateCheckResult.indeterminate(); } - 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 {}.", latestVersion, currentVersion); - return Optional.empty(); + return UpdateCheckResult.upToDate(); } // 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(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(); + return UpdateCheckResult.indeterminate(); } - List assets = GitHubReleaseJson.extractAssets(responseJson); + List assets = GitHubReleaseJson.extractAssets(releaseJson); String expectedAssetName = "md2pdf-" + latestVersion + platform.assetSuffix(); String downloadUrl = findAssetUrl(assets, expectedAssetName); if (downloadUrl == null) { @@ -117,14 +126,150 @@ public static Optional parseAndEvaluate( "Release {} is newer but ships no '{}' asset for this platform.", tagName, expectedAssetName); - return Optional.empty(); + return UpdateCheckResult.indeterminate(); } String checksumsUrl = findAssetUrl(assets, "SHA256SUMS"); - return Optional.of( + return UpdateCheckResult.updateAvailable( new UpdateInfo( latestVersion, tagName, expectedAssetName, downloadUrl, checksumsUrl, htmlUrl)); } + /** + * 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 = 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"); + } + 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("[")) { + if (fetchedPages.size() == 1) { + return page; + } + 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()) { + if (!firstPage) { + releases.append(','); + } + releases.append(pageItems); + firstPage = false; + } + nextPage = findNextPage(response.headers(), nextPage); + } + return releases.append(']').toString(); + } + + 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\"")) { + 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"); + } + 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 + * candidates whose version cannot be parsed, or {@code null} if none match. + * + *

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) { + 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; + for (String candidate : GitHubReleaseJson.splitTopLevelObjects(arrayBody)) { + String tagName = GitHubReleaseJson.extractTagName(candidate); + 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; + } + } + 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/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java b/gui/src/main/java/se/alipsa/md2pdf/gui/update/VersionComparator.java index c8054d1..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 @@ -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/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))); + } +} 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..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 @@ -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; /** @@ -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 @@ -66,6 +68,23 @@ private void respond(int status, String body) { }); } + 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"); @@ -74,17 +93,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 @@ -94,20 +117,96 @@ 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)); - 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()); + } + } + + @Test + void nextPageIsFetchedBeforeSelectingTheLatestGuiRelease() throws UpdateCheckException { + UpdatePlatform platform = UpdatePlatform.detectCurrent(); + String assetName = "md2pdf-0.1.1" + platform.assetSuffix(); + respondTwoPages( + """ + [ + { + "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), + """ + [ + { + "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"); + + if (platform == UpdatePlatform.UNSUPPORTED) { + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + } else { + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + 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()); + } } } 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..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) { @@ -36,75 +43,90 @@ 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); + 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("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 = + void alreadyLatestVersionIsUpToDate() { + 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()); + 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() { - String json = + void updateAvailableButNoAssetForThisPlatformIsIndeterminate() { + 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()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().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); + 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() { - String json = + void missingHtmlUrlIsIndeterminate() { + String release = """ { - "tag_name": "v0.1.2", + "tag_name": "MarkdownToPdf-v0.1.2", "assets": [%s] } """ @@ -113,19 +135,19 @@ 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()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().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 = + void authorProfileShapedHtmlUrlIsIndeterminate() { + String release = """ { - "tag_name": "v0.1.2", + "tag_name": "MarkdownToPdf-v0.1.2", "html_url": "https://github.com/someuser", "assets": [%s] } @@ -135,17 +157,185 @@ 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()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.LINUX_X64, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().isEmpty()); } @Test - void unsupportedPlatformReturnsEmpty() { - String json = + void unsupportedPlatformIsIndeterminate() { + 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()); + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate("0.1.1", UpdatePlatform.UNSUPPORTED, releasesArray(release)); + + assertEquals(UpdateCheckOutcome.INDETERMINATE, result.outcome()); + assertTrue(result.updateInfo().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")); + + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate( + "0.1.1", UpdatePlatform.LINUX_X64, releasesArray(libRelease, guiRelease)); + + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.1.2", result.updateInfo().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")); + + UpdateCheckResult result = + UpdateChecker.parseAndEvaluate( + "0.1.0", UpdatePlatform.LINUX_X64, releasesArray(olderGuiRelease, newerGuiRelease)); + + assertEquals(UpdateCheckOutcome.UPDATE_AVAILABLE, result.outcome()); + assertEquals("0.1.2", result.updateInfo().get().latestVersion()); + } + + @Test + 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")); + + 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(" ")); + } } 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" 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 diff --git a/release.sh b/release.sh index d7f936f..7ab858e 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,35 @@ 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) + SKIP_DEPLOY=0 + case "${2:-}" in + "") ;; + --skip-deploy) die "gui has no Maven Central deploy step, so --skip-deploy does not apply. usage: ./release.sh gui" ;; + *) die "unrecognized argument: ${2}. usage: ./release.sh gui" ;; + esac + ;; + "") + 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 +62,73 @@ 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 +# 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. 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" ] && [ -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" + +# 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" + 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 + 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" -# 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 ─────────────────────────────────────────────── @@ -84,12 +160,10 @@ 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" -# 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 +171,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 ────────────────────────────────────────────────── @@ -116,20 +193,26 @@ 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. 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 +225,22 @@ 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, 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 20000 ;; # 20 KB + *-sources.jar) echo 5000 ;; # 5 KB *) echo 1 ;; esac } @@ -162,13 +251,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 +270,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" @@ -194,14 +287,39 @@ 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" +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" \ - --notes-file "$NOTES" + --title "$TITLE" \ + --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"/* \ + --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 + # 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\n' "$VERSION" +printf '\nReleased %s %s\n' "$MODULE" "$VERSION"