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.6.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"]
appimage --> installer["installer<br/>.deb / .dmg / .msi"]
plugins["plugin JARs"] --> full["full installer<br/>-full.deb / .dmg / .msi"]
desktop --> full
src -.-> plugins["plugin JARs<br/>(built separately)"]The WAR
mvn package in marginalia/ produces target/marginalia-1.6.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 user manual is packed too: <resources> in marginalia/pom.xml copies the Markdown pages of manual/user (no
pictures, never filtered) to classpath:/manual/, where ManualService reads them (see
Services). The source folder is outside marginalia/, so a build that copies only
marginalia/src must copy manual/user as well, as both Dockerfiles do (/build/manual/user); without it the WAR
builds, with an empty manual.
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 the parent
pom.xmlandmarginalia/pom.xmland runsmvn dependency:go-offline, so the dependency layer is cached while only the sources change; -
copies
marginalia/src,manual/user(the manual packed into the WAR) and.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 /healthand the container is healthy when the status line is 200 (the application context is up and the database answers, see Architecture). 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, marginalia/node_modules/ and the plugin target/ and build/ folders out of the build context
(the plugin sources are in it, because the
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;
-
(second execution,
desktop-installer) wraps the app image into an installer withjpackage --type deb|dmg|msi --app-imageand names it like the archives. Windows installs per user (no administrator rights) with a fixed upgrade UUID (--win-upgrade-uuid) so a newer.msireplaces the older one; Linux installs to/opt/marginaliawith a menu entry and theLICENSEas the copyright file. jpackage needsdpkg-debandfakeroot(Linux),hdiutil(macOS) or WiX Toolset 3 (Windows). A machine without them prints a warning and goes on, unless-Ddesktop.installerRequired=true(CI sets it);-Ddesktop.skipInstaller=trueleaves the installer out. The installer steps live insrc/desktop/installer.xml(targetsplainandfull), called from the antrun executionsdesktop-installeranddesktop-full-installer; -
(
desktop-full-installer, off unless-Ddesktop.skipFull=false) the full installer: a second app image made with the plugin JARs ofdesktop.pluginsDir(defaulttarget/desktop-plugins, the build fails when it is empty) as extra--app-content(plugins/next toserver/), and an installer of that image named…-full.deb|dmg|msi. It has the same name, package name and Windows upgrade UUID as the plain one, so they replace each other.
Outputs in target/desktop/:
<os> is linux, macos or windows, <arch> is Java's os.arch (aarch64...), except that amd64 is written x86_64 on every system. 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.
Bundled plugins. In a full build the plugin JARs are in plugins/ next to server/ (Contents/plugins in the
macOS bundle). BundledPlugins (JDK only, with unit tests in BundledPluginsTest, the launcher sources are a test source
root) copies them into <home>/.marginalia/extensions before Jetty starts: once per JAR file (the names copied are kept
in .marginalia/desktop/bundled-plugins.txt), never a plugin that was copied earlier and is gone from the folder (removed
by the administrator), a newer version of an installed plugin next to the old JAR (OsgiService keeps the newest and moves
the older ones to old-versions/), never over a JAR of the same name. A failure is logged and does not stop the start.
-Dmarginalia.pluginsDir overrides the folder.
The icon is src/desktop/icons/marginalia.{icns,ico,png} (made from src/main/webapp/icons/icon.svg): the antrun step
picks the file for the OS (desktop.icon) for jpackage --icon, and copies the PNG into the launcher jar for the tray
icon. The browser favicon is AppShellConfig (SVG) plus the PNG that Vaadin's @PWA reads from icons/icon.png.
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 and installers 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:
mvn -N install # the parent POM in the repository root
cd marginalia && mvn install -DskipTests
cd plugins/reviewer && mvn package # target/reviewer-1.6.0.jar
server.yml builds all the 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.
Building the plugins in Docker
marginalia/plugins/Dockerfile is a separate build environment for the plugins, used by servers that build from
source (and by anyone without a local JDK/Maven). Its builder stage (Maven + JDK 25, like the main Dockerfile)
installs the application into the image's local Maven repository (with -Dvaadin.skip=true: the plugins need only the
classes JAR, so the frontend is not built, see Building) and builds every plugin against it; the final
stage is a small Alpine image that holds the JARs in /jars. Its command copies them to /output, replacing the
*.jar files there, so the compose file (marginalia/plugins/docker-compose.yml, build context ../..) mounts
marginalia/plugins/build as /output. The plugins are built one after the other and none of them needs another one
installed first (the Lore Generator finds the Name generator at run time, not at build time). The Name generator's
build clones the sources of its names from GitHub (the Maven image has git); without network it keeps the tables
committed in the repository, see its README:
docker compose -f marginalia/plugins/docker-compose.yml build
docker compose -f marginalia/plugins/docker-compose.yml run --rm plugins
The JARs are not copied while the image is built, because a bind mount exists only when a container runs.
rebuild.sh (Linux, macOS) and rebuild.bat (Windows) in the repository root update a Docker server: when the
upstream branch has new commits they stop the container, git pull, build the plugins as above, copy the JARs into
data/extensions (overwriting the installed ones; extensions load on start) and run docker compose up -d --build.
--force skips the check for new commits. The two scripts must be kept in step; .gitattributes keeps the shell
script on LF and the batch file on CRLF.
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
plugins["plugins<br/>plugin JARs (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>. On Linux it also installs the .deb and then the full .deb and starts the installed /opt/marginalia/bin/Marginalia each time; the plain package must install no plugin, the full one must have copied its plugins into the extensions folder. 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 + 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 the parent
pom.xmlin the repository root and in the<parent>of the application and the plugin POMs -mvn versions:set -DnewVersion=<version> -DgenerateBackupPoms=falsein the root does all of it (the plugins'marginaliadependency follows${project.version}). 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
tools/retype_build.sh, 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.6.0 git push origin master v1.6.0 - Release. Create the GitHub release for the tag (by hand or
gh release create v1.6.0) with the release notes.desktop.ymlattaches the desktop archives and installers (twenty files) 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.