Publishing to Maven Central

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Maven 3.10.0 with the Maven Source Plugin 3.4.0, the Maven Javadoc Plugin 3.12.0, the Maven GPG Plugin 3.2.8, the Maven Deploy Plugin 3.2.0 and the Central Publishing Maven Plugin 0.12.0 (released on 2026-10-13), on JDK 26 and with GnuPG 2.2.41. Every example was run on 2026-10-16 in a scratch copy of the tidepool project (the parent POM io.tidepool:tidepool-parent with the modules tidepool-model and tidepool-engine, version 1.0.0). Maven 3.9.16 and Maven 4.0.0-rc-7 (a release candidate) were run where the text says so. Console output was shortened: the scratch directory is shown as ., and the lines about downloads, the Scanning for projects line and blank lines were left out. All runs used batch mode (-B -ntp).

Nothing on this page was published to Maven Central. io.tidepool is a made-up namespace that nobody has verified, and no account, user token or real GPG key was used. What was run, and what was not:

  • Run for real: generating a throwaway GnuPG key in a scratch GNUPGHOME, building the sources and Javadoc JARs, signing with the GPG plugin (with gpg and with the pure-Java bc signer), building the bundle with the Central Publishing plugin up to the point where it would send it (skipUpload), unpacking the bundle and verifying every signature and checksum in it, and verifying the signature of a real library downloaded from Central (the consumer side of the key story).

  • Run against a stand-in: the part of the plugin that talks to the Portal. A 66-line Python file server (portalstub.py) on localhost answers the three Portal Publisher API calls the plugin makes (upload, status) and accepts PUT requests like a legacy staging API. It is not Central, it validates nothing, and the texts it returns (the failure message, the list of published packages) are the stand-in’s own. It exists to show what the real plugin prints and sends. Every such block says "against the stand-in".

  • Not run here: registering and verifying a namespace, generating a user token, uploading a key to a keyserver, and any request to central.sonatype.com. Where those matter the page describes them and links the official documentation.

The shape of a release to Central

Since 2025-06-30 there is one way in for open source releases: the Central Publisher Portal at central.sonatype.com, which replaced the OSSRH service (see "The end of OSSRH" below). A release travels like this:

sequenceDiagram participant Dev as Developer or CI participant Mvn as mvn -Prelease deploy participant Gpg as GnuPG participant Portal as Central Portal participant Central as Maven Central Dev->>Mvn: build with sources, Javadoc and signing Mvn->>Mvn: compile, test, package, source JAR, Javadoc JAR Mvn->>Gpg: sign POM and every JAR (verify phase) Gpg-->>Mvn: .asc files Mvn->>Mvn: stage files, add checksums, zip the bundle (deploy phase) Mvn->>Portal: POST bundle with the user token Portal-->>Mvn: deployment id loop until the wanted state Mvn->>Portal: status of the deployment Portal-->>Mvn: PENDING, VALIDATING, VALIDATED end Note over Portal: validation: coordinates, POM metadata, sources and Javadoc, checksums, signatures alt autoPublish is false Dev->>Portal: press Publish in the Portal end Portal->>Central: PUBLISHING Central-->>Portal: PUBLISHED Portal-->>Mvn: PUBLISHED (only if waitUntil is published)

Three things in that picture are the whole page. The build must produce more than a JAR: a POM with complete metadata, a sources JAR, a Javadoc JAR and a signature for every file. The Central Publishing plugin turns what the build produced into a bundle and sends it. And the Portal validates the bundle, and, only when it is valid and has been released, copies it into Central, where it can never be changed (Central does not replace or remove a published release). A release that fails validation is not published and can be dropped.

The build tools differ only in the first two steps. Ant, Gradle and Maven all end in the same bundle format; the cross-tool view, including the raw curl call to the Publisher API, is on Repositories & Publishing Across Tools. Gradle and Android projects publish with a community plugin and are on Gradle: Publishing and Android Libraries & Maven Central. How mvn install and mvn deploy work in general, and the distributionManagement that the Central plugin does not use, is on Install, Deploy & Distribution Management; the version bumping and tagging that precede a publication are on Releasing.

What Central requires

Central checks every deployment against these rules. The right column says where in a Maven build each one comes from.

Requirement Detail In a Maven build

Namespace

The groupId must start with a namespace you have verified in the Portal: a reversed domain you control (proved with a DNS TXT record), or io.github.<user> for a GitHub account. A namespace is registered once and covers every artifact below it.

Not a build step. Done in the Portal; not run here.

Release version

The version cannot end in -SNAPSHOT. A published release is immutable.

The version of the POM. See Releasing.

POM metadata

name, description, url, at least one license, at least one developer, and an scm section.

Elements of the POM, inherited from the parent. Nothing in Maven enforces them.

Sources and Javadoc

A -sources.jar and a -javadoc.jar for every JAR (packaging pom is exempt). Placeholder JARs are accepted when there is nothing to document.

maven-source-plugin and maven-javadoc-plugin.

Checksums

.md5 and .sha1 for every file; .sha256 and .sha512 are accepted.

Generated by the Central Publishing plugin (checksums option, default all).

Signatures

A detached .asc OpenPGP signature for every file except checksums and signatures, including the POM. The public key must be on a keyserver the Portal uses.

maven-gpg-plugin.

The next sections take the build part first, one requirement at a time, and the Portal part afterwards.

The POM metadata

The tidepool parent POM carries all the metadata, and the modules inherit it. The module POMs add only their own description:

pom.xml (the parent; the build section follows later)
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-parent</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>

  <name>${project.groupId}:${project.artifactId}</name>
  <description>Predicts tide heights for coastal stations from harmonic constituents.</description>
  <url>https://tidepool.example.org</url>

  <licenses>
    <license>
      <name>Apache License, Version 2.0</name>
      <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
      <distribution>repo</distribution>
    </license>
  </licenses>
  <developers>
    <developer>
      <name>Tidepool Maintainers</name>
      <email>maintainers@tidepool.example.org</email>
      <organization>Tidepool</organization>
      <organizationUrl>https://tidepool.example.org</organizationUrl>
    </developer>
  </developers>
  <scm>
    <connection>scm:git:https://git.example.org/tidepool.git</connection>
    <developerConnection>scm:git:ssh://git.example.org/tidepool.git</developerConnection>
    <url>https://git.example.org/tidepool</url>
    <tag>v1.0.0</tag>
  </scm>
  <!-- modules, properties and build follow -->
</project>

Composing the name from the coordinates, as here, is an accepted practice that avoids writing a name per module. The scm URLs have to be present but need not be public; the developer entry may link a profile page if you have no website. The <tag> records the release tag and is what the release plugin updates (see Releasing).

Maven does not check any of this. A build that lacks the licence and the scm section succeeds, signs, bundles and (in the real world) uploads, and it is the Portal that rejects it afterwards. To see the difference in a local run, the copy of the project without those two elements was built with -DskipUpload=true (the option is explained below) and the resulting bundle was inspected with the pre-flight script from the section "A local pre-flight check". The Maven build itself ended with BUILD SUCCESS:

54 files, 3 POMs
PROBLEM io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom: POM has no <licenses>
PROBLEM io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom: POM has no <scm>
PROBLEM io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom: POM has no <licenses>
PROBLEM io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom: POM has no <scm>
PROBLEM io/tidepool/tidepool-parent/1.0.0/tidepool-parent-1.0.0.pom: POM has no <licenses>
PROBLEM io/tidepool/tidepool-parent/1.0.0/tidepool-parent-1.0.0.pom: POM has no <scm>
6 problem(s)

The POM that Central keeps is the one in the bundle. Maven 3 deploys the file you wrote, with parent and ${project.version} references intact, so the parent POM must be in the same bundle (or already on Central), which is why the tidepool bundle holds three POMs. Maven 4 (release candidate) additionally builds a flattened consumer POM; how that interacts with publication is on Maven 4: POM Model & Consumer POM.

Sources and Javadoc JARs

A project with Java sources must ship tidepool-model-1.0.0-sources.jar and tidepool-model-1.0.0-javadoc.jar next to the main JAR. The two plugins attach them to the build, so that install, deploy and the Central plugin treat them as part of the project:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-source-plugin</artifactId>
  <executions>
    <execution>
      <id>attach-sources</id>
      <goals><goal>jar-no-fork</goal></goals>
    </execution>
  </executions>
</plugin>
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-javadoc-plugin</artifactId>
  <executions>
    <execution>
      <id>attach-javadocs</id>
      <goals><goal>jar</goal></goals>
    </execution>
  </executions>
</plugin>

source:jar-no-fork is the goal to bind: source:jar would fork its own generate-sources lifecycle and build the project a second time. The Javadoc goal is javadoc:jar, which runs the JDK javadoc tool and packs the result. Both are bound to the default phases of their goals (package), after the main JAR. The versions are pinned in pluginManagement, as every plugin version should be (see Maven Plugins & Their Configuration).

Where the plugins live in the POM is a design choice. Putting them in the main <build> makes every mvn package slower by the Javadoc run; putting them in a profile that only the release activates keeps the daily build fast. tidepool uses a profile named release, which the whole page builds up (the complete profile is shown in the section "The Central Publishing plugin"). Profiles are on Maven Profiles. The effect is visible in the module’s target directory. With no profile Maven 3.10.0 builds only the JAR; with -Prelease it builds the two extra JARs and the signatures that the next section adds:

mvn clean verify
ls tidepool-model/target | grep -E '\.(jar|asc)$'
tidepool-model-1.0.0.jar
mvn -Prelease clean verify
ls tidepool-model/target | grep -E '\.(jar|asc)$'
tidepool-model-1.0.0-javadoc.jar
tidepool-model-1.0.0-javadoc.jar.asc
tidepool-model-1.0.0-sources.jar
tidepool-model-1.0.0-sources.jar.asc
tidepool-model-1.0.0.jar
tidepool-model-1.0.0.jar.asc

Javadoc errors break the release

The Javadoc tool is strict, and a Javadoc error fails the build, which is one of the commonest reasons a release stops at the last minute. A copy of tidepool-engine with a {@link Tidal} to a class that does not exist in the class comment of TideEngine failed in the attach-javadocs execution:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-javadoc-plugin:3.12.0:jar (attach-javadocs) on project tidepool-engine: MavenReportException: Error while generating Javadoc:
[ERROR] Exit code: 1
[ERROR] ./tidepool-engine/src/main/java/io/tidepool/engine/TideEngine.java:8: error: reference not found
[ERROR] /** Predicts a tide height as a sum of harmonic constituents, see {@link Tidal}. */
[ERROR]                                                                          ^
[ERROR] ./tidepool-engine/src/main/java/io/tidepool/engine/StationReader.java:17: warning: no comment
...
[ERROR] The generated documentation contains diagnostic markers for invalid input.

(The first line also names the full path of each source file; the ... marks the remaining warnings.) Fix the comment. For sources you cannot fix quickly there are two switches, both tried on the same broken copy: -Ddoclint=none (the plugin’s doclint parameter) turned the error into a reference not found: Tidal warning and the build ended in BUILD SUCCESS, and -Dmaven.javadoc.failOnError=false (failOnError, default true) let the build continue after the error, with a Javadoc JAR that has the problem in it. Prefer fixing the comment. The warnings no comment on public methods are not errors; the tidepool-engine build shows them on every run, and Central never reads the content of the Javadoc JAR.

Central also accepts a placeholder JAR for a project that has nothing to document (for example a module of resources only): a JAR containing a README.md that points to the project is enough. This was not built here.

GnuPG keys and keyservers

Central requires the signature of every file, and a signature only means something if a consumer can find your public key. The key pair is created once, with GnuPG; the secret half signs your releases, the public half goes to a keyserver. Central lists keyserver.ubuntu.com, keys.openpgp.org and pgp.mit.edu as the keyservers its servers consult.

The key used on this page is a throwaway generated into a scratch GNUPGHOME, so that the real keyring of the machine is never read or written. Two practical constraints came out of it. The home directory has to be a short path: gpg-agent creates a Unix socket in it and the long scratch path of these runs made key generation fail with can't connect to the agent: File name too long, so the scratch directory was reached through a short symbolic link. And the key was given a one-day lifetime (1d); a real signing key is normally valid for a few years and extended before it expires.

export GNUPGHOME=/tmp/g6gp                      # a short path to a scratch directory
export SIGNING_PASSWORD='changeit-example'      # throwaway, never a real passphrase
gpg --batch --pinentry-mode loopback --passphrase "$SIGNING_PASSWORD" \
    --quick-gen-key "Tidepool Demo (throwaway) <demo@example.invalid>" ed25519 sign 1d
gpg --list-secret-keys --keyid-format long
gpg --fingerprint
/tmp/g6gp/pubring.kbx
---------------------
sec   ed25519/F2D1073812FC5ED7 2026-10-16 [SC] [expires: 2026-10-17]
      96DD76989DF42AFBFAC5C629F2D1073812FC5ED7
uid                 [ultimate] Tidepool Demo (throwaway) <demo@example.invalid>

pub   ed25519 2026-10-16 [SC] [expires: 2026-10-17]
      96DD 7698 9DF4 2AFB FAC5  C629 F2D1 0738 12FC 5ED7
uid           [ultimate] Tidepool Demo (throwaway) <demo@example.invalid>

On a workstation you would use gpg --gen-key or gpg --full-generate-key and type the passphrase into the pinentry dialog instead of putting it on a command line. If you have several keys, GnuPG signs with the first signing key it finds; name the one to use with the plugin’s keyname parameter (passed to gpg as --local-user).

The public key then has to be published, so that the Portal and your users can fetch it. The command is the one the Central documentation shows; it was not run here, because it would put the key on a public server for good (a key that has been published to the public keyservers is hard to take back):

gpg --keyserver keyserver.ubuntu.com --send-keys 96DD76989DF42AFBFAC5C629F2D1073812FC5ED7

What the keyserver is for is easiest to see from the consumer side. The signature file of a real library, commons-csv-1.14.1.jar.asc, was fetched from Central, and verified against its JAR in an empty GnuPG home, first with no key and then after fetching the signer’s key from a keyserver (which only reads from it):

gpg --verify commons-csv-1.14.1.jar.asc commons-csv-1.14.1.jar
gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB
gpg --verify commons-csv-1.14.1.jar.asc commons-csv-1.14.1.jar
gpg: Signature made Sun Jul 27 15:09:57 2025 CEST
gpg:                using RSA key 2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB
gpg: Can't check signature: No public key
gpg: key 86FDC7E2A11262CB: public key "Gary David Gregory (Code signing key) <ggregory@apache.org>" imported
gpg: Total number processed: 1
gpg:               imported: 1
gpg: Signature made Sun Jul 27 15:09:57 2025 CEST
gpg:                using RSA key 2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB
gpg: Good signature from "Gary David Gregory (Code signing key) <ggregory@apache.org>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 2DB4 F1EF 0FA7 61EC C4EA  935C 86FD C7E2 A112 62CB

(The output of the first and the second command was shortened: the lines about creating the empty keybox and the trust database were left out.) The warning at the end is normal: a signature proves that the holder of that key signed the file, not who the key belongs to; deciding to trust a key is a separate step that is covered with checksums and dependency verification on Supply Chain Security. An unpublished key is the first reason signatures "fail" on Central: the validator, like this consumer, cannot find a public key.

Keep the secret key and its passphrase out of the repository and out of build logs, keep a revocation certificate (GnuPG writes one to openpgp-revocs.d at generation time), and extend the key with gpg --edit-key before it expires; the Central documentation describes how.

Signing with the Maven GPG Plugin

The gpg:sign goal signs the project artifact, the POM and every attached artifact (sources, Javadoc) and attaches the .asc files, so that the next steps of the build carry them along. It is bound to the verify phase, after packaging and before install and deploy:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-gpg-plugin</artifactId>
  <executions>
    <execution>
      <id>sign-artifacts</id>
      <phase>verify</phase>
      <goals><goal>sign</goal></goals>
    </execution>
  </executions>
</plugin>

The plugin has four goals (gpg:help): sign, sign-and-deploy-file, sign-deployed and help. Its important parameters, as gpg:help -Ddetail -Dgoal=sign reports them for 3.2.8:

Parameter (user property) Meaning Default

signer (gpg.signer)

gpg runs the GnuPG executable; bc signs inside Maven with Bouncy Castle and needs the key as text.

gpg

keyname (gpg.keyname)

Key to sign with; passed to GnuPG as --local-user. gpg signer only.

first signing key

homedir (gpg.homedir)

The GnuPG home directory to use. gpg signer only.

GnuPG’s own default

executable (gpg.executable)

Path of the gpg executable.

gpg or gpg.exe

gpgArguments

Extra arguments for gpg.

none

useAgent (gpg.useagent)

Whether gpg-agent may supply the passphrase. In batch mode the plugin passes --pinentry-mode error, so only passphrases already cached in the agent work.

true

passphraseEnvName (gpg.passphraseEnvName)

Name of the environment variable that holds the passphrase.

MAVEN_GPG_PASSPHRASE

keyEnvName (gpg.keyEnvName)

Name of the environment variable that holds the secret key in armored (TSK) form. bc signer only.

MAVEN_GPG_KEY

keyFilePath (gpg.keyFilePath)

A file holding the key, resolved against the user’s home directory. bc signer only.

maven-signing-key.key

keyFingerprint (gpg.keyFingerprint)

Which key of the supplied ones to use. bc signer only.

first key

excludes

Ant-style patterns of files that are not signed.

checksums, .asc and .sigstore files

ascDirectory

Where the signatures are written.

${project.build.directory}/gpg

skip (gpg.skip)

Skip signing.

false

bestPractices (gpg.bestPractices)

Fail the build when a secret is read from the POM, the command line or settings.xml.

false

passphrase, passphraseServerId

Deprecated. A passphrase written in the configuration, or looked up in a settings.xml server entry.

none

Passphrases: the agent, the environment, and what not to do

There are four ways to give the plugin a passphrase, in decreasing order of preference.

The agent, on a workstation. gpg-agent asks you once in a pinentry dialog and caches the answer. This is the best option for a person: no secret reaches the terminal history or a file. The catch is that Maven in batch mode (and the release plugin, which always runs a batch build) cannot make the agent open a dialog, so the agent has to be "primed" first with a signing operation, for example echo test | gpg --clearsign. Without a primed agent a batch build fails. That is the first of the three failures below, run with a passphrase-protected key and no passphrase supplied:

mvn -B -Prelease -pl . clean verify
[INFO] --- gpg:3.2.8:sign (sign-artifacts) @ tidepool-parent ---
[INFO] Signer 'gpg' is signing 1 file with key default
gpg: signing failed: No pinentry
gpg: signing failed: No pinentry
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-gpg-plugin:3.2.8:sign (sign-artifacts) on project tidepool-parent: Exit code: 2 -> [Help 1]

(-pl . builds only the parent, which is enough to show the signing step. The message is GnuPG’s: the plugin ran gpg --armor --detach-sign --status-fd 1 --batch --no-tty --pinentry-mode error …​, which -X prints, and asked GnuPG not to open a pinentry.) A wrong passphrase is reported by the same step as gpg: signing failed: Bad passphrase.

The environment, in CI. Set MAVEN_GPG_PASSPHRASE from the secret store of the CI system for the duration of the job. The plugin hands it to GnuPG over a loopback pinentry, so it never appears in a command line. This is what all the other runs on this page used (with the throwaway changeit-example), and what the signed build above did:

export MAVEN_GPG_PASSPHRASE='changeit-example'
mvn -B -Prelease clean verify
[INFO] --- gpg:3.2.8:sign (sign-artifacts) @ tidepool-model ---
[INFO] Signer 'gpg' is signing 4 files with key default

The key itself in the environment, with the bc signer. A CI runner has no keyring. With signer set to bc, the plugin reads the secret key, in armored form, from MAVEN_GPG_KEY and signs in Java, without GnuPG on the machine and without a home directory. The key was exported from the scratch home (the export is passphrase-protected, so the passphrase variable is still needed), and the build ran with an empty GNUPGHOME to show that the keyring is not used:

export MAVEN_GPG_PASSPHRASE='changeit-example'
export MAVEN_GPG_KEY="$(gpg --batch --pinentry-mode loopback --passphrase "$MAVEN_GPG_PASSPHRASE" \
    --armor --export-secret-keys 96DD76989DF42AFBFAC5C629F2D1073812FC5ED7)"
GNUPGHOME=$(mktemp -d) mvn -B -Prelease -pl . clean verify -Dgpg.signer=bc
gpg --verify target/tidepool-parent-1.0.0.pom.asc target/tidepool-parent-1.0.0.pom
[INFO] --- gpg:3.2.8:sign (sign-artifacts) @ tidepool-parent ---
[INFO] Signer 'bc' is signing 1 file with key Tidepool Demo (throwaway) <demo@example.invalid>
[INFO] BUILD SUCCESS
gpg: Signature made Fri Oct 16 16:12:54 2026 CEST
gpg:                using EDDSA key 96DD76989DF42AFBFAC5C629F2D1073812FC5ED7
gpg: Good signature from "Tidepool Demo (throwaway) <demo@example.invalid>" [ultimate]

(The gpg --verify ran afterwards in the scratch home that holds the public key.) The secret key must be stored as a protected secret of the CI system and never be echoed; keyFilePath reads it from a file instead, which belongs outside any repository.

A passphrase in the configuration: discouraged. The plugin still accepts -Dgpg.passphrase=..., a <passphrase> element, or a settings.xml server entry, but it warns, because the value ends up in the shell history, the process list or a file:

mvn -B -Prelease -pl . clean verify -Dgpg.passphrase=changeit-example
mvn -B -Prelease -pl . clean verify -Dgpg.passphrase=changeit-example -Dgpg.bestPractices=true
[WARNING]  Parameter 'passphrase' (user property 'gpg.passphrase') is deprecated: Do not use this configuration, it may leak sensitive information. Rely on gpg-agent or env variables instead.
[WARNING]
[WARNING] W A R N I N G
[WARNING]
[WARNING] Do not store passphrase in any file (disk or SCM repository),
[WARNING] instead rely on GnuPG agent or provide passphrase in
[WARNING] MAVEN_GPG_PASSPHRASE environment variable for batch mode.
[WARNING]
[WARNING] Sensitive content loaded from Mojo configuration
...
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-gpg-plugin:3.2.8:sign (sign-artifacts) on project tidepool-parent: Do not store passphrase in any file (disk or SCM repository), rely on GnuPG agent or provide passphrase in MAVEN_GPG_PASSPHRASE environment variable. -> [Help 1]

(The first command ends with BUILD SUCCESS after the warning; the second is the one that fails.) With bestPractices set to true the plugin refuses such a build, and the documentation says that this will become the default behaviour in a future release; set <bestPractices>true</bestPractices> in the plugin configuration now. A settings.xml entry, in clear or encrypted form, is a variant of the same thing and has the same warning; the Maven password encryption that can protect it is on settings.xml & Password Encryption.

Verifying what was signed

The signatures are plain files, so gpg --verify on any pair checks it, and the bundle check further down does it for a whole bundle. Before a release, make sure that the key is not expired, that its public half is on a keyserver, that the passphrase reaches the batch job, and that gpg:sign ran in the build: look for the Signer 'gpg' is signing N files line, whose count includes the POM.

The Central Publishing plugin

org.sonatype.central:central-publishing-maven-plugin (version 0.12.0) replaces the Maven Deploy Plugin for publication to Central. It is added with <extensions>true</extensions>, which lets it hook into the build lifecycle: it observes every module, stages the files of each into a directory, and in the last module of the reactor it adds checksums, zips the staged files into one bundle and sends it. The release profile of tidepool in full:

<profiles>
  <profile>
    <id>release</id>
    <build>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-source-plugin</artifactId>
          <executions>
            <execution>
              <id>attach-sources</id>
              <goals><goal>jar-no-fork</goal></goals>
            </execution>
          </executions>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-javadoc-plugin</artifactId>
          <executions>
            <execution>
              <id>attach-javadocs</id>
              <goals><goal>jar</goal></goals>
            </execution>
          </executions>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-gpg-plugin</artifactId>
          <executions>
            <execution>
              <id>sign-artifacts</id>
              <phase>verify</phase>
              <goals><goal>sign</goal></goals>
            </execution>
          </executions>
        </plugin>
        <plugin>
          <groupId>org.sonatype.central</groupId>
          <artifactId>central-publishing-maven-plugin</artifactId>
          <version>0.12.0</version>
          <extensions>true</extensions>
          <configuration>
            <publishingServerId>central</publishingServerId>
          </configuration>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

The plugin does no signing, no Javadoc and no metadata checking: its documentation says explicitly that it only generates the checksums and that the remaining prerequisites of a valid bundle are the build’s job. There is no <distributionManagement> entry for Central: the plugin knows the Portal URL (centralBaseUrl, https://central.sonatype.com by default).

The effective POM shows what the extension does. help:effective-pom -Prelease on the module lists the plugin with an execution that you did not write, bound to the deploy phase:

<plugin>
  <groupId>org.sonatype.central</groupId>
  <artifactId>central-publishing-maven-plugin</artifactId>
  <version>0.12.0</version>
  <extensions>true</extensions>
  <executions>
    <execution>
      <id>injected-central-publishing</id>
      <phase>deploy</phase>
      <goals>
        <goal>publish</goal>
      </goals>
      <configuration>
        <publishingServerId>central</publishingServerId>
      </configuration>
    </execution>
  </executions>
  <configuration>
    <publishingServerId>central</publishingServerId>
  </configuration>
</plugin>

The deploy:deploy goal does not run when the extension is active: in the logs of the Central builds on this page there is no deploy:3.2.0:deploy line, while the build with the legacy staging API further down has one per module. A classic mistake is to leave <extensions>true</extensions> out. The plugin then does nothing, the ordinary deploy goal runs, and the build asks for a repository you never configured:

[INFO] --- deploy:3.2.0:deploy (default-deploy) @ tidepool-parent ---
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-deploy-plugin:3.2.0:deploy (default-deploy) on project tidepool-parent: Deployment failed: repository element was not specified in the POM inside distributionManagement element or in -DaltDeploymentRepository=id::url parameter -> [Help 1]

Credentials: a user token in settings.xml

The plugin reads its credentials from settings.xml, from the <server> whose id equals publishingServerId (central by default). The username and password are a user token generated in the Portal’s account page, not your login: a pair of random strings that can be revoked without changing your account. Generating the token was not run here. In the file, never write the token itself, use environment variables, which Maven interpolates into settings:

settings.xml
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0">
  <servers>
    <server>
      <id>central</id>
      <username>${env.CENTRAL_TOKEN_USERNAME}</username>
      <password>${env.CENTRAL_TOKEN_PASSWORD}</password>
    </server>
  </servers>
</settings>

On a workstation, the token pair can instead be stored encrypted (plugin version 0.6.0 and later support Maven password encryption; see settings.xml & Password Encryption). On CI, write the file from the secret store, or give the job’s settings.xml through -s. All of this page’s runs used -s settings.xml with the file above and the values tokenuser-example and changeit-example in the two variables.

Two failures show what the plugin does with a missing credential, run against the stand-in. When the server entry is missing from settings.xml the build stops before anything is sent, with a message that reads like a bug in the plugin:

[ERROR] Failed to execute goal org.sonatype.central:central-publishing-maven-plugin:0.12.0:publish (injected-central-publishing) on project tidepool-parent: Execution injected-central-publishing of goal org.sonatype.central:central-publishing-maven-plugin:0.12.0:publish failed: Unable to get publisher server properties for server id: central: Cannot invoke "org.apache.maven.settings.Server.clone()" because "server" is null -> [Help 1]

When the entry exists but the environment variables are not set, Maven does not complain: it leaves the unresolved ${env.CENTRAL_TOKEN_USERNAME} text as the value, the plugin logs Using credentials from server id central in settings.xml, and it sends the literal text. With the variables unset, the build ended in BUILD SUCCESS against the stand-in (which accepts anything), and the request it received was:

POST /api/v1/publisher/status?name=Deployment&publishingType=USER_MANAGED&id=ID&userId=%24%7Benv.CENTRAL_TOKEN_USERNAME%7D&orgId=org

(ID stands for the deployment id, and %24%7Benv.CENTRAL_TOKEN_USERNAME%7D is the percent-encoded text of the unresolved expression.) Against the real Portal this is a rejected request; the symptom to recognise is a user name that still looks like an expression. Check that the job has the variables before debugging the plugin. The same stand-in log showed that the plugin sends the token in an Authorization header with the UserToken scheme, the non-standard scheme the Portal accepts next to Bearer.

Building the bundle without sending it: skipUpload

Version 0.12.0 added skipUpload, which does everything except the upload, and is the right way to test a release configuration, or to build the bundle on a CI machine and upload it by hand from the Portal. (Before 0.12.0 there was no option to build the bundle and stop; skipPublishing, since 0.9.0, leaves a module out altogether and builds no bundle.) The run below is the complete signed release build of tidepool on Maven 3.10.0 with the throwaway key; the output was filtered to the lines of the plugins that matter and the repeated central-publishing lines of the first two modules were collapsed:

export MAVEN_GPG_PASSPHRASE='changeit-example'
mvn -B -ntp -s settings.xml -Prelease clean deploy -DskipUpload=true
[INFO] --- source:3.4.0:jar-no-fork (attach-sources) @ tidepool-model ---
[INFO] --- javadoc:3.12.0:jar (attach-javadocs) @ tidepool-model ---
[INFO] --- gpg:3.2.8:sign (sign-artifacts) @ tidepool-model ---
[INFO] Signer 'gpg' is signing 4 files with key default
[INFO] --- central-publishing:0.12.0:publish (injected-central-publishing) @ tidepool-model ---
[INFO] Staging 7 files
[INFO] Pre Bundling - deleted ./target/central-staging/io/tidepool/tidepool-model/maven-metadata-local.xml
[INFO] Pre Bundling - deleted ./target/central-staging/io/tidepool/tidepool-model/1.0.0/_remote.repositories
[INFO] Pre Bundling - deleted ./target/central-staging/.locks
[INFO] Generate checksums for dir: io/tidepool/tidepool-model/1.0.0
...
[INFO] --- central-publishing:0.12.0:publish (injected-central-publishing) @ tidepool-engine ---
[INFO] Staging 7 files
...
[INFO] Going to create ./target/central-publishing/central-bundle.zip by bundling content at ./target/central-staging
[INFO] Created bundle successfully ./target/central-staging/central-bundle.zip
[INFO] Skipping the upload of the bundle at user's request (skipUpload). The bundle is at ./target/central-publishing/central-bundle.zip
[INFO] BUILD SUCCESS

Four things in that output are worth knowing. The bundle is created once, in the last module (here tidepool-engine), in target/central-publishing of the directory where the build started. Before zipping, the plugin deletes files that Maven wrote into the staging directory and that do not belong on Central (maven-metadata-local.xml, _remote.repositories, .locks). The ZIP’s name and location are configurable (outputFilename, outputDirectory, stagingDirectory). And the first module, tidepool-parent, has packaging pom and so contributes only a POM and its signature (Staging 2 files), not a JAR, a sources JAR or a Javadoc JAR. The bundle holds, per module, the main JAR, the sources and Javadoc JARs and the POM, each with .asc, .md5, .sha1, .sha256 and .sha512 files, in the repository layout (which is explained on Philosophy, Coordinates & Directory Layout):

unzip -Z1 target/central-publishing/central-bundle.zip | grep -v '/$' | grep tidepool-model | sed 's|.*/||' | sort
tidepool-model-1.0.0-javadoc.jar
tidepool-model-1.0.0-javadoc.jar.asc
tidepool-model-1.0.0-javadoc.jar.md5
tidepool-model-1.0.0-javadoc.jar.sha1
tidepool-model-1.0.0-javadoc.jar.sha256
tidepool-model-1.0.0-javadoc.jar.sha512
tidepool-model-1.0.0-sources.jar
tidepool-model-1.0.0-sources.jar.asc
tidepool-model-1.0.0-sources.jar.md5
tidepool-model-1.0.0-sources.jar.sha1
tidepool-model-1.0.0-sources.jar.sha256
tidepool-model-1.0.0-sources.jar.sha512
tidepool-model-1.0.0.jar
tidepool-model-1.0.0.jar.asc
tidepool-model-1.0.0.jar.md5
tidepool-model-1.0.0.jar.sha1
tidepool-model-1.0.0.jar.sha256
tidepool-model-1.0.0.jar.sha512
tidepool-model-1.0.0.pom
tidepool-model-1.0.0.pom.asc
tidepool-model-1.0.0.pom.md5
tidepool-model-1.0.0.pom.sha1
tidepool-model-1.0.0.pom.sha256
tidepool-model-1.0.0.pom.sha512

The whole bundle has 54 files: 24 for each of the two JAR modules and 6 for the parent POM. The .asc files have no checksums of their own and the checksum files are not signed, which is what the requirements say.

A local pre-flight check

Because the Portal reports problems only after the upload, it pays to check the bundle yourself. The 45-line script below, run on the extracted or zipped bundle, mirrors the requirements table (it is not Central’s validator and knows nothing about namespaces or keys). POM metadata is looked up through the parent references that are inside the bundle, as for the tidepool modules. The script is this page’s own lab tool, not an example from the Maven or Central documentation.

check-bundle.py
#!/usr/bin/env python3
import sys, zipfile, re
import xml.etree.ElementTree as ET

NS = "{http://maven.apache.org/POM/4.0.0}"
z = zipfile.ZipFile(sys.argv[1])
names = {n for n in z.namelist() if not n.endswith("/")}
problems = []

def text(n, tag):
    e = n.find(NS + tag)
    return e.text.strip() if e is not None and e.text else None

poms = {n: ET.fromstring(z.read(n)) for n in sorted(names) if n.endswith(".pom")}

def inherited(root, tag, depth=0):
    if root.find(NS + tag) is not None:
        return root.find(NS + tag)
    parent = root.find(NS + "parent")
    if parent is None or depth > 5:
        return None
    g, a, v = (text(parent, t) for t in ("groupId", "artifactId", "version"))
    path = f"{g.replace('.', '/')}/{a}/{v}/{a}-{v}.pom"
    return inherited(poms[path], tag, depth + 1) if path in poms else None

for path, root in poms.items():
    base = path[:-4]
    for tag in ("name", "description", "url", "licenses", "developers", "scm"):
        if inherited(root, tag) is None:
            problems.append(f"{path}: POM has no <{tag}>")
    for suffix in (".pom", ".jar", "-sources.jar", "-javadoc.jar"):
        f = base + suffix
        if suffix != ".pom" and (text(root, "packaging") or "jar") == "pom":
            continue
        if f not in names:
            problems.append(f"{f}: file is missing")
            continue
        for ext in (".asc", ".md5", ".sha1"):
            if f + ext not in names:
                problems.append(f"{f}: no {ext[1:]} file")
for n in sorted(names):
    if re.search(r"(maven-metadata|_remote\.repositories|\.lastUpdated)", n):
        problems.append(f"{n}: local-repository leftover that does not belong in a bundle")

print(f"{len(names)} files, {len(poms)} POMs")
for p in problems:
    print("PROBLEM", p)
print("OK" if not problems else f"{len(problems)} problem(s)")
sys.exit(1 if problems else 0)

On the bundle above it prints 54 files, 3 POMs and OK. To see it complain, the same project was built with signing and Javadoc turned off (-Dgpg.skip=true -Dmaven.javadoc.skip=true, both user properties of the plugins), which yields a bundle that Maven is perfectly happy to build and that Central would reject:

35 files, 3 POMs
PROBLEM io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom: no asc file
PROBLEM io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.jar: no asc file
PROBLEM io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0-sources.jar: no asc file
PROBLEM io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0-javadoc.jar: file is missing
PROBLEM io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom: no asc file
PROBLEM io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar: no asc file
PROBLEM io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-sources.jar: no asc file
PROBLEM io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-javadoc.jar: file is missing
PROBLEM io/tidepool/tidepool-parent/1.0.0/tidepool-parent-1.0.0.pom: no asc file
9 problem(s)

The check proves the presence of the files, not that they are valid. Verifying the content is what the next commands do on the unpacked good bundle: each signature against its file with gpg --verify, and each .sha1 against shasum. All 9 signatures reported a good signature, and all the .sha1 files matched:

unzip -q target/central-publishing/central-bundle.zip -d verify && cd verify
for a in $(find io -name '*.asc'); do gpg --verify "$a" "${a%.asc}" >/dev/null 2>&1 && echo ok || echo "BAD $a"; done | sort | uniq -c
for s in $(find io -name '*.sha1'); do [ "$(cat "$s")" = "$(shasum -a 1 "${s%.sha1}" | cut -d' ' -f1)" ] || echo "BAD $s"; done
   9 ok

(The second loop printed nothing, which is the passing result.) These loops are this page’s own check, not an example from the Maven or Central documentation.

Uploading and waiting: manual, automatic, and waitUntil

Without skipUpload, the plugin uploads the bundle and then waits. What it waits for, and whether it publishes, is controlled by two settings.

  • autoPublish (default false). With false the Portal validates the bundle and then waits for a person to press Publish in the Portal (the deployment is listed on central.sonatype.com/publishing/deployments). With true it publishes by itself as soon as validation passes. Use false while you are getting the configuration right and when a human review before release is part of your process; use true in a CI pipeline that has already been gated by a tag and a review.

  • waitUntil (default validated). The state the plugin waits for before it lets the build finish: uploaded (the bundle arrived; validation failures are not reported), validated (arrived and passed validation; failures are reported in the build) or published (all the way into Central; requires autoPublish). The time to wait is bounded by waitMaxTime (default and minimum 1800 seconds) and the polling interval is waitPollingInterval (default 5 seconds, at least 5).

The shortcut for both is to set them as plugin configuration in the POM or as user properties on the command line, but beware of the precedence: a value written in the POM’s <configuration> wins over -D. In the first attempt of this page’s runs the POM had <autoPublish>false</autoPublish>, and -DautoPublish=true was silently ignored; the build waited for a manual release. The final tidepool POM above therefore leaves these two out so that the command line can choose.

All three runs below were against the stand-in on localhost:18765, with -DcentralBaseUrl pointing at it, so the lines come from the real plugin and the answers from the stand-in. The default (manual publication, waitUntil=validated):

mvn -B -ntp -s settings.xml -Prelease clean deploy -DcentralBaseUrl=http://localhost:18765
[INFO] --- central-publishing:0.12.0:publish (injected-central-publishing) @ tidepool-engine ---
[INFO] Using Central baseUrl: http://localhost:18765
[INFO] Using credentials from server id central in settings.xml
[INFO] Going to upload ./target/central-publishing/central-bundle.zip
[INFO] Uploaded bundle successfully, deployment name: Deployment, deploymentId: 00000000-0000-4000-8000-f0b183a9623e. Deployment will require manual publishing
[INFO] Waiting until Deployment 00000000-0000-4000-8000-f0b183a9623e is validated
[INFO] Deployment 00000000-0000-4000-8000-f0b183a9623e has been validated. To finish publishing visit http://localhost:18765/publishing/deployments
[INFO] BUILD SUCCESS

What the stand-in saw is the whole conversation of the plugin with the Portal: one upload and a status poll until the state is reached (the userId and orgId query parameters were removed for readability; the polls are five seconds apart, which is why that build took 20 seconds):

POST /api/v1/publisher/upload?name=Deployment&publishingType=USER_MANAGED
  stored bundle: 54 files, publishingType=USER_MANAGED
POST /api/v1/publisher/status?name=Deployment&publishingType=USER_MANAGED&id=ID
  status -> PENDING
POST /api/v1/publisher/status?name=Deployment&publishingType=USER_MANAGED&id=ID
  status -> VALIDATING
POST /api/v1/publisher/status?name=Deployment&publishingType=USER_MANAGED&id=ID
  status -> VALIDATED

autoPublish maps to publishingType=AUTOMATIC and false to USER_MANAGED, the two values of the Publisher API’s publishingType; the states are the Portal’s deploymentState values (PENDING, VALIDATING, VALIDATED, PUBLISHING, PUBLISHED, FAILED). With -DautoPublish=true -DwaitUntil=published:

[INFO] Uploaded bundle successfully, deployment name: Deployment, deploymentId: 00000000-0000-4000-8000-93a3ff967f75. Deployment will publish automatically
[INFO] Waiting until Deployment 00000000-0000-4000-8000-93a3ff967f75 is published
[INFO] Deployment 00000000-0000-4000-8000-93a3ff967f75 was successfully published
Packages
 - https://repo1.maven.org/maven2/io/tidepool/tidepool-engine/1.0.0/
 - https://repo1.maven.org/maven2/io/tidepool/tidepool-model/1.0.0/
 - https://repo1.maven.org/maven2/io/tidepool/tidepool-parent/1.0.0/

(The list of packages is the stand-in’s answer; the plugin turns each pkg:maven/... entry of the real status response into a URL under repo1.maven.org. The published state is the Portal’s, not a promise about how soon mirrors and search indexes show the release.) And when validation fails the build fails, with the errors of the Portal printed. The stand-in’s message is its own, but the format (a header, the component, the list of messages) and the failing build are the plugin’s:

[INFO] Waiting until Deployment 00000000-0000-4000-8000-25b14cb46201 is validated
[ERROR]

Deployment 00000000-0000-4000-8000-25b14cb46201 failed
pkg:maven/io.tidepool/tidepool-model@1.0.0:
 - stand-in message: validation failed


[ERROR] Failed to execute goal org.sonatype.central:central-publishing-maven-plugin:0.12.0:publish (injected-central-publishing) on project tidepool-engine: Execution injected-central-publishing of goal org.sonatype.central:central-publishing-maven-plugin:0.12.0:publish failed: Deployment 00000000-0000-4000-8000-25b14cb46201 (Deployment) failed while publishing -> [Help 1]

A failed deployment is not published. Fix the cause, then drop the failed deployment in the Portal (or with the DELETE /api/v1/publisher/deployment/<id> call), and run the build again. Do not drop a failed deployment you want Central support to look at.

All plugin options

Option Purpose Default

publishingServerId

The id of the settings.xml server that holds the token.

central

autoPublish

Publish automatically after successful validation.

false

waitUntil

uploaded, validated or published.

validated

waitMaxTime, waitPollingInterval

Seconds to wait at most (minimum 1800) and between polls (minimum 5).

1800, 5

checksums

all (MD5, SHA1, SHA256, SHA512), required (MD5 and SHA1) or none.

all

centralBaseUrl

Portal URL that bundles are uploaded to.

https://central.sonatype.com

centralSnapshotsUrl

Where snapshots go; falls back to the distributionManagement of the POM when not set.

not set

deploymentName

Name shown on the Portal’s Deployments page.

Deployment

excludeArtifacts

artifactId values that are not added to the bundle.

none

ignorePublishedComponents

Leave out components already published, to release only the new modules of a multi-module build. Asks Central which components exist.

false

failOnBuildFailure

Whether the plugin may fail the build before uploading and publishing.

true

outputDirectory, outputFilename, stagingDirectory

Where the bundle and the staging tree are written.

central-publishing, central-bundle.zip, central-staging

skipPublishing

Leave this module’s artifacts out of the bundle entirely.

false

skipUpload

Build the bundle, do not send it (0.12.0).

false

waitForPublishCompletion, publishCompletionPollInterval

Deprecated. Use autoPublish with waitUntil, and waitPollingInterval.

false, 1000

Multi-module builds

The extension collects every module of the reactor into one bundle, so one mvn deploy publishes the whole group as a single deployment. That matters for two reasons. A module POM refers to its parent POM, and a release is valid only if everything it refers to is on Central or in the same bundle. And the bundle is one deployment, validated and published as a whole.

Not every module belongs on Central: a module of integration tests, an application, an example. The module’s own plugin configuration decides. With <skipPublishing>true</skipPublishing> in the central-publishing-maven-plugin configuration of tidepool-engine, the whole build ran and ended with BUILD SUCCESS, and the bundle had 30 files instead of 54, for the parent and the model only:

  24 io/tidepool/tidepool-model
   6 io/tidepool/tidepool-parent

The other tools are excludeArtifacts (by artifactId, from the plugin configuration or the command line) and ignorePublishedComponents for the case "release version 1.0.1 of one module and leave the others": the Portal refuses a bundle that contains a component already published, and this option leaves such components out. Remember that the other modules of the reactor must still resolve against what is already on Central. The reactor, its order and -pl/-am are on Multi-Module Builds & the Reactor.

SNAPSHOT versions

Snapshots are published separately. Central accepts them only for namespaces where snapshots have been enabled, validates nothing, and removes them after a period (currently 90 days). The plugin, from version 0.7.0, handles them: a -SNAPSHOT version is not zipped but uploaded to the location for snapshots (centralSnapshotsUrl, or, if that is not set, the snapshotRepository of distributionManagement). With skipUpload the artifacts are staged but not deployed. A copy of tidepool at version 1.1.0-SNAPSHOT built with -DskipUpload=true:

[INFO] --- central-publishing:0.12.0:publish (injected-central-publishing) @ tidepool-engine ---
[INFO] Skipping the deployment of the staged SNAPSHOT artifacts at user's request (skipUpload). They are in ./target/central-deferred
[INFO] BUILD SUCCESS

No bundle is built for a snapshot. Consumers add the snapshot repository https://central.sonatype.com/repository/maven-snapshots/ to their POM with releases disabled and snapshots enabled (repositories are on Repositories, Mirrors & Repository Managers; the general rules for snapshots are on Versions, Snapshots & Ranges). Enabling snapshots for a namespace is done in the Portal and was not run here; a real snapshot deployment was not attempted.

Maven 3.9, 3.10 and 4: what differs

Maven Result with plugin 0.12.0 What was run

3.9.16

Works. The staging directory holds maven-metadata-central-staging.xml, which the plugin deletes before bundling.

-Prelease clean deploy -DskipUpload=true: BUILD SUCCESS; bundle of 54 files; the pre-flight script printed OK.

3.10.0

Works with 0.12.0. Maven 3.10 writes maven-metadata-local.xml and _remote.repositories into the staging directory, and 0.12.0 removes them (see below).

The runs of the page.

4.0.0-rc-7 (release candidate)

Does not publish. The build succeeds but the plugin’s publish step never runs.

-Prelease clean deploy -DskipUpload=true and a run against the stand-in; see below.

Maven 3.10 needs plugin 0.12.0. Its release notes list as the first fix that deployments were "rejected by Central when built with Apache Maven 3.10.0" with the error Bundle has content that does NOT have a .pom file, because the two kinds of local-repository files above were not removed before the ZIP was created. This was reproduced by running the previous release, 0.11.1, on Maven 3.10.0 against the stand-in, which saves the bundle it receives. The bundle had 72 files instead of 54, the extra 18 being:

io/tidepool/tidepool-engine/maven-metadata-local.xml
io/tidepool/tidepool-engine/1.0.0/_remote.repositories
io/tidepool/tidepool-engine/1.0.0/_remote.repositories.md5
io/tidepool/tidepool-engine/1.0.0/_remote.repositories.sha1
io/tidepool/tidepool-engine/1.0.0/_remote.repositories.sha256
io/tidepool/tidepool-engine/1.0.0/_remote.repositories.sha512
... (the same six files for tidepool-model and for tidepool-parent)

The pre-flight script flagged all of them as "local-repository leftover". Whether Central’s own message is the quoted one was not run here; the evidence is the extra files and the release notes. The consequence: when you move a project to Maven 3.10, move the plugin to 0.12.0 or later in the same change. The version in the tidepool POM is a property (central.publishing.version) only so that this experiment could switch it from the command line.

Maven 4 (release candidate) is not supported by the plugin yet. The plugin’s documentation lists "Publishing doesn’t work when using Maven 4.x.x" as a known issue. On 4.0.0-rc-7 the extension announces itself (Installing Central Publishing features, Inspecting build with total of 3 modules), the GPG plugin signs (including the consumer POMs that Maven 4 builds), and the build ends in BUILD SUCCESS, but there is no central-publishing:publish line, no deploy line, no bundle, and the stand-in received no request at all. A successful build that published nothing is the dangerous part: if you run Maven 4 builds, check for the Uploaded bundle successfully or the bundle path in the log before you trust a release. Maven 4’s own changes are on Maven 4: Lifecycle and Migrating to Maven 4. The GPG plugin works on all three.

JDK 26 prints three WARNING: Final field artifactRepositoryFactory ... has been mutated reflectively lines at the start of the Central plugin’s first module, from the dependency-injection library Maven uses; they are noise and did not affect any run.

The end of OSSRH and the Staging API compatibility service

Until 2025-06-30 projects published to Central through OSSRH, the Sonatype Nexus service at oss.sonatype.org and s01.oss.sonatype.org, with the nexus-staging-maven-plugin or the Maven Deploy Plugin and a staging repository that you "closed" and "released". OSSRH reached end of life on 2025-06-30 and was shut down; the namespaces were migrated to the Portal, where the old account can log in. Every book, blog post and POM that has <repository> entries for oss.sonatype.org (and the ossrh server id that went with them) describes a service that is gone; the nexus-staging-maven-plugin stanza with <serverId>ossrh</serverId> has no server left to talk to. The modern replacement is the Central Publishing plugin of the previous sections.

For projects that cannot change their build quickly, the Portal offers the OSSRH Staging API, a compatibility service that translates a subset of the Nexus 2 staging API to the Portal. You replace the OSSRH host with https://ossrh-staging-api.central.sonatype.com and the OSSRH token with a Portal user token (an OSSRH token gets a 401). Two kinds of plugins are distinguished:

  • Nexus Repository Manager 2 staging plugins (nexus-staging-maven-plugin, gradle-nexus/publish-plugin) need only the new URL and token; the repository is uploaded to the Portal when it is closed.

  • "Maven-API-like" plugins, which just PUT files into a Maven 2 repository layout, like the Maven Deploy Plugin, send no hint of where one release ends. The service separates them by IP address, and you must tell it to hand the files over: either end the CI job with a POST /manual/upload/defaultRepository/<namespace> from the same IP, or later look up the open repositories with GET /manual/search/repositories and send each with POST /manual/upload/repository/<key> (or remove it with DELETE /manual/drop/repository/<key>). Without this, the deployment never shows up in the Portal.

The shape of the Maven-API-like flow was run with the stand-in answering PUT as the staging repository. The release build, with the Central plugin removed from the profile, deployed to the central-staging server of settings.xml through the altDeploymentRepository property of the Deploy Plugin:

mvn -B -ntp -s settings.xml -Prelease clean deploy \
    -DaltDeploymentRepository=central-staging::http://localhost:18765/service/local/staging/deploy/maven2/
PUT /service/local/staging/deploy/maven2/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom bytes=560
PUT /service/local/staging/deploy/maven2/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.sha1 bytes=40
PUT /service/local/staging/deploy/maven2/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.md5 bytes=32
PUT /service/local/staging/deploy/maven2/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-sources.jar bytes=2936
...
PUT /service/local/staging/deploy/maven2/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.asc bytes=228
...
PUT /service/local/staging/deploy/maven2/io/tidepool/tidepool-model/maven-metadata.xml bytes=305
PUT /service/local/staging/deploy/maven2/io/tidepool/tidepool-model/maven-metadata.xml.sha1 bytes=40
PUT /service/local/staging/deploy/maven2/io/tidepool/tidepool-model/maven-metadata.xml.md5 bytes=32

(These are the stand-in’s log lines, 45 PUT requests for the three modules, abridged to part of tidepool-model.) Note that nothing in them says "this release is complete". The last step, the manual call, is a plain authenticated request; it was sent to the stand-in, which only acknowledged it with HTTP 200, so the real service was not tried:

export CENTRAL_TOKEN=$(printf 'tokenuser-example:changeit-example' | base64)
curl -s -o /dev/null -w "HTTP %{http_code}\n" --request POST \
     --header "Authorization: Bearer ${CENTRAL_TOKEN}" \
     http://localhost:18765/manual/upload/defaultRepository/io.tidepool
HTTP 200

The Bearer value is the Base64 encoding of username:password of the token. The compatibility service is a bridge: its documentation states the hope that plugins will adopt the Portal API, its repositories are per user and, for the Maven-like API, isolated by IP address, and the Central Publishing plugin needs none of that.

Releasing in CI: a checklist

The pieces above fit together in a release job like this, which was not run as a whole against Central:

  1. The release is prepared and tagged (see Releasing; for a tag-driven pipeline with no commits by the build, see CI-Friendly Versions). The version has no -SNAPSHOT.

  2. The job has these secrets, from the CI system’s store, as environment variables: CENTRAL_TOKEN_USERNAME and CENTRAL_TOKEN_PASSWORD for settings.xml, and either MAVEN_GPG_PASSPHRASE with a key in a keyring on the runner, or MAVEN_GPG_KEY plus MAVEN_GPG_PASSPHRASE with -Dgpg.signer=bc.

  3. It writes or selects a settings.xml with the central server shown above, and runs mvn -B -Prelease clean deploy (with -DautoPublish=true -DwaitUntil=published if a pipeline, not a person, releases).

  4. For the first release of a project, or of a changed build, run with -DskipUpload=true, look at the bundle (the pre-flight script, or upload it by hand in the Portal) and only then let the job publish.

  5. Afterwards, the artifact is on repo1.maven.org within a short time; resolve it from a clean project to see it, as the cross-tool page does.

CI workflow examples for GitHub Actions are on CI/CD for Android with GitHub Actions and JMeter CI/CD with Maven, Docker & GitHub Actions; keeping credentials out of the repository is also on settings.xml & Password Encryption, and build reproducibility and signing from the consumer’s point of view are on Security & Reproducible Builds. The tidepool parent POM sets project.build.outputTimestamp so that the JARs of two builds of the same tag are byte-identical, which is what makes the signatures reproducible-build friendly.

Where older sources are out of date

  • Posts that publish through oss.sonatype.org with nexus-staging-maven-plugin, <serverId>ossrh</serverId> and mvn nexus-staging:release describe the shut-down OSSRH. Use the Central Publishing plugin, or the Staging API service as a bridge.

  • Posts that ask you to open a Jira ticket on issues.sonatype.org to get a groupId are from the era before the Portal; namespaces are now registered and verified in the Portal.

  • The older Maven guide to uploading artifacts shows the minimal POM sample with only a licence and a one-line scm URL; the Central requirements page is the current list, and requires developers, name, description, url and a full scm.

  • Advice to keep the GPG passphrase in settings.xml or on the command line (-Dgpg.passphrase) is deprecated by the plugin itself; use the agent or MAVEN_GPG_PASSPHRASE.

  • Maven 4 is still a release candidate and plugin 0.12.0 does not publish with it: do not assume that a Maven 4 build uploads anything (see above).