build(distrib): add jdeploy packaging for native installers

Introduces native installer generation for Windows, macOS, and Linux
using jDeploy. The packaging is driven by package.json and a GitHub
Actions workflow that builds platform-specific bundles with embedded
Azul Zulu FX JRE 26.

Key additions:
- package.json descriptor with jDeploy configuration targeting 7
  platforms (excludes windows-arm64 due to missing Azul FX build)
- jdeploy-maven-plugin behind a profile to sync descriptor fields
  from POM without requiring Node in default builds
- GitHub workflow triggered by v* tags (versioned releases) or
  *-snapshot branches (rolling prereleases)
- Version-free finalName (pholio.jar) so jar path stays stable
- Launcher reads package-info.json from jdeploy tag for auto-update

The spring-boot-maven-plugin's mainClass is the single source of
truth for the entry point: it lands in the repackaged jar's manifest
as Start-Class, which jDeploy's native launcher uses to bootstrap.
This commit is contained in:
2026-07-29 23:32:30 -04:00
parent f581d05c68
commit 7b2a184dd4
6 changed files with 200 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# Builds the native installers for Windows, macOS and Linux and publishes them as GitHub release assets.
#
# Two channels, distinguished by what was pushed:
# * a `v*` tag -> a versioned release; the tag name minus the leading `v` becomes the app version.
# * a `*-snapshot` branch -> a rolling prerelease named after the branch, replaced on every push.
#
# Auto-update needs nothing else from us. The jDeploy action writes a package-info.json to a `jdeploy` tag in
# this repository, and the native launcher installed on a user's machine reads it on startup to notice a newer
# release. That only works while the repository is public — a private repository needs a separate public
# release-only repository passed as `target_repository`.
name: jdeploy
on:
push:
tags:
- 'v*'
branches:
- '*-snapshot'
# Lets the pipeline be exercised without cutting a tag. Running it on a branch produces the snapshot
# prerelease for that branch, exactly as a push would.
workflow_dispatch:
# The action rewrites a shared `jdeploy` tag, so two runs must never overlap. Queued rather than cancelled:
# cancelling mid-upload is what leaves that tag half written.
concurrency:
group: jdeploy-release-${{ github.repository }}
cancel-in-progress: false
jobs:
bundle:
runs-on: ubuntu-latest
permissions:
# Needed to create the release and to push the `jdeploy` tag that carries package-info.json.
contents: write
steps:
- uses: actions/checkout@v4
# Zulu rather than Temurin: the JavaFX dependencies are pinned to a Zulu FX build locally, and Azul
# publishes non-LTS feature releases, which Java 26 is.
- name: Set up JDK 26
uses: actions/setup-java@v4
with:
java-version: '26'
distribution: 'zulu'
cache: maven
# The -Pjdeploy profile synchronises package.json by shelling out to `npm pkg set`, so Node has to be on
# PATH before Maven runs, not just before the jDeploy action.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build
run: ./mvnw -B --no-transfer-progress -Pjdeploy package
- name: Build app installer bundles
uses: shannah/jdeploy@v6.1.5
with:
github_token: ${{ github.token }}
# Kept in step with jdeploy.cli.version in pom.xml. The action's own default lags its release tag.
jdeploy_version: '6.1.5'
+6
View File
@@ -42,3 +42,9 @@ logs/
*.mv.db
*.trace.db
### jDeploy
# Generated by the jDeploy CLI: the npm-shaped launcher bundle and the installer/release staging directory.
jdeploy-bundle/
jdeploy/
node_modules/
+29
View File
@@ -11,6 +11,35 @@ This project is a desktop application for images / pictures management. Like col
* For packaging, use spring boot generated jar, and jdeploy to create OS package (jdk/jre included)
* Use lombok when applicable (Constructor and getter / setter)
## Packaging & distribution (jDeploy)
Native installers with a bundled JRE are produced by jDeploy, driven from two files: `package.json` at the
repository root (the descriptor) and `.github/workflows/jdeploy.yml` (the release pipeline).
* **Entry point.** jDeploy has no main-class setting — it runs the jar. So the entry point is declared once, as
`spring-boot-maven-plugin`'s `<mainClass>${main.class}</mainClass>`, which lands in the repackaged jar's
manifest as `Start-Class`. `jdeploy.jar` in `package.json` points at `target/pholio.jar`; `<finalName>` is
version-free precisely so that path never needs updating.
* **Runtime.** `jdeploy.javaVersion: "26"` with `jdeploy.javafx: true` provisions an Azul Zulu FX **JRE** per
platform. `windows-arm64` is deliberately absent from `downloadPage.platforms`: Azul publishes no FX build for
it at 26, so listing it would produce an installer that cannot start.
* **Syncing the descriptor.** `mvn -Pjdeploy package` runs `jdeploy-maven-plugin:sync-package-json`, which
copies version, name, description, title, jar path, Java version and the JavaFX flag from the POM into
`package.json`. It works by shelling out to `npm pkg set`, which is why it sits behind a profile — a plain
`mvn package` must not require Node. Every other key in `package.json` is hand-maintained.
* **Releasing.** Push a `v<semver>` tag (the leading `v` is stripped to form the app version); the workflow
builds the bundles and attaches them to that release. Pushing a `*-snapshot` branch publishes a rolling
prerelease instead. Branch and tag names must be at most 16 characters of `[A-Za-z0-9._-]` — the jDeploy
action silently skips anything else.
* **Auto-update.** Handled entirely by the native launcher: it reads a `package-info.json` that the action
writes to a `jdeploy` tag in this repository, and picks up a newer release on next start. This requires the
repository to stay public; a private one needs a separate public release repository passed to the action as
`target_repository`.
* **Do not add `documentTypes`, `urlSchemes` or `singleton` without changing the launcher first.** jDeploy
passes opened files and URIs to `main`, and `LaunchOptions` reads any unrecognised argument as a subcommand
and switches to `HEADLESS`. File associations would therefore make a double-clicked photo start Pholio with
no window.
## Testing
* for unit test use AssertJ with SoftAssertion
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+35
View File
@@ -0,0 +1,35 @@
{
"name": "pholio",
"version": "0.1.0-SNAPSHOT",
"description": "High-volume desktop photo and media library manager",
"author": "Christophe Lallement",
"repository": "https://github.com/Imag-In/pholio",
"bin": {
"pholio": "jdeploy-bundle/jdeploy.js"
},
"files": [
"jdeploy-bundle"
],
"preferGlobal": true,
"jdeploy": {
"jar": "target/pholio.jar",
"javaVersion": "26",
"javafx": true,
"jdk": false,
"title": "Pholio",
"args": [
"-Djdeploy.file.limit=8192"
],
"downloadPage": {
"platforms": [
"mac-arm64",
"mac-x64",
"windows-x64",
"linux-x64",
"linux-arm64",
"debian-x64",
"debian-arm64"
]
}
}
}
+66
View File
@@ -43,6 +43,16 @@
<!-- caffeine, h2, flyway and jackson versions come from the Spring Boot 4.1.0 BOM. -->
<main.class>io.pholio.PholioApplication</main.class>
<!-- ========================== Packaging =========================== -->
<jdeploy-maven-plugin.version>1.0.8</jdeploy-maven-plugin.version>
<!--
The jDeploy CLI release that builds the native bundles. Kept in step with the version pinned in
.github/workflows/jdeploy.yml on purpose: the CLI that generates package.json fields locally and the
one that assembles the installers in CI must agree, or a locally-synced descriptor can carry keys the
release build does not understand.
-->
<jdeploy.cli.version>6.1.5</jdeploy.cli.version>
</properties>
<dependencies>
@@ -162,10 +172,22 @@
</dependencies>
<build>
<!--
A version-free jar name. jDeploy points at the executable jar by path from package.json, so a name
carrying the version would go stale on every release and have to be re-synced before the descriptor
was usable. The installed Maven artifact keeps its normal versioned coordinates either way.
-->
<finalName>pholio</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!--
The repackaged jar is what jDeploy ships. Its manifest carries Main-Class=JarLauncher and
Start-Class=${main.class}, which is how the native launcher finds the entry point: jDeploy has
no main-class setting of its own, it runs the jar. So this mainClass is the single declaration
of the entry point for the packaged application as well as for `java -jar`.
-->
<configuration>
<mainClass>${main.class}</mainClass>
</configuration>
@@ -188,4 +210,48 @@
</plugin>
</plugins>
</build>
<profiles>
<!--
Keeps package.json in step with the POM before the native bundles are built.
Behind a profile rather than in the default build because the plugin does its work by shelling out to
`npm pkg set` for every field, so it makes Node a hard requirement of the phase it runs in. A plain
`mvn package` must stay buildable on a machine that has never seen npm; the release workflow activates
this profile, and so should anyone about to run the jDeploy CLI by hand.
What it synchronises: version, name, description and jdeploy.title from the POM, jdeploy.jar from the
build output, jdeploy.javaVersion from ${java.version}, and jdeploy.javafx from the presence of the
org.openjfx dependencies. Everything else in package.json is hand-maintained and left untouched.
-->
<profile>
<id>jdeploy</id>
<build>
<plugins>
<plugin>
<groupId>ca.weblite</groupId>
<artifactId>jdeploy-maven-plugin</artifactId>
<version>${jdeploy-maven-plugin.version}</version>
<configuration>
<jdeployVersion>${jdeploy.cli.version}</jdeployVersion>
</configuration>
<executions>
<execution>
<id>sync-package-json</id>
<!--
Declared after spring-boot-maven-plugin's repackage, which is also bound to
`package`: same-phase executions run in declaration order, and profile plugins
come last, so the jar the descriptor points at already exists.
-->
<phase>package</phase>
<goals>
<goal>sync-package-json</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>