From 6fe91a4d78aaffdabb86770861cecb436d7e7166 Mon Sep 17 00:00:00 2001 From: senleft Date: Sat, 22 Aug 2026 17:58:21 -0500 Subject: [PATCH] Publish to Maven Central with `zpublish`, a portable single-file publisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`: ```bash mvn dependency:get -Dartifact=com.airhacks:zb: curl -O https:///com/airhacks/zb//zb-.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) Claude-Session: https://claude.ai/code/session_01Ur3dbMkDBneKoGkiSVPzmx --- .github/workflows/publish-central.yml | 312 ++ .zpublish | 28 + AGENTS.md | 18 + README.md | 83 +- llms.txt | 26 + zpublish | 4139 +++++++++++++++++++++++++ 6 files changed, 4605 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-central.yml create mode 100644 .zpublish create mode 100755 zpublish diff --git a/.github/workflows/publish-central.yml b/.github/workflows/publish-central.yml new file mode 100644 index 0000000..1bac03c --- /dev/null +++ b/.github/workflows/publish-central.yml @@ -0,0 +1,312 @@ +name: publish-central + +# Two non-obvious facts shape this file: +# +# 1. There is deliberately no `push: tags` trigger. `release.yml` creates its tag with +# `gh release create` running under GITHUB_TOKEN, and GitHub's recursion prevention +# means a tag pushed by GITHUB_TOKEN does not start any other workflow. An +# `on: push: tags: ['v*']` trigger would therefore silently never fire for exactly +# the releases it exists to publish, so it is omitted rather than shipped broken. +# +# 2. `workflow_dispatch` only appears in the Actions UI once this file is on the default +# branch. The `preflight`/`build`/`publish` chain below cannot be exercised from a pull +# request branch at all; pre-merge, only the `validate` job runs. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish, e.g. v2026.08.16.01.42' + required: true + type: string + pull_request: + # `release.yml` cuts a release on every push to `main`, and a `workflow_dispatch` on that + # tag publishes an immutable Central version. Without this trigger the selftest and the + # dry run - the only checks that `.zpublish` is complete and loadable - would never have + # run for the commit being published, and a broken `.zpublish` would first surface inside + # the job holding the signing key and the Central token. + push: + branches: [main] + +permissions: + contents: read + +jobs: + # The workflow's own test: it exercises staging, sources/javadoc packaging, the + # signing-skip path, checksums, bundling and validation with no secrets at all. + validate: + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + + - name: Fetch zb + run: java --source 25 zbinstall + + - name: Build + run: java -jar zb.jar + + - name: Selftest + run: java --source 25 zpublish -selftest + + - name: Dry run + run: java --source 25 zpublish -dry-run + + # Three jobs rather than one, and the split is a trust boundary, not tidiness. + # + # `build` runs `zbinstall`, which downloads `zb.jar` from the *latest* release - a + # mutable artifact that the tag under publication does not pin - and then executes it. + # `publish` holds the GPG private key and the Central token. Keeping those two in one + # job would hand the builder every channel a later step in that job reads: it could + # rewrite the checked-out `zpublish` before it runs with the token in its environment, + # append to `$GITHUB_PATH` to shim the `gpg` that the private key is piped into, or + # append to `$GITHUB_ENV`. Ordering the builder ahead of the key import closes only the + # direct read of `~/.gnupg`, not any of those. + # + # That a compromised builder could still poison the jar `publish` signs is a different + # and lesser failure: it yields one bad, revocable release, whereas an exfiltrated + # signing key and Central token are a standing capability to publish anything under + # `com.airhacks:zb`. The split is what separates the two, and the price is one artifact + # hop. + # + # `preflight` exists so a dispatch with unconfigured secrets or a malformed tag fails in + # under a minute instead of after the build, without those secrets ever entering the job + # that runs the builder. + preflight: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + # No `tag` output: what the later jobs need is the commit, and exposing the name + # beside it would only invite a checkout of the name again. + maven-version: ${{ steps.version.outputs.maven-version }} + sha: ${{ steps.commit.outputs.sha }} + steps: + # The `secrets` context is not available in `if:` conditions at either job or step + # level - `if: secrets.X != ''` is an expression error, not a skip. Mapping the + # secrets to `env` and checking them in a step is the working form. + # + # All five, not just the token user: a run missing only GPG_KEY_ID would otherwise + # check out, build and import the key before failing inside zpublish. + - name: Require publishing secrets + env: + CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + ZPUBLISH_GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + ZPUBLISH_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + missing="" + [ -n "$CENTRAL_TOKEN_USERNAME" ] || missing="$missing CENTRAL_TOKEN_USERNAME" + [ -n "$CENTRAL_TOKEN_PASSWORD" ] || missing="$missing CENTRAL_TOKEN_PASSWORD" + [ -n "$GPG_PRIVATE_KEY" ] || missing="$missing GPG_PRIVATE_KEY" + [ -n "$ZPUBLISH_GPG_PASSPHRASE" ] || missing="$missing GPG_PASSPHRASE" + [ -n "$ZPUBLISH_GPG_KEY_ID" ] || missing="$missing GPG_KEY_ID" + if [ -n "$missing" ]; then + echo "::error::Repository secrets are missing:$missing" + exit 1 + fi + + # `grep` is line-oriented and would accept a multi-line input whose *first* line + # looks like a tag; the remaining lines would then be written verbatim into + # $GITHUB_OUTPUT, defining arbitrary job outputs that the later jobs expand into a + # checkout ref and a shell. Bash's `=~` matches the whole string, newlines included. + - name: Resolve version + id: version + shell: bash + env: + TAG: ${{ inputs.tag }} + run: | + if [[ ! "$TAG" =~ ^v[0-9][0-9A-Za-z.-]*$ ]]; then + echo "::error::tag is not of the expected form v" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "maven-version=${TAG#v}" >> "$GITHUB_OUTPUT" + + # The regex above makes the input tag-shaped, not a tag. `actions/checkout` given a + # bare name fetches `refs/heads/` and `refs/tags/` and prefers the + # branch, so a branch named `v...` would win over the tag of that name; and a tag + # can be moved between `build` and `publish`, whose checkouts are independent. Both + # end the same way: the binary jar and the sources jar of one immutable Central + # release built from different commits. Resolving the tag to a commit once, here, + # and checking out that commit in both jobs closes both, and a dispatch naming a + # tag that does not exist now fails in the preflight instead of in a checkout. + - name: Resolve tag commit + id: commit + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ steps.version.outputs.tag }} + run: | + # The singular `git/ref/` endpoint is an exact match - a prefix that hits no tag + # is a 404 here, where the plural one would answer with a list of near misses. + if ! object=$(gh api "repos/$REPO/git/ref/tags/$TAG" \ + --jq '.object.type + " " + .object.sha'); then + echo "::error::$REPO has no tag $TAG" + exit 1 + fi + type=${object%% *} + sha=${object#* } + # An annotated tag's ref names the tag object, not the commit under it. + if [ "$type" = tag ]; then + object=$(gh api "repos/$REPO/git/tags/$sha" \ + --jq '.object.type + " " + .object.sha') + type=${object%% *} + sha=${object#* } + fi + if [ "$type" != commit ] || [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::tag $TAG does not resolve to a commit" + exit 1 + fi + echo "$TAG is $sha" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + # No `secrets` mapping anywhere in this job: it is the one that runs unpinned code. + # + # No `if:` either - a job-level `if:` replaces the implicit `success()`, so restating + # the event condition here would let this job run after a failed `preflight`. Depending + # on `preflight` is what skips this job on a pull request. + build: + needs: preflight + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + MAVEN_VERSION: ${{ needs.preflight.outputs.maven-version }} + steps: + # Build from the commit the tag resolved to in `preflight`, not from main, so the + # Central artifact is built from the same source as the GitHub Release - and, since + # `publish` checks out that same sha, from the same source as the sources jar + # published beside it. Only tags cut after this workflow lands on main carry + # `zpublish`. + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.sha }} + # Nothing here talks to git after the checkout, and the next step runs code from + # a mutable release: the default would leave the job token in .git/config for it + # to read. + persist-credentials: false + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + + - name: Stamp version + run: | + # Keeps the jar's Implementation-Version equal to its Central coordinate; + # without it `java -jar zb.jar` would print a version that resolves in no + # repository. Working-tree only - never committed. + echo "$MAVEN_VERSION" > src/main/resources/version.txt + + - name: Fetch zb + run: java --source 25 zbinstall + + - name: Build + run: java -jar zb.jar + + # A single explicit file, not `zbo/`: the builder writes that directory, and this is + # the only channel it has into the job that holds the key. `if-no-files-found: error` + # turns a build that silently produced nothing into a failure here rather than into + # a confusing "build zb first" from zpublish two jobs later. + - name: Upload built jar + uses: actions/upload-artifact@v4 + with: + name: zb-jar + path: zbo/zb.jar + if-no-files-found: error + retention-days: 1 + + publish: + needs: [preflight, build] + runs-on: ubuntu-latest + # An upload that stalls would otherwise sit until GitHub's 6-hour default while + # holding the signing key and the Central token. + timeout-minutes: 30 + env: + MAVEN_VERSION: ${{ needs.preflight.outputs.maven-version }} + steps: + # A fresh checkout of the same commit `build` used, not the build job's workspace: + # `zpublish`, `src/` and everything else this job executes or packages comes from + # the sha `preflight` pinned, and the only thing carried over from the build is the + # jar itself. + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.sha }} + # Nothing in this job talks to git after the checkout, and this is the job that + # holds the signing key and the Central token: the default would leave the job + # token in .git/config for every later step. + persist-credentials: false + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + + # Repeated here, not inherited from the build job: `zpublish` packages + # `src/main/resources` into the sources jar, so an unstamped checkout would publish + # a sources jar whose `version.txt` disagrees with the jar beside it. + - name: Stamp version + run: echo "$MAVEN_VERSION" > src/main/resources/version.txt + + - name: Download built jar + uses: actions/download-artifact@v4 + with: + name: zb-jar + path: zbo + + - name: Prepare gpg agent + run: | + mkdir -p ~/.gnupg + chmod 700 ~/.gnupg + # zpublish signs with --pinentry-mode loopback and feeds the passphrase on + # stdin. The runner's gpg-agent refuses loopback unless allowed explicitly; + # omitting this is the most common Central-publishing CI failure. + echo allow-loopback-pinentry >> ~/.gnupg/gpg-agent.conf + gpg-connect-agent reloadagent /bye + + - name: Import signing key + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import + fingerprint=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ {print $10; exit}') + echo "$fingerprint:6:" | gpg --batch --import-ownertrust + + - name: Verify signing key + env: + ZPUBLISH_GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + run: | + gpg --list-secret-keys --keyid-format LONG + # Fail here rather than at the first signature: a mistyped id produces a far + # clearer message than a signing run that finds no such key. gpg resolves the + # id itself, which is what makes this gate agree with the signing step: + # grepping the colon listing by hand accepts neither the `0x`-prefixed nor the + # uid form that `--local-user` takes, and an unanchored match over that listing + # also hits creation timestamps and uid text, so a mistyped id would pass here + # and fail at the first signature. + if ! gpg --list-secret-keys --with-colons -- "$ZPUBLISH_GPG_KEY_ID" > /dev/null 2>&1; then + echo "::error::GPG_KEY_ID is not among the imported secret keys" + exit 1 + fi + + # Mapped per step rather than for the whole job: the checkout and the artifact + # download have no business being able to read the signing key out of their + # environment. + - name: Publish to Maven Central + env: + CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + ZPUBLISH_GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + ZPUBLISH_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: java --source 25 zpublish -version-string "$MAVEN_VERSION" diff --git a/.zpublish b/.zpublish new file mode 100644 index 0000000..5c3721b --- /dev/null +++ b/.zpublish @@ -0,0 +1,28 @@ +# Publishing metadata for zpublish. Copy this file into any zb-built project, replace the +# values, and the project publishes to Maven Central without a pom.xml. +# +# Required: groupId, artifactId, name, description, url, license.name, developer.id, +# developer.name, scm.url, scm.connection +# Optional: inceptionYear, license.url, developer.email, developer.url, +# scm.developerConnection, scm.tag - unset ones are omitted from the POM +# +# ~/.zpublish supplies local convenience defaults; this file wins key by key. Required keys +# belong here, in the repository: a CI runner has no home directory. + +groupId=com.airhacks +artifactId=zb +name=zb +description=Zero Dependencies Builder - compiles Java 25 projects and packages \ + executable JARs with no external dependencies. +url=https://github.com/AdamBien/zb + +license.name=MIT License +license.url=https://github.com/AdamBien/zb/blob/main/LICENSE + +developer.id=AdamBien +developer.name=Adam Bien +developer.url=https://airhacks.com + +scm.url=https://github.com/AdamBien/zb +scm.connection=scm:git:https://github.com/AdamBien/zb.git +scm.developerConnection=scm:git:git@github.com:AdamBien/zb.git diff --git a/AGENTS.md b/AGENTS.md index 2524818..e35e78a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,3 +51,21 @@ Run the built JAR: ```bash java -jar zbo/app.jar ``` + +## Publishing (zpublish) + +`zpublish` is a single-file Java 25 script in the repository root that publishes the built +JAR to Maven Central. It never builds — build first. Configuration is split: `.zb` supplies +the build paths (`jar.dir`, `jar.file.name`, `classpath`), `.zpublish` supplies the POM +metadata. Never write to `.zb` from `zpublish`; the build tool owns that file. + +Its tests live inside the script, not under `src/`, so zunit does not reach them and +`java -jar zb.jar` does not exercise them: + +```bash +java --source 25 zpublish -selftest # in-script assertions +java --source 25 zpublish -dry-run # stage, sign, checksum, bundle - no upload +``` + +Both run on every pull request and every push to `main` +(`.github/workflows/publish-central.yml`). diff --git a/README.md b/README.md index 4025b8f..52e51e6 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,24 @@ The download is atomic — a partial download cannot replace an existing `zb.jar Based on the [`java-cli-script`](https://airails.dev) skill from [airails.dev](https://airails.dev) — single-file, zero-dependency, shebang-launched Java 25 utilities. +### Corporate / Maven Repository Install + +Starting with the first release published through the `publish-central` workflow, zb releases go to Maven Central as `com.airhacks:zb`, so environments that mirror Central through Nexus or Artifactory can fetch zb from their internal repository instead of reaching out to GitHub. Nothing is on Central yet — check [the Central listing](https://central.sonatype.com/artifact/com.airhacks/zb) for what is actually there before pointing a build at it: + +```bash +# Via any Maven client — resolves through the configured corporate mirror +mvn dependency:get -Dartifact=com.airhacks:zb: + +# Or directly from the mirror, no Maven client involved +curl -O https:///com/airhacks/zb//zb-.jar +``` + +`` is the release tag without its leading `v`: tag `v2026.08.16.01.19` resolves as `2026.08.16.01.19`. The Central channel covers the releases tagged after it was set up — every earlier release is on GitHub only, and the [GitHub Releases](https://github.com/AdamBien/zb/releases) page is the authoritative list of what exists. The fetched `zb-.jar` is built from that release's tag and runs as is — rename it to `zb.jar` if you prefer. It is not byte-identical to the jar attached to the GitHub Release: the Central build stamps the full Maven version into the jar, so its manifest and its startup banner name the exact version the mirror resolved. + +Central carries that jar plus the POM, sources and javadoc artifacts it mandates. The `zb.sh` wrapper stays on GitHub and is optional — `java -jar zb.jar` needs none of it. + +zb still has no Maven dependency. Central is a distribution channel only: nothing in the build changes, there is no `pom.xml` in this repository, and building a project remains `java -jar zb.jar`. + ### Build from Source ```bash @@ -191,9 +209,72 @@ zb && zunit A [/zunit skill](https://github.com/AdamBien/airails/tree/main/java/zunit) is available for AI-assisted generation and execution of zunit tests. +## Releasing + +Every push to `main` runs `release.yml`, which builds zb, attaches `zb.jar` and `zb.sh` to a GitHub Release and tags it `v.`. + +Maven Central is published separately and on demand: run the `publish-central` workflow from the Actions tab with that tag as its input. It checks out the tag, stamps `src/main/resources/version.txt` with the Maven version, rebuilds, and hands the result to [`zpublish`](zpublish) — a single-file Java 25 script that assembles the Central bundle by hand (jar, POM, sources jar and javadoc jar, each with a detached GPG signature and MD5/SHA-1 checksums) and uploads it to the Central Portal. + +`zpublish` publishes an already-built jar and never builds one itself. Its build paths come from `.zb` (`jar.dir`, `jar.file.name`, `classpath`) and its POM comes from [`.zpublish`](.zpublish); both are read from the directory it is started in. Sources, resources and `version.txt` are discovered rather than configured, because zb writes those keys and then rediscovers them anyway. + +```bash +# Stage, sign, checksum and bundle into zbo/bundle.zip, then stop before the upload +java -jar zb.jar +java --source 25 zpublish -dry-run + +# Run the in-script assertions - zpublish lives outside src/, so it carries its own tests +java --source 25 zpublish -selftest +``` + +Publishing needs a JDK rather than a JRE — the javadoc jar is generated in-process — plus `gpg` on the `PATH` and five repository secrets: + +| Secret | Value | +| --- | --- | +| `CENTRAL_TOKEN_USERNAME`, `CENTRAL_TOKEN_PASSWORD` | a Central Portal **user token**, not the account password | +| `GPG_PRIVATE_KEY` | the ASCII-armored private key | +| `GPG_PASSPHRASE` | its passphrase | +| `GPG_KEY_ID` | the key id or fingerprint to sign with | + +The matching public key has to be on a keyserver Central queries (`keys.openpgp.org`, `keyserver.ubuntu.com`), or validation fails. + +A deployment stops at `VALIDATED` and waits in the [Portal](https://central.sonatype.com/publishing/deployments) for a human to release it, so a green workflow run means validated, not published. `zpublish -automatic` skips that confirmation and cannot be undone — Central versions are immutable, and a mistake needs a new version rather than a fix. + +### Adopting zpublish in Another Project + +Nothing in `zpublish` is specific to zb. Copy the file into any zb-built project, write a `.zpublish` beside it, build, and dry-run: + +```bash +cp ../zb/zpublish . +$EDITOR .zpublish +java -jar zb.jar +java --source 25 zpublish -dry-run +``` + +The dry run stages, signs, checksums and bundles into `/bundle.zip`, prints the staging listing, and stops before the upload. Read the generated `-.pom` in the staging directory before publishing — Central versions are immutable. + +Two prerequisites the recipe above assumes: + +- **`.zb` has to exist beside `.zpublish`, and in CI it has to be committed.** `zpublish` reads `jar.dir`, `jar.file.name` and `classpath` from it and refuses to run without it. `java -jar zb.jar` writes one locally, but a pipeline that builds and publishes in separate jobs — as [`publish-central.yml`](.github/workflows/publish-central.yml) does — never sees the builder's copy. +- **The sources cannot sit in the project root.** zb compiles `**/*.java` from the current directory as its last resort, but `zpublish` packages the source tree unfiltered, so a root-level source root would ship `.git`, the build output and the bundle itself inside an immutable sources jar. It refuses that layout by name; move the sources under `src/main/java` (or `src`) first. + +| `.zpublish` | Keys | +| --- | --- | +| Required | `groupId`, `artifactId`, `name`, `description`, `url`, `license.name`, `developer.id`, `developer.name`, `scm.url`, `scm.connection` | +| Optional | `inceptionYear`, `license.url`, `developer.email`, `developer.url`, `scm.developerConnection`, `scm.tag` | + +Optional keys are omitted from the POM when unset, never rendered empty. + +`~/.zpublish` is read first, `./.zpublish` second, and the local file wins key by key — the home file defaults the optional keys only. **The required keys are read from the repository-tracked `./.zpublish` and demanded of it**, never of the merge: a CI runner has no home directory, so a key that exists only globally would turn a green local dry-run into a red workflow, and a `groupId` left in `~/.zpublish` by another project would otherwise publish this jar under that project's coordinates. + +The Maven version resolves in order: the `-version-string` option, the `ZPUBLISH_VERSION` environment variable, then `version.txt` probed in the project root and in the resource root. + +Signing and upload credentials are read from the environment only, never from either file: `ZPUBLISH_GPG_KEY_ID` and `ZPUBLISH_GPG_PASSPHRASE` for the detached signatures (either one unset skips signing, which a dry run allows and an upload does not), `CENTRAL_TOKEN_USERNAME` and `CENTRAL_TOKEN_PASSWORD` for the Portal. + +Exactly one developer and one license are supported, and `jar` and `repo` are fixed. A project that needs several developers or a dual license needs a different configuration shape. + ## AI-Assisted Development -zb includes a [SKILL.md](SKILL.md) for use with [airails.dev](https://airails.dev) AI-assisted development workflows. +zb works with [airails.dev](https://airails.dev) AI-assisted development workflows through its `/zb` skill. ## Architecture diff --git a/llms.txt b/llms.txt index e8437bb..e526463 100644 --- a/llms.txt +++ b/llms.txt @@ -12,6 +12,15 @@ curl -o ~/bin/zb.sh https://raw.githubusercontent.com/AdamBien/zb/main/src/main/ chmod +x ~/bin/zb.sh ``` +From a Maven repository (Maven Central, or any corporate mirror proxying it): + +```bash +mvn dependency:get -Dartifact=com.airhacks:zb: +curl -O https:///com/airhacks/zb//zb-.jar +``` + +`` is the release tag without its leading `v` (tag `v2026.08.16.01.19` resolves as `2026.08.16.01.19`), and `zb-.jar` is built from that release's tag, carrying the full Maven version as its `Implementation-Version`. Central carries the releases tagged after the channel was set up; every earlier release is on GitHub only, and nothing has been published to Central yet — check https://central.sonatype.com/artifact/com.airhacks/zb before pointing a build at it. Central carries only that jar plus the POM, sources and javadoc artifacts it mandates; `zb.sh` stays on GitHub and is optional. zb has no Maven dependency — Central is a distribution channel only. + From source: ```bash @@ -54,6 +63,23 @@ classpath= External JARs are listed colon-separated in `classpath` (e.g. `classpath=lib/a.jar:lib/b.jar`); they are passed to `javac` and referenced in the manifest `Class-Path` — never resolved or copied. +## Publishing to Maven Central + +`zpublish` is a single-file Java 25 script in the repository root that publishes an already-built jar to Central without a `pom.xml` and without Maven. Nothing in it is zb-specific: copy it into any zb-built project, add a `.zpublish`, build, then `java --source 25 zpublish -dry-run`. + +Build paths come from `.zb` (`jar.dir`, `jar.file.name`, `classpath`); sources, resources and `version.txt` are discovered. `.zb` must exist beside `.zpublish` and be committed when the pipeline builds and publishes in separate jobs. Sources in the project root are refused: the sources jar is packaged unfiltered, so a root-level source root would ship `.git` and the build output; move them under `src/main/java` or `src`. POM metadata comes from `.zpublish`: + +```properties +# required +groupId, artifactId, name, description, url, license.name, developer.id, developer.name, scm.url, scm.connection +# optional - omitted from the POM when unset +inceptionYear, license.url, developer.email, developer.url, scm.developerConnection, scm.tag +``` + +`~/.zpublish` then `./.zpublish`, the local file winning key by key. The required keys are demanded of the repo-tracked `./.zpublish` alone and a global file never supplies them — a CI runner has no home directory, and a stale global `groupId` would publish under another project's coordinates. `~/.zpublish` defaults the optional keys. Version order: `-version-string`, `ZPUBLISH_VERSION`, `version.txt` in the project root then the resource root. `ZPUBLISH_GPG_KEY_ID`, `ZPUBLISH_GPG_PASSPHRASE`, `CENTRAL_TOKEN_USERNAME` and `CENTRAL_TOKEN_PASSWORD` come from the environment only; either GPG variable unset skips signing, which a dry run allows and an upload does not. Exactly one developer and one license are supported. + +`zpublish` lives outside `src/`, so `java -jar zb.jar` does not test it. Its assertions run only via `java --source 25 zpublish -selftest`; `java --source 25 zpublish -dry-run` stages, checksums and bundles without uploading. Both run on every pull request and every push to `main`. + ## When to Use zb - Java 21+ projects without dependency resolution — external JARs are listed explicitly via `classpath` diff --git a/zpublish b/zpublish new file mode 100755 index 0000000..93be0a1 --- /dev/null +++ b/zpublish @@ -0,0 +1,4139 @@ +#!/usr/bin/env -S java --source 25 + +/* + * zpublish - publishes a jar built without Maven to Maven Central. + * + * Nothing here is specific to one project: the build paths come from `.zb`, the POM comes + * from `.zpublish`, and both are read from the directory the tool is started in. Adopting + * it elsewhere is copying this file and writing a `.zpublish`. + * + * Read from `.zb` (never written back; the build tool owns that file): + * jar.dir output directory, default `zbo/`; the staging tree and the bundle + * derive from it + * jar.file.name the built jar inside `jar.dir`, default `app.jar` + * classpath colon-separated compile classpath, replayed for javadoc + * `` and `` are the build tool's own placeholders and read as + * unset. The source root, the resource root and `version.txt` are *not* read from `.zb`: + * the build tool writes those keys and then discovers them again itself, so they are + * discovered here the same way rather than trusted. + * + * Read from `.zpublish` (`java.util.Properties`), `~/.zpublish` then `./.zpublish`, the + * local file winning key by key. The required keys are demanded of the repo-tracked + * `./.zpublish` alone and a run without them fails there: a CI runner has no home + * directory, so a key that exists only globally would turn a green local dry-run into a + * red workflow - and a global `groupId` left over from another project would publish this + * jar under that project's coordinates. `~/.zpublish` defaults the optional keys. + * required groupId, artifactId, name, description, url, license.name, developer.id, + * developer.name, scm.url, scm.connection + * optional inceptionYear, license.url, developer.email, developer.url, + * scm.developerConnection, scm.tag - omitted from the POM when unset, never + * rendered empty + * `jar` and `repo` are fixed. Exactly + * one developer and one license are supported; more needs a different config shape and no + * project in the family needs it. + * + * Version, in order: `-version-string`, then the `ZPUBLISH_VERSION` environment variable, + * then `version.txt` probed in the project root and then the resource root. + * + * Read from the environment, never from either file, because they are secrets: + * ZPUBLISH_GPG_KEY_ID, ZPUBLISH_GPG_PASSPHRASE signing; both unset skips signing, which + * a dry run allows and an upload does not + * CENTRAL_TOKEN_USERNAME, CENTRAL_TOKEN_PASSWORD the Central Portal token, upload only + * + * The name `zb` still appears above that boundary, and deliberately: it names the build + * tool whose file format is being read - its placeholders, its lookup order, its `zbo/` + * default for an absent `jar.dir`. That is the contract, not a hardcoded project. What no + * longer appears is any project's coordinates, repository or build command; `-selftest` + * asserts that of every failure a run can raise before it reaches the network. + * + * Production ends at the `testMetadata` declaration a little past the halfway mark; + * everything below it belongs to `-selftest` and runs no other way. That split is why a + * single-file tool is 3000+ lines: the tests ship inside it, because a repo-root script + * has no `src/test/java` for a test runner to reach. + */ + +import module java.net.http; +import module java.xml; +import module jdk.httpserver; + +/// `java.xml` is imported for the selftest's POM parser and brings a second `Duration` with +/// it. The single-type import settles which one this script means, rather than qualifying +/// every timeout below. +import java.time.Duration; + +String name = MethodHandles.lookup().lookupClass().getName(); +String version = "2026-08-22.4"; + +/// Coordinates and POM fields, resolved once per run and handed down to every step that +/// needs them. None of those steps reads them back out of a field: a publisher whose +/// staging path came from a global while its POM came from a parameter can stage one +/// project's coordinates and describe another project's. +/// The six `Optional` components are the POM's optional elements. They are `Optional` rather +/// than nullable or blank-by-convention because "unset" has to survive all the way to the +/// renderer: an element that is absent is valid where the same element rendered empty is not. +record Metadata(String groupId, String artifactId, String name, String description, String url, + Optional inceptionYear, String licenseName, Optional licenseUrl, + String developerId, String developerName, Optional developerEmail, + Optional developerUrl, String scmConnection, + Optional scmDeveloperConnection, Optional scmTag, String scmUrl) { +} +String versionEnvironmentVariable = "ZPUBLISH_VERSION"; +String versionFileName = "version.txt"; +Path projectRoot = Path.of("."); +Path buildFile = Path.of(".zb"); + +/// Defaults, so `-help`, `-version` and `-selftest` run in a directory that has no `.zb` +/// at all. A publishing run replaces every one of them from the build configuration in +/// [#resolveBuildPaths] before it stages anything. +Path outputDirectory = Path.of("zbo"); +Path stagingRoot = stagingRoot(outputDirectory); +Path bundleFile = bundleFile(outputDirectory); +Path builtJar = outputDirectory.resolve("app.jar"); +Path sourceDirectory = Path.of("src/main/java"); +Path resourceDirectory = Path.of("src/main/resources"); + +/// The compile classpath zb was given, replayed for javadoc. Empty by default and empty +/// for every project in the family: an absent `classpath` key is the ordinary case, not a +/// degraded one. +List classpath = List.of(); + +String usage = """ + Usage: %s [options] + -dry-run stage, sign, checksum and bundle locally, then stop before upload + -automatic publish immediately (publishingType=AUTOMATIC) instead of USER_MANAGED + -version-string Maven version to publish; overrides %s and the + probed %s + -selftest run the in-script assertions and exit + -help show this help + -version show version + """; + +/// Publishes an already-built jar to Maven Central without introducing a Maven +/// dependency: the project keeps building itself with whatever wrote its `.zb`, and this +/// script assembles the Central bundle by hand. +void main(String... args) throws Exception { + IO.println("%s %s".formatted(name, version)); + var options = List.of(args); + if (options.contains("-help")) { + IO.println(usage.formatted(name, versionEnvironmentVariable, + versionFileLocations(projectRoot, resourceDirectory))); + return; + } + if (options.contains("-version")) { + return; + } + if (options.contains("-selftest")) { + selftest(); + return; + } + try { + publish(args); + } catch (IllegalArgumentException | IllegalStateException problem) { + System.err.println("%s: %s".formatted(name, problem.getMessage())); + System.exit(1); + } +} + +void publish(String... args) throws Exception { + var options = List.of(args); + requireKnownOptions(args); + resolveBuildPaths(projectRoot, buildFile); + var dryRun = options.contains("-dry-run"); + var publishingType = publishingType(options); + var mavenVersion = resolveVersion(args); + var metadata = metadata(); + IO.println("%s: mode %s".formatted(name, mode(dryRun, publishingType))); + IO.println("%s: %s:%s:%s" + .formatted(name, metadata.groupId(), metadata.artifactId(), mavenVersion)); + // Both secrets are resolved here, ahead of the first expensive step. A publish with an + // unset ZPUBLISH_GPG_* or CENTRAL_TOKEN_* is a condition known at second zero, and + // reporting it after javadoc, staging, checksumming and a multi-megabyte bundle is a + // multi-minute run spent to learn it. A dry run needs neither and resolves neither. + var signer = configuredSigner(); + if (!dryRun && signer.isEmpty()) { + skipSigning(false); + } + var authorization = dryRun ? null : authorization(); + var staging = stage(metadata, mavenVersion); + var signed = sign(staging, dryRun, signer); + checksum(staging); + report(staging); + validate(metadata, staging, signed); + var bundle = bundle(); + if (dryRun) { + IO.println("%s: dry-run complete, %s was not uploaded".formatted(name, bundle)); + return; + } + var deploymentId = + uploaded(bundle, authorization, deploymentName(metadata, mavenVersion), publishingType); + IO.println("%s: uploaded %s as deployment %s".formatted(name, bundle, deploymentId)); + awaitState(deploymentId, authorization, publishingType, metadata, mavenVersion); +} + +/// Formatted from the very values the run acts on rather than derived from the options a +/// second time: a banner with its own copy of the decision can announce `USER_MANAGED` +/// while the request carries `AUTOMATIC`. +String mode(boolean dryRun, String publishingType) { + if (dryRun) { + return "dry-run (no upload)"; + } + return "publish " + publishingType; +} + +List knownOptions = + List.of("-dry-run", "-automatic", "-version-string", "-selftest", "-help", "-version"); + +/// An unrecognised option is rejected rather than ignored. `--dry-run` and `-dryrun` are +/// the plausible typos for the one flag whose entire purpose is to *not* upload, and +/// carrying on with a real publish is the single mistake this script must never make. +void requireKnownOptions(String[] args) { + for (var index = 0; index < args.length; index++) { + if ("-version-string".equals(args[index])) { + index++; + continue; + } + if (!knownOptions.contains(args[index])) { + throw new IllegalArgumentException( + "unknown option [%s]: run %s -help for the accepted options" + .formatted(args[index], name)); + } + } +} + +String resolveVersion(String... args) throws Exception { + return resolveVersion(args, System.getenv(versionEnvironmentVariable), projectRoot, + resourceDirectory); +} + +/// zb's own order (`Packer.locateVersionFile`): the project root first, the resource root +/// second. A single fixed location would refuse a jar zb was happy to build - zb packages +/// `version.txt` from whichever rung it finds, and both are in use across the family. +Optional versionFile(Path root, Path resources) { + return Stream.of(root, resources) + .map(directory -> directory.resolve(versionFileName)) + .filter(Files::isRegularFile) + .map(Path::normalize) + .findFirst(); +} + +/// Both rungs, named in every message the probe's failure reaches. Naming only one sends +/// the reader off to create a file in a place the other rung already shadows. +String versionFileLocations(Path root, Path resources) { + return Stream.of(root, resources) + .map(directory -> directory.resolve(versionFileName).normalize().toString()) + .collect(Collectors.joining(" or ")); +} + +/// Precedence: `-version-string` (CI passes the dispatched tag without its leading `v`), +/// then the environment variable, then `version.txt` for local dry-runs. +/// +/// A blank environment value counts as absent — CI systems routinely export an empty +/// string for an unset input, and failing on that would make the file fallback +/// unreachable. A blank `-version-string` is rejected instead, because passing the flag +/// is an explicit act. +String resolveVersion(String[] args, String environmentVersion, Path root, Path resources) + throws Exception { + return requireMavenVersion(rawVersion(args, environmentVersion, root, resources)); +} + +Pattern mavenVersionPattern = Pattern.compile("[0-9A-Za-z][0-9A-Za-z._-]*"); + +Pattern coordinatePattern = Pattern.compile("[0-9A-Za-z_-]+(\\.[0-9A-Za-z_-]+)*"); + +/// The same hazard [#requireMavenVersion] guards, reached through the other two operands of +/// the `Path.resolve` chain in [#stagingDirectory(Path,Metadata,String)]: `groupId` has its +/// dots turned into separators, so `groupId=..` resolves to an absolute path and hands +/// [#stage]'s `deleteTree` a directory that has nothing to do with this project, and a `/` +/// or a `..` in either coordinate writes the artifacts outside `jar.dir`. +/// +/// Rejected rather than sanitised: both values name the staging directories, every artifact +/// file and the POM, so a corrected coordinate would publish a bundle that no longer matches +/// what it says it is. The pattern is Maven's own — dot-separated segments of letters, +/// digits, `_` and `-` — so nothing a repository can carry is lost. Applied here, in the one +/// place `.zpublish` becomes a [Metadata], and hand-written `.zpublish` files are exactly +/// where the typo happens. +String requireCoordinate(String tag, String value) { + if (coordinatePattern.matcher(value).matches()) { + return value; + } + throw new IllegalArgumentException( + "%s [%s] is not a Maven coordinate: letters, digits, '_' and '-' in dot-separated segments" + .formatted(tag, value)); +} + +/// The version is not merely printed: it becomes a directory name under the staging root +/// and XML text in the POM. `Path.resolve` discards its base when handed an absolute +/// operand, which would turn [#stage]'s `deleteTree(stagingRoot)` into a recursive delete +/// of whatever directory the version names, and `..` segments would write the artifacts +/// outside `zbo/`. Validated once here, ahead of every use. +String requireMavenVersion(String version) { + if (mavenVersionPattern.matcher(version).matches()) { + return version; + } + throw new IllegalArgumentException( + "version [%s] is not a Maven version: letters, digits, '.', '-' and '_' only, starting with a letter or a digit" + .formatted(version)); +} + +String rawVersion(String[] args, String environmentVersion, Path root, Path resources) + throws Exception { + var argument = optionValue(args, "-version-string"); + if (argument != null) { + return requireVersion(argument, "-version-string was passed without a version"); + } + if (environmentVersion != null && !environmentVersion.isBlank()) { + return environmentVersion.strip(); + } + var file = versionFile(root, resources); + if (file.isEmpty()) { + return missingVersion(root, resources); + } + var fromFile = Files.readString(file.get()).strip(); + if (fromFile.isEmpty()) { + return missingVersion(root, resources); + } + return fromFile; +} + +String missingVersion(Path root, Path resources) { + throw new IllegalStateException( + "no version available: pass -version-string, set %s, or provide %s" + .formatted(versionEnvironmentVariable, versionFileLocations(root, resources))); +} + +String requireVersion(String candidate, String message) { + if (candidate.isBlank()) { + throw new IllegalArgumentException(message); + } + return candidate.strip(); +} + +/// Returns the operand following `flag`, `null` when the flag is absent, and an empty +/// string when the flag is last on the command line — which [#requireVersion] rejects. +String optionValue(String[] args, String flag) { + var index = List.of(args).indexOf(flag); + if (index < 0) { + return null; + } + if (index + 1 >= args.length) { + return ""; + } + return args[index + 1]; +} + +String configurationFileName = ".zpublish"; + +/// Resolved against [#projectRoot] like every other path this tool derives, rather than +/// against the working directory a second way. +Path localConfigurationFile() { + return projectRoot.resolve(configurationFileName); +} + +/// The local file wins key by key rather than wholesale, so a global file carrying optional +/// niceties - `developer.email`, `developer.url`, `license.url` - stays useful beside a +/// project file that names only what Central requires. The merge decides *values*; it does +/// not decide whether a project is configured at all, which [#requireConfiguration] asks of +/// the local file alone. +Properties configuration(Path global, Path local) { + return configuration(readProperties(global), readProperties(local)); +} + +/// The merge itself, over properties already read. [#metadata()] needs the local file both +/// merged and on its own, and reading it twice would let one run answer two questions about +/// two versions of the same file. +Properties configuration(Properties global, Properties local) { + var merged = new Properties(); + merged.putAll(global); + merged.putAll(local); + return merged; +} + +/// The home directory is a field so the selftest can run [#publish] against a project of +/// its own without the developer's own `~/.zpublish` reaching the assertions. +Path homeDirectory = Path.of(System.getProperty("user.home", ".")); + +Path globalConfigurationFile() { + return homeDirectory.resolve(configurationFileName); +} + +/// A missing file is not an error — either level may be absent. Anything else is: +/// an unreadable or malformed file read as empty would publish an immutable release +/// carrying defaulted metadata. +/// +/// Read as UTF-8 rather than through [Properties#load(InputStream)], which is specified to +/// decode ISO-8859-1. `.zpublish` is hand-written and the POM is written back as UTF-8, so +/// the byte stream would double-encode every accented developer name and every em dash into +/// an immutable release. `.zb` is unaffected either way: zb writes it with +/// [Properties#store(OutputStream,String)], which escapes non-ASCII to `\\uXXXX`. +Properties readProperties(Path file) { + var properties = new Properties(); + if (!Files.exists(file)) { + return properties; + } + try (var input = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + properties.load(input); + } catch (IOException | IllegalArgumentException problem) { + throw new IllegalStateException( + "%s cannot be read: %s".formatted(file, problem.getMessage())); + } + return properties; +} + +/// Absent and blank are one answer: `groupId=` is an unset key that has to fail as +/// missing rather than reach the POM as an empty element. +Optional property(Properties configuration, String key) { + var value = configuration.getProperty(key); + if (isBlank(value)) { + return Optional.empty(); + } + return Optional.of(value.strip()); +} + +/// Values zb writes into `.zb` as placeholders for "I worked this out myself". Neither is +/// a usable path, and both have to read as unset rather than reach `Path.of` and produce a +/// directory literally named ``. +List unresolvedBuildValues = List.of("", ""); + +/// An absent key, a blank value and a placeholder are one answer: the fallback. zb defaults +/// these keys itself, so a `.zb` that omits `jar.file.name` is an ordinary file, not a +/// broken one. +String zbProperty(Properties build, String key, String fallback) { + var value = build.getProperty(key); + if (isBlank(value) || unresolvedBuildValues.contains(value.strip())) { + return fallback; + } + return value.strip(); +} + +/// zb's configuration is read and never written back. A publishing tool that rewrites the +/// build tool's state changes what the next build does, and `.zb` is a generated file whose +/// comment header records the zb version that produced it. +/// +/// A missing `.zb` is fatal rather than defaulted: without it there is no evidence this +/// directory is a zb project at all, and defaulting would stage from a `zbo/` that belongs +/// to nobody. +Properties buildConfiguration(Path file) { + if (!Files.exists(file)) { + throw new IllegalStateException( + "%s not found: run %s from the project root, beside the %s the build wrote" + .formatted(file, name, file.getFileName())); + } + return readProperties(file); +} + +/// `jar.dir` is where zb put the jar, so it is also where the staging tree and the bundle +/// belong. Missing it would read the jar from `build/` and stage into a stray `zbo/`. +/// +/// `jar.dir=.` is a legal value that zb honours, and `Path.of(".").resolve(".").normalize()` +/// is the *empty* path — not `.`. Left as it is, the bundle would resolve to a bare +/// `bundle.zip` whose `getParent()` is `null` and the run would end in a bare +/// [NullPointerException] after javadoc, staging and checksumming had all completed. +Path outputDirectory(Path root, Properties build) { + var output = root.resolve(zbProperty(build, "jar.dir", "zbo/")).normalize(); + if (output.toString().isEmpty()) { + return Path.of("."); + } + return output; +} + +Path stagingRoot(Path output) { + return output.resolve("staging"); +} + +Path bundleFile(Path output) { + return output.resolve("bundle.zip"); +} + +/// `app.jar` is zb's own default for `jar.file.name`, so a `.zb` that leaves the key out +/// still names the jar zb would have built. +Path builtJar(Path root, Properties build) { + return outputDirectory(root, build).resolve(zbProperty(build, "jar.file.name", "app.jar")); +} + +/// zb writes `classpath` as a colon-separated list and `AppArguments.parseClasspath:58-67` +/// splits on `:`, trims and drops blanks — replicated here rather than reinterpreted, so a +/// project documents against exactly the classpath it compiled against. `` and an +/// absent key are one answer, because zb defaults the key to the placeholder itself and a +/// project that never set it must not end up with a `--class-path `. +/// +/// Resolved against the root and normalised, matching every other path this tool derives: +/// zb reads the entries relative to the directory it runs in, which is the project root. +/// `Path.resolve` leaves an absolute entry absolute. +List classpath(Path root, Properties build) { + return classpathEntries(zbProperty(build, "classpath", "")) + .map(root::resolve) + .map(Path::normalize) + .toList(); +} + +/// Split out from [#classpath(Path,Properties)] so the parsing rule is one expression: a +/// trailing separator, a doubled separator and a padded entry all describe the same list. +Stream classpathEntries(String value) { + if (isBlank(value)) { + return Stream.of(); + } + return Stream.of(value.split(":")) + .map(String::strip) + .filter(entry -> !entry.isBlank()); +} + +/// A missing entry is a warning rather than a failure, matching `Build:29-31` and +/// `UserHint.classpathEntryNotFound`: javadoc resolves what it can and reports the rest, +/// and refusing to publish over a stale entry in a key zb itself tolerates would be +/// stricter than the build that produced the jar. +/// +/// Returned rather than printed, so the message is asserted as a value instead of by +/// capturing output. +List classpathWarnings(List classpath) { + return classpath.stream() + .filter(entry -> !Files.exists(entry)) + .map(this::classpathEntryNotFound) + .toList(); +} + +String classpathEntryNotFound(Path entry) { + return "%s: warning: classpath entry not found: %s - check the classpath property in %s" + .formatted(name, entry.toAbsolutePath().normalize(), buildFile); +} + +/// zb's ladders, replicated rather than read from `.zb`. `sources.dir` and `resources.dir` +/// are written into `.zb` and then never consulted — `AppArguments:45-46` calls the +/// locators unconditionally — so honouring them here would *create* drift: `sources.dir=foo` +/// would publish `foo` as the sources jar while zb compiled `src/main/java`. +List sourceCandidates = List.of("src/main/java", "src", "."); +List resourceCandidates = List.of("src/main/resources", "resources"); + +/// Normalised, so a ladder anchored at `.` yields `src/main/java` rather than +/// `./src/main/java` — the same value the paths carried when they were literals. +Optional firstDirectory(Path root, List candidates) { + return candidates.stream() + .map(root::resolve) + .filter(Files::isDirectory) + .map(Path::normalize) + .findFirst(); +} + +Path sourcesDirectory(Path root) { + return requireSourcesBelowRoot(root, firstDirectory(root, sourceCandidates).orElse(root)); +} + +/// A missing resource root is not an error, so the ladder falls back to the nominal +/// `src/main/resources`; [#roots] already treats a directory that is not there as absent. +Path resourcesDirectory(Path root) { + return firstDirectory(root, resourceCandidates) + .orElseGet(() -> root.resolve(resourceCandidates.getFirst()).normalize()); +} + +/// The ladder ends at `.`, so it always matches something — which makes the last rung the +/// dangerous one rather than a not-found case. [#files] packs every regular file under the +/// root with no filter, so at a project root the sources jar would carry `.git`, `.idea`, +/// `zbo/` and the bundle zip itself, with javadoc then run over the lot. zb survives that +/// because it compiles only `**/*.java`; an immutable published sources jar does not. +Path requireSourcesBelowRoot(Path root, Path sources) { + if (!sources.normalize().equals(root.normalize())) { + return sources; + } + throw new IllegalStateException( + "sources resolve to the project root [%s]: an unfiltered sources jar would carry .git, build output and every other file under it - move the sources under %s" + .formatted(root.toAbsolutePath().normalize(), sourceCandidates.getFirst())); +} + +/// Resolved once, ahead of every step that touches the filesystem, so a `.zb` naming a +/// directory that is not there fails before the previous run's staging tree is deleted. +void resolveBuildPaths(Path root, Path file) { + var build = buildConfiguration(file); + this.outputDirectory = outputDirectory(root, build); + this.stagingRoot = stagingRoot(this.outputDirectory); + this.bundleFile = bundleFile(this.outputDirectory); + this.builtJar = builtJar(root, build); + this.sourceDirectory = sourcesDirectory(root); + this.resourceDirectory = resourcesDirectory(root); + this.classpath = classpath(root, build); + IO.println("%s: %s resolves jar %s, sources %s, output %s" + .formatted(name, file, this.builtJar, this.sourceDirectory, this.outputDirectory)); + classpathWarnings(this.classpath).forEach(IO::println); +} + +/// `com.example` becomes `com/example` — the Maven repository layout the Central +/// bundle zip has to mirror. `Path.resolve` parses `/` as a separator on Windows too. +Path stagingDirectory(Path stagingRoot, Metadata metadata, String version) { + return stagingRoot + .resolve(metadata.groupId().replace('.', '/')) + .resolve(metadata.artifactId()) + .resolve(version); +} + +/// The keys a project must set before it can publish. All of them are reported together +/// when they are missing: discovering them one failed run at a time is the same work spread +/// over ten attempts, and every one of them backs an element Central validates. +List requiredConfigurationKeys = List.of("groupId", "artifactId", "name", "description", + "url", "license.name", "developer.id", "developer.name", "scm.url", "scm.connection"); + +/// The metadata this project publishes under, resolved once from `.zpublish` and passed down +/// from [#publish]. Reading it from a field at each site instead would let the staging path +/// and the POM disagree about which project is being released. +/// +/// The one place both levels are read: `./.zpublish` carries the metadata and answers for it +/// alone, `~/.zpublish` defaults the optional keys underneath. The local file is read once +/// and used twice - as the file the required keys are demanded of and as the winning half of +/// the merge - so a run cannot answer the two questions from two versions of one file. +Metadata metadata() { + var local = readProperties(localConfigurationFile()); + return metadata(local, configuration(readProperties(globalConfigurationFile()), local), + configurationLocations()); +} + +/// Both files are named even though only one of them may exist: a user told a key is missing +/// needs to know where it was looked for, and the answer is not the same on a developer +/// machine as on a CI runner with no home directory. +String configurationLocations() { + return "%s or %s".formatted(globalConfigurationFile(), localConfigurationFile()); +} + +/// Resolved before anything is staged, signed or uploaded, so an incomplete configuration is +/// reported in the first second of a run rather than after a bundle has been built. +/// +/// `groupId` and `artifactId` are rejected on XML markup rather than escaped, because they +/// also name the staging directories and every artifact file - an escaped coordinate in the +/// POM would no longer match the bundle around it. The prose fields are escaped instead: an +/// ampersand in a description is ordinary text, and failing on it would leave the user +/// nothing to do about it. +/// +/// One in-memory configuration is the single-level case: it is both the file the required +/// keys have to come from and the merge the optional ones are read out of. +Metadata metadata(Properties configuration, String locations) { + return metadata(configuration, configuration, locations); +} + +/// Two levels, kept apart on purpose: the required keys are demanded of `local` while every +/// value is read from `configuration`, the merge of `~/.zpublish` under `./.zpublish`. For a +/// required key the two agree by construction - the local file wins the merge - so the split +/// costs nothing and buys the guarantee in [#requireConfiguration]. +Metadata metadata(Properties local, Properties configuration, String locations) { + requireConfiguration(local, locations); + return new Metadata( + requireCoordinate("groupId", coordinate("groupId", required(configuration, "groupId"))), + requireCoordinate("artifactId", + coordinate("artifactId", required(configuration, "artifactId"))), + required(configuration, "name"), + required(configuration, "description"), + required(configuration, "url"), + property(configuration, "inceptionYear"), + required(configuration, "license.name"), + property(configuration, "license.url"), + required(configuration, "developer.id"), + required(configuration, "developer.name"), + property(configuration, "developer.email"), + property(configuration, "developer.url"), + required(configuration, "scm.connection"), + property(configuration, "scm.developerConnection"), + property(configuration, "scm.tag"), + required(configuration, "scm.url")); +} + +/// Asked of the repository-tracked `./.zpublish` alone, never of the merge with +/// `~/.zpublish`. Validating the merge would let a stale global file answer for a project +/// that has none of its own: the coordinates decide what the release is called, so the same +/// jar would go out under another project's `groupId`/`artifactId`, and any required key that +/// lives only in `~` turns a green local dry-run into a red workflow on a CI runner with no +/// home directory. The optional keys stay merged - none of them says which project this is. +void requireConfiguration(Properties local, String locations) { + var missing = requiredConfigurationKeys.stream() + .filter(key -> property(local, key).isEmpty()) + .toList(); + if (missing.isEmpty()) { + return; + } + throw new IllegalStateException( + "publishing metadata is incomplete: %s not set in the repository-tracked %s (looked in %s) - required keys are read from the local file only, because a CI runner has no home directory to read them from" + .formatted(String.join(", ", missing), configurationFileName, locations)); +} + +/// Only ever reached for a key [#requireConfiguration] has already accepted, so the throw +/// guards the two against drifting apart rather than a path a user can walk into. +String required(Properties configuration, String key) { + return property(configuration, key) + .orElseThrow(() -> new IllegalStateException( + "%s is required but not set".formatted(key))); +} + +String pomIndentation = " "; + +/// Central validates the POM rather than the jar: coordinates, packaging, name, +/// description, url, licenses, developers and scm are all mandatory. There is no +/// `` element - a zero-dependency project has none, which is the point. +/// +/// Assembled element by element rather than interpolated into one template, because an +/// unset optional has to leave nothing behind. A template can only ever render every hole +/// it declares, and `` is a declared, blank url where a missing `` is no url at +/// all - Central rejects the first and accepts the second. +String pom(Metadata metadata, String version) { + var lines = new ArrayList(); + lines.add(""); + lines.add(element(1, "modelVersion", "4.0.0")); + lines.add(coordinateElement(1, "groupId", metadata.groupId())); + lines.add(coordinateElement(1, "artifactId", metadata.artifactId())); + lines.add(coordinateElement(1, "version", version)); + lines.add(element(1, "packaging", "jar")); + lines.add(element(1, "name", metadata.name())); + lines.add(element(1, "description", metadata.description())); + lines.add(element(1, "url", metadata.url())); + optionalElement(lines, 1, "inceptionYear", metadata.inceptionYear()); + lines.addAll(licenses(metadata)); + lines.addAll(developers(metadata)); + lines.addAll(scm(metadata)); + lines.add(""); + return String.join("\n", lines) + "\n"; +} + +/// `repo` is fixed rather than configurable: `repo` is what +/// makes the artifact redistributable from Central, and a project publishing there has +/// already answered that question by showing up. +List licenses(Metadata metadata) { + var lines = new ArrayList(); + lines.add(open(1, "licenses")); + lines.add(open(2, "license")); + lines.add(element(3, "name", metadata.licenseName())); + optionalElement(lines, 3, "url", metadata.licenseUrl()); + lines.add(element(3, "distribution", "repo")); + lines.add(close(2, "license")); + lines.add(close(1, "licenses")); + return lines; +} + +/// Exactly one developer and exactly one license, both documented limitations. Several of +/// either needs a configuration shape that can express a list, and no project in this +/// family has one. +List developers(Metadata metadata) { + var lines = new ArrayList(); + lines.add(open(1, "developers")); + lines.add(open(2, "developer")); + lines.add(element(3, "id", metadata.developerId())); + lines.add(element(3, "name", metadata.developerName())); + optionalElement(lines, 3, "email", metadata.developerEmail()); + optionalElement(lines, 3, "url", metadata.developerUrl()); + lines.add(close(2, "developer")); + lines.add(close(1, "developers")); + return lines; +} + +List scm(Metadata metadata) { + var lines = new ArrayList(); + lines.add(open(1, "scm")); + lines.add(element(2, "connection", metadata.scmConnection())); + optionalElement(lines, 2, "developerConnection", metadata.scmDeveloperConnection()); + optionalElement(lines, 2, "tag", metadata.scmTag()); + lines.add(element(2, "url", metadata.scmUrl())); + lines.add(close(1, "scm")); + return lines; +} + +String open(int depth, String tag) { + return "%s<%s>".formatted(pomIndentation.repeat(depth), tag); +} + +String close(int depth, String tag) { + return "%s".formatted(pomIndentation.repeat(depth), tag); +} + +String element(int depth, String tag, String value) { + return "%s<%s>%s".formatted(pomIndentation.repeat(depth), tag, xmlText(tag, value), tag); +} + +/// An unset optional contributes no line at all. Rendering it empty would be the more +/// uniform code and the wrong document, and the document is immutable once published. +void optionalElement(List lines, int depth, String tag, Optional value) { + value.map(present -> element(depth, tag, present)).ifPresent(lines::add); +} + +String xmlSpecialCharacters = "<>&\"'"; + +/// A coordinate is emitted after rejection rather than after escaping. It names the staging +/// directories and every artifact file as well as the POM, so escaping would publish a +/// coordinate that no longer matches the bundle it arrives in; a coordinate carrying markup +/// is a caller error with no correct encoding. +String coordinateElement(int depth, String tag, String value) { + return "%s<%s>%s" + .formatted(pomIndentation.repeat(depth), tag, coordinate(tag, value), tag); +} + +String coordinate(String tag, String value) { + if (value.chars().anyMatch(this::isXmlSpecial)) { + throw new IllegalArgumentException( + "%s [%s] contains XML-special characters".formatted(tag, value)); + } + return value; +} + +/// `&`, `<` and `>` are the three characters that change what element text means. Quotes +/// and apostrophes do not - no value here is interpolated into an attribute - and escaping +/// `Adam O'Brien` into `Adam O'Brien` would be a correct document nobody wants to read. +String xmlText(String value) { + return value.replace("&", "&").replace("<", "<").replace(">", ">"); +} + +/// Escaping is not enough on its own: XML 1.0 admits no control character but tab, newline +/// and carriage return, and there is no escape that makes one legal - `` is as invalid +/// as the raw byte. `.zpublish` is hand-written and a stray one arrives invisibly, pasted +/// out of a terminal or left behind by an editor. Refused where the value is rendered, so a +/// dry run reports the field by name instead of reporting a complete bundle that Central's +/// parser then rejects, at the one moment the version can no longer be re-published. +String xmlText(String tag, String value) { + var illegal = value.codePoints().filter(this::isXmlIllegal).findFirst(); + if (illegal.isPresent()) { + throw new IllegalArgumentException( + "%s [%s] contains a character XML cannot carry: U+%04X" + .formatted(tag, value, illegal.getAsInt())); + } + return xmlText(value); +} + +/// The complement of the XML 1.0 `Char` production. Read over code points rather than +/// `char`s so an accented name outside the BMP is one legal character, while a lone +/// surrogate - which no encoder can write out - is caught as the broken input it is. +boolean isXmlIllegal(int codePoint) { + if (codePoint == '\t' || codePoint == '\n' || codePoint == '\r') { + return false; + } + return codePoint < 0x20 || (codePoint >= 0xD800 && codePoint <= 0xDFFF) + || codePoint == 0xFFFE || codePoint == 0xFFFF; +} + +boolean isXmlSpecial(int character) { + return xmlSpecialCharacters.indexOf(character) >= 0; +} + +/// Central rejects a release without a sources jar. The resource root is packaged +/// alongside the java root so `version.txt` travels with the sources it belongs to. +Path sourcesJar(Metadata metadata, Path staging, String version, Path sources, Path resources) + throws Exception { + requireSourceDirectory(sources); + var jar = staging.resolve(artifactName(metadata, version, sourcesSuffix)); + writeJar(jar, roots(sources, resources)); + return jar; +} + +/// Central rejects a release without a javadoc jar, but never looks inside it. The +/// generated tree is removed again: it is a complete documentation site per run, and +/// leaving it behind fills the temp directory of every machine that publishes. +Path javadocJar(Metadata metadata, Path staging, String version, Path sources, + List classpath) throws Exception { + requireSourceDirectory(sources); + var documentation = Files.createTempDirectory("zpublish-javadoc"); + try { + javadoc(sources, documentation, classpath); + var jar = staging.resolve(artifactName(metadata, version, javadocSuffix)); + writeJar(jar, List.of(documentation)); + return jar; + } finally { + deleteTree(documentation); + } +} + +/// `ToolProvider` runs javadoc in-process, which keeps the script zero-dependency and +/// free of an external `javadoc` executable on the `PATH`. +/// +/// `-Xdoclint:none` is required rather than cosmetic: sources in this family use `///` +/// markdown doc comments and leave many members undocumented, so the default doclint +/// escalates to errors and the jar would never be produced. +void javadoc(Path sources, Path target, List classpath) throws Exception { + var tool = ToolProvider.findFirst("javadoc") + .orElseThrow(this::missingJavadocTool); + var output = new StringWriter(); + var writer = new PrintWriter(output); + var status = tool.run(writer, writer, javadocOptions(sources, target, classpath)); + writer.flush(); + if (status != 0) { + throw new IllegalStateException( + "javadoc failed with exit code %d:%n%s".formatted(status, output.toString().strip())); + } +} + +IllegalStateException missingJavadocTool() { + return new IllegalStateException( + "the javadoc tool is unavailable: run %s with a JDK, not a JRE".formatted(name)); +} + +/// The classpath zb compiled against is replayed here, so a project with external jars +/// documents instead of failing on an import javadoc cannot resolve. Omitted entirely when +/// empty rather than passed as `--class-path ""`, matching `Compiler:24-28`. +/// +/// Joined with `File.pathSeparator` rather than the `:` the `.zb` value was written with: +/// the property is a zb-format list, the option is a platform-format one. +/// +/// No `--release`: `Compiler:22-28` passes none either, so zb compiles at the running JDK's +/// default level and javadoc has to read the same sources the same way. Pinning a release +/// here would fail the javadoc jar — and with it the publish — for any project built on a +/// newer JDK that uses a language feature that JDK introduced. +String[] javadocOptions(Path sources, Path target, List classpath) throws Exception { + var options = new ArrayList<>(List.of("-Xdoclint:none", "-quiet", "-d", target.toString())); + if (!classpath.isEmpty()) { + options.addAll(List.of("--class-path", joinedClasspath(classpath))); + } + options.addAll(javaSources(sources)); + return options.toArray(String[]::new); +} + +String joinedClasspath(List classpath) { + return classpath.stream() + .map(Path::toString) + .collect(Collectors.joining(File.pathSeparator)); +} + +List javaSources(Path root) throws Exception { + try (var tree = Files.walk(root)) { + return tree.filter(this::isJavaSource) + .map(Path::toString) + .toList(); + } +} + +boolean isJavaSource(Path file) { + return Files.isRegularFile(file) && file.getFileName().toString().endsWith(".java"); +} + +/// A missing resource root is not an error — the java root alone is a valid sources jar. +/// +/// A resource root *inside* the source root is not an error either, and it is not +/// hypothetical: a project with `src` but no `src/main/java` takes the second rung of the +/// sources ladder and the first rung of the resources ladder, so `sources` is `src` and +/// `resources` is `src/main/resources`. [#files] walks recursively, so packaging both roots +/// would write every resource twice — once as `main/resources/version.txt` and once as +/// `version.txt` — into an immutable published sources jar that still validates. +List roots(Path sources, Path resources) { + if (Files.isDirectory(resources) && !nested(resources, sources)) { + return List.of(sources, resources); + } + return List.of(sources); +} + +boolean nested(Path candidate, Path root) { + return candidate.normalize().startsWith(root.normalize()); +} + +void requireSourceDirectory(Path directory) { + if (!Files.isDirectory(directory)) { + throw new IllegalStateException( + "%s is missing: run %s from the project root".formatted(directory, name)); + } +} + +void writeJar(Path target, List roots) throws Exception { + Files.createDirectories(parentOf(target)); + var written = new HashSet(); + try (var jar = new JarOutputStream(Files.newOutputStream(target))) { + for (var root : roots) { + addTree(jar, root, written); + } + } +} + +/// `JarOutputStream` is a `ZipOutputStream` and accepts a plain `ZipEntry`, so the jars +/// and the bundle zip share one tree-walking writer. +void addTree(ZipOutputStream zip, Path root) throws Exception { + addTree(zip, root, new HashSet<>()); +} + +/// Two disjoint roots can still carry the same relative path — `src/app.properties` and +/// `resources/app.properties` both become `app.properties` — and a repeated entry name +/// aborts [JarOutputStream] with a [java.util.zip.ZipException] that names the entry and +/// nothing else, mid-run, after staging has already started. Refused by name instead, so +/// the operator learns which file collided with what. +void addTree(ZipOutputStream zip, Path root, Set written) throws Exception { + for (var file : files(root)) { + var entry = entryName(root, file); + if (!written.add(entry)) { + throw new IllegalStateException( + "%s would be packaged twice, the second time from %s: two packaged roots carry the same relative path" + .formatted(entry, file)); + } + zip.putNextEntry(new ZipEntry(entry)); + Files.copy(file, zip); + zip.closeEntry(); + } +} + +/// `jar.dir=.` normalises to the empty path, whose `getParent()` is `null`: an absolute +/// path always has one. Used wherever a target file's directory has to be created. +Path parentOf(Path file) { + return file.toAbsolutePath().normalize().getParent(); +} + +/// Sorted, so two runs over the same tree write their entries in the same order. +List files(Path root) throws Exception { + try (var tree = Files.walk(root)) { + return tree.filter(Files::isRegularFile).sorted().toList(); + } +} + +/// Jar entry names are root-relative, `/`-separated and never start with a separator. +/// `Path.toString` would emit `\` on Windows and produce an unreadable jar there. +String entryName(Path root, Path file) { + return StreamSupport.stream(root.relativize(file).spliterator(), false) + .map(Path::toString) + .collect(Collectors.joining("/")); +} + +String jarSuffix = ".jar"; +String pomSuffix = ".pom"; +String sourcesSuffix = "-sources.jar"; +String javadocSuffix = "-javadoc.jar"; + +/// `-` is the Maven repository file naming rule. Central +/// relates the pom, the sources jar and the javadoc jar to the main jar by file name +/// alone, so every artifact is named here rather than at each producer. +String artifactName(Metadata metadata, String version, String suffix) { + return "%s-%s%s".formatted(metadata.artifactId(), version, suffix); +} + +/// Lays out the four artifacts Central requires in the Maven repository structure the +/// bundle zip mirrors. The whole staging root is dropped first, not just this version's +/// directory: the bundle packages the root, so a version staged by an earlier run would +/// otherwise be uploaded alongside this one. +/// +/// The inputs are checked ahead of that drop, so a run started before the jar was built +/// fails with the previous run's staging tree still on disk. +Path stage(Metadata metadata, String version) throws Exception { + requireBuiltJar(builtJar); + requireSourceDirectory(sourceDirectory); + requireJarVersion(builtJar, version, versionFileLocations(projectRoot, resourceDirectory)); + deleteTree(stagingRoot); + var staging = stage(metadata, stagingDirectory(stagingRoot, metadata, version), version, + builtJar, sourceDirectory, resourceDirectory, classpath); + IO.println("%s: staged %s".formatted(name, staging)); + return staging; +} + +Path stage(Metadata metadata, Path staging, String version, Path jar, Path sources, + Path resources, List classpath) throws Exception { + requireBuiltJar(jar); + requireSourceDirectory(sources); + recreate(staging); + Files.copy(jar, staging.resolve(artifactName(metadata, version, jarSuffix))); + Files.writeString(staging.resolve(artifactName(metadata, version, pomSuffix)), + pom(metadata, version)); + sourcesJar(metadata, staging, version, sources, resources); + javadocJar(metadata, staging, version, sources, classpath); + return staging; +} + +/// The jar is copied to Central under `-.jar`, but the copy alone does not +/// make the jar agree with that name: the workflow stamps `version.txt` in a single step +/// ahead of the build, and a run started by hand skips it entirely. A published version +/// is immutable, so a jar whose banner and `Implementation-Version` name a version that +/// resolves in no repository cannot be corrected afterwards - it is refused here, where +/// the check no longer depends on a step in one workflow file. +void requireJarVersion(Path jar, String version, String locations) throws Exception { + var built = jarVersion(jar); + if (built.filter(version::equals).isPresent()) { + return; + } + throw new IllegalStateException( + "%s carries version [%s] but would be published as [%s]: write the version to %s, rebuild %s, then publish again" + .formatted(jar, built.orElse("none"), version, locations, jar)); +} + +/// `version.txt` is what zb packages and what its startup banner reads back; +/// `Implementation-Version` is stamped from that same file and is the fallback for a jar +/// packaged without the resource. +Optional jarVersion(Path jar) throws Exception { + try (var archive = new ZipFile(jar.toFile())) { + var entry = archive.getEntry(versionFileName); + if (entry == null) { + return manifestVersion(archive); + } + try (var content = archive.getInputStream(entry)) { + return version(new String(content.readAllBytes(), StandardCharsets.UTF_8)); + } + } catch (IOException unreadable) { + throw new IllegalStateException( + "%s is not a readable jar: rebuild %s, then publish again".formatted(jar, jar)); + } +} + +Optional manifestVersion(ZipFile archive) throws Exception { + var entry = archive.getEntry("META-INF/MANIFEST.MF"); + if (entry == null) { + return Optional.empty(); + } + try (var content = archive.getInputStream(entry)) { + return version(new Manifest(content).getMainAttributes().getValue("Implementation-Version")); + } +} + +Optional version(String value) { + return Optional.ofNullable(value).map(String::strip).filter(Predicate.not(String::isEmpty)); +} + +void requireBuiltJar(Path jar) { + if (Files.isRegularFile(jar)) { + return; + } + throw new IllegalStateException( + "%s not found: build %s first, then publish".formatted(jar, jar)); +} + +/// The staging tree is wiped rather than merged into: the bundle zip packages whatever +/// is on disk, so an artifact left behind by a previous version would be uploaded with +/// the current one. +void recreate(Path directory) throws Exception { + deleteTree(directory); + Files.createDirectories(directory); +} + +void deleteTree(Path directory) throws Exception { + if (!Files.exists(directory)) { + return; + } + try (var tree = Files.walk(directory)) { + for (var path : tree.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } +} + +List checksumAlgorithms = List.of("MD5", "SHA-1"); +String signatureSuffix = ".asc"; + +/// Every staged artifact is checksummed, but a checksum and a signature never are: +/// `app-1.0.0.jar.md5.md5` is not part of the Maven repository layout, and the step is +/// re-run over a directory that already holds the files it produced. +void checksum(Path staging) throws Exception { + var artifacts = artifacts(staging); + for (var artifact : artifacts) { + writeChecksums(artifact); + } + IO.println("%s: checksummed %d artifacts".formatted(name, artifacts.size())); +} + +/// The artifacts Central deploys, as opposed to the checksums and signatures derived +/// from them. Signing and checksumming select the same files, and both run over a +/// directory that already holds what they produced. +List artifacts(Path directory) throws Exception { + try (var files = Files.list(directory)) { + return files.filter(Files::isRegularFile) + .filter(this::isArtifact) + .sorted() + .toList(); + } +} + +boolean isArtifact(Path file) { + var fileName = file.getFileName().toString(); + return derivedSuffixes().stream().noneMatch(fileName::endsWith); +} + +List derivedSuffixes() { + return Stream.concat(checksumAlgorithms.stream().map(this::checksumSuffix), Stream.of(signatureSuffix)) + .toList(); +} + +/// The Portal generates md5 and sha1 when they are absent, so writing them is +/// belt-and-braces rather than mandatory. That is also why sha256 and sha512 are left +/// out: more files to sign and upload for no guarantee the mirror does not already give. +void writeChecksums(Path file) throws Exception { + for (var algorithm : checksumAlgorithms) { + Files.writeString(checksumFile(file, algorithm), digest(file, algorithm)); + } +} + +/// `SHA-1` names the file `.sha1` — the repository layout spells the extension without +/// the algorithm's hyphen. +String checksumSuffix(String algorithm) { + return ".%s".formatted(algorithm.toLowerCase(Locale.ROOT).replace("-", "")); +} + +Path checksumFile(Path file, String algorithm) { + return file.resolveSibling(file.getFileName().toString() + checksumSuffix(algorithm)); +} + +String digest(Path file, String algorithm) throws Exception { + return hex(MessageDigest.getInstance(algorithm).digest(Files.readAllBytes(file))); +} + +/// Lowercase hex with no trailing newline — byte for byte what `mvn deploy` writes, and +/// what a mirror's integrity check compares against. +String hex(byte[] digest) { + return HexFormat.of().formatHex(digest); +} + +String keyIdEnvironmentVariable = "ZPUBLISH_GPG_KEY_ID"; +String passphraseEnvironmentVariable = "ZPUBLISH_GPG_PASSPHRASE"; + +/// The passphrase is deliberately not a component of any printed representation: a +/// stack trace or a stray debug print would otherwise leak it into CI logs. +record Signer(String keyId, String passphrase) { + + @Override + public String toString() { + return "Signer[keyId=%s]".formatted(this.keyId); + } +} + +/// Central rejects a deployment whose artifacts carry no detached signature, so a real +/// publish fails here rather than after the upload, while a dry-run still produces the +/// complete staging tree and says which files went unsigned. +boolean sign(Path staging, boolean dryRun) throws Exception { + return sign(staging, dryRun, configuredSigner()); +} + +/// Split out so [#publish] can resolve the key before it stages anything: an unset secret is +/// known at second zero and must not be reported after javadoc has run. +Optional configuredSigner() { + return signer(System.getenv(keyIdEnvironmentVariable), + System.getenv(passphraseEnvironmentVariable)); +} + +boolean sign(Path staging, boolean dryRun, Optional signer) throws Exception { + if (signer.isEmpty()) { + return skipSigning(dryRun); + } + var key = signer.get(); + var artifacts = artifacts(staging); + for (var artifact : artifacts) { + sign(artifact, key.keyId(), key.passphrase()); + } + IO.println("%s: signed %d artifacts with %s".formatted(name, artifacts.size(), key.keyId())); + return true; +} + +/// A CI system routinely exports an empty string for an unset secret, so a blank value +/// counts as absent rather than as a key that gpg would reject much later. +Optional signer(String keyId, String passphrase) { + if (isBlank(keyId) || isBlank(passphrase)) { + return Optional.empty(); + } + return Optional.of(new Signer(keyId.strip(), passphrase)); +} + +boolean isBlank(String value) { + return value == null || value.isBlank(); +} + +boolean skipSigning(boolean dryRun) { + if (!dryRun) { + throw new IllegalStateException( + "no GPG key configured: set %s and %s before publishing, or re-run with -dry-run" + .formatted(keyIdEnvironmentVariable, passphraseEnvironmentVariable)); + } + IO.println("%s: signing skipped, %s".formatted(name, unsetSigningVariables())); + return false; +} + +/// [#signer(String,String)] disables signing when *either* value is blank, so the skip +/// message reports which one actually is. Naming both would tell an operator who set the +/// key id and forgot the passphrase that the variable they just exported is unset. +String unsetSigningVariables() { + var unset = new ArrayList(); + if (isBlank(System.getenv(keyIdEnvironmentVariable))) { + unset.add(keyIdEnvironmentVariable); + } + if (isBlank(System.getenv(passphraseEnvironmentVariable))) { + unset.add(passphraseEnvironmentVariable); + } + if (unset.isEmpty()) { + return "%s and %s are set but signing was not configured for this run" + .formatted(keyIdEnvironmentVariable, passphraseEnvironmentVariable); + } + return "%s %s unset".formatted(String.join(" and ", unset), unset.size() == 1 ? "is" : "are"); +} + +/// gpg writes the armored signature to `.asc` beside the artifact, which is where +/// the Maven repository layout expects it. +/// +/// The passphrase is terminated with a literal `\n` rather than [System#lineSeparator()]: +/// gpg reads `--passphrase-fd 0` up to the first newline and keeps whatever precedes it, +/// so the platform separator would make the trailing `\r` part of the passphrase on +/// Windows and fail every signature with a bad-passphrase error. +void sign(Path file, String keyId, String passphrase) throws Exception { + var gpg = gpg(gpgCommand(file, keyId)); + try (var stdin = gpg.getOutputStream()) { + stdin.write((passphrase + "\n").getBytes(StandardCharsets.UTF_8)); + } + var output = new String(gpg.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var status = gpg.waitFor(); + if (status != 0) { + throw new IllegalStateException( + "gpg failed with exit code %d for %s:%n%s".formatted(status, file, output.strip())); + } +} + +/// `--passphrase-fd 0` is what keeps the passphrase off the command line, where `ps` +/// would show it to every user on the machine, and out of the environment gpg inherits. +/// `--pinentry-mode loopback` is what makes gpg accept it there on a headless runner. +/// A gpg that is not on the `PATH` fails in [ProcessBuilder#start()] with an `error=2` +/// [IOException], which would reach the operator as a stack trace — and only after +/// staging and javadoc, the slowest part of a run. Named here like every other +/// prerequisite instead. +Process gpg(List command) throws Exception { + try { + return new ProcessBuilder(command).redirectErrorStream(true).start(); + } catch (IOException missing) { + throw new IllegalStateException( + "gpg is not available: install gnupg and put it on the PATH, or unset %s and %s to stage without signatures" + .formatted(keyIdEnvironmentVariable, passphraseEnvironmentVariable)); + } +} + +/// A field rather than a literal so the selftest can point the signing loop at a stub and +/// exercise it — artifact selection, the passphrase on stdin, the exit-code check and the +/// missing-executable message — without a GPG key on the machine running the tests. +String gpgExecutable = "gpg"; + +List gpgCommand(Path file, String keyId) { + return List.of(gpgExecutable, "--batch", "--yes", "--pinentry-mode", "loopback", + "--passphrase-fd", "0", + "--local-user", keyId, "--detach-sign", "--armor", file.toString()); +} + +Path signatureFile(Path file) { + return file.resolveSibling(file.getFileName().toString() + signatureSuffix); +} + +Path bundle() throws Exception { + var bundle = bundle(stagingRoot, bundleFile); + IO.println("%s: bundled %s (%d bytes)".formatted(name, bundle, Files.size(bundle))); + return bundle; +} + +/// The upload is a single zip whose entries mirror the Maven repository layout, so it is +/// rooted at the staging root and its first path segment is `com/` — not the version +/// directory `stage` returns. Central resolves the coordinate from those entry names +/// alone. +Path bundle(Path staging, Path target) throws Exception { + requireStagedArtifacts(staging); + Files.createDirectories(parentOf(target)); + try (var zip = new ZipOutputStream(Files.newOutputStream(target))) { + addTree(zip, staging); + } + return target; +} + +/// A zip with no entries is a structurally valid zip, so nothing before the upload would +/// catch it: Central would answer with a validation failure minutes later. Failing here +/// keeps the diagnosis local. +void requireStagedArtifacts(Path staging) throws Exception { + if (Files.isDirectory(staging) && !files(staging).isEmpty()) { + return; + } + throw new IllegalStateException( + "%s holds no staged artifacts: an empty bundle has nothing to publish".formatted(staging)); +} + +List requiredSuffixes = List.of(jarSuffix, pomSuffix, sourcesSuffix, javadocSuffix); + +/// The dry-run's oracle: the staging tree exactly as it goes into the bundle, so a +/// missing signature or checksum shows up on the developer's machine instead of in a +/// Central validation failure minutes after the upload. +void report(Path staging) throws Exception { + IO.println(stagingReport(staging)); +} + +/// One line per deployable artifact. The checksums and the signature get no line of +/// their own — they are reported as the accompaniment of the artifact they belong to, +/// which is how Central relates them too. +String stagingReport(Path staging) throws Exception { + var lines = new ArrayList(); + lines.add("%s: staging %s".formatted(name, staging)); + var staged = artifacts(staging); + var line = " %%-%ds %%9d bytes %%s".formatted(nameColumnWidth(staged)); + for (var artifact : staged) { + lines.add(line.formatted(artifact.getFileName(), Files.size(artifact), + accompaniment(artifact))); + } + return String.join(System.lineSeparator(), lines); +} + +/// Wide enough for the longest artifact name in this staging tree, so the size and +/// accompaniment columns line up whatever the coordinates are: a long `artifactId` and a +/// `-javadoc` classifier together outrun any fixed width, and the lines that overflow it +/// are exactly the ones a reader compares against the shorter ones. The 32 is a floor, +/// not a width - short names keep the roomier layout instead of being packed tight. +int nameColumnWidth(List staged) { + return staged.stream() + .map(artifact -> artifact.getFileName().toString().length()) + .reduce(minimumNameColumn, Math::max); +} + +int minimumNameColumn = 32; + +/// `asc md5 sha1` when all three are on disk; an absent one renders as dashes of the +/// same width, so the columns stay readable down the whole report. +String accompaniment(Path artifact) { + return derivedFiles(artifact).stream() + .map(this::presence) + .collect(Collectors.joining(" ")); +} + +String presence(Path file) { + var marker = extension(file); + if (Files.isRegularFile(file)) { + return marker; + } + return "-".repeat(marker.length()); +} + +String extension(Path file) { + var fileName = file.getFileName().toString(); + return fileName.substring(fileName.lastIndexOf('.') + 1); +} + +List derivedFiles(Path artifact) { + return Stream.concat(Stream.of(signatureFile(artifact)), checksumFiles(artifact).stream()).toList(); +} + +List checksumFiles(Path artifact) { + return checksumAlgorithms.stream() + .map(algorithm -> checksumFile(artifact, algorithm)) + .toList(); +} + +/// Central validates the deployment only after it has been uploaded, and rejects the +/// whole bundle over a single missing file. The same requirement set is therefore checked +/// here, where the diagnosis is local and the fix is a re-run. +void validate(Metadata metadata, Path staging, boolean signed) throws Exception { + var missing = missing(metadata, staging, signed); + if (!missing.isEmpty()) { + throw new IllegalStateException( + "the staged deployment is incomplete: %s".formatted(String.join(", ", missing))); + } + IO.println("%s: validated %d artifacts%s".formatted(name, requiredSuffixes.size(), unsignedNote(signed))); +} + +/// Signing is skipped only by a dry-run with no key configured, and that run uploads +/// nothing — the report says so rather than passing the tree off as Central-valid. +String unsignedNote(boolean signed) { + if (signed) { + return ", Central-valid"; + } + return ", unsigned - a real publish requires signatures"; +} + +/// The four artifacts Central requires, each accompanied by its checksums and — unless +/// signing was skipped — its detached signature, plus any derived file left behind by an +/// artifact that is no longer there. +List missing(Metadata metadata, Path staging, boolean signed) throws Exception { + var version = stagedVersion(staging); + var missing = new ArrayList(); + for (var suffix : requiredSuffixes) { + var artifact = staging.resolve(artifactName(metadata, version, suffix)); + if (Files.isRegularFile(artifact)) { + missing.addAll(missingDerived(artifact, signed)); + continue; + } + missing.add(artifact.getFileName().toString()); + } + missing.addAll(orphans(staging)); + return missing; +} + +List missingDerived(Path artifact, boolean signed) { + return required(artifact, signed).stream() + .filter(file -> !Files.isRegularFile(file)) + .map(file -> file.getFileName().toString()) + .toList(); +} + +List required(Path artifact, boolean signed) { + if (signed) { + return derivedFiles(artifact); + } + return checksumFiles(artifact); +} + +/// A signature or checksum whose artifact is gone is the symptom of a half-written +/// staging tree: the bundle would carry proof for a file Central never receives. +List orphans(Path staging) throws Exception { + return files(staging).stream() + .filter(this::isOrphan) + .map(file -> "%s has no artifact".formatted(file.getFileName())) + .toList(); +} + +boolean isOrphan(Path file) { + return !isArtifact(file) && !Files.isRegularFile(artifactOf(file)); +} + +/// `app-1.0.0.jar.asc` derives from `app-1.0.0.jar`: the derived suffix is always the last +/// extension, so dropping it names the artifact the file belongs to. +Path artifactOf(Path derived) { + var fileName = derived.getFileName().toString(); + return derived.resolveSibling(fileName.substring(0, fileName.lastIndexOf('.'))); +} + +/// The staging layout ends in `//`, so the version the +/// report and the validator need is the name of the directory they are pointed at. +String stagedVersion(Path staging) { + return staging.getFileName().toString(); +} + +String uploadEndpoint = "https://central.sonatype.com/api/v1/publisher/upload"; +String statusEndpoint = "https://central.sonatype.com/api/v1/publisher/status"; +String portalDeployments = "https://central.sonatype.com/publishing/deployments"; +String tokenUserEnvironmentVariable = "CENTRAL_TOKEN_USERNAME"; +String tokenPasswordEnvironmentVariable = "CENTRAL_TOKEN_PASSWORD"; +String userManagedPublishing = "USER_MANAGED"; +String automaticPublishing = "AUTOMATIC"; +String validatedState = "VALIDATED"; +String publishingState = "PUBLISHING"; +String publishedState = "PUBLISHED"; +String failedState = "FAILED"; +Duration connectTimeout = Duration.ofSeconds(30); +Duration uploadTimeout = Duration.ofMinutes(10); +Duration statusTimeout = Duration.ofSeconds(60); +Duration pollInterval = Duration.ofSeconds(5); +Duration userManagedPollCeiling = Duration.ofMinutes(5); +Duration automaticPollCeiling = Duration.ofMinutes(30); + +/// `USER_MANAGED` leaves the deployment sitting in the Portal for a human to release, +/// which is the only reversible option: an `AUTOMATIC` deployment reaches Maven Central +/// on its own and cannot be taken back. +String publishingType(List options) { + if (options.contains("-automatic")) { + return automaticPublishing; + } + return userManagedPublishing; +} + +/// The Portal lists deployments under this name, so it carries the version a maintainer +/// has to recognise when releasing a `USER_MANAGED` deployment by hand. +String deploymentName(Metadata metadata, String version) { + return "%s-%s".formatted(metadata.artifactId(), version); +} + +/// The Portal API takes the *user token* — not the account password — as HTTP Basic +/// credentials, but under the `Bearer` scheme rather than `Basic`. Sending `Basic` is +/// answered with 401, which is the single most common way this call is got wrong. +String authorization(String user, String password) { + var token = "%s:%s".formatted(user, password); + return "Bearer " + Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)); +} + +String authorization() { + return credentials(System.getenv(tokenUserEnvironmentVariable), + System.getenv(tokenPasswordEnvironmentVariable)); +} + +/// Read before the bundle is offered to the network, so a run with no credentials fails +/// in a second rather than after a multi-megabyte upload. A blank value counts as absent +/// for the same reason it does in [#signer]: CI exports an empty string for an unset +/// secret. +String credentials(String user, String password) { + if (isBlank(user) || isBlank(password)) { + throw new IllegalStateException( + "no Central credentials: set %s and %s to a Portal user token, or re-run with -dry-run" + .formatted(tokenUserEnvironmentVariable, tokenPasswordEnvironmentVariable)); + } + return authorization(user.strip(), password); +} + +String multipartBoundary = "zpublishBundleBoundary"; +String bundlePartName = "bundle"; + +/// A boundary occurring inside the payload ends the part where the zip has not ended, and +/// Central receives a truncated bundle - the one upload failure that is neither reported by +/// this tool nor obvious in the Portal. A deflated zip carrying these exact ASCII bytes is +/// not a realistic accident, but the scan runs over bytes already read into memory and buys +/// the property outright. +/// +/// Grown by suffix rather than randomised: a random boundary is the usual answer and would +/// make this the one part of the request the selftest could not state an expected value for. +/// The loop terminates because a payload of n bytes holds at most n distinct substrings. +String boundaryFor(byte[] content) { + var boundary = multipartBoundary; + for (var attempt = 0; occurs(content, boundary); attempt++) { + boundary = "%s%d".formatted(multipartBoundary, attempt); + } + return boundary; +} + +/// The boundary is ASCII by construction, so the candidate is compared byte by byte rather +/// than by decoding the zip into a `String` - which would replace every undecodable byte and +/// answer about a payload that is not the one being sent. +boolean occurs(byte[] content, String candidate) { + var bytes = candidate.getBytes(StandardCharsets.US_ASCII); + for (var start = 0; start <= content.length - bytes.length; start++) { + if (Arrays.equals(content, start, start + bytes.length, bytes, 0, bytes.length)) { + return true; + } + } + return false; +} + +String multipartContentType(String boundary) { + return "multipart/form-data; boundary=" + boundary; +} + +/// Hand-assembled because `HttpClient` ships no multipart publisher and pulling in a +/// library for one part would cost more than the whole script. `ofByteArrays` over +/// `[preamble, zip, epilogue]` is what keeps the zip's bytes untouched between the two +/// text parts — building one big `String` would corrupt them. +List multipart(String boundary, String fileName, byte[] content) { + return List.of(multipartPreamble(boundary, fileName).getBytes(StandardCharsets.UTF_8), + content, + multipartEpilogue(boundary).getBytes(StandardCharsets.UTF_8)); +} + +/// The CRLFs are not cosmetic: RFC 7578 delimits parts by `CRLF--boundary`, and with a +/// bare `\n` Central reads the headers as the first bytes of the file. +String multipartPreamble(String boundary, String fileName) { + return """ + --%s\r + Content-Disposition: form-data; name="%s"; filename="%s"\r + Content-Type: application/octet-stream\r + \r + """.formatted(boundary, bundlePartName, fileName); +} + +String multipartEpilogue(String boundary) { + return "\r\n--%s--\r\n".formatted(boundary); +} + +/// A successful upload answers `201` with the deployment id as **plain text** — a bare +/// UUID, not JSON. Parsing it as JSON is the classic way to break this call, so the id is +/// matched against the UUID shape instead: an error page or an HTML redirect would +/// otherwise be carried on as a deployment id and polled for five minutes. +String upload(Path bundle, String authorization, String deploymentName, String publishingType) + throws Exception { + var uri = URI.create("%s?name=%s&publishingType=%s" + .formatted(uploadEndpoint, encoded(deploymentName), encoded(publishingType))); + var content = Files.readAllBytes(bundle); + var boundary = boundaryFor(content); + var request = HttpRequest.newBuilder(uri) + .header("Authorization", authorization) + .header("Content-Type", multipartContentType(boundary)) + .timeout(uploadTimeout) + .POST(HttpRequest.BodyPublishers.ofByteArrays(multipart(boundary, + bundle.getFileName().toString(), content))) + .build(); + return requireDeploymentId(body(http().send(request, HttpResponse.BodyHandlers.ofString()), + "upload")); +} + +/// A dropped connection or an expired [#uploadTimeout] arrives as an [IOException] only +/// after the whole bundle has gone out, so the deployment may well be sitting in the +/// Portal even though its answer was lost — unlike a poll, this call cannot simply be +/// repeated: a blind retry either duplicates the deployment or fails against a version +/// that is already published. Left to [#main] the same event would land as a bare stack +/// trace, which says nothing about which of the two happened. Naming the one action that +/// is always right first is what turns it into an instruction. +String uploaded(Path bundle, String authorization, String deploymentName, String publishingType) + throws Exception { + try { + return upload(bundle, authorization, deploymentName, publishingType); + } catch (IOException lost) { + throw new IllegalStateException(""" + the upload did not complete (%s), and the bundle may still have reached \ + Central. Check %s before retrying: a deployment that is already there has \ + to be dropped in the Portal first, and a published version can never be \ + republished.""".formatted(lost, portalDeployments)); + } +} + +Pattern deploymentIdPattern = + Pattern.compile("[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", Pattern.CASE_INSENSITIVE); + +String requireDeploymentId(String body) { + var deploymentId = body.strip(); + if (deploymentIdPattern.matcher(deploymentId).matches()) { + return deploymentId; + } + throw new IllegalStateException( + "the upload did not answer with a deployment id:%n%s".formatted(deploymentId)); +} + +/// Polls until Central settles the deployment. `USER_MANAGED` stops at `VALIDATED`, where +/// the deployment waits in the Portal for a human to release it; `-automatic` runs on to +/// `PUBLISHED`. +/// +/// `PUBLISHED` ends the wait in **both** modes, and observing `PUBLISHING` raises the +/// ceiling to the `-automatic` one: a human who releases the deployment in the Portal +/// while this loop is still polling pushes a `USER_MANAGED` run past `VALIDATED`, and +/// insisting on the exact target state there would fail the run for the one reason it +/// exists to produce. +void awaitState(String deploymentId, String authorization, String publishingType, + Metadata metadata, String mavenVersion) throws Exception { + var success = successState(publishingType); + var ceiling = pollCeiling(publishingType); + var start = System.nanoTime(); + var state = "unknown"; + while (System.nanoTime() - start < ceiling.toNanos()) { + var body = status(deploymentId, authorization); + if (body == null) { + Thread.sleep(pollInterval); + continue; + } + state = deploymentState(body); + if (success.equals(state) || publishedState.equals(state)) { + IO.println("%s: deployment %s reached %s".formatted(name, deploymentId, state)); + IO.println(releaseNote(state)); + return; + } + if (failedState.equals(state)) { + throw deploymentFailure(metadata, body, mavenVersion); + } + if (publishingState.equals(state)) { + ceiling = automaticPollCeiling; + } + IO.println("%s: deployment %s is %s".formatted(name, deploymentId, state)); + Thread.sleep(pollInterval); + } + throw new IllegalStateException("deployment %s was still %s after %d minutes: it is not lost, " + .formatted(deploymentId, state, ceiling.toMinutes()) + + "check %s".formatted(portalDeployments)); +} + +/// A `USER_MANAGED` run is green the moment Central validates the bundle, but nothing is +/// on Maven Central until a human releases the deployment in the Portal. Saying so on the +/// success path is what keeps a passing workflow from reading as a finished publish. +/// +/// Keyed on the state Central actually reported rather than on the mode that was asked +/// for: a `USER_MANAGED` deployment released by hand mid-poll ends at `PUBLISHED`, and +/// telling its operator to go and release it would be exactly backwards. +String releaseNote(String state) { + if (publishedState.equals(state)) { + return "%s: the deployment is released to Maven Central".formatted(name); + } + return "%s: nothing is on Maven Central yet - release the deployment by hand at %s" + .formatted(name, portalDeployments); +} + +String successState(String publishingType) { + if (automaticPublishing.equals(publishingType)) { + return publishedState; + } + return validatedState; +} + +/// A `USER_MANAGED` run settles at `VALIDATED`, which Central reaches in seconds. An +/// `AUTOMATIC` run has to wait out `PUBLISHING` as well - the push to the Central +/// repository itself, which regularly runs far longer. Giving both the same ceiling +/// would report an `-automatic` publish that in fact succeeded as a failure, and a +/// published version is immutable: the run cannot be repeated to find out. +Duration pollCeiling(String publishingType) { + if (automaticPublishing.equals(publishingType)) { + return automaticPollCeiling; + } + return userManagedPollCeiling; +} + +/// `POST`, not `GET` — the status endpoint takes its id as a query parameter but is +/// documented and implemented as a POST, and a GET is answered with 405. +/// +/// Returns `null` for a retryable answer. By the time this is polled the bundle is +/// already uploaded, so a single 503 from an edge proxy must not end the run and report +/// a deployment that is actually in flight as a failure. A 401 or a 400 still throws: +/// those do not become true by asking again. +/// +/// A dropped connection and an expired [#statusTimeout] arrive as an [IOException] rather +/// than as a status code, and they are the more common shape of the same event, so they +/// retry too — otherwise the per-request timeout would end a run whose bundle is already +/// in flight, which is exactly what the ceiling below exists to avoid. +String status(String deploymentId, String authorization) throws Exception { + var uri = URI.create("%s?id=%s".formatted(statusEndpoint, encoded(deploymentId))); + var request = HttpRequest.newBuilder(uri) + .header("Authorization", authorization) + .timeout(statusTimeout) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + HttpResponse response; + try { + response = http().send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException unreachable) { + IO.println("%s: status query failed with %s, retrying".formatted(name, unreachable)); + return null; + } + if (isRetryable(response.statusCode())) { + IO.println("%s: status query answered HTTP %d, retrying" + .formatted(name, response.statusCode())); + return null; + } + return body(response, "status query"); +} + +List retryableStatusCodes = List.of(408, 429, 500, 502, 503, 504); + +boolean isRetryable(int statusCode) { + return retryableStatusCodes.contains(statusCode); +} + +Pattern deploymentStatePattern = Pattern.compile("\"deploymentState\"\\s*:\\s*\"([A-Z_]+)\""); + +/// A narrow regex rather than a JSON parser: the status response is a flat object and +/// `deploymentState` is the only field this script reads, so copying `/zjson` into the +/// tree for one string would cost more than it buys. The trade-off is deliberate — and it +/// is why an unmatched body throws instead of yielding an empty state, which the caller +/// would poll on until the ceiling and then report as a timeout it never was. +String deploymentState(String body) { + var matcher = deploymentStatePattern.matcher(body); + if (matcher.find()) { + return matcher.group(1); + } + throw new IllegalStateException( + "the status response carries no deploymentState:%n%s".formatted(body.strip())); +} + +Pattern duplicateVersionPattern = + Pattern.compile("already\\s+(?:been\\s+)?(?:published|exist)", Pattern.CASE_INSENSITIVE); + +/// Matched on the rejection text rather than on a status code, because Central answers a +/// duplicate with a perfectly ordinary `FAILED` state and buries the reason in `errors`. +/// The pattern is deliberately wider than any single observed wording: getting this +/// slightly wrong costs an unexplained failure message, not a wrong publish. +boolean isDuplicateVersion(String body) { + return duplicateVersionPattern.matcher(body).find(); +} + +/// Re-running the workflow for a tag that is already out is the failure a maintainer will +/// actually hit, and the raw `errors` array does not say what to do about it. Naming the +/// coordinate and the immutability turns "the deployment failed" into an instruction. +IllegalStateException deploymentFailure(Metadata metadata, String body, String mavenVersion) { + if (isDuplicateVersion(body)) { + return new IllegalStateException(""" + %s:%s:%s is already published: Maven Central versions are immutable and can \ + neither be replaced nor deleted. Publish a new version instead. + %s""".formatted(metadata.groupId(), metadata.artifactId(), mavenVersion, + body.strip())); + } + return new IllegalStateException("the deployment failed:%n%s".formatted(body.strip())); +} + +String encoded(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); +} + +/// Every non-2xx answer is surfaced with its body: Central explains a rejected namespace +/// or an expired token there, and a bare status code would send the reader to the Portal +/// UI to find out why. Only the response is printed — the request, and with it the +/// `Authorization` header, never is. +String body(HttpResponse response, String step) { + if (response.statusCode() / 100 != 2) { + throw new IllegalStateException("Central refused the %s with HTTP %d:%n%s" + .formatted(step, response.statusCode(), response.body().strip())); + } + return response.body(); +} + +HttpClient client; + +/// Built on first use: `-help`, `-version`, `-selftest` and `-dry-run` never touch the +/// network, and an eagerly created client would start its executor threads for all of +/// them. +HttpClient http() { + if (this.client == null) { + this.client = HttpClient.newBuilder() + .connectTimeout(connectTimeout) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + return this.client; +} + +int assertions = 0; +List failures = new ArrayList<>(); + +/// Everything above this line is production. Everything below it is the selftest, and the +/// two meet only here: the record the assertions run on is written out in full rather than +/// resolved, so no file on the machine running the tests can move an outcome. +/// +/// It carries this project's own metadata because the assertions below name `com.airhacks`, +/// `zb` and `zb-1.0.0.jar` - a fixture with invented coordinates would prove the same +/// threading while making every expected value unreadable. +Metadata testMetadata = new Metadata("com.airhacks", "zb", "zb", + "Zero Dependencies Builder - compiles Java 25 projects and packages executable JARs with no external dependencies.", + "https://github.com/AdamBien/zb", + Optional.empty(), + "MIT License", Optional.of("https://github.com/AdamBien/zb/blob/main/LICENSE"), + "AdamBien", "Adam Bien", Optional.empty(), Optional.of("https://airhacks.com"), + "scm:git:https://github.com/AdamBien/zb.git", + Optional.of("scm:git:git@github.com:AdamBien/zb.git"), Optional.empty(), + "https://github.com/AdamBien/zb"); + +/// The production signatures all take the record first. These bind [#testMetadata] to it so +/// an assertion states the version and the suffix it is about and nothing else; the ones +/// that are *about* the record pass it explicitly instead. +String artifactName(String version, String suffix) { + return artifactName(testMetadata, version, suffix); +} + +String pom(String version) { + return pom(testMetadata, version); +} + +String deploymentName(String version) { + return deploymentName(testMetadata, version); +} + +IllegalStateException deploymentFailure(String body, String mavenVersion) { + return deploymentFailure(testMetadata, body, mavenVersion); +} + +Path sourcesJar(Path staging, String version, Path sources, Path resources) throws Exception { + return sourcesJar(testMetadata, staging, version, sources, resources); +} + +Path javadocJar(Path staging, String version, Path sources) throws Exception { + return javadocJar(staging, version, sources, List.of()); +} + +Path javadocJar(Path staging, String version, Path sources, List classpath) throws Exception { + return javadocJar(testMetadata, staging, version, sources, classpath); +} + +Path stage(Path staging, String version, Path jar, Path sources, Path resources) throws Exception { + return stage(testMetadata, staging, version, jar, sources, resources, List.of()); +} + +List missing(Path staging, boolean signed) throws Exception { + return missing(testMetadata, staging, signed); +} + +void validate(Path staging, boolean signed) throws Exception { + validate(testMetadata, staging, signed); +} + +/// The tests live inside the script because a repo-root single-file tool has no home in +/// `src/test/java`, so zunit cannot reach it. `-selftest` keeps them zero-dependency, +/// single-file and runnable in CI. +void selftest() throws Exception { + try { + resolveVersionAssertions(); + optionAssertions(); + configurationAssertions(); + buildConfigurationAssertions(); + metadataAssertions(); + configuredMetadataAssertions(); + stagingDirectoryAssertions(); + pomAssertions(); + sourcesJarAssertions(); + javadocJarAssertions(); + jarVersionAssertions(); + stageAssertions(); + checksumAssertions(); + signAssertions(); + bundleAssertions(); + reportAssertions(); + uploadAssertions(); + portalAssertions(); + lostUploadAssertions(); + publishAssertions(); + messageAssertions(); + } finally { + dropTemporaryDirectories(); + } + summary(); +} + +List temporaryDirectories = new ArrayList<>(); + +/// Registered rather than created directly, so the trees are dropped again: the javadoc +/// assertions alone generate three complete documentation sites per run, and the rule +/// [#javadocJar(Path,String,Path)] states for the production path holds here too. +Path temporaryDirectory(String prefix) throws Exception { + var directory = Files.createTempDirectory(prefix); + this.temporaryDirectories.add(directory); + return directory; +} + +void dropTemporaryDirectories() throws Exception { + for (var directory : this.temporaryDirectories) { + deleteTree(directory); + } + this.temporaryDirectories.clear(); +} + +void resolveVersionAssertions() throws Exception { + var directory = temporaryDirectory("zpublish-selftest"); + var root = directory.resolve("project"); + Files.createDirectories(root); + var file = root.resolve("version.txt"); + Files.writeString(file, " 2026.08.16.01\n"); + var empty = new String[0]; + assertEquals("2026.01.01.1", resolvedVersion(args("-version-string", "2026.01.01.1"), "9.9.9", root), + "argument beats environment and file"); + assertEquals("2026.02.02.2", resolvedVersion(empty, "2026.02.02.2", root), + "environment beats file"); + assertEquals("2026.08.16.01", resolvedVersion(empty, null, root), + "file is the fallback and is trimmed"); + assertEquals("2026.08.16.01", resolvedVersion(empty, " ", root), + "blank environment value falls through to the file"); + assertEquals("2026.03.03.3", resolvedVersion(args("-version-string", " 2026.03.03.3 "), null, root), + "argument is trimmed"); + var missing = directory.resolve("bare"); + Files.createDirectories(missing); + assertFailure("no version available", "missing version file is reported", + () -> resolvedVersion(empty, null, missing)); + Files.writeString(file, " \n"); + assertFailure("no version available", "blank version file is reported", + () -> resolvedVersion(empty, null, root)); + assertFailure("without a version", "blank -version-string is rejected", + () -> resolvedVersion(args("-version-string", " "), null, missing)); + assertFailure("without a version", "dangling -version-string is rejected", + () -> resolvedVersion(args("-version-string"), null, missing)); + versionFileAssertions(); + mavenVersionAssertions(root, missing); +} + +/// The nominal resource root, so a call site reads like the production one without +/// spelling the second rung of the probe out every time. +String resolvedVersion(String[] args, String environmentVersion, Path root) throws Exception { + return resolveVersion(args, environmentVersion, root, root.resolve("src/main/resources")); +} + +/// zb prefers a root-level `version.txt` over the packaged one, and a publisher that +/// disagreed would stage a jar under a version zb never wrote into it. +void versionFileAssertions() throws Exception { + var directory = temporaryDirectory("zpublish-version-file-selftest"); + var both = directory.resolve("both"); + var bothResources = both.resolve("src/main/resources"); + Files.createDirectories(bothResources); + Files.writeString(both.resolve("version.txt"), "1.0.0"); + Files.writeString(bothResources.resolve("version.txt"), "2.0.0"); + assertEquals(Optional.of(both.resolve("version.txt")), versionFile(both, bothResources), + "the project root wins over the resource root"); + assertEquals("1.0.0", resolvedVersion(new String[0], null, both), + "the root version is the one published when both exist"); + var packaged = directory.resolve("packaged"); + var packagedResources = packaged.resolve("src/main/resources"); + Files.createDirectories(packagedResources); + Files.writeString(packagedResources.resolve("version.txt"), "3.0.0"); + assertEquals(Optional.of(packagedResources.resolve("version.txt")), + versionFile(packaged, packagedResources), + "the resource root is the second rung of the probe"); + assertEquals("3.0.0", resolvedVersion(new String[0], null, packaged), + "a project keeping the version only under resources still publishes"); + var neither = directory.resolve("neither"); + Files.createDirectories(neither); + assertEquals(Optional.empty(), versionFile(neither, neither.resolve("src/main/resources")), + "neither location present reports nothing rather than a path that is not there"); + var nominal = neither.resolve("src/main/resources"); + assertEquals("%s or %s".formatted(neither.resolve("version.txt"), nominal.resolve("version.txt")), + versionFileLocations(neither, nominal), "both probed locations are named"); + assertFailure(neither.resolve("version.txt").toString(), + "the not-found message names the project root", + () -> resolvedVersion(new String[0], null, neither)); + assertFailure(nominal.resolve("version.txt").toString(), + "the not-found message names the resource root", + () -> resolvedVersion(new String[0], null, neither)); + var directories = directory.resolve("directories"); + Files.createDirectories(directories.resolve("version.txt")); + assertEquals(Optional.empty(), + versionFile(directories, directories.resolve("src/main/resources")), + "a directory named version.txt is not a version file"); +} + +/// The staging path is built from the version, so a version that is not a plain Maven +/// version is a delete or a write outside `zbo/` waiting to happen. +void mavenVersionAssertions(Path root, Path missing) throws Exception { + var empty = new String[0]; + var file = root.resolve("version.txt"); + assertEquals("2026.08.16.01.19", requireMavenVersion("2026.08.16.01.19"), + "a release version passes unchanged"); + assertEquals("1.0.0-SNAPSHOT", requireMavenVersion("1.0.0-SNAPSHOT"), + "a qualifier of letters and hyphens is a Maven version"); + assertFailure("not a Maven version", "an absolute path would escape the staging root", + () -> resolvedVersion(args("-version-string", "/tmp/victim"), null, missing)); + assertFailure("not a Maven version", "a relative path would escape the staging root", + () -> resolvedVersion(args("-version-string", "../../etc"), null, missing)); + assertFailure("not a Maven version", "a separator never becomes a path segment", + () -> resolvedVersion(args("-version-string", "1.0.0/x"), null, missing)); + assertFailure("not a Maven version", "XML markup is rejected before the POM is rendered", + () -> resolvedVersion(args("-version-string", "1.0.0"), null, missing)); + assertFailure("not a Maven version", "a leading dot is not a version", + () -> resolvedVersion(args("-version-string", ".."), null, missing)); + assertFailure("not a Maven version", "an environment version is validated too", + () -> resolvedVersion(empty, "/tmp/victim", missing)); + Files.writeString(file, "/tmp/victim\n"); + assertFailure("not a Maven version", "a version file is validated too", + () -> resolvedVersion(empty, null, root)); +} + +/// `requireKnownOptions` returns nothing, so [#assertFailure] needs a `Callable`. +String checkedOptions(String... args) { + requireKnownOptions(args); + return "accepted"; +} + +/// Both files are passed in rather than read from `~` and `.`, so a developer with a +/// global `.zpublish` for another project cannot change the outcome of a test run. +void configurationAssertions() throws Exception { + var directory = temporaryDirectory("zpublish-selftest"); + var global = directory.resolve("global.zpublish"); + var local = directory.resolve("local.zpublish"); + var missing = directory.resolve("absent.zpublish"); + Files.writeString(global, "groupId=com.example\ndeveloper.name=Global Developer\n"); + Files.writeString(local, "groupId=com.airhacks\n"); + assertEquals(Optional.of("com.airhacks"), property(configuration(global, local), "groupId"), + "the local file wins over the global one"); + assertEquals(Optional.of("Global Developer"), property(configuration(global, local), "developer.name"), + "a global key survives a local file that does not mention it"); + assertEquals(Optional.of("com.airhacks"), property(configuration(missing, local), "groupId"), + "a missing global file is not an error"); + assertEquals(Optional.of("com.example"), property(configuration(global, missing), "groupId"), + "a missing local file leaves the global values in place"); + assertEquals(Optional.empty(), property(configuration(missing, missing), "groupId"), + "with neither file present a key is absent, not an empty string"); + assertEquals(Optional.empty(), property(configuration(global, local), "scm.url"), + "a key named in no file is absent, not an empty string"); + var blank = directory.resolve("blank.zpublish"); + Files.writeString(blank, "groupId= \n"); + assertEquals(Optional.empty(), property(configuration(missing, blank), "groupId"), + "a blank value is absent rather than an empty coordinate"); + var padded = directory.resolve("padded.zpublish"); + Files.writeString(padded, "groupId= com.airhacks \n"); + assertEquals(Optional.of("com.airhacks"), property(configuration(missing, padded), "groupId"), + "a padded value is trimmed"); + var accented = directory.resolve("accented.zpublish"); + Files.writeString(accented, "developer.name=Adam Bień\ndescription=zero — dependency\n", + StandardCharsets.UTF_8); + assertEquals(Optional.of("Adam Bień"), property(configuration(missing, accented), "developer.name"), + "a UTF-8 developer name survives the load instead of arriving as mojibake"); + assertEquals(Optional.of("zero — dependency"), property(configuration(missing, accented), "description"), + "a UTF-8 em dash in prose survives the load"); + assertContains(pom(metadata(configurationWith("developer.name", "Adam Bień"), + testConfigurationLocations), "1.0.0"), "Adam Bień", + "the accented name reaches the POM that is written out as UTF-8"); + var latin = directory.resolve("latin.zpublish"); + Files.write(latin, "developer.name=Adam Bieñ\n".getBytes(StandardCharsets.ISO_8859_1)); + assertFailure("cannot be read", + "a file that is not UTF-8 fails loudly rather than publishing a mis-decoded name", + () -> readProperties(latin)); + assertTrue(readProperties(missing).isEmpty(), "a missing file reads as no properties at all"); + assertFailure("cannot be read", "a directory where the file belongs fails loudly", + () -> readProperties(directory)); + var malformed = directory.resolve("malformed.zpublish"); + Files.writeString(malformed, "developer.name=\\uZZZZ\n"); + assertFailure("cannot be read", "a malformed escape fails loudly rather than reading as empty", + () -> readProperties(malformed)); + assertFailure(malformed.toString(), "an unreadable file is named in the failure", + () -> readProperties(malformed)); + assertFailure("cannot be read", "an unreadable file fails the whole lookup, never half of it", + () -> configuration(malformed, local)); +} + +/// The `.zb` under test is synthetic and the resolved globals are restored afterwards, so +/// a run in this repository asserts the same values as a run anywhere else. +void buildConfigurationAssertions() throws Exception { + var directory = temporaryDirectory("zpublish-selftest"); + var build = new Properties(); + assertEquals("zbo/", zbProperty(build, "jar.dir", "zbo/"), + "an absent key falls back rather than failing"); + build.setProperty("jar.dir", ""); + assertEquals("zbo/", zbProperty(build, "jar.dir", "zbo/"), + "the placeholder reads as unset, never as a directory name"); + build.setProperty("jar.dir", ""); + assertEquals("zbo/", zbProperty(build, "jar.dir", "zbo/"), + "the placeholder reads as unset"); + build.setProperty("jar.dir", " "); + assertEquals("zbo/", zbProperty(build, "jar.dir", "zbo/"), + "a blank value falls back rather than resolving to the project root"); + build.setProperty("jar.dir", " build/ "); + assertEquals("build/", zbProperty(build, "jar.dir", "zbo/"), + "a padded value is trimmed, never used as a path with spaces"); + buildPathAssertions(directory); + classpathAssertions(directory); + discoveryAssertions(directory); + resolveBuildPathsAssertions(directory); +} + +/// The list is asserted rather than the option string alone, because the two rules that can +/// drift apart are the split (zb's `:`) and the join (the platform separator): asserting +/// only the joined value would pass on a platform where they happen to agree. +void classpathAssertions(Path directory) throws Exception { + var root = Path.of("."); + var build = new Properties(); + assertEquals(List.of(), classpath(root, build), + "an absent classpath key is an empty classpath, which is every project in the family"); + build.setProperty("classpath", ""); + assertEquals(List.of(), classpath(root, build), + "the placeholder reads as unset, never as a directory named "); + build.setProperty("classpath", " "); + assertEquals(List.of(), classpath(root, build), + "a blank classpath is an empty classpath, not a single blank entry"); + build.setProperty("classpath", "libs/one.jar"); + assertEquals(List.of(Path.of("libs/one.jar")), classpath(root, build), + "a single entry is the whole classpath"); + build.setProperty("classpath", "libs/one.jar:libs/two.jar"); + assertEquals(List.of(Path.of("libs/one.jar"), Path.of("libs/two.jar")), classpath(root, build), + "a colon separates entries, exactly as zb splits them"); + build.setProperty("classpath", "libs/one.jar:libs/two.jar:"); + assertEquals(List.of(Path.of("libs/one.jar"), Path.of("libs/two.jar")), classpath(root, build), + "a trailing separator adds no entry, so no empty path reaches javadoc"); + build.setProperty("classpath", "libs/one.jar::libs/two.jar"); + assertEquals(List.of(Path.of("libs/one.jar"), Path.of("libs/two.jar")), classpath(root, build), + "a doubled separator drops the blank between them"); + build.setProperty("classpath", " libs/one.jar : libs/two.jar "); + assertEquals(List.of(Path.of("libs/one.jar"), Path.of("libs/two.jar")), classpath(root, build), + "entries are trimmed, never used as paths with surrounding spaces"); + build.setProperty("classpath", "/opt/lib/absolute.jar"); + assertEquals(List.of(Path.of("/opt/lib/absolute.jar")), classpath(root, build), + "an absolute entry stays absolute rather than being nested under the root"); + var module = directory.resolve("module"); + build.setProperty("classpath", "libs/one.jar"); + assertEquals(List.of(module.resolve("libs/one.jar")), classpath(module, build), + "entries are read relative to the project root, as zb reads them from its own directory"); + javadocOptionAssertions(directory); + classpathWarningAssertions(directory); +} + +void javadocOptionAssertions(Path directory) throws Exception { + var sources = directory.resolve("documented/src/main/java"); + Files.createDirectories(sources); + var unit = sources.resolve("Documented.java"); + Files.writeString(unit, "public class Documented {}\n"); + var target = directory.resolve("documented/out"); + assertFalse(List.of(javadocOptions(sources, target, List.of())).contains("--class-path"), + "an empty classpath adds no --class-path option, rather than an empty one"); + var single = List.of(javadocOptions(sources, target, List.of(Path.of("libs/one.jar")))); + assertTrue(single.contains("--class-path"), + "a configured classpath reaches javadoc, so an external import resolves"); + assertEquals("libs/one.jar", single.get(single.indexOf("--class-path") + 1), + "the classpath is the operand of the option, not a bare argument"); + var pair = List.of(Path.of("libs/one.jar"), Path.of("libs/two.jar")); + assertEquals(String.join(File.pathSeparator, "libs/one.jar", "libs/two.jar"), + joinedClasspath(pair), + "entries are joined with the platform separator javadoc expects, not with the : they were written with"); + var options = List.of(javadocOptions(sources, target, pair)); + assertEquals(joinedClasspath(pair), options.get(options.indexOf("--class-path") + 1), + "every configured entry reaches javadoc, not only the first"); + assertTrue(options.indexOf("--class-path") < options.indexOf(unit.toString()), + "the option precedes the source files, where javadoc accepts options"); +} + +/// The warning is asserted on a real missing path rather than a synthetic string, because +/// the case that matters is a `.zb` naming a jar that was deleted after the last build. +void classpathWarningAssertions(Path directory) throws Exception { + var libraries = directory.resolve("libraries"); + Files.createDirectories(libraries); + var present = libraries.resolve("present.jar"); + Files.writeString(present, ""); + var absent = libraries.resolve("absent.jar"); + assertEquals(List.of(), classpathWarnings(List.of(present)), + "an entry that is there is not warned about"); + assertEquals(List.of(), classpathWarnings(List.of()), + "an empty classpath produces no output at all"); + assertEquals(1, classpathWarnings(List.of(present, absent)).size(), + "only the missing entry is warned about, once"); + var warning = classpathWarnings(List.of(absent)).getFirst(); + assertContains(warning, absent.toAbsolutePath().normalize().toString(), + "the warning names the entry that is missing, absolute so it is unambiguous"); + assertContains(warning, "classpath property", + "the warning points at the key to fix rather than only at the symptom"); + assertContains(warning, buildFile.toString(), + "the warning names the file the key lives in"); + assertEquals(2, classpathWarnings(List.of(absent, libraries.resolve("second.jar"))).size(), + "every missing entry is reported, not just the first"); +} + +void buildPathAssertions(Path directory) throws Exception { + var root = Path.of("."); + var defaults = new Properties(); + assertEquals(Path.of("zbo"), outputDirectory(root, defaults), + "an empty .zb still resolves zb's own default output directory"); + assertEquals(Path.of("zbo/app.jar"), builtJar(root, defaults), + "jar.file.name defaults to app.jar, exactly as zb defaults it"); + var moved = new Properties(); + moved.setProperty("jar.dir", "build/"); + moved.setProperty("jar.file.name", "zsmith.jar"); + assertEquals(Path.of("build"), outputDirectory(root, moved), + "jar.dir moves the output directory"); + assertEquals(Path.of("build/zsmith.jar"), builtJar(root, moved), + "the built jar is jar.dir joined with jar.file.name"); + assertEquals(Path.of("build/staging"), stagingRoot(outputDirectory(root, moved)), + "the staging tree follows jar.dir instead of staying in a stray zbo/"); + assertEquals(Path.of("build/bundle.zip"), bundleFile(outputDirectory(root, moved)), + "the bundle follows jar.dir too"); + assertEquals(Path.of("zbo/staging"), stagingRoot(outputDirectory(root, defaults)), + "an empty .zb stages under zb's own default output directory"); + assertEquals(Path.of("zbo/bundle.zip"), bundleFile(outputDirectory(root, defaults)), + "an empty .zb bundles under zb's own default output directory"); + var nested = directory.resolve("module"); + assertEquals(nested.resolve("zbo/zsmith.jar"), + builtJar(nested, propertiesOf("jar.dir", "zbo/", "jar.file.name", "zsmith.jar")), + "a nested project root prefixes the jar rather than being discarded"); + var inPlace = propertiesOf("jar.dir", ".", "jar.file.name", "widget.jar"); + assertEquals(Path.of("."), outputDirectory(root, inPlace), + "jar.dir=. yields . rather than the empty path Path.normalize produces"); + assertEquals(Path.of("./bundle.zip"), bundleFile(outputDirectory(root, inPlace)), + "a jar.dir of . still names a bundle with a parent directory"); + assertTrue(parentOf(bundleFile(outputDirectory(root, inPlace))) != null, + "the bundle's parent directory can be created rather than arriving as null"); + assertEquals(Path.of("./widget.jar"), builtJar(root, inPlace), + "a jar.dir of . reads the jar from the project root"); +} + +Properties propertiesOf(String... pairs) { + var properties = new Properties(); + for (var index = 0; index + 1 < pairs.length; index += 2) { + properties.setProperty(pairs[index], pairs[index + 1]); + } + return properties; +} + +void discoveryAssertions(Path directory) throws Exception { + var full = directory.resolve("full"); + Files.createDirectories(full.resolve("src/main/java")); + Files.createDirectories(full.resolve("src/main/resources")); + Files.createDirectories(full.resolve("resources")); + assertEquals(full.resolve("src/main/java"), sourcesDirectory(full), + "src/main/java wins the sources ladder"); + assertEquals(full.resolve("src/main/resources"), resourcesDirectory(full), + "src/main/resources wins the resources ladder"); + var flat = directory.resolve("flat"); + Files.createDirectories(flat.resolve("src")); + Files.createDirectories(flat.resolve("resources")); + assertEquals(flat.resolve("src"), sourcesDirectory(flat), + "src is the second rung when src/main/java is absent"); + assertEquals(flat.resolve("resources"), resourcesDirectory(flat), + "resources is the second rung when src/main/resources is absent"); + var bare = directory.resolve("bare"); + Files.createDirectories(bare.resolve("src/main/java")); + assertEquals(bare.resolve("src/main/resources"), resourcesDirectory(bare), + "a project without resources reports the nominal path, which roots() treats as absent"); + var declared = directory.resolve("declared"); + Files.createDirectories(declared.resolve("src/main/java")); + Files.createDirectories(declared.resolve("foo")); + assertEquals(declared.resolve("src/main/java"), sourcesDirectory(declared), + "sources.dir is ignored: the ladder runs unconditionally, as zb runs it"); + assertEquals(full.resolve("src/main/java"), sourcesDirectory(full.resolve(".")), + "a root spelled with a redundant . yields a normalized path, not one carrying ./"); + var rootOnly = directory.resolve("root-only"); + Files.createDirectories(rootOnly); + assertFailure("resolve to the project root", + "a project with no source directory is refused rather than publishing everything under it", + () -> sourcesDirectory(rootOnly)); + assertFailure(".git", + "the refusal names what an unfiltered sources jar would have swallowed", + () -> sourcesDirectory(rootOnly)); + assertEquals(full.resolve("src/main/java"), requireSourcesBelowRoot(full, full.resolve("src/main/java")), + "a directory below the root passes the guard unchanged"); + assertFailure("resolve to the project root", "the guard rejects the root reached by any spelling", + () -> requireSourcesBelowRoot(full, full.resolve("."))); +} + +/// Also the "never write to .zb" assertion: the file's bytes are compared after a full +/// resolution, because zb's own `PropertyFile` writes missing keys back and this tool must +/// not. +void resolveBuildPathsAssertions(Path directory) throws Exception { + var root = directory.resolve("resolved"); + Files.createDirectories(root.resolve("src/main/java")); + Files.createDirectories(root.resolve("src/main/resources")); + var file = root.resolve(".zb"); + var content = "jar.dir=build/\njar.file.name=zsmith.jar\nsources.dir=\nclasspath=libs/one.jar:libs/two.jar\n"; + Files.writeString(file, content); + var previous = List.of(outputDirectory, stagingRoot, bundleFile, builtJar, sourceDirectory, + resourceDirectory); + var previousClasspath = classpath; + try { + resolveBuildPaths(root, file); + assertEquals(root.resolve("build"), outputDirectory, + "a jar.dir of build/ becomes the output directory of the run"); + assertEquals(root.resolve("build/staging"), stagingRoot, + "the staging root moves with jar.dir"); + assertEquals(root.resolve("build/bundle.zip"), bundleFile, + "the bundle moves with jar.dir"); + assertEquals(root.resolve("build/zsmith.jar"), builtJar, + "the built jar is read from where zb wrote it, under the name zb gave it"); + assertEquals(root.resolve("src/main/java"), sourceDirectory, + "sources are discovered even though .zb declares sources.dir"); + assertEquals(root.resolve("src/main/resources"), resourceDirectory, + "resources are discovered rather than read from .zb"); + assertEquals(List.of(root.resolve("libs/one.jar"), root.resolve("libs/two.jar")), classpath, + "the classpath zb compiled against is resolved for the run, ready for javadoc"); + assertEquals(content, Files.readString(file), ".zb is read and never written back"); + } finally { + outputDirectory = previous.get(0); + stagingRoot = previous.get(1); + bundleFile = previous.get(2); + builtJar = previous.get(3); + sourceDirectory = previous.get(4); + resourceDirectory = previous.get(5); + classpath = previousClasspath; + } + var plain = root.resolve("plain.zb"); + Files.writeString(plain, "jar.dir=build/\n"); + try { + resolveBuildPaths(root, plain); + assertEquals(List.of(), classpath, + "a .zb without a classpath key leaves the run with an empty classpath"); + } finally { + outputDirectory = previous.get(0); + stagingRoot = previous.get(1); + bundleFile = previous.get(2); + builtJar = previous.get(3); + sourceDirectory = previous.get(4); + resourceDirectory = previous.get(5); + classpath = previousClasspath; + } + var absent = directory.resolve("no-such-project/.zb"); + assertFailure("not found", "a missing .zb is refused rather than defaulted", + () -> buildConfiguration(absent)); + assertFailure("run zpublish from the project root", "the missing-.zb message says what to do about it", + () -> buildConfiguration(absent)); + assertFailure("cannot be read", "a .zb that is a directory fails loudly", + () -> buildConfiguration(root.resolve("src"))); + var malformed = root.resolve("malformed.zb"); + Files.writeString(malformed, "jar.dir=\\uZZZZ\n"); + assertFailure("cannot be read", "a malformed .zb never reads as an empty configuration", + () -> buildConfiguration(malformed)); +} + +void optionAssertions() { + assertEquals("publish USER_MANAGED", mode(false, publishingType(List.of())), + "a bare run publishes reversibly"); + assertEquals("dry-run (no upload)", mode(true, publishingType(List.of("-dry-run"))), + "a dry-run announces itself before anything is staged"); + assertEquals("publish AUTOMATIC", mode(false, publishingType(List.of("-automatic"))), + "-automatic is named in the very first line of output"); + assertEquals("dry-run (no upload)", mode(true, publishingType(List.of("-automatic", "-dry-run"))), + "-dry-run wins over -automatic, so no run uploads by accident"); + assertEquals("accepted", checkedOptions("-dry-run", "-automatic", "-version-string", "1.0.0"), + "every documented option is accepted together"); + assertEquals("accepted", checkedOptions("-version-string", "-unknown"), + "the -version-string operand is not read as an option"); + assertFailure("unknown option", "a double-dashed -dry-run is rejected, never published", + () -> checkedOptions("--dry-run")); + assertFailure("unknown option", "a misspelled -dry-run is rejected, never published", + () -> checkedOptions("-dryrun")); + assertFailure("unknown option", "an unrecognised flag never reaches the publish path", + () -> checkedOptions("-unknown")); + assertEquals("2026.01.01.1", optionValue(args("-version-string", "2026.01.01.1"), "-version-string"), + "the operand follows the flag"); + assertEquals(null, optionValue(args("-dry-run"), "-version-string"), + "an absent flag has no operand"); + assertEquals("", optionValue(args("-dry-run", "-version-string"), "-version-string"), + "a flag left last on the command line yields an empty operand"); + assertEquals("-automatic", optionValue(args("-version-string", "-automatic"), "-version-string"), + "the operand is taken verbatim, flag-looking or not"); + assertTrue(isBlank(null), "an unset value is blank"); + assertTrue(isBlank(" "), "a whitespace-only value is blank"); + assertFalse(isBlank(" 0xDEADBEEF "), "a padded value is not blank"); + assertEquals(", Central-valid", unsignedNote(true), + "a signed tree is reported as Central-valid"); + assertContains(unsignedNote(false), "requires signatures", + "an unsigned tree is never passed off as Central-valid"); + assertEquals(sampleDeploymentId, encoded(sampleDeploymentId), + "a deployment id survives query encoding unchanged"); + assertEquals("a%2Fb", encoded("a/b"), "a separator is percent-encoded"); + assertEquals("a+b", encoded("a b"), "a space is encoded rather than breaking the query"); +} + +/// A record that differs from [#testMetadata] in every field, so an assertion can tell +/// "followed the argument" apart from "read the field that used to be there" - which two +/// records sharing a groupId could not. +Metadata coordinates(String groupId, String artifactId) { + return new Metadata(groupId, artifactId, artifactId, "a sibling project", + "https://example.com/" + artifactId, + Optional.of("2025"), + "Apache License, Version 2.0", Optional.of("https://example.com/LICENSE"), + "sibling", "Sibling Maintainer", Optional.of("sibling@example.com"), + Optional.of("https://example.com/sibling"), + "scm:git:https://example.com/%s.git".formatted(artifactId), + Optional.of("scm:git:ssh://git@example.com/%s.git".formatted(artifactId)), + Optional.of("HEAD"), + "https://example.com/" + artifactId); +} + +/// Hermeticity, asserted rather than assumed. The selftest must not read `./.zpublish`, +/// `~/.zpublish` or the environment: a developer keeping a global configuration for another +/// project would otherwise change what these assertions prove, and the run that matters - +/// CI, with no home directory - is the one that would never see it. +void metadataAssertions() throws Exception { + var directory = temporaryDirectory("zpublish-hermetic-selftest"); + var foreign = directory.resolve("foreign.zpublish"); + Files.writeString(foreign, "groupId=com.example.foreign%nartifactId=not-this-project%n" + .formatted()); + assertEquals(Optional.of("com.example.foreign"), + property(configuration(foreign, directory.resolve("absent.zpublish")), "groupId"), + "a foreign configuration is real and would be honoured if anything consulted it"); + assertEquals(Path.of(System.getProperty("user.home", ".")).resolve(configurationFileName), + globalConfigurationFile(), + "the global configuration is sought in the home directory, wherever HOME points"); + assertEquals(projectRoot.resolve(configurationFileName), localConfigurationFile(), + "the local configuration is sought under the project root, like every other resolved path"); + assertEquals("%s or %s".formatted(globalConfigurationFile(), localConfigurationFile()), + configurationLocations(), + "the reported locations are the global file and the repository-tracked one, in that order"); + assertContains(configurationLocations(), configurationFileName, + "the reported locations name the file the keys belong in"); + assertFalse(globalConfigurationFile().equals(localConfigurationFile()), + "the two locations are distinct paths, so naming both tells the user something"); + assertEquals("com.airhacks", testMetadata.groupId(), + "the record the assertions run on is written out in the script, never resolved"); + assertEquals("zb-1.0.0.jar", artifactName(testMetadata, "1.0.0", jarSuffix), + "an artifact is named from the passed record"); + assertEquals("widget-2.0.0-sources.jar", + artifactName(coordinates("org.example", "widget"), "2.0.0", sourcesSuffix), + "a different record names a different artifact"); + assertEquals("widget-2.0.0", deploymentName(coordinates("org.example", "widget"), "2.0.0"), + "the Portal deployment name follows the passed record"); + assertEquals(Path.of("zbo/staging/org/example/widget/2.0.0"), + stagingDirectory(stagingRoot(Path.of("zbo")), coordinates("org.example", "widget"), + "2.0.0"), + "a different record stages at a different Maven repository path"); + assertEquals(Path.of("elsewhere/com/airhacks/zb/1.0.0"), + stagingDirectory(Path.of("elsewhere"), testMetadata, "1.0.0"), + "the staging root is the passed one rather than the resolved field"); + var sibling = pom(coordinates("org.example", "widget"), "2.0.0"); + assertContains(sibling, "org.example", "the POM carries the passed groupId"); + assertContains(sibling, "widget", + "the POM carries the passed artifactId"); + assertContains(sibling, "widget", "the POM carries the passed name"); + assertContains(sibling, "a sibling project", + "the POM carries the passed description"); + assertContains(sibling, "https://example.com/widget", "the POM carries the passed url"); + assertContains(sibling, "Apache License, Version 2.0", + "the POM carries the passed license"); + assertContains(sibling, "sibling", "the POM carries the passed developer"); + assertContains(sibling, "scm:git:https://example.com/widget.git", + "the POM carries the passed scm connection"); + assertContains(sibling, "scm:git:ssh://git@example.com/widget.git", + "the POM carries the passed scm developerConnection"); + assertFalse(sibling.contains("com.airhacks"), + "no coordinate of this project survives into a sibling's POM"); + assertFalse(sibling.contains("com.example.foreign"), + "no configuration file reaches the POM the assertions render"); + var duplicate = deploymentFailure(coordinates("org.example", "widget"), duplicateFailureBody, + "2.0.0").getMessage(); + assertContains(duplicate, "org.example:widget:2.0.0", + "the duplicate-version message names the passed coordinate"); + assertTrue(duplicate.startsWith("org.example:widget:2.0.0 is already published"), + "the duplicate-version explanation opens with the passed coordinate, never this project's"); +} + +/// The configuration under test is assembled in memory and the locations are a literal, so +/// these assertions state what a `.zpublish` produces without any file on the machine +/// running them being able to answer differently. +String testConfigurationLocations = "/nowhere/.zpublish or .zpublish"; + +/// Exactly the required keys and nothing else, so "the optionals are omitted" is asserted +/// against a configuration that genuinely omits them rather than one that blanks them. +Properties completeConfiguration() { + return propertiesOf( + "groupId", "org.example", + "artifactId", "widget", + "name", "widget", + "description", "a sibling project", + "url", "https://example.com/widget", + "license.name", "Apache License, Version 2.0", + "developer.id", "sibling", + "developer.name", "Sibling Maintainer", + "scm.url", "https://example.com/widget", + "scm.connection", "scm:git:https://example.com/widget.git"); +} + +Properties configurationWith(String key, String value) { + var configuration = completeConfiguration(); + configuration.setProperty(key, value); + return configuration; +} + +Properties configurationWithout(String key) { + var configuration = completeConfiguration(); + configuration.remove(key); + return configuration; +} + +/// Every element Central requires, spelled out against a configuration that is not this +/// project's: a list matching `com.airhacks` would pass on a `metadata()` that ignored its +/// argument and read the repository's own `.zpublish` instead. +List mandatoryConfiguredPomElements = List.of( + "4.0.0", + "org.example", + "widget", + "2.0.0", + "jar", + "widget", + "a sibling project", + "https://example.com/widget", + "Apache License, Version 2.0", + "repo", + "sibling", + "Sibling Maintainer", + "scm:git:https://example.com/widget.git"); + +void configuredMetadataAssertions() throws Exception { + var required = metadata(completeConfiguration(), testConfigurationLocations); + assertEquals("org.example", required.groupId(), "a coordinate comes from the configuration"); + assertEquals("Sibling Maintainer", required.developerName(), + "a dotted key resolves to the component it names"); + assertEquals(Optional.empty(), required.inceptionYear(), + "an unset optional resolves to empty rather than to a blank value"); + var minimal = pom(required, "2.0.0"); + for (var element : mandatoryConfiguredPomElements) { + assertContains(minimal, element, "a complete configuration renders the mandatory element"); + } + assertContains(minimal, "", "a configured POM is closed"); + assertFalse(minimal.contains(""), "an unset scm.tag leaves no element behind"); + assertFalse(minimal.contains(""), + "an unset optional is omitted, never emitted as the empty element Central rejects"); + assertEquals(2, occurrences(minimal, ""), + "with license.url and developer.url unset only the project and scm urls remain"); + configuredOptionalAssertions(); + configuredFailureAssertions(); + escapedPomAssertions(); +} + +void configuredOptionalAssertions() throws Exception { + var everything = completeConfiguration(); + everything.setProperty("inceptionYear", "2025"); + everything.setProperty("license.url", "https://example.com/LICENSE"); + everything.setProperty("developer.email", "sibling@example.com"); + everything.setProperty("developer.url", "https://example.com/sibling"); + everything.setProperty("scm.developerConnection", "scm:git:ssh://git@example.com/widget.git"); + everything.setProperty("scm.tag", "v2.0.0"); + var full = pom(metadata(everything, testConfigurationLocations), "2.0.0"); + assertContains(full, "2025", "a set inceptionYear is rendered"); + assertContains(full, "sibling@example.com", "a set developer.email is rendered"); + assertContains(full, "v2.0.0", "a set scm.tag is rendered"); + assertContains(full, "scm:git:ssh://git@example.com/widget.git", + "a set scm.developerConnection is rendered"); + assertEquals(4, occurrences(full, ""), + "every configured url reaches the POM: project, license, developer and scm"); + assertEquals("2025", parsedText(full, "inceptionYear"), + "the optional elements sit where a POM reader looks for them"); + assertEquals("v2.0.0", parsedText(full, "scm/tag"), "scm.tag is nested inside "); + assertEquals("sibling@example.com", parsedText(full, "developers/developer/email"), + "developer.email is nested inside "); + assertEquals("https://example.com/LICENSE", parsedText(full, "licenses/license/url"), + "license.url is nested inside "); +} + +/// A configuration is checked whole rather than key by key: ten runs to discover ten +/// missing keys is the same work spread over ten mistakes, and each of them would have to +/// be made against a live Central deployment to be worth making at all. +void configuredFailureAssertions() { + for (var key : requiredConfigurationKeys) { + assertFailure(key, "a missing required key is named in the failure", + () -> metadata(configurationWithout(key), testConfigurationLocations)); + } + for (var key : requiredConfigurationKeys) { + assertFailure(key, "an empty configuration reports every missing key at once, not the first", + () -> metadata(new Properties(), testConfigurationLocations)); + } + assertFailure(testConfigurationLocations, "the failure names the files the keys were sought in", + () -> metadata(new Properties(), testConfigurationLocations)); + assertFailure(configurationFileName, "the failure names the file the keys belong in", + () -> metadata(new Properties(), testConfigurationLocations)); + globalConfigurationAssertions(); + assertFailure("groupId", "a blank value is missing rather than an empty coordinate", + () -> metadata(configurationWith("groupId", " "), testConfigurationLocations)); + assertFailure("XML-special", "a groupId carrying markup is rejected rather than escaped", + () -> metadata(configurationWith("groupId", "com."), testConfigurationLocations)); + assertFailure("artifactId [widget&more]", "the rejection names the coordinate and its value", + () -> metadata(configurationWith("artifactId", "widget&more"), testConfigurationLocations)); + assertFailure("XML-special", "a version carrying markup is rejected as a coordinate too", + () -> pom(metadata(completeConfiguration(), testConfigurationLocations), "2.0