Pinning & bills of materials

Dependencies pinned a single module at a version you chose. Jenesis aims further: a pin for every dependency in the transitive closure, each with the SHA-256 checksum of its jar, written for you rather than by hand and committed with your sources. A later build that resolves a jar whose bytes do not match the recorded checksum fails, so the build is resistant to a supply-chain swap at a coordinate you already trusted. The second half of the chapter is how to share one such record across modules and projects instead of repeating it.

Recording the pins

You rarely write pins by hand. The pin selector resolves the closure, hashes each jar, and rewrites your sources:

java build/jenesis/Make.java pin

It is opt-in - not part of build - and it writes into your project tree rather than under target/: a @jenesis.pin tag per dependency in a modular project, a <dependencyManagement> block with a <!--Checksum/…--> comment per entry in a pom.xml project. Commit the result and the pin set travels with the project. pin runs the build it pins, so whatever the changed versions reach is built again, a module's tests included; -Djenesis.test.skip=true leaves the tests to the next build. It runs last, after stage and a dry release, so the tool a release plugin resolves is pinned too. In a <dependencyManagement>, pin updates an entry it resolves where it stands and adds the others at its end; every other entry, an imported bill of materials, the entry of a module the build builds and every comment stays as written. A -SNAPSHOT is given no checksum, as it is published again under its version, and an entry that carries a checksum pin wrote but that nothing resolves any longer is dropped. A managed entry that pin rewrites keeps its <exclusions>, those an imported bill of materials declares for it included, and the <scope> such a bill of materials gives it. An entry whose <version> is a property, such as ${kotlin.version}, keeps the property, and its comment names the version the checksum was taken of (<!--Checksum/SHA-256/… 2.2.20-->): once the property names another version, the checksum no longer applies, so a strict build asks for pin again rather than failing on a mismatched digest. The main code and the tests of a pom.xml share its one <dependencyManagement>, so where they resolve a coordinate at different versions, its entry takes the version the POM declares itself, which the other half then resolves as well. A module of a pom.xml project inherits the <!--jenesis.plugin--> comments of a parent POM within the project, but its <!--jenesis.pin--> comment is its own: pin writes one into every module's POM, and a parent's is not read for it.

pin is project-wide, and a +<module> selector beside it narrows build rather than the pin. To pin one module, name its step - <path> is the module's folder with + in place of /, as the step's folder under target/ is named:

java build/jenesis/Make.java pin/module-api+client

A pin in module-info.java reads:

/**
 * @jenesis.pin org.apache.commons.text 1.12.0 SHA-256/8f2b...c41
 */
module demo.app {
    requires org.apache.commons.text;
}

A project on the JEP 467 Markdown form declares them in /// comments instead, and pin writes back in whichever form the comment already uses - creating a /** … */ only where there is no comment at all, so a project never ends up with one of each.

Explanatory prose goes above the tag block, never below or between the tags. A javadoc tag owns every line beneath it until the next tag, so a paragraph written under a @jenesis.pin arrives as part of that pin's value. Jenesis rejects such a declaration and quotes the absorbed text back at you; the fix is always to move the description into the comment's body.
Re-run pin whenever you change a dependency; it refreshes the versions and checksums from the new closure. To record versions without checksums, pass -Djenesis.pin.checksum=false. The digest defaults to SHA-256 and is set with -Djenesis.project.digest=<algorithm>.

In the modular_to_maven layout, which a module-info.java project uses by default, a module name pins the module a requires names, and everything that module's POM brings in is pinned by its Maven coordinate. To hold one of those at another version, change its coordinate line:

/**
 * @jenesis.pin org.apache.commons.text 1.12.0
 * @jenesis.pin org.apache.commons/commons-lang3 3.17.0
 */
module demo.app {
    requires org.apache.commons.text;
}

commons-text's POM asks for commons-lang3 3.14.0; the coordinate line overrides it wherever the closure reaches it, and the next pin records the checksum of the version you chose.

A coordinate with a type or a classifier is longer than <groupId>/<artifactId>, so it is written with its repository in front, as maven/<groupId>/<artifactId>/jar/<classifier> names the type jar and a classifier. Without the maven/, the group ID would read as a dependency group, and the build refuses such a token, naming the spelling to write.

A refresh rewrites every line of the closure it resolved and removes every other line, except those of a group this run resolved no closure for: the documentation tool's pins, in a group of their own that only -Djenesis.project.documentation=true resolves, survive a run that does not build documentation. -Djenesis.pin.retain chooses what survives:

Value Keeps a line the refresh did not write when
groups (default) the run resolved nothing in its group
all always - for a project built in more than one layout, since modular pins a transitive module by its name where modular_to_maven pins it by its Maven coordinate
none never

-Djenesis.print.pins=true reports every line a refresh kept:

[KEPT]      ./source/store/module-info.java: kept, resolved by no closure: dokka/maven/org.jsoup/jsoup 1.16.1 SHA-256/1f11...e901

Each module resolves its own closure, and nothing makes two modules agree on a version. pin/divergence writes every coordinate the project pins at more than one version into divergence.properties, naming the versions and the modules holding each, and -Djenesis.print.divergence=true prints the same:

[DIVERGED]  main/maven/org.slf4j/slf4j-api is pinned at 2.0.13 (greeter-testing), 2.0.16 (app greeter greeter-test)

That is not a failure - more than one version is legitimate until you decide otherwise - but it is the signal that a shared bill of materials is overdue.

Pinning one variant of a module

Some libraries publish several jars under one coordinate - same module name, different bytes - distinguished by a classifier. Because a module name has exactly one artifact on the module path, the classifier is a value on the pin rather than part of the coordinate, written with a leading colon:

/**
 * @jenesis.pin mutiny.zero :jdk-flow:0.4.3 SHA-256/0556f076...
 */
module demo.classifier {
    requires mutiny.zero;
}

The pin stays keyed by the bare module name, so it applies wherever that module turns up in the closure - directly or transitively - and exactly one variant is ever present, mirroring the module path's own rule. The classified jar takes the place of the module's own jar on every path, and the pin's checksum describes the classified jar. This works in both modular layouts, and for a module named by an alias as well.

A module of the same build is selected by its classifier alone, without a version or a checksum, since it is resolved as it is built:

/**
 * @jenesis.pin demo.natives :platform-a
 */
module demo.app {
    requires demo.natives;
}

It resolves to the variant declared by that module's classifiers.properties. Where that file declares the classifier only in a profile that is not active, the build fails and names the profile to activate; where no file declares it, the build names the file to write. A pom.xml selects a variant of a module of the same build with a <classifier> on the dependency, as Maven does.

Choosing the variant per machine

Where a classifier commits to one variant, a platform guard declares several and lets the build pick. Each pin line may end with a parenthesised guard, and the line whose guard matches the machine wins:

/**
 * @jenesis.pin org.openjfx.javafx.base :linux:21.0.3 SHA-256/...
 * @jenesis.pin org.openjfx.javafx.base :win:21.0.3 SHA-256/... (windows)
 * @jenesis.pin org.openjfx.javafx.base :mac-aarch64:21.0.3 SHA-256/... (macos,aarch64)
 */

The active platform is a set of tokens: the detected operating system and chipset, one of windows/linux/macos plus one of x86_64/aarch64. A guard matches when all its tokens are active, the most specific match wins, and an unguarded line is the fallback. Every variant stays committed with its own checksum. Selection only decides which checksum-validated line applies, so the build stays reproducible on every machine.

-Djenesis.platform.<token>=true adds a token and =false removes a detected one. That is how -Djenesis.platform.linux=false -Djenesis.platform.windows=true cross-resolves a Windows closure from a Linux host, and how a free-form token (fips, musl) names a build flavour of your own. The same [<guard>] suffix works on a pom.xml's <!--jenesis.pin ... --> block, where it selects a coordinate's version per platform.

Two equally specific guards fail the build, while an unmatched guard with no fallback simply leaves the module unpinned.

Enforcing the pins

How strictly the recorded pins are enforced is controlled by one property, -Djenesis.dependency.pin:

-Djenesis.dependency.pin Versions Checksums
(unset - the default) honoured where pinned verified where a pin carries one; a dependency with no checksum is allowed
strict honoured required - any third-party dependency without a pinned checksum fails the build
versions honoured not verified
ignore float freely not verified

The default already validates every checksum you have recorded; a mismatch always fails the build. Strict mode goes further and refuses to build at all until nothing is left unpinned, which is what you want in CI once a project is fully pinned. Run pin, commit, then build under -Djenesis.dependency.pin=strict so no new un-vetted artifact can slip in unnoticed.

Strict pinning is checked in a step of its own, pinned, over what the resolution recorded as unpinned, so switching it on or off resolves nothing again. The step names the first coordinate without a checksum:

[FAILED]   build/maven/compose/module/build-module-/dependencies/artifacts/pinned: java.lang.IllegalStateException: No checksum pinned for maven/org.apache.commons/commons-lang3/3.14.0 (strict pinning is enabled)

Strict mode pairs well with the managed resolution strategy from Dependencies. Because pin writes the whole resolved closure, a pinned project satisfies managed as it stands, and the strategy keeps it that way. The two answer different questions: strict pinning asks whether every artifact has a checksum, managed asks whether every version was a decision somebody wrote down.

First-party artifacts built within the project are exempt from the strict checksum requirement - only third-party jars pulled from a repository must be pinned. So a multi-module project's own modules never need a checksum to satisfy strict mode.

A pinned project that has built once holds everything it needs in .jenesis/artifacts, so it can build with -Djenesis.repository.offline=true, which downloads nothing and fails the build with the URL of any file the local caches lack - see Dependencies.

Refreshing the pins

Pins freeze the closure, so a pinned project never picks up a newer version on its own. To deliberately refresh them, run pin with the enforcement turned off:

java -Djenesis.dependency.pin=ignore build/jenesis/Make.java pin

ignore sets the recorded checksums aside and keeps a pinned version only for a dependency the project declares directly; every other version is resolved afresh. pin then rewrites each pom.xml (or module-info.java) with the resulting versions and freshly computed checksums.

Resolving afresh does not by itself mean newer: versions are chosen by the resolution strategy, as in any build. To move the closure forward, pick the strategy for the refresh. stable takes the newest version of each Maven coordinate that is not a pre-release, declared ones included:

java -Djenesis.dependency.pin=ignore -Djenesis.resolver.maven=stable build/jenesis/Make.java pin
This step establishes trust rather than enforcing it. Because it bypasses checksum verification while it resolves, it re-blesses whatever the repository currently serves; a swapped artifact would be written in as an accepted pin just the same. Run it only on a trusted machine against a trusted repository, review the resulting diff, and commit it. Every subsequent build then enforces the new pins against the artifacts you just vetted. Recording who signed a dependency closes exactly this gap: it checks who signed an artifact before a checksum for it is written.

Sharing pins: a bill of materials

Pins written per module are exact but repetitive: the same versions recur across modules, and across projects that want to stay in step. A bill of materials is that same pin set in one file, imported instead of repeated. A module then declares only what it requires while the BOM decides which version and which bytes.

A local BOM is a pin-<name>.properties file in the project's BOM location - by default the same build.jenesis/ configuration folder everything else uses. Its keys name a module or a Maven <groupId>/<artifactId>, as a pin does, and its values are a version and an optional checksum:

# build.jenesis/pin-lang3.properties
org.apache.commons.lang3 = 3.20.0
org.apache.commons/commons-lang3 = 3.20.0 SHA-256/69e5c9fa...263f4

A module imports it with a @jenesis.bom tag, which mirrors @jenesis.pin:

/**
 * @jenesis.bom pin-lang3.properties
 * @jenesis.bom org.slf4j/slf4j-bom 2.0.16
 */
module demo.bom {
    requires org.apache.commons.lang3;
    requires org.slf4j;
}

The second line shows the other source a BOM can come from: a published Maven BOM, whose <dependencyManagement> is imported the way Maven imports it, parent chains and nested imports included. A third form names a module, which resolves a BOM published under that module name through the module index or from your local module repository, versioned and checksummed, or floating to the latest published file.

Which source seals how much

The three forms differ in how far they can be sealed, and that difference is the whole reason to know which one you are importing:

Source Written as Checksums
A local file pin-<name>.properties Whatever the file records - byte-stable, so entries can carry hashes and need no pins at all.
A published module BOM <module> [<version> [<algorithm>/<hash>]] The reference itself is content-verified; strict pinning requires the hash.
A Maven BOM <groupId>/<artifactId> <version> The reference carries none: repositories re-serialise POMs, so a hash over one is not stable. pin records the resolved artifacts instead.

All three satisfy -Djenesis.dependency.pin=strict, a Maven BOM by way of the artifact pins pin writes for what resolves through it. Precedence is local-first: an explicit @jenesis.pin always overrides a BOM entry, and when two BOMs manage the same coordinate the last declared wins, so broad curation is declared first and local refinement last.

What pin does with a BOM

By default it writes no @jenesis.pin line for a coordinate a BOM already supplies, removes one that has become redundant, and pins the BOM reference itself by content. -Djenesis.pin.bom=flatten inverts the migration: the BOM declarations go and the closure is pinned in full.

Refreshing a bill of materials

A project that keeps its pins in a local BOM had nowhere for pin to write: the goal rewrites module declarations, so the shared file was maintained by hand. -Djenesis.pin.file names a properties file to write instead:

java -Djenesis.pin.file=build.jenesis/pin-project.properties build/jenesis/Make.java pin

The run then adds a single step over the union of every module's closure rather than one per module, so one writer produces one file, and it emits the keys @jenesis.bom reads: a module under its own name, a Maven artifact as <groupId>/<artifactId>, and anything carrying a type or a classifier under its repository. The module declarations are left alone.

Only the main group is written. A BOM is read under the group its declaration names, so a coordinate belonging to a tool's own group - the linter or alternative compiler a build module resolves for itself - would be read back as a repository name, and is left out rather than written wrong. First-party modules of the project are left out too, for the same reason a pin never records them: they are built, not resolved.