Skip to content

Publish to Maven Central with zpublish, a portable single-file publisher - #4

Open
senleft wants to merge 1 commit into
AdamBien:mainfrom
senleft:feature/maven-central-publishing
Open

Publish to Maven Central with zpublish, a portable single-file publisher#4
senleft wants to merge 1 commit into
AdamBien:mainfrom
senleft:feature/maven-central-publishing

Conversation

@senleft

@senleft senleft commented Aug 22, 2026

Copy link
Copy Markdown

Fixes #3.

Z* tools cannot be onboarded where the only approved intake path is an internal Maven mirror.

Read this first — namespace gate

This PR is dead weight until one thing is confirmed: com.airhacks must be a verified
namespace in your Central Portal account.

The newest artifact under com.airhacks dates from 2020-06-08, five years before the OSSRH
sunset. Sonatype says all OSSRH namespaces were migrated to the Central Portal, but publishing
still depends on using an account that has access to the migrated namespace. Artifacts on
repo1.maven.org prove the coordinate exists, not that the current account can publish to it.
Check Portal → Namespaces before adding any secrets.

Log in with the same username/password account used for OSSRH. If com.airhacks is still
missing, contact Central Support so they can restore the existing publishing access; do not
register a replacement namespace or change the coordinate as a first response.

Problem

Corporate environments mirror open source through Nexus or Artifactory proxying Central. The
intake is automated and GitHub is usually blocked. zb ships only via GitHub Releases, so in
those environments it can't be adopted at all.

Publishing to Central needs no per-company setup — every mirror already proxying Central
proxies com.airhacks:zb:

mvn dependency:get -Dartifact=com.airhacks:zb:<version>
curl -O https://<mirror>/com/airhacks/zb/<version>/zb-<version>.jar

Central versions are immutable by definition. GitHub can also lock future release assets when
release immutability is enabled, but that still does not make them available through corporate
mirrors of Central.

Approach

No pom.xml, no Maven, no Gradle, no JReleaser. zb keeps building itself with zb. A
single-file Java 25 script assembles the Central bundle by hand — jar, POM, sources jar,
javadoc jar, each with a detached GPG signature and MD5/SHA-1 — and uploads it to the Central
Portal.

JReleaser was the obvious alternative and was rejected: with no Maven or Gradle in the repo it
still needs the staging layout and POM built by hand, so it saves less than it looks like while
adding a ~40 MB CI download. A publish-only pom.xml was rejected for putting a pom.xml in
the repository of a tool whose pitch is that you don't need one.

What this adds

6 files, 4605 insertions, 1 deletion.

Path
zpublish new, 4139 lines (1897 production + 2242 in-script tests)
.github/workflows/publish-central.yml new, 312 lines — pull_request validation plus a workflow_dispatch preflightbuildpublish chain
.zpublish new, 28 lines — zb's POM metadata, and the worked example siblings copy
README.md Corporate/Maven install, Releasing, adoption recipe
llms.txt Maven coordinates
AGENTS.md short Publishing section

.github/workflows/release.yml is untouched. Nothing under src/ changes.

Portability — this is not a zb-only tool

zpublish has no zb-specific code. Configuration splits along ownership:

  • .zb supplies build paths — jar.dir, jar.file.name, classpath.
  • .zpublish supplies POM metadata.
  • Sources, resources and version.txt are discovered, matching AppArguments:45-46.
    Note sources.dir and resources.dir in .zb are inert — zb writes them and never reads
    them — so honouring them would have created drift rather than preventing it.

Proven on two other projects, not just zb: dry-runs in throwaway copies of zsmith and
lightmetal, from an unmodified zpublish plus a .zpublish each. Both resolve their own
layout — zsmith's nested module and module-root version.txt, lightmetal's
lightmetal.jar. Every metadata element of the generated zsmith POM matches the hand-written
zsmith/zsmith/pom.xml, and both generated POMs validate against maven-4.0.0.xsd.

Adoption elsewhere is: copy zpublish, write a .zpublish, run -dry-run.

Verification

java --source 25 zpublish -selftest    # 644 assertions
java --source 25 zpublish -dry-run     # stage, sign, checksum, bundle - no upload

Both run on every pull request. The selftest binds a loopback HttpServer on an ephemeral
port — no traffic reaches Central, despite log lines that say PUBLISHED.

Not verified, and not verifiable locally: the authenticated upload. There is no Central
sandbox. That's why publishingType defaults to USER_MANAGED — a deployment stops at
VALIDATED and waits for a human to release it, so a bad first attempt gets dropped in the
Portal instead of becoming an immutable release. -automatic opts out and cannot be undone.

Two things to push back on

The file is 4139 lines. In a repo that ships a 30 KB jar, that's the obvious objection.
2242 of those lines are the in-script test suite; production is 1897. The tests live in the
script because zpublish sits outside src/ and zunit can't reach it. Splitting them into a
second file would make adoption a two-file copy, which defeats the point. Happy to split
anyway if you'd rather.

A generic publisher inside zb is backwards. It serves the whole family from inside one
member, and every sibling that adopts it copies from a repo it otherwise has nothing to do
with. The right end state is its own repository with zb consuming it like any other sibling.
That stays cheap precisely because the file has no zb-specific code left. Say the word and
I'll extract it instead.

Setup required from you

Five repository secrets:

Secret
CENTRAL_TOKEN_USERNAME, CENTRAL_TOKEN_PASSWORD Central Portal user token, not the account password
GPG_PRIVATE_KEY ASCII-armored private key
GPG_PASSPHRASE its passphrase
GPG_KEY_ID key id or fingerprint

The public key must be on a keyserver Central supports — keys.openpgp.org,
keyserver.ubuntu.com, or pgp.mit.edu — or validation fails.

Until those exist the PR changes nothing — the publish job fails fast on missing secrets and
nothing else in the repo is affected. workflow_dispatch also only appears in the Actions tab
once the workflow is on main, so this can't be exercised from the PR branch.

Releasing after merge

release.yml is unchanged: every push to main still tags and attaches to a GitHub Release.
Central is separate and on demand — run publish-central from the Actions tab with the tag as
input. It checks out that tag, stamps version.txt with the Maven version, rebuilds, and
publishes.

One consequence of that stamp: the Central jar's Implementation-Version is the full Maven
version (2026.08.16.01.19), where the GitHub Release asset carries 2026.08.16.01. Without
it java -jar zb.jar would print a version resolvable in no repository. It's one line in the
workflow if you'd rather drop it.

…isher

Fixes AdamBien#3.

Z* tools cannot be onboarded where the only approved intake path is an internal Maven mirror.

## Read this first — namespace gate

This PR is dead weight until one thing is confirmed: **`com.airhacks` must be a verified
namespace in your Central Portal account.**

The newest artifact under `com.airhacks` dates from 2020-06-08, five years before the OSSRH
sunset. Sonatype says all OSSRH namespaces were migrated to the Central Portal, but publishing
still depends on using an account that has access to the migrated namespace. Artifacts on
`repo1.maven.org` prove the coordinate exists, not that the current account can publish to it.
Check Portal → Namespaces before adding any secrets.

Log in with the same username/password account used for OSSRH. If `com.airhacks` is still
missing, contact Central Support so they can restore the existing publishing access; do not
register a replacement namespace or change the coordinate as a first response.

## Problem

Corporate environments mirror open source through Nexus or Artifactory proxying Central. The
intake is automated and GitHub is usually blocked. zb ships only via GitHub Releases, so in
those environments it can't be adopted at all.

Publishing to Central needs no per-company setup — every mirror already proxying Central
proxies `com.airhacks:zb`:

```bash
mvn dependency:get -Dartifact=com.airhacks:zb:<version>
curl -O https://<mirror>/com/airhacks/zb/<version>/zb-<version>.jar
```

Central versions are immutable by definition. GitHub can also lock future release assets when
release immutability is enabled, but that still does not make them available through corporate
mirrors of Central.

## Approach

No `pom.xml`, no Maven, no Gradle, no JReleaser. zb keeps building itself with zb. A
single-file Java 25 script assembles the Central bundle by hand — jar, POM, sources jar,
javadoc jar, each with a detached GPG signature and MD5/SHA-1 — and uploads it to the Central
Portal.

JReleaser was the obvious alternative and was rejected: with no Maven or Gradle in the repo it
still needs the staging layout and POM built by hand, so it saves less than it looks like while
adding a ~40 MB CI download. A publish-only `pom.xml` was rejected for putting a `pom.xml` in
the repository of a tool whose pitch is that you don't need one.

## What this adds

6 files, 4605 insertions, 1 deletion.

| Path | |
| --- | --- |
| `zpublish` | new, 4139 lines (1897 production + 2242 in-script tests) |
| `.github/workflows/publish-central.yml` | new, 312 lines — `pull_request` validation plus a `workflow_dispatch` `preflight` → `build` → `publish` chain |
| `.zpublish` | new, 28 lines — zb's POM metadata, and the worked example siblings copy |
| `README.md` | Corporate/Maven install, Releasing, adoption recipe |
| `llms.txt` | Maven coordinates |
| `AGENTS.md` | short Publishing section |

`.github/workflows/release.yml` is untouched. Nothing under `src/` changes.

## Portability — this is not a zb-only tool

`zpublish` has no zb-specific code. Configuration splits along ownership:

- `.zb` supplies build paths — `jar.dir`, `jar.file.name`, `classpath`.
- `.zpublish` supplies POM metadata.
- Sources, resources and `version.txt` are **discovered**, matching `AppArguments:45-46`.
  Note `sources.dir` and `resources.dir` in `.zb` are inert — zb writes them and never reads
  them — so honouring them would have created drift rather than preventing it.

Proven on two other projects, not just zb: dry-runs in throwaway copies of `zsmith` and
`lightmetal`, from an unmodified `zpublish` plus a `.zpublish` each. Both resolve their own
layout — zsmith's nested module and module-root `version.txt`, lightmetal's
`lightmetal.jar`. Every metadata element of the generated zsmith POM matches the hand-written
`zsmith/zsmith/pom.xml`, and both generated POMs validate against `maven-4.0.0.xsd`.

Adoption elsewhere is: copy `zpublish`, write a `.zpublish`, run `-dry-run`.

## Verification

```bash
java --source 25 zpublish -selftest    # 644 assertions
java --source 25 zpublish -dry-run     # stage, sign, checksum, bundle - no upload
```

Both run on every pull request. The selftest binds a loopback `HttpServer` on an ephemeral
port — no traffic reaches Central, despite log lines that say `PUBLISHED`.

Not verified, and not verifiable locally: the authenticated upload. There is no Central
sandbox. That's why `publishingType` defaults to `USER_MANAGED` — a deployment stops at
`VALIDATED` and waits for a human to release it, so a bad first attempt gets dropped in the
Portal instead of becoming an immutable release. `-automatic` opts out and cannot be undone.

## Two things to push back on

**The file is 4139 lines.** In a repo that ships a 30 KB jar, that's the obvious objection.
2242 of those lines are the in-script test suite; production is 1897. The tests live in the
script because `zpublish` sits outside `src/` and zunit can't reach it. Splitting them into a
second file would make adoption a two-file copy, which defeats the point. Happy to split
anyway if you'd rather.

**A generic publisher inside `zb` is backwards.** It serves the whole family from inside one
member, and every sibling that adopts it copies from a repo it otherwise has nothing to do
with. The right end state is its own repository with zb consuming it like any other sibling.
That stays cheap precisely because the file has no zb-specific code left. Say the word and
I'll extract it instead.

## Setup required from you

Five repository secrets:

| Secret | |
| --- | --- |
| `CENTRAL_TOKEN_USERNAME`, `CENTRAL_TOKEN_PASSWORD` | Central Portal **user token**, not the account password |
| `GPG_PRIVATE_KEY` | ASCII-armored private key |
| `GPG_PASSPHRASE` | its passphrase |
| `GPG_KEY_ID` | key id or fingerprint |

The public key must be on a keyserver Central supports — `keys.openpgp.org`,
`keyserver.ubuntu.com`, or `pgp.mit.edu` — or validation fails.

Until those exist the PR changes nothing — the publish job fails fast on missing secrets and
nothing else in the repo is affected. `workflow_dispatch` also only appears in the Actions tab
once the workflow is on `main`, so this can't be exercised from the PR branch.

## Releasing after merge

`release.yml` is unchanged: every push to `main` still tags and attaches to a GitHub Release.
Central is separate and on demand — run `publish-central` from the Actions tab with the tag as
input. It checks out that tag, stamps `version.txt` with the Maven version, rebuilds, and
publishes.

One consequence of that stamp: the Central jar's `Implementation-Version` is the full Maven
version (`2026.08.16.01.19`), where the GitHub Release asset carries `2026.08.16.01`. Without
it `java -jar zb.jar` would print a version resolvable in no repository. It's one line in the
workflow if you'd rather drop it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ur3dbMkDBneKoGkiSVPzmx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Z* tools are unusable in corporate environments: no artifacts in Maven Central

1 participant