DEV Community

Cover image for How to Publish Java Components to Maven Central: Navigating the New Central Portal
Shivam Bhardwaj
Shivam Bhardwaj

Posted on

How to Publish Java Components to Maven Central: Navigating the New Central Portal

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.

  1. Export your public key: gpg --armor --export <KEY_ID> > public.asc
  2. Upload the contents of public.asc to a public key server, such as keyserver.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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Build: Run mvn clean package -DskipTests
  2. Sign: Create .asc files for your compiled JAR, Sources JAR, Javadoc JAR, and POM.
  3. Checksum: Generate .md5 and .sha1 hashes for all files (including the .asc signature files).
  4. Layout: Place everything in a strictly nested folder structure mirroring your group ID: io/github/yourusername/project/Your_Project_Name/1.0.0/.
  5. Zip: Compress the root io/ folder into bundle.zip.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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 new io.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 groupId in your pom.xml matches 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)"
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Top comments (0)