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