The cover image is AI, but my work is 2 days worth.
Publishing a Java library to Maven Central has historically been a rite of passage for Java developers. With recent updates to Sonatype’s infrastructure — specifically the introduction of the new Central Portal — the process has changed.
Maven now supports two primary workflows: the Standard Maven Deploy Command and the Central Portal ZIP Upload. Depending on when your namespace was registered, you might be forced to use one over the other.
Here is a comprehensive guide to getting your Java project successfully published using both methods.
1. Prerequisites (Mandatory for both methods)
Before you build or upload anything, you need to configure your environment, your credentials, and your project’s pom.xml.
1.1 Namespace & Account
- Portal URL: Go to central.sonatype.com and create an account.
-
Official Namespace: Register your namespace (e.g.,
io.github.yourusername.project). - Status: Your namespace must show as “Verified” (with a green checkmark) on the portal before proceeding.
1.2 POM.xml Requirements
Maven Central has strict requirements for the metadata in your pom.xml. Ensure the following fields are present and correct:
-
Version: Must be a clean release version (e.g.,
1.0.0) instead of a snapshot (1.0.0-SNAPSHOT). -
URL: Must include your project’s repository URL (e.g.,
<url>https://github.com/yourusername/your-repo</url>). -
Metadata Blocks: You must explicitly include
<licenses>,<developers>, and<scm>blocks. - Properties: Update any internal jar version properties to match the release format.
1.3 GPG Key Registration
All artifacts must be cryptographically signed.
- Export your public key:
gpg --armor --export <KEY_ID> > public.asc - Upload the contents of
public.ascto a public key server, such askeyserver.ubuntu.com.
1.4 User Token Generation
You no longer use your raw account password. Generate a token via the Generate User Token button in your Central Portal account settings.
1.5 Configure your settings.xml
Update your ~/.m2/settings.xml to include your server tokens and GPG passphrase.
<settings>
<servers>
<server>
<id>central</id>
<username>YOUR_SONATYPE_TOKEN_USER</username>
<password>YOUR_SONATYPE_TOKEN_PASS</password>
</server>
</servers>
<profiles>
<profile>
<id>release</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<gpg.passphrase>YOUR_GPG_KEY_PASSPHRASE</gpg.passphrase>
<gpg.executable>gpg</gpg.executable>
<gpg.keyname>YOUR_GPG_KEY_NAME</gpg.keyname>
</properties>
</profile>
</profiles>
</settings>
2. Method 1: The “New” Central Portal (ZIP Bundle)
This is the most reliable method for the new Sonatype infrastructure. It involves creating a single ZIP bundle that contains all JARs, POMs, Signatures, and Checksums, which you then upload manually or via their API.
What Happens Under the Hood
To create a valid bundle manually, you must follow these exact steps:
-
Build: Run
mvn clean package -DskipTests -
Sign: Create
.ascfiles for your compiled JAR, Sources JAR, Javadoc JAR, and POM. -
Checksum: Generate
.md5and.sha1hashes for all files (including the.ascsignature files). -
Layout: Place everything in a strictly nested folder structure mirroring your group ID:
io/github/yourusername/project/Your_Project_Name/1.0.0/. -
Zip: Compress the root
io/folder intobundle.zip. - Upload: Click the Publish Component button on the Central Portal and upload the ZIP.
Pro Tip: Because the manual layout and checksum process is highly error-prone, writing a quick Python or Bash script to automate the signing, hashing, and zipping phase is highly recommended. You can find mine at the end of the post.
3. Method 2: Standard mvn deploy (Command Line)
If your namespace allows it, this method pushes directly to the Sonatype staging repository via the CLI.
The Command
mvn clean deploy -P release -Dgpg.passphrase=YOUR_PASSPHRASE
Why this might fail (The 401/405 Error)
If you run this command and receive a 401 Unauthorized or 405 Method Not Allowed error, it is likely due to how your namespace is registered.
- “New” Namespaces (The Portal API): If you registered your namespace recently on the new portal, Sonatype’s servers are often configured to only accept ZIP Bundles via their new API. Maven tries to talk to the legacy Nexus servers, and the server rejects it.
-
“Legacy” Namespaces (OSSRH): Older projects that have been on Maven Central for years can still use
mvn deploy. However, for many newio.github.*namespaces, Sonatype is enforcing the ZIP bundle method.
If the command line fails, pivot to Method 1!
4. Troubleshooting Common Errors
-
“Namespace not allowed”: Verify your namespace is approved on the portal and ensure the
groupIdin yourpom.xmlmatches it exactly. -
“Project URL missing”: Check the
<url>tag in the POM; it cannot be blank or missing. -
“Validation Failed (Signature)”: Ensure your public key is fully synced and searchable on
keyserver.ubuntu.com. It can sometimes take an hour to propagate.
Bonus: The Automation Scripts
Here are the two files you can use to automate the ZIP bundle process: set_env.sh and push_maven.py.
1. set_env.sh
#!/bin/bash
# 1. Maven Credentials (Used by settings.xml or custom scripts)
# Replace these with your actual Sonatype Token credentials,
# or local Nexus admin credentials if testing locally.
export MAVEN_USERNAME="YOUR_SONATYPE_USER_TOKEN"
export MAVEN_PASSWORD="YOUR_SONATYPE_PASSWORD_TOKEN"
# 2. Java Paths
# Useful if your project has multiple modules requiring different JDKs.
export JAVA8_HOME="/path/to/your/java-1.8.0-openjdk"
export JAVA21_HOME="/path/to/your/java-21-openjdk"
# Default to Java 8 for Maven (Change as needed for your primary build)
export JAVA_HOME="${JAVA8_HOME}"
export PATH="${JAVA_HOME}/bin:${PATH}"
# 3. CI/CD Environment Variables (Used by push_maven.py)
# Mimics a typical CI environment variable (e.g., GitLab or GitHub Actions).
export CI_PROJECT_DIR="/path/to/your/source/code"
echo "Maven environment variables (MAVEN_USERNAME, MAVEN_PASSWORD, CI_PROJECT_DIR) have been set."
echo "Using Java version: $(java -version 2>&1 | head -n 1)"
2. push_maven.py
#!/usr/bin/env python3
"""
push_maven.py
Unified Maven push script for Java Projects.
Handles build, staging, signing, zipping, and direct API upload to the Maven Central Portal.
Automatically extracts credentials and GPG passphrase from ~/.m2/settings.xml.
USAGE EXAMPLES:
1. Fresh Build & Public Release (Module A):
python3 push_maven.py --target public --build
2. Sign & Zip existing jars (no build):
python3 push_maven.py --target public
3. Upload existing signed ZIP bundle only:
python3 push_maven.py --target public --upload-only
4. Release Secondary Component (Module B):
python3 push_maven.py --target public --module-b --build
5. Override with manual JAR paths:
python3 push_maven.py --target public --jar-path ./my.jar
"""
import argparse, hashlib, os, re, shutil, subprocess, sys, zipfile, base64
from pathlib import Path
import xml.etree.ElementTree as ET
# ---------------------------------------------------------------------------
# Configuration & Metadata Extraction
# ---------------------------------------------------------------------------
def get_common_config(project_dir: Path, is_module_b=False):
"""
Determine base paths and extract groupId/artifactId from the project's pom.xml.
"""
if is_module_b:
rel_pom_path = "module-b/pom.xml"
cfg = {
"staging_dir": "staging_module_b",
"component_dir": "module-b",
"java_home_env": "JAVA21_HOME",
"artifact_src_dir": "module-b/target"
}
else:
rel_pom_path = "module-a/pom.xml"
cfg = {
"staging_dir": "staging_module_a",
"component_dir": "module-a",
"java_home_env": "JAVA8_HOME",
"artifact_src_dir": "module-a/target"
}
pom_path = project_dir / rel_pom_path
if not pom_path.exists():
sys.exit(f"ERROR: POM not found at {pom_path}")
# Register namespace to handle Maven POMs correctly
ET.register_namespace('', "[http://maven.apache.org/POM/4.0.0](http://maven.apache.org/POM/4.0.0)")
tree = ET.parse(pom_path)
root = tree.getroot()
ns = {'mvn': '[http://maven.apache.org/POM/4.0.0](http://maven.apache.org/POM/4.0.0)'}
def find_text(xpath):
node = root.find(xpath, ns)
return node.text.strip() if node is not None else None
group_id = find_text('mvn:groupId') or find_text('mvn:parent/mvn:groupId')
artifact_id = find_text('mvn:artifactId')
cfg.update({
"group_id": group_id,
"artifact_id": artifact_id,
"pom_src": str(rel_pom_path),
"version_file": "config/release.env", # Path to file containing version variables
"jar_release_env_var": "RELEASE_VERSION",
})
return cfg
def get_settings_info():
"""
Extract Sonatype credentials and GPG passphrase from ~/.m2/settings.xml.
Looks for a server with ID 'central'.
"""
settings_path = Path(os.path.expanduser("~/.m2/settings.xml"))
if not settings_path.exists():
return None, None, None
with open(settings_path, 'r') as f:
content = f.read()
# 1. Credentials
server_block = re.search(r"<server>\s*<id>central</id>(.*?)</server>", content, re.DOTALL)
user = re.search("<username>(.*?)</username>", server_block.group(1)).group(1) if server_block else None
pw = re.search("<password>(.*?)</password>", server_block.group(1)).group(1) if server_block else None
# 2. GPG Passphrase
gpg_pass_match = re.search("<gpg.passphrase>(.*?)</gpg.passphrase>", content)
gpg_pass = gpg_pass_match.group(1) if gpg_pass_match else None
return user, pw, gpg_pass
TARGET_CONFIG = {
"public": {
"label": "Public (Central Portal Release)",
"default_job_type": "releases",
"api_url": "[https://central.sonatype.com/api/v1/publisher/upload](https://central.sonatype.com/api/v1/publisher/upload)",
"include_javadoc": True
},
"private": {
"label": "Private (Central Portal Snapshots)",
"default_job_type": "snapshots",
"repository_url_template": "[https://central.sonatype.com/repository/maven-snapshots/](https://central.sonatype.com/repository/maven-snapshots/)",
"include_javadoc": True
},
"local": {
"label": "Local Nexus Docker (Test)",
"default_job_type": "snapshots",
"repository_url_template": "http://localhost:8090/repository/maven-{job_type}/",
"include_javadoc": True
},
}
# ---------------------------------------------------------------------------
# Core Functions
# ---------------------------------------------------------------------------
def run(cmd: list, dry_run: bool = False, cwd: Path = None, env=None, **kwargs) -> subprocess.CompletedProcess:
"""Execute a shell command with logging and sensitive data masking."""
dir_label = f"[{cwd.name}] " if cwd else ""
cmd_log = ' '.join(cmd)
if "Authorization:" in cmd_log:
cmd_log = re.sub(r'Basic [A-Za-z0-9+/=]+', 'Basic ********', cmd_log)
if "--passphrase" in cmd_log:
cmd_log = re.sub(r'--passphrase \S+', '--passphrase ********', cmd_log)
print(f" $ {dir_label}{cmd_log}")
if dry_run:
return subprocess.CompletedProcess(cmd, returncode=0)
return subprocess.run(cmd, check=True, cwd=str(cwd) if cwd else None, env=env, **kwargs)
def build_maven_artifacts(project_dir: Path, common: dict, dry_run: bool):
"""Execute 'mvn clean package' to generate fresh JARs and Javadocs."""
comp_dir = project_dir / common["component_dir"]
print(f"\n[Build] Running mvn clean package in {common['component_dir']} ...")
env = os.environ.copy()
java_home = os.environ.get(common["java_home_env"])
if java_home:
env["JAVA_HOME"] = java_home
env["PATH"] = f"{java_home}/bin:{env.get('PATH', '')}"
run(["mvn", "clean", "package", "-DskipTests"], dry_run=dry_run, cwd=comp_dir, env=env)
def load_version_from_file(version_file: Path) -> dict:
"""Extract environment variables from a shell script (used for versioning)."""
if not version_file.exists():
sys.exit(f"ERROR: Version file not found: {version_file}\nPlease create it with e.g. RELEASE_VERSION=1.0.0")
cmd = ["bash", "-c", f"source {version_file} && env"]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, check=True)
env_vars = {}
for line in result.stdout.splitlines():
if "=" in line:
key, _, value = line.partition("=")
env_vars[key.strip()] = value.strip()
return env_vars
def sign_file(filepath: Path, passphrase: str, dry_run: bool):
"""Generate a GPG detached signature (.asc) for a file."""
print(f" Signing {filepath.name} ...")
asc_file = filepath.parent / (filepath.name + ".asc")
if asc_file.exists() and not dry_run:
asc_file.unlink()
cmd = ["gpg", "--batch", "--pinentry-mode", "loopback", "--detach-sign", "--armor"]
if passphrase:
cmd.extend(["--passphrase", passphrase])
cmd.append(str(filepath))
run(cmd, dry_run=dry_run)
def generate_checksums(filepath: Path, dry_run: bool):
"""Generate MD5, SHA1, SHA256, and SHA512 checksum files."""
if dry_run:
return
for algo in ["md5", "sha1", "sha256", "sha512"]:
h = hashlib.new(algo)
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
h.update(chunk)
with open(filepath.parent / f"{filepath.name}.{algo}", "w") as f:
f.write(h.hexdigest())
def prepare_staging(common, jar_release, release_type, project_dir, include_javadoc, overrides):
"""Copy source artifacts into a clean staging folder and rename to the target version."""
staging_dir = project_dir / common["staging_dir"]
if staging_dir.exists():
shutil.rmtree(staging_dir)
staging_dir.mkdir(parents=True)
versioned = f"{jar_release}{release_type}"
artifact_id = common["artifact_id"]
def find_best_match(role, base_dir: Path, filename_template: str):
if overrides.get(role):
path = Path(overrides[role]).resolve()
if path.exists():
return path
sys.exit(f"ERROR: Override file for {role} not found: {path}")
for v in [versioned, f"{jar_release}-SNAPSHOT", jar_release]:
path = base_dir / filename_template.format(v=v)
if path.exists():
return path
return None
artifact_src_dir = project_dir / common["artifact_src_dir"]
src_map = {
"jar": find_best_match("jar", artifact_src_dir, f"{artifact_id}-{{v}}.jar"),
"pom": project_dir / common["pom_src"],
"sources": find_best_match("sources", artifact_src_dir, f"{artifact_id}-{{v}}-sources.jar")
}
if include_javadoc:
src_map["javadoc"] = find_best_match("javadoc", artifact_src_dir, f"{artifact_id}-{{v}}-javadoc.jar")
staged_files = {}
for role, src in src_map.items():
if not src or not src.exists():
continue
ext = ".pom" if role == "pom" else f"-{role}.jar" if role in ["sources", "javadoc"] else ".jar"
dst_name = f"{artifact_id}-{versioned}{ext}"
dst = staging_dir / dst_name
print(f" Staging {src.name} -> {dst_name}")
shutil.copy2(src, dst)
staged_files[role] = dst
return staged_files
def upload_to_central(api_url, bundle_path, deployment_name, dry_run):
"""POST the ZIP bundle to the Sonatype Central Portal API using curl."""
user, pw, _ = get_settings_info()
if not user or not pw:
sys.exit("ERROR: Credentials missing in settings.xml")
auth_bytes = f"{user}:{pw}".encode()
auth_str = base64.b64encode(auth_bytes).decode()
print(f"\n[Upload] POSTing bundle to Central Portal ...")
cmd = [
"curl", "-X", "POST", api_url,
"-H", f"Authorization: Basic {auth_str}",
"-F", f"bundle=@{bundle_path}",
"-F", f"name={deployment_name}"
]
run(cmd, dry_run=dry_run)
def main():
"""Main entry point - parse CLI arguments and orchestrate the publishing flow."""
parser = argparse.ArgumentParser()
parser.add_argument("--target", choices=["public", "private", "local"], required=True)
parser.add_argument("--module-b", action="store_true", help="Build the secondary module instead of the primary one")
parser.add_argument("--project-dir", type=Path, default=Path(os.environ.get("CI_PROJECT_DIR", ".")))
parser.add_argument("--build", action="store_true")
parser.add_argument("--upload-only", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--jar-path", help="Override JAR")
parser.add_argument("--sources-path", help="Override Sources")
parser.add_argument("--javadoc-path", help="Override Javadoc")
args = parser.parse_args()
project_dir = args.project_dir.resolve()
common = get_common_config(project_dir, args.module_b)
cfg = TARGET_CONFIG[args.target]
job_type = cfg.get("default_job_type", "snapshots")
dry_run = args.dry_run
_, _, gpg_pass = get_settings_info()
if args.build and not args.upload_only:
build_maven_artifacts(project_dir, common, dry_run)
# Resolve Version
version_file = project_dir / common["version_file"]
env_vars = load_version_from_file(version_file)
jar_release = env_vars.get(common["jar_release_env_var"])
versioned = f"{jar_release}{'' if job_type == 'releases' else '-SNAPSHOT'}"
bundle_path = project_dir / common["staging_dir"] / f"{common['artifact_id']}-{versioned}-bundle.zip"
print("=" * 60)
print(f" Automated Maven Publisher")
print(f" Namespace: {common['group_id']}")
print(f" Artifact: {common['artifact_id']}")
print(f" Version: {versioned}")
print("=" * 60)
if not args.upload_only:
overrides = {"jar": args.jar_path, "sources": args.sources_path, "javadoc": args.javadoc_path}
staged = prepare_staging(common, jar_release, '' if job_type == 'releases' else '-SNAPSHOT', project_dir, cfg["include_javadoc"], overrides)
print(f"\n[3/5] Signing and generating checksums ...")
for filepath in staged.values():
sign_file(filepath, gpg_pass, dry_run)
generate_checksums(filepath, dry_run)
# Signature file checksums
sig_file = filepath.parent / (filepath.name + ".asc")
generate_checksums(sig_file, dry_run)
print(f"\n[Zip] Creating bundle at {bundle_path.name} ...")
if not dry_run:
group_path = common["group_id"].replace(".", "/")
internal_base_path = Path(group_path) / common["artifact_id"] / versioned
with zipfile.ZipFile(bundle_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for primary_file in staged.values():
zf.write(primary_file, arcname=internal_base_path / primary_file.name)
for suffix in [".asc", ".md5", ".sha1", ".sha256", ".sha512"]:
sidecar = primary_file.parent / (primary_file.name + suffix)
if sidecar.exists():
zf.write(sidecar, arcname=internal_base_path / sidecar.name)
deployment_name = f"{common['group_id']}:{common['artifact_id']}:{versioned}"
print(f"\n[Portal Info] Deployment Name: {deployment_name}")
print(f"[Portal Info] Bundle File: {bundle_path.name}")
if args.target == "public":
upload_to_central(cfg["api_url"], bundle_path, deployment_name, dry_run)
print("\nProcess complete.")
if __name__ == "__main__":
main()
Top comments (0)