Skip to content

build(publish): ship the release train to Maven Central as one deployment - #803

Merged
DemchaAV merged 6 commits into
developfrom
build/central-single-deployment
Oct 1, 2026
Merged

DemchaAV merged 6 commits into
developfrom
build/central-single-deployment

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Why

publish.yml deployed the lockstep train as eight separate ./mvnw -f <module>/pom.xml -P release deploy runs. The central-publishing-maven-plugin builds and uploads one bundle per Maven run, so one GraphCompose version became eight Central deployments: eight validation queues, eight Publish clicks (autoPublish=false), and eight entries against the organisation's monthly Release Count, which Central defines as one per "distinct publish operation" and enforces from 2026-10-01. A failure part-way also left the first modules uploaded as separate deployments and the rest missing, which is what the start_at resume input existed to patch.

What moved

  • One reactor run, one deployment. The eight deploy steps and the start_at plan step become one step:
    ./mvnw -B -ntp -P release -DskipTests -Dgpg.skip=false -DdeploymentName="GraphCompose $TAG" -DignorePublishedComponents=$SKIP_PUBLISHED deploy -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-bundle

    • The plugin (0.11.0) stages every module that declares it into one shared directory and bundles and uploads once, from the last such project in the reactor (MojoUtilsImpl.isThisLastProjectWithThisMojoInExecution). A failing module stops the build before that last module, so nothing partial is uploaded.
    • -pl over the root aggregator is not deploy from the root: the aggregator itself is not selected, and no train module inherits its maven.deploy.skip (they are standalone poms). examples, benchmarks, qa, coverage, fonts and emoji are not selected. publish.yml's japicmp step already uses the same -pl pattern.
    • Coordinates, POMs, jars, sources/javadoc jars and signatures are unchanged; only the upload transaction is shared. No module pom changes.
  • Recovery is opt-in. start_at is replaced by a boolean skip_published dispatch input (default false), passed as -DignorePublishedComponents: the plugin asks the Portal which components are already published and leaves them out. On a tag push it is always false.

  • The bundle is kept. The uploaded central-bundle.zip is a workflow artifact (central-bundle-v<X.Y.Z>-attempt-<N>; the attempt keeps a re-run from colliding with an earlier attempt's artifact), so the exact bytes can be audited or smoked before the Publish click.

  • PublishTrainGuardTest (new, 7 tests) holds publish.yml to that shape. Its train is derived from the poms (standalone, declares the plugin, on the reactor version), not restated. It requires:

    • exactly one deploy line, invoking Maven once, with no chaining operators, and exactly one module selection (Maven merges repeated -pl / --projects);
    • no -am, which would pull fonts and emoji (core's test-scope deps) into the deployment, and no -amd;
    • a -pl set equal to the derived train, with fonts, emoji and the build-only modules never deployed;
    • every non-comment deploy or direct central-publishing…:publish line in a publish*.yml to be one the parser reads;
    • one identical central-publishing declaration per train pom, because the upload runs with the settings of whichever module the reactor orders last; no pom may set ignorePublishedComponents / skipPublishing / excludeArtifacts, since a pom value overrides the workflow's -D;
    • skip_published to stay a boolean defaulting to false, with a single SKIP_PUBLISHED definition.
  • PublishedModules now reads -pl / --projects deploys as well as -f ones, so CodeQlScopeGuardTest keeps its deployed-module inventory. Its order=-based train test is gone with the order= line; the train is held against the poms by the new guard.

  • Release smoke before publishing. scripts/release-smoke/run.sh --staged-repo <dir> / run.ps1 -StagedRepo <dir> runs the nine consumer scenarios against an unzipped bundle: the GraphCompose coordinates resolve from <dir> through a settings file whose Central-only mirror excludes that one repository, everything else from Central.

    • A scenario passes only if every file of every train artifact it resolved records staged in _remote.repositories, and no train artifact resolved at another version (fonts and emoji, independently versioned, are exempt).
    • An empty --staged-repo value is refused rather than silently smoking the published default.
  • Runbook (docs/contributing/release-process.md): §2.F explains the one-deployment train with a dead-endpoint dry-run recipe, and step 9 adds the smoke-the-VALIDATED-bundle step. The recovery table is rewritten around two rules:

    • run the tag's own workflow (dispatch with --ref v<X.Y.Z>, or re-run the tag's run), since a dispatch uses the workflow file of the ref it is dispatched from and main is merged after the tag;
    • once the log shows deploymentId, never run the deploy again, because the plugin does not retry status polling and a second run uploads a second deployment.

    Partial publication of a tag cut before this change uses that tag's own start_at.

  • CHANGELOG.md (v2.4.2: Build / Tests / Documentation), the root pom.xml comment and a ci.yml comment describe the reactor deploy.

Verification

  • ./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa,:graph-compose-coverage -am → BUILD SUCCESS, 3260 tests, 0 failures, 2 skipped (both pre-existing). PublishTrainGuardTest 7/7, CodeQlScopeGuardTest 3/3, BinaryCompatibilityGateGuardTest 7/7.
  • Each guard fails closed. Twenty deliberate breakages of publish.yml / bundle/pom.xml each turn the intended test red:
    • a module dropped from -pl, -am, fonts added to the train (plain, a second -pl, --projects=, --proj), a typo'd selector;
    • a second deploy (separate step, - run: item, \ continuation, mvn, &&, & with quoted tokens, a direct plugin publish goal);
    • recovery switched on (constant, default true, a second SKIP_PUBLISHED);
    • Central settings drifting in one train pom (autoPublish, skipPublishing, a second artifactId-first plugin block).
  • Dead-endpoint dry run of the exact deploy line from this publish.yml, in a throwaway worktree with a separate local repository, versions flipped to 2.4.2, -DcentralBaseUrl=http://127.0.0.1:9, signed with a throwaway key:
    • eight modules staged, one Created bundle successfully, one Going to upload (deployment named GraphCompose v2.4.2), then Connection refused;
    • the zip holds 8 components / 29 artifacts / 174 files, the same file shape 2.4.1 has on Central (wrapper jar+javadoc+pom, bundle jar+pom), about 15.4 MB;
    • 29/29 signatures verify, 116/116 checksums match, no checksums on .asc, no SNAPSHOT or aggregator reference in any POM;
    • the wrapper's javadoc jar carries index.html, GraphCompose.html and DocumentSession.html, built from core's sources inside the same reactor.
  • Release smoke against that bundle:
    • run.sh --staged-repo and run.ps1 -StagedRepo: 9/9 scenarios each, every train file resolved from the bundle, fonts 1.1.0 and emoji 1.0.0 from Central.
    • Negative, asking for 2.4.1 (not in the bundle): 9/9 Maven builds succeed and all 9 scenarios fail provenance.
    • Negative, wrapper POM edited to pin core 2.4.1: exactly the 5 scenarios that consume the wrapper fail ("resolved at 2.4.1, not the staged 2.4.2").

Notes for review

  • The plugin behaviour above is read from 0.11.0's bytecode and matches its documentation, except one point: the docs describe skipPublishing as "creates only the bundle", but 0.11.0 drops every artifact before staging when it is set. Do not use it for dry runs; use a dead centralBaseUrl, as the runbook recipe does.
  • The upload, skip_published and the Portal round trip need credentials and a tag, so they are proven by the dry run and the plugin code, not by a live run. On the first release cut with this workflow, confirm:
    1. one deployment holding eight components in the portal;
    2. the bundle artifact smokes green while it is VALIDATED;
    3. Publish once;
    4. the Usage Center Release Count moves by one.
  • The whole train now shares the plugin's waitMaxTime (1800 s) instead of 1800 s per module. A timeout after deploymentId leaves a valid deployment; the runbook says to act on it in the portal, not to run the deploy again.
  • A bundle built on Windows carries backslash entry paths (the plugin zips with the platform separator); real uploads come from the Linux runner.
  • Fonts and emoji are untouched: publish-fonts.yml / publish-emoji.yml still publish them alone, on their own tags.

Lane: build — release tooling (publish workflow, guard tests, release-smoke scripts, runbook); no library code, no module pom, no public API change.


Pre-merge checklist
  • Targets develop (not main); branch is build/central-single-deployment.
  • ./mvnw -B -ntp clean verify (the reactor gate above) passes locally — this is the Verification proof above.
  • Java 17 compatible — test-only Java, no 21+ API or syntax.
  • Public API changed → no public API change; CHANGELOG.md carries Build / Tests / Documentation entries under ## v2.4.2 — Planned.
  • README / examples touched → neither touched.

…ment

publish.yml deployed the lockstep train as eight separate
`-f <module>/pom.xml -P release deploy` runs, so one GraphCompose version
was eight Central deployments - eight Release Count events, eight
validation queues, eight Publish clicks - and a failure part-way left the
first modules of a version published and the rest missing.

The train now deploys in one reactor run over the root aggregator,
`-P release deploy -pl` the eight train artifacts. The
central-publishing plugin stages each module and uploads one
central-bundle.zip from the last, so a version is one deployment holding
eight components. The root aggregator, the build-only modules, fonts and
emoji are not selected; fonts and emoji keep their own workflows.

start_at is replaced by skip_published (boolean, default false), passed
as -DignorePublishedComponents for recovering a partially published
version. The uploaded zip is kept as a workflow artifact.

PublishTrainGuardTest holds the deploy to one -pl run over exactly the
lockstep modules derived from the poms, forbids also-make, keeps fonts,
emoji and build-only modules out, requires identical plugin declarations
across the train, and keeps the recovery switch opt-in. PublishedModules
reads -pl deploys so the CodeQL scope guard keeps its inventory.
--staged-repo <dir> / -StagedRepo <dir> resolves the GraphCompose
coordinates from a Maven repository-layout directory - the unzipped
central-bundle.zip - and everything else from Central, through a
settings file whose Central-only mirror excludes that one repository.
The version defaults to the single core version staged there.

A scenario passes only if every GraphCompose artifact of that version it
resolved records the staged repository in _remote.repositories, so a
stale cache or a Central copy cannot stand in for the staged bytes, and
a scenario that resolved none fails. Staged mode refuses --warm.
The runbook explains the one-deployment train, how to dry-run it against
a dead endpoint and smoke the staged bundle, and replaces the per-module
start_at recovery with the failure cases a single deployment has.
CHANGELOG entry under v2.4.2.
…per file

PublishTrainGuardTest fails on any non-comment line of a publish
workflow that names the deploy goal in a shape PublishedModules does not
read (`mvn`, a `- run:` list item, a `\` continuation), so a second
deployment cannot ship invisible to the one-deploy and train checks. The
deploy step's id and the install step's name no longer contain the word.

The staged release smoke checks every file line of _remote.repositories,
not one line per artifact, and fails a train artifact resolved at any
version other than the staged one: a staged POM pinning a sibling at a
drifted version would otherwise pull it from Central unchecked. fonts
and emoji stay exempt. The settings file is written without a BOM, its
path is XML-escaped, and run.sh converts the path with cygpath where it
exists.

The bundle artifact uploads whenever the deploy step ran rather than
always. The runbook adds the validation-timeout case (do not
re-dispatch: a deployment exists) and smoking the VALIDATED deployment's
bundle artifact before publishing. The root aggregator's comment
describes the reactor deploy.
…ter an upload

A workflow_dispatch runs the workflow file of the ref it is dispatched
from, and main - the Actions UI default - is merged only after the tag,
so a recovery dispatched from main could run the previous per-module
publish workflow. Every recovery now dispatches from the tag:
gh workflow run publish.yml --ref vX.Y.Z -f tag=vX.Y.Z.

Once the log shows "Uploaded bundle successfully ... deploymentId", a
deployment exists whatever turned the job red afterwards; the plugin
does not retry its status polling, so one transient error is enough. The
runbook now states that rule once and acts on the deployment's portal
state instead of re-dispatching, which would upload a second deployment
of the same coordinates. Upload rejections without a deploymentId (a bad
token, an outage) get their own row; the staged smoke is documented as
unavailable for a skip_published bundle.

The release smoke refuses an empty --staged-repo / -StagedRepo value,
which used to switch staged mode off and smoke the published default
from Central. run.sh reads a final _remote.repositories line without a
newline and infers the staged version from directories only, matching
run.ps1; run.ps1 requires the staged path to be a directory.

PublishTrainGuardTest requires the deploy line to invoke Maven once,
chain no other command and carry one module selection, treats a direct
central-publishing publish goal as an upload, holds SKIP_PUBLISHED to a
single definition, and requires one plugin declaration per train pom.
PublishedModules reads every -pl / --projects selection.

The bundle artifact name carries the run attempt, and the concurrency
comment records that GitHub keeps one pending run per group.
… never re-run after an upload

A tag cut before the single-deployment workflow carries the old publish
workflow, which declares start_at and no skip_published, so dispatching
it from the tag with -f skip_published=true is rejected as an unexpected
input. The partial-publication recovery now names both cases:
skip_published for tags cut with the single-deployment workflow, the
tag's own start_at for v2.4.1 and earlier.

"Re-run failed jobs" after the log shows deploymentId uploads a second
deployment just as a dispatch does. The rule now covers both, and names
a re-run of the tag's own run as the other way to get the tag's
workflow file. The publish.yml header and deploy-step comment no longer
suggest running the workflow again after an upload. A validated-but-wrong
deployment needs a patch version when the cause is in the content.

PublishTrainGuardTest reads deploy tokens unquoted, treats a single `&`
as chaining, and counts the --projects prefixes Maven accepts as a
module selection; PublishedModules reads those spellings too. A
central-publishing plugin block is recognised whatever order its child
elements are written in.

run.sh refuses an empty --version= value; run.ps1 resolves the staged
path literally.
@DemchaAV
DemchaAV merged commit aa669bf into develop Oct 1, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the build/central-single-deployment branch October 1, 2026 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant