Publishing

A build ends at artifacts under target/. Publishing is what makes them available to somebody else: the artifacts laid out as a repository, carrying the metadata a repository demands, and finally uploaded and signed. Publishing to Maven Central is really two jobs - produce a correct, complete bundle and upload it - and Jenesis owns the first while deliberately leaving the signed upload to a dedicated release tool.

Publishing locally with export

Another project on the same machine can require a module only once it has been exported: a build writes under its own target/, which no other project reads. export builds the project, stages the release tree that the next section describes, and copies it into the local repositories:

java build/jenesis/Make.java export
  • The maven layout exports only the Maven artifacts, into the local Maven repository (~/.m2/repository), under <group>/<artifact>/<version>/. Maven, and Gradle through mavenLocal(), consume them from there.
  • The modular layout exports only the modules, into the local module repository (~/.jenesis), under <module>/<version>/. Every export also refreshes <module>/<module>.jar, which holds the latest one.
  • modular_to_maven, the default, exports both, so one export is consumed everywhere: by a POM-based project of any build tool through the local Maven repository, and by a Jenesis project that requires the module by name through the local module repository.

A project that sets no version exports an unversioned module, whose POM carries 0-SNAPSHOT. Another project requires the module by name, and the local repositories are read before any remote: without a pin it takes the latest export, with a pinned version that version's build. A consumer does not notice a new export on its own; it takes one when it is rebuilt with -Djenesis.executor.rebuild=true.

Staging the release tree

The stage target materialises the full release tree in Maven repository layout under target/stage/maven/output/: the main jar, the POM, and - when you ask for them - the -sources.jar and -javadoc.jar that Central demands. You enable those and set the version from the command line:

java -Djenesis.project.version=1.0.0 \
     -Djenesis.project.sources=true \
     -Djenesis.project.documentation=true \
     build/jenesis/Make.java stage

Central requires the -javadoc.jar but not that it documents anything, and rendered documentation can make up most of a release's size. With -Djenesis.documentation.empty=true, the jar is still staged but holds nothing but a file named INTENTIONALLY_EMPTY, and no documentation tool runs.

Central also requires the POM to carry name, description, url, <licenses>, <developers>, and <scm>. Jenesis folds two channels into each POM. Everything it can derive from the source comes first: the coordinate from the module name, and the name and description from its Javadoc, or all three from the source pom.xml. The first sentence of the module's documentation comment is its name, and the comment's second paragraph its description, each reduced to plain text: HTML, <!-- --> comments, {@code} and {@link} markup give way to the words they mark, and a third paragraph is left for the reader of the source.

A Markdown comment (///) keeps its inline markup: `code` and **emphasis** reach the name and the description as written, and only a link is reduced to its label. The JDK renders Markdown in its javadoc tool alone, so a build has no Markdown to plain text conversion to call.

A project.properties file at the project root carries only what a module declaration cannot express. Jenesis reads it when it is there; -Djenesis.project.metadata names other files instead, and an empty value reads none:

# project.properties
url=https://github.com/jenesis/jenesis
license.apache-2_0.name=Apache-2.0
license.apache-2_0.url=https://www.apache.org/licenses/LICENSE-2.0.txt
developer.raphw.name=Rafael Winterhalter
developer.raphw.email=rafael.wth@gmail.com
scm.connection=scm:git:https://github.com/jenesis/jenesis.git
scm.url=https://github.com/jenesis/jenesis
organization.name=Example Ltd
organization.url=https://example.com
copyright=Copyright 2026 Example Ltd
manufacturer.name=Example Ltd
manufacturer.url=https://example.com
publisher=Example Ltd

organization.name and organization.url become the POM's <organization>, and a source pom.xml supplies them from its own <organization>. copyright has no place in a POM; it is taken as written into the SBOM and an installer, and no year is added to it. manufacturer.name, manufacturer.url and publisher have no place in a POM either and are recorded in the SBOM alone. Every one of these keys is optional, and nothing is recorded for a key the project does not declare.

The jar carries the POM it is published with as well, at META-INF/maven/<groupId>/<artifactId>/pom.xml, beside a pom.properties that holds its groupId, artifactId and version - where Maven places them in every jar it builds. Tools that find a jar inside an image or an archive identify it by these files: a scanner such as Syft, or GraalVM's own SBOM of a native image. The POM is generated before the jar is packed so that both carry the same one, and a module under the modular layout, which has no Maven coordinate, carries neither. -Djenesis.maven.embed=false leaves both out of the jar.

A staged bundle is reproducible: jar entries carry a fixed timestamp, the manifest records Created-By: Jenesis rather than the JDK that ran the build, and Javadoc is generated with -notimestamp, so two independent builds of the same sources hash bit-for-bit identically - a consumer can verify the bytes on Central were built from the published sources. Declare your own Created-By and it is kept.
A POM generated from a module declaration lists the resolved closure: every artifact the module was built and tested against, at the version it resolved to, each a direct dependency of its own. Because the list is already complete, every entry also excludes everything beneath it - so a consumer inherits exactly what this build verified rather than re-deriving those subtrees from today's POMs. Nothing is hidden by that: each artifact is a first-class dependency, so dependency management and version overrides still reach it.

Pointing a release at its sources

The repository URL says where a project lives, not which revision a release was built from. Three properties say that: jenesis.project.revision names the revision, for Git the commit id; jenesis.project.tag names the tag the release carries, if it has one; and jenesis.project.tree names the Git tree of that revision, which identifies the content of the whole repository independently of its history. Pass them on the command that stages the release:

java -Djenesis.project.version=1.0.0 \
     -Djenesis.project.tag=v1.0.0 \
     -Djenesis.project.revision=$(git rev-parse HEAD) \
     -Djenesis.project.tree=$(git rev-parse HEAD^{tree}) \
     build/jenesis/Make.java stage

A GitHub Actions workflow has the first two at hand: ${{ github.sha }} is the commit the run builds, and ${{ github.ref_name }} is the tag when a pushed tag started the run. The tree comes from the checkout.

- run: >
    java -Djenesis.project.tag=${{ github.ref_name }}
    -Djenesis.project.revision=${{ github.sha }}
    -Djenesis.project.tree=$(git rev-parse HEAD^{tree})
    build/jenesis/Make.java stage

Neither value is derived: projects name their tags differently, so the tag is not guessed from the version. A value that does not change from release to release can also be declared in project.properties, as scm.tag, scm.revision or scm.tree, and a source pom.xml contributes the <tag> of its <scm>. A property takes precedence over a declared value, and an empty property, such as -Djenesis.project.tag=, records nothing even when a value is declared.

The tag becomes the <tag> of the POM's <scm>; the POM has no element for a revision or a tree. The SBOM records the tag and the revision as the jenesis:scm:tag and jenesis:scm:revision properties of the project's component, and the tree in the component's swhid field, as the SWHID swh:1:dir:<tree>; a tree that is not a 40-character Git tree id fails the build. When scm.connection names a URL, such as scm:git:https://github.com/jenesis/jenesis.git, the SBOM also carries a vcs reference that locates the sources at the revision, or at the tag when no revision is given, in the notation SPDX uses for a download location: git+https://github.com/jenesis/jenesis.git@<revision>. A tag of HEAD, which a pom.xml declares for the root of its repository, counts as no tag there.

Publishing a bill of materials

A module can also publish the pin set of its own resolved closure, so a downstream project imports the versions this one was built and tested against instead of curating its own. It is the emitting counterpart of the bills of materials you already know how to consume.

A bom.properties file in the configuration folder switches it on - the file may be empty, presence is the switch. The modular layouts then render the module's closure as a properties file that export publishes into the local module repository beside the module jar:

~/.jenesis/demo.bom/1.0.0/demo.bom.properties

Another project consumes it with @jenesis.bom demo.bom, exactly the way it consumes a hand-written file. The BOM travels through the module layout only; the Maven export never carries it.

Signing the jar itself

There are two signatures around a published jar, and they answer different questions. The detached one the next section covers travels beside the artifact and says who published it. The other lives inside the archive: jarsigner, the JDK's own signer, writes a manifest of digests and a signature block into the jar, which a JVM can check as it loads the classes.

Naming a key store turns it on:

jenesis.jarsigner.keystore     the key store to sign with
jenesis.jarsigner.alias        the key within that store
jenesis.jarsigner.storepass    where the store's password is read from
jenesis.jarsigner.keypass      the same, where the key has a password of its own
jenesis.jarsigner.storetype    the JDK's own default otherwise, normally PKCS12
jenesis.jarsigner.tsa          a timestamp authority, so a signature outlives the certificate
jenesis.jarsigner.arguments    anything else jarsigner accepts

These are properties rather than a configuration file because where the key is and what unlocks it differ between a laptop, a release machine and a CI runner, and a developer without the key still has to be able to build. They belong to the machine that signs: a project's jenesis.properties or profile that sets any of them is refused, since a file travelling with the sources would decide which key a build reaches for and which file a password is read from. A machine holds them once, for everything built on it:

# ~/.jenesis/jenesis.properties
jenesis.jarsigner.keystore=/home/me/.keys/signing.p12
jenesis.jarsigner.alias=me
jenesis.jarsigner.storepass=file /home/me/.keys/signing.pass

and a release runner names them for one run, where a -D wins over that file:

java -Djenesis.jarsigner.keystore=/etc/jenesis/release.p12 \
     -Djenesis.jarsigner.alias=release \
     -Djenesis.jarsigner.storepass=env JENESIS_KEYSTORE_PASSWORD \
     build/jenesis/Make.java

Naming any of those keys says the project signs its jar. Saying that and then leaving the key store, the alias or the password location unnamed stops the build rather than producing an unsigned jar - a release that shipped unsigned because a runner forgot a flag is the one outcome worth refusing. A project that names none of them does not sign.

A password is never a value, not even a property value. storepass and keypass take a location in jarsigner's own grammar - env <variable> or file <path> - and anything else fails the build naming the two forms; a password passed as -D would otherwise stand in the process list of the machine that runs the build. The key store itself is referenced by path and never copied into target/, so no private key reaches the build tree or a shared build cache.

Signing is part of producing the artifact rather than a step after it: the signer reads the jar the archiver wrote and writes the signed jar in its place, so the unsigned jar never leaves the toolchain. The module's inventory, both staged repositories, an export and a publication all see only the signed jar - and a detached signature made afterwards covers the signed bytes.

The step re-runs when the jar changes or when any of those settings change. It does not hash the key store's contents, so replacing the key behind an unchanged path does not by itself invalidate the step: delete target/ after a key rotation.

Releasing into a Jenesis module repository

One destination the tool publishes to by itself: a Jenesis module repository, the layout a modular build resolves module names from. export reaches one machine; once jenesis.release.uri names a repository, release puts each staged module there as its release/jenesis step, for every machine that resolves from it. A jenesis repository of a Jenesis Repository takes that release and serves the layout, at the same address a build names in jenesis.module.uri:

java -Djenesis.project.version=1.0.0 \
     -Djenesis.release.uri=https://repo.example.com/repository/releases/<repo>/ \
     -Djenesis.release.token=<key> \
     build/jenesis/Make.java release

Each module is put once: its jar, under its version, at module/<module>/<version>/<module>.jar below that address. What is staged beside the jar, such as the -sources.jar or the POM, stays in the staged tree. A project whose jenesis.module.uri names the same address then resolves the module by its name at that version. release does this in the modular and modular_to_maven layouts, and refuses to run in the maven layout, which stages no modular tree.

A release needs a version: a module staged without one, as when jenesis.project.version is not set, fails the release before anything is put into the repository, and so does a module staged at more than one version.

The key is sent as the Authorization header, exactly as given. Where the settings are not given, a release reads the JENESIS_RELEASE_URI and JENESIS_RELEASE_TOKEN environment variables - never the variables naming the repositories a build resolves from, so a CI job keeps its release key apart from the key it reads with. The key is a credential: only the command line, ~/.jenesis/jenesis.properties or the environment may name it, and it is never sent to a repository that a file of the project named, so a project that names its own release address in jenesis.properties releases without a key. A plaintext http: address is refused unless -Djenesis.repository.insecure=true allows it.

A project that publishes to Maven does not need this step to reach module consumers: a java repository of a Jenesis Repository takes Maven publishes only, and makes a module available from one. A modular jar deployed to its Maven layout, by JReleaser, mvn deploy or any other tool, is served by its module name as well.

The last mile: signing and uploading

For Maven Central, and for every other publication beyond a Jenesis module repository, the remote upload and GPG signing are not Jenesis's job. Point JReleaser at target/stage/maven/output/ and it signs every artifact and uploads the bundle to Central. Jenesis stops at the unsigned, validated bundle, so credentials and signing keys never enter the build.

This split is deliberate. Most people building a project never release it: releasing is a rare, tightly controlled job for CI or a hardened environment that holds the keys. And the way you release evolves independently of how the build produces artifacts.

Driving the release tool from the build

You can still reach that tool through the same selector vocabulary as everything else. release is a target on every layout, and it depends on stage, so what a release tool uploads is always the tree this build just produced:

java build/jenesis/Make.java release

The target exists whether or not anything is configured; the tool is what a file activates. A jreleaser.yml (or .yaml, .toml, .json) at the project root adds a release/jreleaser step - a project-root lookup rather than the usual per-module configuration folder, because JReleaser resolves every path in its configuration against one base directory. -Djenesis.jreleaser.config=<path> names a different file.

It contributes two steps. The first writes a jreleaser.properties holding JRELEASER_PROJECT_VERSION, the version this build stamped, so the version is stated once rather than passed to two tools that can then disagree. Point a configuration at it with environment: { variables: target/release/jreleaser/environment/output/jreleaser.properties }. The second runs the jreleaser executable found in the environment, forwarding the process environment unchanged. Every JRELEASER_* credential is therefore read by JReleaser itself and never touched, logged, or stored by the build.

release is a dry run by default: every local phase runs and every remote one is skipped. A real release needs -Djenesis.jreleaser.dry=false, the single switch that separates a rehearsal from a publication - so no combination of selectors alone can publish.
JReleaser is expected from the environment rather than resolved as a dependency, the way native-image is. Every tool that shapes an artifact - a compiler, a linter - is pinned, because reproducing a build means reproducing it exactly. A release tool shapes nothing: it transmits a finished, already-reproducible tree. What it needs instead is credentials, network access, and a git identity, which belong to the release environment.
Prefer a dedicated release integration wherever your CI offers one - JReleaser ships a GitHub Action, and other platforms offer equivalents. They pin the tool version, wire the platform's secret store, and publish the release logs. The release target is for what they do not cover: rehearsing a release locally, and releasing from a pipeline that has no such integration.