Half the guides still point at a JIRA board that no longer exists.
If you search for how to publish to Maven Central, you will find detailed walkthroughs telling you to open a ticket on issues.sonatype.org, wait for a human to approve your group id, then deploy to OSSRH and "close" a staging repository. That flow is gone. Since the Central Portal took over new registrations, the ticket step does not exist, the endpoint is different, and the plugin is different. The old posts are not wrong so much as archaeological.
Here is what the path actually looks like now, including the four places I lost time. I was publishing two small libraries — a conventions module and a transactional-outbox module — out of a Spring Cloud starter I am building in public.
Four prerequisites, all of which must be true at once
1. A verified namespace. In the Portal you claim a namespace rather than a group id. If you own no domain, io.github.<your-github-username> is free: the Portal verifies it by checking a GitHub repository you create with a name it dictates. Mine came out as io.github.danzizhangdev, and the Publishing Settings page shows it with a green Verified badge. That badge is the thing to look for; nothing downstream works without it.
2. A Portal user token. Generate it under your account, not your password, and drop it into ~/.m2/settings.xml as a <server> entry.
3. A GPG key pair, published to a keyserver. Central rejects unsigned artifacts. You need the private half locally and the public half discoverable.
4. Release plugins in the pom — sources jar, javadoc jar, GPG signing, and the Portal's own publishing plugin.
Each is simple. The interesting part is how they fail.
Trap 1: ${server} is a placeholder, not a variable
The Portal shows you a ready-made settings.xml snippet to paste. It looks like this:
<server>
<id>${server}</id>
<username>...</username>
<password>...</password>
</server>
${server} is not something Maven interpolates. It is a fill-in-the-blank, and the value has to match the publishingServerId of the publishing plugin — central by default. Paste it verbatim and your deploy fails to find credentials, with an error that does not point anywhere near your settings file.
Trap 2: "different login methods are different accounts, even with the same email"
That sentence is in Sonatype's own documentation, and it is the most expensive sentence on the page. Sign up with GitHub, come back later and sign in with email, and you are a different principal: your namespace is not there and your token belongs to someone else. The symptom is a 401 or 403 at deploy time, which reads like a credential problem, so you regenerate the token — and it happens again.
The rule that follows: always sign in the same way you signed in when you got the verified namespace. If the Namespace page ever looks empty, suspect the login method before you suspect the namespace.
Trap 3: gpg --full-generate-key times out and never says why
I answered every prompt — RSA, 4096, expiry, identity — watched it print "We need to generate a lot of random bytes", and then it sat there until:
gpg: agent_genkey failed: Timeout
Key generation failed: Timeout
Nothing in that message mentions the actual cause: gpg-agent had no way to ask me for a passphrase. I had gnupg and a terminal-only pinentry installed, no ~/.gnupg/gpg-agent.conf, and no GPG_TTY exported — so the agent could neither open a dialog nor draw a prompt in the terminal, and waited until it gave up.
Three fixes, and you want all three:
brew install pinentry-mac
cat >> ~/.gnupg/gpg-agent.conf <<'EOF'
pinentry-program /opt/homebrew/bin/pinentry-mac
allow-loopback-pinentry
default-cache-ttl 600
EOF
echo 'export GPG_TTY=$(tty)' >> ~/.zshrc # GnuPG asks for this explicitly
gpgconf --kill gpg-agent
Note the tutorials that use %no-protection (a passphraseless key in a batch file) sail straight past this. That is why so few of them mention it.
Trap 4: dirmngr does not read your proxy environment variables
Uploading the public key failed with something that looks like a network outage:
gpg: sending key ... failed: No route to host
http_proxy was set and working — curl was fine. But keyserver traffic goes through dirmngr, a separate daemon that does not inherit http_proxy. It needs its own file:
cat >> ~/.gnupg/dirmngr.conf <<'EOF'
honor-http-proxy
http-proxy http://127.0.0.1:7897
EOF
gpgconf --kill dirmngr
One more thing that looks like a failure and is not: after uploading to keys.openpgp.org, fetch your key back and gpg --show-keys prints nothing. The key is there — that server deliberately strips every user id until you click the link in the verification email it sent. Central only needs the key material, so this does not block a release. Check keyserver.ubuntu.com alongside it and you will see the same key with its identity attached.
The pom side
Two plugin groups, and one attribute that matters more than the rest.
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.8.0</version>
<extensions>true</extensions>
<configuration>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>published</waitUntil>
</configuration>
</plugin>
autoPublish plus waitUntil=published is what replaces the old "close the staging repository in the web UI" dance: the build blocks until the Portal says the deployment is live, so a green build means a real release.
Signing, sources and javadoc go in a profile, so day-to-day builds stay fast:
<profile>
<id>release</id>
<build><plugins>
<plugin><artifactId>maven-source-plugin</artifactId>
<executions><execution><goals><goal>jar-no-fork</goal></goals></execution></executions></plugin>
<plugin><artifactId>maven-javadoc-plugin</artifactId>
<configuration><doclint>none</doclint></configuration>
<executions><execution><goals><goal>jar</goal></goals></execution></executions></plugin>
<plugin><artifactId>maven-gpg-plugin</artifactId>
<configuration><keyname>${gpg.keyname}</keyname></configuration>
<executions><execution><phase>verify</phase><goals><goal>sign</goal></goals></execution></executions></plugin>
</plugins></build>
</profile>
My repository is a multi-module build where only two modules are meant to be consumed as libraries; the three runnable services are reference implementations. Those get one line:
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<configuration><skipPublishing>true</skipPublishing></configuration>
</plugin>
Publishing the whole reactor by accident is easy, and unpublishing is impossible.
Verify like you mean it
A release is not verified until you have pulled it back from Central on a machine that could not have had it cached. The trap is your own local repository:
rm -rf ~/.m2/repository/io/github/danzizhangdev
mvn dependency:get -Dartifact=io.github.danzizhangdev:starter-common:0.1.0
Without the rm, Maven answers from disk and you have proven nothing.
There is a second false positive that bit me for a different reason. My network cannot reliably download Spring Boot's dependency tree straight from Central — parallel downloads get their TLS connections cut — so I resolve through a mirror. A mirror silently invalidates the check above: "pulled it from Central" becomes "pulled it from whatever the mirror had". So I keep a second settings file with no mirrors at all, purely for release verification:
mvn -s ~/.m2/settings-nomirror.xml dependency:get -Dartifact=...:0.1.0
Mirrors affect resolution, never publication — the publishing plugin talks to the Portal API directly. But if you do not separate the two, your verification step is measuring your mirror.
What I would tell someone starting today
- Ignore anything that mentions a JIRA ticket; you want the Central Portal documentation.
- Do the GPG work first, with a passphrase and a GUI pinentry, before you touch the pom.
- Keep one login method for the Portal and write down which one it was.
- Treat
skipPublishingand a no-mirror verification profile as part of the release setup, not as polish.
The starter itself — gateway, JWT auth with a JWKS endpoint, a transactional outbox library, and both Compose and Helm deployments of the same images — is Apache-2.0 here:
Next in this series: what the memory budget actually looks like when you measure it, and why Kafka's default heap is the first thing to pin on a small box.
Top comments (0)