Packaging & releases
Marginalia is built once as a WAR and then shipped in two forms: a Docker image for servers and a desktop app with its own Java runtime and Jetty. This page describes how each is built, what CI does, and how a release is made. For everyday builds see Building from source.
flowchart LR
src["marginalia/ sources"] --> war["marginalia-1.0.0.war"]
war --> docker["Docker image<br/>Jetty 12 + WAR"]
war --> desktop["Desktop distribution<br/>jlink runtime + Jetty + WAR + launcher"]
desktop --> portable["portable archive<br/>marginalia-<v>-<os>-<arch>"]
desktop --> appimage["native app image<br/>marginalia-<v>-<os>-<arch>-app"]
src -.-> plugins["plugin JARs<br/>(built separately)"]The WAR
mvn package in marginalia/ produces target/marginalia-1.0.0.war (see
Building the application). The WAR always runs in Vaadin production mode:
build-frontend writes the production flow-build-info.json into it, and a development one left in
src/main/resources is never packaged (see Development and production mode).
One resource differs between a development build and a release build:
The build decides, not the packaging: IDE runs and mvn jetty:run use target/classes, which has the development
file. The WAR plugin adds src/main/resources-release/ to WEB-INF/classes and leaves the development file out, so
every WAR from mvn package - the Docker image, the desktop profile, a WAR deployed by hand - logs at INFO.
The build also needs the Git history: git-commit-id-plugin writes the commit id and the closest tag into
git.properties, and the WAR filters them into its build info. Build from a clone, not from a source archive.
Docker image
The Dockerfile in the repository root has two stages:
- builder (
maven:3.9-eclipse-temurin-25)-
copies
marginalia/pom.xmland runsmvn dependency:go-offline, so the dependency layer is cached while only the sources change; -
copies
marginalia/srcand.git(forgit-commit-id-plugin); -
runs
mvn clean package -DskipTests.
-
- runtime (
jetty:12-jdk25-eclipse-temurin)-
creates a Jetty base with the modules
server, http, ee11-deploy, ee11-websocket-jakarta, ee11-webapp, ee11-jsp(the WebSocket module is required for Vaadin push); -
deploys the WAR as
webapps/ROOT.war, so Marginalia is at/; -
creates
/var/marginalia/.marginaliafor the data, owned by thejettyuser; -
declares a
HEALTHCHECK(every 30 s, 120 s start period): the image has nocurl, sobashopens a socket to127.0.0.1:8080, sendsGET /and the container is healthy when the status line is 2xx or 3xx (the login redirect counts). Docker only reports the state, it does not restart the container; -
starts with
docker-entrypoint.sh(asmarginalia-entrypoint.sh) as root: it creates the data folder if needed and, when it isn't owned byjetty- a./databind mount that Docker created on a Linux host is owned by root - changes its owner tojetty(recursively, only then). It then runs the Jetty image's own/docker-entrypoint.shand Jetty asjetty(setpriv), on port 8080. Started with another user (--user, composeuser:), it changes nothing and the folder must already be writable for that user.
-
Tests are skipped in the image build - they run in CI. .dockerignore keeps marginalia/target/, .idea/, the
server's data/ folder and the plugin folders out of the build context.
docker-compose.yml builds the image and sets the runtime configuration:
server.yml publishes the image to the GitHub Container Registry (see docker compose up -d --build from a clone (see
Server (Docker)). A published image is used with the same data volume and
JAVA_TOOL_OPTIONS as in docker-compose.yml.
To try a change in the image:
docker compose build
docker compose up # foreground, logs in the terminal; Ctrl+C stops it
Use a different ./data folder (edit the volume) when testing, so you don't touch a real installation's data.
Desktop app
The desktop Maven profile turns the WAR into a self-contained application for the operating system and CPU it is
built on:
cd marginalia
mvn package -Pdesktop -DskipTests
It needs a full JDK 25 (with jlink and jpackage) and, on Windows, tar.exe from System32 (present on Windows 10
and later).
What the profile does
In prepare-package:
- downloads
jetty-home(versiondesktop.jetty.version) intotarget/desktop/download/.
In package (after the WAR is built):
-
compiles
src/desktop/java(DesktopLauncher, JDK only) intoapp/marginalia-launcher.jar; -
copies the WAR to
server/marginalia.warand unpacksjetty-homeintoserver/jetty-home/; -
builds a trimmed Java runtime with
jlink(the modules indesktop.modules) intoruntime/; -
copies the launch scripts from
src/desktop/bin/intobin/; -
builds a native app image with
jpackage --type app-image(no installer), with the runtime, the launcher JAR and theserver/folder as app content; -
packs both into archives.
Outputs in target/desktop/:
<os> is linux, macos or windows, <arch> is Java's os.arch (amd64, aarch64...). Archives are made with
tar so the executable bits of the launchers survive.
The launcher
DesktopLauncher is the main class of the app image and of the portable scripts. It depends on the JDK only and
starts Jetty in its own JVM by loading start.jar through a class loader, so it never needs Jetty on the classpath
and never ends up in the WAR. On start it:
-
resolves the data folder
<home>/.marginalia(--homesetsuser.home) and redirects its output to.marginalia/desktop/logs/; -
finds
server/next to or above the launcher JAR (jpackage places app content differently per OS); -
checks the port (default 8765): if Marginalia already runs there, it just opens the browser; if another program uses it, it picks a free port (unless
--portwas given); -
writes a Jetty base into
.marginalia/desktop/jetty-base/(modulesee11-*,webapps/ROOT.xmlpointing to the WAR, a persistent work folder) - so the installation itself can stay read-only, e.g. inside a signed app bundle; -
starts Jetty on
127.0.0.1, waits up to three minutes for the start page, then shows the tray icon (or a small window where there is no system tray) and opens the browser.
The JVM options needed by the extension agent (-Djdk.attach.allowAttachSelf=true -XX:+EnableDynamicAgentLoading,
plus --enable-native-access=ALL-UNNAMED for SQLite) are passed by jpackage --java-options and by the scripts in
bin/. The scripts add MARGINALIA_JAVA_OPTS. Keep the three places in sync - the desktop.javaOptions property
and both scripts. On macOS, -Dapple.awt.UIElement=true keeps the app out of the Dock; it lives only in the tray.
Command-line options are described in Desktop app.
Cross-platform builds
jlink and jpackage only build for the platform they run on, so each operating system and CPU is built on its own
machine. CI does this with a build matrix (below). Locally you get only your own platform.
If you add a dependency that needs a JDK module not in desktop.modules (a ClassNotFoundException or
NoClassDefFoundError for a java.* / javax.* / jdk.* class in the desktop log only), add the module to the
list. jdeps --print-module-deps on the WAR's libraries helps to find it.
Signing
The app images are not signed or notarized. macOS Gatekeeper and Windows SmartScreen warn on the first start; the
Desktop app page tells users how to get past it. Signing would be added to
the jpackage call (--mac-sign, --mac-signing-key-user-name...) and, for Windows, as a separate signtool step
in CI.
Plugins
Each plugin in marginalia/plugins/ is a Maven project with bundle packaging (Apache Felix
maven-bundle-plugin) that depends on the application's classes JAR with provided scope. They are built against
one exact version of the application:
cd marginalia && mvn install -DskipTests
cd plugins/reviewer && mvn package # target/reviewer-1.0.0.jar
server.yml builds all five plugins against the freshly installed application and attaches the JARs to the release
of a v* tag; the Docker image doesn't contain them (server users put them into <data folder>/extensions or install
them in Admin → Extensions). When you change application classes that a plugin
uses (decorated @Extendable methods, UI classes, services), rebuild the plugin and test it against the new
application. See Plugin development.
Continuous integration
Two workflows, desktop.yml (tests and the desktop archives) and server.yml (WAR, plugins, Docker image). Both run
on pushes to master, on v* tags, on pull requests that change marginalia/** or the workflow, and by hand
(workflow_dispatch). A newer run on the same ref cancels the running one.
desktop.yml
flowchart LR
test["test<br/>mvn test (ubuntu)"] --> matrix
subgraph matrix["desktop (matrix)"]
l64[linux-x64]
la64[linux-arm64]
w64[windows-x64]
ma[macos-arm64]
mx[macos-x64]
end
matrix --> release["release<br/>(v* tags only)"]All jobs use Temurin JDK 25 with the Maven cache and check out the full history (fetch-depth: 0) for
git-commit-id-plugin.
The smoke test starts the native app (and on Linux and macOS also the portable bin/marginalia) with a temporary
--home, a fixed port, --no-browser --no-tray, and waits up to three minutes until the start page contains
<title>Marginalia</title>. It fails and prints the launcher log otherwise. It catches broken packaging - a missing
JDK module, a launcher that can't find server/, a WAR that doesn't deploy - but doesn't test any feature.
server.yml
flowchart LR
build["build<br/>WAR + 5 plugins"] --> publish
docker["docker (amd64, arm64)<br/>build, smoke test, push by digest"] --> publish["publish<br/>multi-platform manifest"]
publish --> release["release<br/>(v* tags only)"]The image is ghcr.io/<owner>/marginalia and is pushed with the workflow's GITHUB_TOKEN. The package is private
the first time it is pushed; make it public in the package settings on GitHub so that users can pull it without logging in.
Making a release
- Version. Set the new version in
marginalia/pom.xml(<version>) and in the plugin POMs (the plugin version and the version of themarginaliadependency). The archive names and the app version shown by the OS come from the POM, not from the tag, so the two must match: for av*tag thetestjob first checks that the tag isv+ the POM version and fails otherwise, so no archives are built or attached.jpackageon macOS requires a version of the formx.y.zwithx≥ 1. - Database. If the release changes the schema, check that the migrations upgrade a database of the previous
release (keep a copy of one around) -
FlywayMigrationTestcovers only the migrations themselves. - Manual. Update the manual for user-visible changes, run
retype build, commitdocs/. - Test.
mvn test, andmvn package -Pdesktopon at least your own platform; start the app and generate a part against a real model. -
Tag. Commit, then create and push the tag:
git tag v1.1.0 git push origin master v1.1.0 - Release. Create the GitHub release for the tag (by hand or
gh release create v1.1.0) with the release notes.desktop.ymlattaches the ten desktop archives when its matrix finishes,server.ymlthe WAR and the plugin JARs and pushes the Docker image - whichever finishes first creates the release if it doesn't exist yet (softprops/action-gh-release). - Plugins. The plugin versions must match the application version (point 1); the JARs are attached automatically.
- Announce upgrades. Docker users update with
docker compose pull && docker compose up -d(published image) orgit pull && docker compose up -d --build(built from source), desktop users replace the app. Remind them to make a database backup first when the schema changed.
Publishing the manual
The manual is part of the repository: Retype reads manual/ and writes the static site to docs/ (configured in
retype.yml). GitHub Pages serves docs/ from the master branch, so the published manual changes when a commit
with a new docs/ reaches master. Rebuild and commit docs/ in the same change as the sources - see
Building the manual.