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 (withgpgand with the pure-Javabcsigner), 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) onlocalhostanswers the three Portal Publisher API calls the plugin makes (upload, status) and acceptsPUTrequests 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:
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 |
Not a build step. Done in the Portal; not run here. |
Release version |
The version cannot end in |
The |
POM metadata |
|
Elements of the POM, inherited from the parent. Nothing in Maven enforces them. |
Sources and Javadoc |
A |
|
Checksums |
|
Generated by the Central Publishing plugin ( |
Signatures |
A detached |
|
Source: Central: Requirements, Javadoc and sources, checksums, signatures and POM metadata; Central: Register a Namespace; Maven: Guide to uploading artifacts to the Central Repository.
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:
<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)
Source: Central: Required POM metadata
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 |
|---|---|---|
|
|
|
|
Key to sign with; passed to GnuPG as |
first signing key |
|
The GnuPG home directory to use. |
GnuPG’s own default |
|
Path of the |
|
|
Extra arguments for |
none |
|
Whether |
|
|
Name of the environment variable that holds the passphrase. |
|
|
Name of the environment variable that holds the secret key in armored (TSK) form. |
|
|
A file holding the key, resolved against the user’s home directory. |
|
|
Which key of the supplied ones to use. |
first key |
|
Ant-style patterns of files that are not signed. |
checksums, |
|
Where the signatures are written. |
|
|
Skip signing. |
|
|
Fail the build when a secret is read from the POM, the command line or |
|
|
Deprecated. A passphrase written in the configuration, or looked up in a |
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.
Source: GPG Plugin: gpg:sign, useAgent
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.
Source: GPG Plugin: gpg:sign, signer
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 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.
Source: Central: skipUpload and skipPublishing
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.
#!/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(defaultfalse). Withfalsethe Portal validates the bundle and then waits for a person to press Publish in the Portal (the deployment is listed oncentral.sonatype.com/publishing/deployments). Withtrueit publishes by itself as soon as validation passes. Usefalsewhile you are getting the configuration right and when a human review before release is part of your process; usetruein a CI pipeline that has already been gated by a tag and a review. -
waitUntil(defaultvalidated). 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) orpublished(all the way into Central; requiresautoPublish). The time to wait is bounded bywaitMaxTime(default and minimum 1800 seconds) and the polling interval iswaitPollingInterval(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.
Source: Central: Automatic publishing, wait for publishing, waitUntil and Publisher API: deployment states
All plugin options
| Option | Purpose | Default |
|---|---|---|
|
The id of the |
|
|
Publish automatically after successful validation. |
|
|
|
|
|
Seconds to wait at most (minimum 1800) and between polls (minimum 5). |
1800, 5 |
|
|
|
|
Portal URL that bundles are uploaded to. |
|
|
Where snapshots go; falls back to the |
not set |
|
Name shown on the Portal’s Deployments page. |
|
|
|
none |
|
Leave out components already published, to release only the new modules of a multi-module build. Asks Central which components exist. |
|
|
Whether the plugin may fail the build before uploading and publishing. |
|
|
Where the bundle and the staging tree are written. |
|
|
Leave this module’s artifacts out of the bundle entirely. |
|
|
Build the bundle, do not send it (0.12.0). |
|
|
Deprecated. Use |
|
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 |
|
3.10.0 |
Works with 0.12.0. Maven 3.10 writes |
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. |
|
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.
Source: Central plugin release notes and known issues
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
PUTfiles 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 aPOST /manual/upload/defaultRepository/<namespace>from the same IP, or later look up the open repositories withGET /manual/search/repositoriesand send each withPOST /manual/upload/repository/<key>(or remove it withDELETE /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:
-
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. -
The job has these secrets, from the CI system’s store, as environment variables:
CENTRAL_TOKEN_USERNAMEandCENTRAL_TOKEN_PASSWORDforsettings.xml, and eitherMAVEN_GPG_PASSPHRASEwith a key in a keyring on the runner, orMAVEN_GPG_KEYplusMAVEN_GPG_PASSPHRASEwith-Dgpg.signer=bc. -
It writes or selects a
settings.xmlwith thecentralserver shown above, and runsmvn -B -Prelease clean deploy(with-DautoPublish=true -DwaitUntil=publishedif a pipeline, not a person, releases). -
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. -
Afterwards, the artifact is on
repo1.maven.orgwithin 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.orgwithnexus-staging-maven-plugin,<serverId>ossrh</serverId>andmvn nexus-staging:releasedescribe 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.orgto get agroupIdare 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
scmURL; the Central requirements page is the current list, and requiresdevelopers,name,description,urland a fullscm. -
Advice to keep the GPG passphrase in
settings.xmlor on the command line (-Dgpg.passphrase) is deprecated by the plugin itself; use the agent orMAVEN_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).