Maven Central
Detection signals
Section titled “Detection signals”Maven Central is present when a pom.xml, a build.gradle, or a build.gradle.kts turns up anywhere in the checkout. Lockfiles are not a present signal on the Maven half at all, because Apache Maven publishes no lockfile format; on the Gradle half a gradle.lockfile or a gradle/verification-metadata.xml is a second signal and never the only one, since both are opt-in.
Maven Central is shipped when a coordinate is deployed there. On Maven the evidence is a release workflow deploying to the Sonatype Central Portal, or an existing artifact under that groupId on central.sonatype.com. On Gradle it is the maven-publish plugin applied with a MavenPublication and a remote repository declared, plus a publishing task in the release path; Gradle documents that plugin as providing “the ability to publish build artifacts to an Apache Maven repository”.
Sources: POM reference, Maven Publish Plugin.
Three cases decide most arguments:
- Read the coordinate before anything else. Maven’s minimum POM is
modelVersion,groupId,artifactId,version, andpackaging, and Maven notes thatgroupIdandversion“do not need to be explicitly defined if they are inherited from a parent POM”. A module POM that names only anartifactIdis not incomplete; its other two thirds are one file up. <packaging>pom</packaging>is required, in Maven’s words, “for parent and aggregation (multi-module) projects”, and such a POM produces no compiled artifact. That does not make it unshipped: a parent or a bill of materials is deployed as a POM and consumed by name. Read the deployment configuration rather than the packaging type.- A Gradle build with no
maven-publishplugin publishes nothing, and a Maven build with no deployment configured publishes nothing. An application, a service, or an Android app built with either is present and not shipped.
Release track
Section titled “Release track”Maven Central takes the registry-push track. A release uploads a built bundle to the Sonatype Central Portal under a credential, so there is an upload to secure and a credential to scope, which is what assigns the track. The roster records "track": "registry-push" for Maven Central and the release area’s preamble names Maven Central in its registry-push list.
Verified 2026-07-31 against https://maven.apache.org/pom.html and https://docs.gradle.org/current/userguide/publishing_maven.html.
Version badge
Section titled “Version badge”Shields.io serves this one at img.shields.io/maven-central/v/GROUP_ID/ARTIFACT_ID, taking the coordinates as two path parameters. An optional third segment is a version prefix, so img.shields.io/maven-central/v/GROUP_ID/ARTIFACT_ID/1. pins the badge to the 1.x line, which is what a project maintaining two supported lines needs.
Behind that path the service is a redirect onto the generic Maven metadata badge. It turns the dots in the group ID into slashes and reads https://repo1.maven.org/maven2/GROUP/ARTIFACT/maven-metadata.xml, and it supplies the label maven-central itself. Keep that label.
Link the badge to the artifact page on the Central Portal:
[](https://central.sonatype.com/artifact/GROUP_ID/ARTIFACT_ID)Install command
Section titled “Install command”There is no install command here, and that is not a gap. Maven and Gradle both take a dependency declaration in a build file rather than a command that edits one, so what a README shows is the declaration itself.
For Maven, in pom.xml, inside <dependencies>:
<dependency> <groupId>GROUP_ID</groupId> <artifactId>ARTIFACT_ID</artifactId> <version>VERSION</version></dependency>For Gradle, in build.gradle.kts:
dependencies { implementation("GROUP_ID:ARTIFACT_ID:VERSION")}Show both only where the project supports both, which for a library published to Central it usually does. The version in each is a literal, so it is the one number in the README most likely to go stale; either keep it current with each release or write the coordinates and send the reader to the artifact page for the version. A version range belongs in neither snippet, because a range resolves differently from a fixed version and a README is the wrong place to teach that difference.
<scope> stays out of a README snippet unless the artifact is genuinely test-only or provided-only. The Maven guide includes it in its own example, and a reader who copies <scope>test</scope> for a runtime library gets a compile error.
Verified 2026-07-31 against Maven Getting Started Guide, Declaring dependencies, Maven Central Version badge, and services/maven-central/maven-central.service.js in badges/shields.
License declaration (R-COM-01)
Section titled “License declaration (R-COM-01)”pom.xml declares it in a <licenses> element holding one <license> per license. Each carries <name>, <url>, an optional <distribution> of repo or manual saying how the project may legally be distributed, and optional <comments>. The POM reference recommends an SPDX identifier for <name> and tells a project to list the licenses that apply to itself and not those of its dependencies.
Two properties of the element change how it is read. It is inherited: the POM reference lists licenses among the elements a child inherits from its parent, so a module declaring none may still be declaring one through the parent chain. Read that chain before concluding a module states nothing. And the reference does not say whether two <license> elements mean both apply or the consumer chooses. Unlike an SPDX expression, which carries AND and OR explicitly, a multi-license POM leaves the relationship unstated, so the license file is what settles it.
A Gradle build publishes a generated POM rather than a checked-in one, so the same <licenses> element is still the target; where the repository has build.gradle or build.gradle.kts and no pom.xml, read the POM configuration in the build script.
So the manifest side of R-COM-01 is the effective <licenses> element of every module the repository publishes, parents included, and the file side is the root license file.
Source: POM reference.
Funding platform name
Section titled “Funding platform name”Tidelift’s platform name for this ecosystem is maven, not maven-central and not java, so the GitHub funding file’s entry reads tidelift: maven/<package-name>. The accepted key format is PLATFORM-NAME/PACKAGE-NAME, described in github.md beside the rest of the accepted keys.
The roster at skills/oss-audit/ecosystems.json records "tidelift": "maven" and is the canonical copy. This line exists because a single-skill install of oss-community does not carry that file; where the two disagree, the roster is right and this line is corrected to it. This is the one ecosystem whose roster key and Tidelift platform name differ, so it is the one worth checking rather than assuming.
Verified 2026-07-31 against POM reference and Displaying a sponsor button in your repository.
Toolchain and matrix (R-CI-03)
Section titled “Toolchain and matrix (R-CI-03)”On GitHub Actions, actions/setup-java installs a JDK; distribution and java-version are both required, so the matrix names a vendor as well as a version.
strategy: matrix: java: ['17', '21', '25']steps: - uses: actions/setup-java@v6 # oss-harden pins this to a commit SHA with: distribution: temurin java-version: ${{ matrix.java }}Where the support claim lives depends on the build tool. A Maven project declares it as the maven.compiler.release property in pom.xml, or as <release> in the compiler plugin’s configuration. A Gradle project declares it as java.toolchain.languageVersion, JavaLanguageVersion.of(17).
Read that as a floor rather than a ceiling: release and the Gradle toolchain state the bytecode level the build targets, so a project claiming Java 17 is claiming to run on 17 and later, and the matrix covers the maintained JDK lines from that floor up. A Gradle toolchain also selects the JDK that compiles, which is a separate thing from the JDK the job installs, so the two can disagree without failing anything.
On GitLab, run the job in an Eclipse Temurin image and vary the tag with parallel:matrix.
Sources: actions/setup-java, maven-compiler-plugin, setting the release, Gradle, toolchains, adoptium/containers.
Dependency caching (R-CI-04)
Section titled “Dependency caching (R-CI-04)”actions/setup-java has a cache input, off by default, accepting maven, gradle, and sbt. Its key has the form setup-java-${platform}-${packageManager}-${fileHash}, and the README lists the files behind that hash: **/pom.xml, **/.mvn/wrapper/maven-wrapper.properties, and **/.mvn/extensions.xml for Maven, and **/*.gradle*, **/gradle-wrapper.properties, buildSrc/**/Versions.kt, buildSrc/**/Dependencies.kt, gradle/*.versions.toml, and **/versions.properties for Gradle.
The Maven half is the one to be honest about. Apache Maven publishes no lockfile format, and the roster records files: [] for it. So there is nothing resolved to key on, and the cache is keyed on the build files that declare requirements instead. Where a pom.xml names a version range or a property another artifact resolves, the same key can serve a different dependency set than the one that filled it, and no CI change fixes that because there is no file recording what was resolved. Maven’s own answers are upstream of CI: fixed versions, dependencyManagement, and the enforcer rule banDynamicVersions. Report this as an ecosystem-level limit rather than a keying mistake, and note that Maven Resolver’s Trusted Checksums record a committed hash file under .mvn/checksums/ that pins content without being a lockfile. A Maven lockfile format would retire it.
Gradle can lock, opt-in per configuration, writing gradle.lockfile. The README’s Gradle hash list does not name that file, and **/*.gradle* does not match it, so a project using dependency locking names it in cache-dependency-path or its lock state stays out of the key.
Two more documented behaviours are worth knowing on a matrix. cache-read-only: true restores without uploading, which the README pairs with a seed job so a fan-out matrix does not have every cell race to write the same key. And cache-path replaces the cached filesystem locations without changing the key, so jobs meant to share a key have to pass the same cache-path.
On GitLab, point the local repository inside the project, mvn -Dmaven.repo.local=.m2/repository or GRADLE_USER_HOME, and key cache:key:files on the build file, because GitLab caches only paths inside the project directory.
Sources: actions/setup-java, Maven Resolver, expected checksums, Gradle, dependency locking, GitLab CI/CD YAML reference, cache.
Test command (R-CI-06)
Section titled “Test command (R-CI-06)”For Maven, mvn test runs the test lifecycle phase, which binds surefire:test by default for jar, war, ejb, rar, and maven-plugin packaging. Maven’s own description of the phase is that these tests should not require the code be packaged or deployed, which is the line between mvn test and mvn verify; a project whose integration tests run under failsafe declares them at verify, so calling only mvn test runs half the suite.
For Gradle, the test task runs the suite, invoked as ./gradlew test through the committed wrapper. The suite is declared by source set layout plus the test framework dependency rather than by a command in the build file, so the build file naming no test task does not mean there is no suite.
Sources: Maven, introduction to the build lifecycle, Gradle, toolchains.
Verified 2026-07-31 against https://github.com/actions/setup-java, https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html, https://maven.apache.org/plugins/maven-compiler-plugin/examples/set-compiler-release.html, https://maven.apache.org/resolver/expected-checksums.html, https://docs.gradle.org/current/userguide/toolchains.html, https://docs.gradle.org/current/userguide/dependency_locking.html, https://github.com/adoptium/containers, and https://docs.gitlab.com/ci/yaml/.
Automated dependency updates (R-SEC-03)
Section titled “Automated dependency updates (R-SEC-03)”Two Dependabot values reach this registry, because two build tools publish to it. maven and gradle both carry version updates and security updates in the table, with private repositories and private registries supported for each.
Read the Gradle qualifier before treating the two as equal. Dependabot’s own note says that for security updates, Gradle support is limited to manual uploads of the dependency graph data through the dependency submission API, and that when an alert lands on a transitive dependency uploaded that way, Dependabot cannot find the vulnerable dependency in the repository and will not open a security update for it. Version updates still open pull requests where the parent is declared directly. Dependabot also does not run Maven; it updates pom.xml files, and it updates build.gradle, build.gradle.kts, gradle/libs.versions.toml, and gradle.lockfile without running Gradle.
On GitLab the Renovate managers are maven and gradle.
Lockfile and frozen install (R-SEC-08)
Section titled “Lockfile and frozen install (R-SEC-08)”This is the one ecosystem in the roster where the answer depends on the build tool rather than the registry, and it is the build tool that earns the exemption.
Apache Maven publishes no lockfile format, opt-in or otherwise. What it offers instead is fixed versions, dependencyManagement, and enforcer rules such as banDynamicVersions and requireReleaseDeps. So the version-lock half of R-SEC-08 falls outside the rule for a Maven build rather than failing it. The integrity half is still reachable: Maven Resolver’s trusted checksums record a committed coreutils-format hash file under ${session.rootDirectory}/.mvn/checksums/, verified at resolution with -Daether.artifactResolver.postProcessor.trustedChecksums.failIfMissing=true and --strict-checksums.
Gradle publishes both halves and stays inside the rule. Dependency locking writes gradle.lockfile per project or subproject, opted into per configuration with resolutionStrategy.activateDependencyLocking() or dependencyLocking { lockAllConfigurations() } and written with --write-locks. The generated file’s own header says it is expected to be part of source control, and a version mismatch already fails the build with no extra flag; LockMode.STRICT additionally fails when a locked configuration has no lock state. Dependency verification is a separate feature, enabled by the presence of gradle/verification-metadata.xml, strict by default, covering checksums and PGP keys.
So a Gradle project publishing to Maven Central that commits no gradle.lockfile is a finding, and the same project built with Maven is not. Say which build tool the repository uses when reporting either.
What breaks the first time you commit the lockfile
Section titled “What breaks the first time you commit the lockfile”These reach a Gradle build. Maven writes no lockfile, so nothing below applies to one.
One build commits many lock files, and --write-locks writes only what it resolved. Lock state is per project and per configuration: a gradle.lockfile sits “at the root of each project or subproject directory”, and the build script’s own classpath is locked separately in buildscript-gradle.lockfile. Gradle writes lock state only for configurations that the invoked tasks actually resolve, and it “won’t write the lock state to disk if the build fails”. So a --write-locks run over one task leaves the rest of a multi-project build stale, and LockMode.STRICT then fails any locked configuration with no lock state. Give the project one command that resolves every locked configuration, and run it before reviewing the diff.
Dependency verification is a second file, and Dependabot does not touch it. Dependabot’s Gradle file fetcher lists gradle.lockfile among the files it fetches and does not list gradle/verification-metadata.xml. Where both features are on, an updater bumps the version and the lock, the new artifact has no recorded checksum, and verification fails the build for a reason that reads like tampering. Regenerate the verification metadata in the same change, and say in the contributing guide that a dependency bump touches both files.
Toolchain and platform are not dimensions here. Gradle documents no per-platform lock state and no regeneration keyed to the JDK. What varies instead is which configurations were resolved, which the first paragraph covers.
Verified 2026-07-31 against Gradle dependency locking and dependabot-core’s Gradle file fetcher.
Static analysis (R-SEC-09)
Section titled “Static analysis (R-SEC-09)”CodeQL supports Java and Kotlin, so CodeQL default setup covers this ecosystem on GitHub, and the dependency graph’s Maven row names Scala alongside Java. GitLab’s SAST table lists Java, Kotlin, Groovy, and Scala under its analyzers.
Vulnerability watch (R-SEC-11)
Section titled “Vulnerability watch (R-SEC-11)”The two build tools diverge here too. The Maven row is one of the better-covered in the dependency graph: it parses pom.xml, supports static transitive dependencies, and supports automatic dependency submission. The Gradle row lists no recommended file and no additional file at all, and supports only automatic dependency submission, so a Gradle project is invisible to the graph until something uploads its resolved set. That is the same repository setting the NuGet, Maven, pip, and Poetry rows use, described in github.md under the controls with no endpoint.
Advisories come from the GitHub Advisory Database, which names this ecosystem Maven against the repo.maven.apache.org registry.
osv-scanner reads pom.xml with transitive dependency scanning, and on the Gradle side gradle.lockfile, buildscript-gradle.lockfile, and gradle/verification-metadata.xml. It also recovers Java uber jars when it scans a container image.
Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, Maven Resolver expected checksums, Gradle dependency locking, Gradle dependency verification, CodeQL supported languages and frameworks, GitLab SAST, Renovate managers, and osv-scanner supported languages and lockfiles.
Concrete flow for the decisions SKILL.md makes, for a component published to Maven Central through the Sonatype Central Portal. This is the one registry on the roster with no OIDC flow of any kind, so Step 2 below is a gap rather than a configuration, and R-PUB-02’s account-scoped clause is what admits the fallback it gives instead. Everything else follows the usual shape, and the approval gate in Step 4 is unusually strong here because the Portal has a registry-side one of its own.
Two things about Maven Central shape every decision below. A release is immutable, and Sonatype’s FAQ answers the question of whether a published component can be changed, modified, deleted, removed, or updated with a plain no. And publishing rights are namespace rights, verified against the account, so the credential’s blast radius is the namespace rather than a single artifact.
Source: Central Portal, Generate a portal token, Central Portal, Publishing by using the Portal Publisher API, and Central Portal, Requirements.
Contents
Section titled “Contents”- Gather facts (Step 1)
- Configure trusted publishing (Step 2): a gap, not a check
- Write the hardened release workflow (Step 3)
- Gate on manual approval (Step 4)
- Verify provenance (Step 5)
- Describe and sign what the release attaches (Step 6)
- Verifying a namespace for the first time
Gather facts (Step 1)
Section titled “Gather facts (Step 1)”Read pom.xml for groupId, artifactId, and version, or the Gradle build script for group, the archives base name, and version. The groupId prefix is the namespace, and namespace ownership rather than artifact ownership is what the account holds.
Note the build tool, because it decides almost everything downstream. Sonatype publishes a Maven plugin and no Gradle plugin of its own; its documentation lists community Gradle plugins and disclaims them. A Gradle project therefore either uses the Portal Publisher API directly or takes a third-party plugin the maintainer has vetted, and this reference does not choose for them.
Confirm the version is not a -SNAPSHOT. The Portal rejects snapshot versions on a release deployment, and a release workflow that pushes one fails at validation rather than at upload.
Check whether a version already exists:
curl -s -o /dev/null -w '%{http_code}' \ "https://repo1.maven.org/maven2/$(printf '%s' '<groupId>' | tr . /)/<artifactId>/<version>/"A 200 means it is published and immutable, so the release needs a new version number rather than a retry.
Configure trusted publishing (Step 2): a gap, not a check
Section titled “Configure trusted publishing (Step 2): a gap, not a check”There is no trusted publishing on Maven Central. Sonatype’s publishing documentation names no OIDC flow, no workload identity federation, and no CI-provider integration; the only authentication it documents for the Portal is a user token. Read the token and API pages before reporting that, and note that the absence is registry-wide rather than forge-specific: GitHub Actions and GitLab CI/CD are in exactly the same position here, which is why this file has no per-forge split in this step.
Source: Central Portal, Generate a portal token and Central Portal, Publishing by using the Portal Publisher API.
The strongest documented credential is a user token. Generate it at https://central.sonatype.com/usertoken, which produces a username and password pair carrying a display name and an expiry. The Portal API authenticates with the pair base64-encoded and joined by a colon:
printf 'example_username:example_password' | base64# Authorization: Bearer <the base64 value>That token is account-scoped. No package-scoped, namespace-scoped, or artifact-scoped form is documented anywhere, so a leaked token reaches every namespace the account can publish to. Scoped tokens appear as a planned item in Sonatype’s OSSRH sunset roadmap of 2025-03-26 and have not shipped, so re-read the token documentation before accepting this fallback rather than treating the gap as settled.
R-PUB-02’s own clause admits an account-scoped token where the registry documents nothing narrower, and it asks for the compensating controls to be reported beside it rather than assumed. Report all four, because each is something the maintainer has to actually do:
- An expiry set when the token is generated, and a rotation date the project keeps.
- Revocation and replacement on compromise, from the same user token page, which invalidates the old pair.
- Namespace ownership verified against the account, so what the token can reach is bounded by what the account owns.
- A publishing type that holds a validated deployment until a person releases it, which is Step 4 below.
This sits below R-PUB-02’s bar and stays there while Sonatype documents nothing narrower than the publishing account. It retires the day scoped tokens ship, or the day the Portal documents an OIDC flow.
Source for the roadmap item: Central Portal, OSSRH sunset.
Write the hardened release workflow (Step 3)
Section titled “Write the hardened release workflow (Step 3)”The publish job holds two long-lived secrets rather than one, since Central requires a GPG or PGP signature on every file. Both belong in the approval-gated job and nowhere else, and neither should be reachable by a job that builds or tests.
Central’s file-level requirements decide what the build has to produce: a .asc signature per file, .md5 and .sha1 checksums per file with .sha256 and .sha512 supported, and sources and javadoc jars for any packaging other than pom. The central-publishing-maven-plugin assembles the bundle from what the build produced, so the requirement to satisfy is on the build rather than on the upload.
<plugin> <groupId>org.sonatype.central</groupId> <artifactId>central-publishing-maven-plugin</artifactId> <version>0.11.0</version> <extensions>true</extensions> <configuration> <publishingServerId>central</publishingServerId> <autoPublish>false</autoPublish> </configuration></plugin>Leave autoPublish at false. The Portal API’s equivalent setting is publishingType, whose default USER_MANAGED holds a validated deployment at the VALIDATED state until a person publishes it, where AUTOMATIC proceeds to Maven Central on its own. Confirm the plugin’s mapping against the plugin documentation before relying on it as the approval gate, because the gate is the whole reason for the setting.
name: Releaseon: push: tags: - 'v*'jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-java@v5 with: java-version: '<the version the project targets>' distribution: temurin - run: mvn --batch-mode verify # oss-ci decides the actual command from CONTRIBUTING.md (R-CI-02)
publish: runs-on: ubuntu-latest needs: [test] environment: release permissions: contents: read steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-java@v5 with: java-version: '<the version the project targets>' distribution: temurin server-id: central server-username: MAVEN_CENTRAL_USERNAME server-password: MAVEN_CENTRAL_PASSWORD gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} gpg-passphrase: MAVEN_GPG_PASSPHRASE - run: test "${GITHUB_REF_NAME#v}" = "$(mvn --batch-mode -q help:evaluate -Dexpression=project.version -DforceStdout)" - run: mvn --batch-mode deploy -DskipTests env: MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}setup-java writes the settings.xml server entry from the two environment variable names, so the credential never appears in a file the repository tracks. server-id must match publishingServerId in the plugin configuration exactly, and the pair the two secrets hold is the token username and password from Step 2, not a Sonatype account password.
-DskipTests in the publish job is deliberate: the tests ran in the uncredentialed job, and re-running them in the job holding the token and the signing key puts the project’s whole test dependency tree inside the credentialed blast radius. mvn deploy still compiles and packages there, which is the residual this reference cannot remove, because the Maven plugin builds the bundle from a build in that same reactor.
oss-harden pins every uses: line above to a commit SHA and sets the test job’s minimal permissions. On GitLab CI/CD the same mvn deploy runs unchanged: the credential path is identical because there is no OIDC on either forge. What differs is only where the two secrets live, which is a masked and protected CI/CD variable, and how the gate is configured, which is a protected environment with approval rules.
Neither setup-java block above sets cache, so nothing is restored. oss-harden owns dependency caching under R-CI-04, and a restored cache in a job that can sign and publish is the same cache-poisoning exposure every other reference in this directory refuses; leave the input off rather than setting it to a value.
Gate on manual approval (Step 4)
Section titled “Gate on manual approval (Step 4)”Two gates apply together here, and Maven Central is unusual in having a real registry-side one.
The workflow gate is environment: release on the publish job above, with required reviewers configured at https://github.com/<owner>/<repo>/settings/environments/new, or a GitLab protected environment with approval rules. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud.
Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser.
ENV=releaseGHUID=$(gh api user --jq .id)gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \ -F wait_timer=0 \ -F prevent_self_review=false \ -f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \ -F 'deployment_branch_policy[protected_branches]=false' \ -F 'deployment_branch_policy[custom_branch_policies]=true'gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \ -f 'name=v*' -f type=tagThree details decide whether that runs. gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for the booleans and the reviewer id, because -f sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable UID: zsh marks it read only, so the assignment fails before gh runs.
reviewers[][id] takes a numeric user or team id rather than a login. A team needs type=Team and that team’s id.
The registry gate is the USER_MANAGED publishing type. A deployment uploaded that way runs through validation, stops at the VALIDATED state, and waits for a person to publish it from the Portal interface or through a second authenticated call to /api/v1/publisher/deployment/<deploymentId>. That is a genuine proof-of-presence gate in the sense R-PUB-04 asks for, and it survives a compromised workflow, because the workflow cannot approve itself without the token being used a second time by whoever is watching the Portal.
Keep both. Where the forge plan provides no native gate, the Portal gate alone can satisfy R-PUB-04, which puts Maven Central in a better position than most of this directory. Say so plainly rather than reporting the rule unmet. What a maintainer must not do is set autoPublish to true to make the pipeline finish green, because that removes the only gate a compromised release workflow cannot walk through.
Verify provenance (Step 5)
Section titled “Verify provenance (Step 5)”Maven Central serves no build provenance. What it does serve is signatures, and the two must not be conflated: the required GPG or PGP signature says a key vouched for these bytes, and provenance says which commit and workflow produced them. The .asc files are a requirement for acceptance, not a link back to a build.
Sigstore is the moving part here, and its current state is easy to overstate. Sonatype announced Sigstore signature validation through the Portal on 2025-01-28, and the announcement’s own words are that “not providing Sigstore signatures will not cause your deployment to fail, but providing invalid Sigstore signatures will eventually do so”. Read that as it stands: signatures are not required today, an invalid bundle produces a warning on the deployments page today, and blocking is stated future intent rather than current behavior. Do not tell a maintainer their deployment will fail on a bad Sigstore bundle, and do not tell them Sigstore satisfies R-PUB-03 either, because a Sigstore signature over a jar is still a signature rather than a statement about the build.
Source: Central Portal, Sigstore signature validation via the Portal.
The strongest substitute is a forge attestation over the exact jars the deployment uploaded, added in a job after the publish and verified against what a consumer downloads from Maven Central:
gh attestation verify <artifactId>-<version>.jar --repo <owner>/<repo>Central does not surface or link to that attestation, so a consumer has to know it exists. What a consumer can check unaided is the GPG signature, if the project publishes the key’s fingerprint somewhere they can find it, which is what R-PUB-06 asks for in the neighbouring case.
This sits below R-PUB-03, which asks for provenance tied to the exact published artifact and the workflow that built it. Report it as unmet with the registry limitation named. It retires the day Central serves an attestation for a published component, and the Sigstore work is the thing to watch, since a required and validated Sigstore bundle from a CI identity would be most of the way there.
Describe and sign what the release attaches (Step 6)
Section titled “Describe and sign what the release attaches (Step 6)”Only for a release that attaches a built asset to the forge release. Deploying to Maven Central attaches nothing to the forge, and the source archives GitHub generates for a tag are not built assets, so a library that only deploys goes to Step 7 instead. The jars themselves are already signed, by the GPG requirement in Step 3, and that signature is what R-PUB-06 asks for on the registry side.
Two rules apply here and they ask for different things. R-PUB-05 wants an inventory of what went into the asset, in SPDX or CycloneDX. R-PUB-06 wants the assets signed, or listed by hash in a signed manifest. A manifest of hashes answers the second and nothing about the first, so do not report R-PUB-05 as met by publishing one.
This reference names no SBOM generator for Java. The ones in common use are plugins rather than part of Maven or Gradle, and a plugin that reads the dependency tree inside a job holding a signing key is one the maintainer vets before it goes there. What answers R-PUB-05 without one, on GitHub, is the forge’s own export of the repository’s dependency graph, which is already SPDX and needs nothing installed and, in particular, nothing inside the credentialed job. The gh api step below writes it into dist/, so it ships as a release asset for R-PUB-05 and is listed in SHA256SUMS and attested alongside the jars for R-PUB-06:
github-release: runs-on: ubuntu-latest needs: [publish] permissions: contents: write id-token: write attestations: write artifact-metadata: write steps: - uses: actions/download-artifact@v8 with: name: jars path: dist/ - run: gh api repos/${{ github.repository }}/dependency-graph/sbom --jq .sbom > dist/sbom.spdx.json env: GH_TOKEN: ${{ github.token }} - run: (cd dist && sha256sum *) > SHA256SUMS - uses: actions/attest@v4 with: subject-checksums: SHA256SUMS - run: gh release upload "$GITHUB_REF_NAME" dist/* SHA256SUMS env: GH_TOKEN: ${{ github.token }}State both of the export’s limits to the maintainer rather than leaving them to be discovered. It is GitHub only, so a GitLab project keeps this gap and R-PUB-05 stays unmet there with the reason named. And it covers the repository’s declared dependency graph rather than what is inside the asset, which is close for a plain jar, because a consumer resolves its dependencies from those same declarations, and an approximation for a shaded or fat jar, whose contents are decided at build time and can differ from what the graph lists. The graph reads pom.xml, build.gradle, and gradle.lockfile; a Maven project publishes no lockfile at all, which R-SEC-08 places outside its version-lock half, so an export from one lists what the POM declares and resolves no further. Read the output back once with gh api repos/<owner>/<repo>/dependency-graph/sbom --jq '.sbom.packages | length' before reporting the rule met: a graph the forge does not parse for this ecosystem returns a near-empty package list rather than an error.
sha256sum runs from inside dist/ so the names it writes are the names the assets carry on the release. subject-checksums makes every file in the manifest a subject of the attestation in its own right, by name and digest; attesting SHA256SUMS itself with subject-path would leave a consumer able to verify the manifest and nothing about the assets it lists.
A consumer verifies an asset, then checks the rest of the download against the manifest:
gh attestation verify <asset> --repo <owner>/<repo>sha256sum -c SHA256SUMSRun the first command against each asset downloaded, never against SHA256SUMS, which is a subject of nothing. --signer-workflow <owner>/<repo>/.github/workflows/release.yml pins which workflow the attestation must have come from.
The four grants above are copied exactly, on this job only, and the workflow’s top-level block stays contents: read. needs: [publish] keeps the assets behind the approval gate. Narrowing anything else, pinning each uses: to a commit SHA, and auditing the result are oss-harden’s. On GitLab CI/CD the forge attestation is unavailable, so a GitLab release can carry the same SHA256SUMS with nothing signing it; say that rather than presenting the file as provenance.
Verifying a namespace for the first time
Section titled “Verifying a namespace for the first time”Publishing rights are per namespace, and a namespace is verified before the first deployment. Two paths are documented: a DNS TXT record on the domain the groupId is derived from, or a temporary public repository named after the verification key the Portal issues. A GitHub signup gets io.github.<username> verified automatically, which is the shortest route for a project that does not own a domain.
Do the namespace work before writing any of the workflow above, because a deployment into an unverified namespace fails at the Portal rather than at the build, and the failure reads like a credential problem.
Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.
Verified 2026-07-31 against Central Portal, Publishing by using the Portal Publisher API, Central Portal, Publishing by using the Maven plugin, Central Portal, Generate a portal token, and Central Portal, Requirements. The OSSRH sunset and Sigstore announcements linked above were read the same day.
Version sources (R-CHG-03)
Section titled “Version sources (R-CHG-03)”With Maven, the source is the <version> element in pom.xml, one of the three coordinates alongside <groupId> and <artifactId>. A multi-module build inherits it from the parent POM, so the number lives once in the parent and each module states it only in its <parent> block.
With Gradle, the published version comes from the project’s version property, which the Maven Publish plugin uses for the POM’s version unless the publication overrides it. So the source is the version assignment in build.gradle or build.gradle.kts, or a version entry in gradle.properties, and a version set inside the MavenPublication block is a second source that wins over it. Read the publication block before trusting the project property.
Version syntax (R-CHG-02)
Section titled “Version syntax (R-CHG-02)”Maven has its own version order specification and it is not SemVer. A version string is split into tokens at dots, hyphens, and underscores, and at every transition between digits and characters, with the separator recorded because it affects ordering; an empty token becomes 0. Then trailing null values are trimmed, where the null values are 0, the empty string, final, and ga. That trimming is why 1.0.0, 1.0, 1.ga, and 1.final are all the same version, reduced to 1.
Qualifiers order as alpha < beta < milestone < rc = cr < snapshot < (empty) = final = ga = release < sp, and alpha, beta, and milestone may be shortened to a, b, and m when a number follows directly. Two entries have no SemVer equivalent. snapshot sorts above rc and below the release, so 2.0-rc1 < 2.0-SNAPSHOT < 2.0, and Maven Central rejects -SNAPSHOT versions outright, so they never reach a release. And sp, for service pack, sorts above the plain release, which SemVer has no way to express at all.
A plain three-part version with no qualifier is both valid SemVer and a well-behaved Maven version, so the practical advice is to use one. The trimming rule is the trap for R-CHG-03: 1.0 in a POM and 1.0.0 in a changelog are one version to Maven and two different strings to a person comparing them.
Major version in package identity (R-CHG-07)
Section titled “Major version in package identity (R-CHG-07)”Maven Central does not encode the major version in package identity. The coordinates that identify an artifact are groupId and artifactId, the <version> element is separate, and nothing requires either coordinate to move when the major does, so R-CHG-07 does not reach this ecosystem. Some projects do carry a number in the artifactId as a convention, which is a naming choice nothing in the build or the repository verifies against the released version.
Withdrawing a release (R-CHG-01)
Section titled “Withdrawing a release (R-CHG-01)”Maven Central has no withdrawal. The rule is stated without exception: “Once a component has been released and published to the Central Repository, it cannot be altered”, and where a published artifact has a bug, “A new version of the component must be published.” The reason given is that build tools cache releases locally and do not re-check them, so a mutable release would break the assumption every consumer’s build depends on.
Source: Can I change a component? and Publish Portal guide.
The only withdrawal window is before publishing. A deployment reaching the VALIDATED state waits in the Portal with Publish and Drop buttons beside it, and dropping it there costs nothing; that window stays open only because publishingType defaults to USER_MANAGED, which holds a validated deployment until a person releases it. Set it to AUTOMATIC and there is no window at all. After publication the deployment record itself is cleaned up from the Deployments tab after 90 days while the artifacts stay in Central permanently.
So a withdrawn version on Maven Central is a changelog fact and nothing else, which sits below what R-CHG-01 expects a [YANKED] heading to correspond to: mark the heading, say what replaced it, and publish the replacement, knowing that the registry will keep serving the withdrawn version to anyone who asks for it by coordinate. Sonatype documenting any per-version state a resolver reads, of the kind crates.io and PyPI already publish, would retire the gap.
Verified 2026-07-31 against POM reference, version order specification, Can I change a component?, Publish Portal guide, and Gradle Maven Publish plugin.