diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2114ac7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +# Two jobs. `gate` runs the full quality gate - the command an agent must +# have green before committing - on the JDK and Maven mise.toml pins. `tests` +# runs the test suite on the Temurin JDKs the library supports, 17, 21 and +# 25: Java 17 is the floor pom.xml declares (maven.compiler.release), so a +# change that works only on a newer Java is caught before it ships. +# +# In `tests` the code is still compiled by the JDK mise.toml pins, with +# Error Prone, at the release level pom.xml declares; only the test run +# moves to the matrix entry's JDK, through Surefire's `jvm` parameter, which +# forks the test JVM from that JDK's `java`. The Error Prone release pom.xml +# pins runs only on Java 21 or later, so the compiler cannot run on the +# Java 17 entry, and compiling every entry on the one gate JDK means the +# bytecode under test is the bytecode the gate checks. The format check and +# the coverage check run in the verify phase, which only `gate` reaches. +# +# Every action is pinned to a full commit SHA, with its version in a +# trailing comment. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +# Read-only: nothing here writes to the repository or authenticates to a +# registry, and no secret is read. +permissions: + contents: read + +# A newer push to the same branch or pull request supersedes the run in +# progress. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gate: + name: Full quality gate (mise.toml JDK) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + # The toolchain comes from mise.toml, provisioned by mise itself: the + # Temurin JDK and Maven at the versions pinned there. A version written + # into this file would drift the first time mise.toml moves. The action + # installs mise at the version below, runs `mise install` against + # mise.toml, and caches the tools. + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.8.2 + + # The gate is mise.toml's `gate` task: `java -version`, then `mvn -B + # -ntp verify` (the enforcer rules, the compile with every javac lint + # and Error Prone, the tests, the jars, the format check and the + # coverage check). The command is read out of .claude/wurk.json + # (gate.full) rather than written here, so the gate CI runs and the + # gate an agent must have green are one definition with one place to + # change it. It is read and executed inside the step rather than + # interpolated into `run:`, so a repository file never reaches the + # shell as expression text. + - name: Full quality gate + run: | + set -euo pipefail + mapfile -t cmd < <(jq -er '.gate.full[]' .claude/wurk.json) + test "${#cmd[@]}" -gt 0 || { echo "::error::gate.full is empty" >&2; exit 1; } + echo "gate command (.claude/wurk.json gate.full): ${cmd[*]}" + "${cmd[@]}" + + # A gate that rewrites a tracked file or leaves an untracked file that + # is not ignored passes locally only because the change is then + # committed; here it is a failure, as it is before a release. + - name: Check the gate left the checkout clean + run: | + set -euo pipefail + if [ -n "$(git status --porcelain)" ]; then + git status --short + echo "::error::the gate left changed or untracked files" >&2 + exit 1 + fi + + tests: + name: Tests on Temurin ${{ matrix.java }} + runs-on: ubuntu-latest + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + java: ["17", "21", "25"] + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + # The JDK under test: the latest Temurin release of the matrix entry's + # line, the one a user on that line runs. + - name: Install Temurin ${{ matrix.java }} + id: java + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: ${{ matrix.java }} + + # The JDK that compiles, and Maven, from mise.toml, as in `gate`. + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.8.2 + + # `mvn test` compiles on the mise.toml JDK and forks the test JVM from + # the JDK installed above. Surefire records the forked JVM's system + # properties in each report, and the last command stops the job unless + # every report names the matrix entry's Java version, so a test run + # that fell back to the build JDK cannot pass here. + - name: Test on Temurin ${{ matrix.java }} + env: + TEST_JAVA_HOME: ${{ steps.java.outputs.path }} + JAVA_LINE: ${{ matrix.java }} + run: | + set -euo pipefail + test -x "${TEST_JAVA_HOME}/bin/java" || { echo "::error::no java under ${TEST_JAVA_HOME}" >&2; exit 1; } + echo "build JDK (mise.toml):" + mise exec -- java -version + echo "test JDK (Temurin ${JAVA_LINE}):" + "${TEST_JAVA_HOME}/bin/java" -version + mise exec -- mvn -B -ntp -Djvm="${TEST_JAVA_HOME}/bin/java" test + shopt -s nullglob + reports=(target/surefire-reports/TEST-*.xml) + test "${#reports[@]}" -gt 0 || { echo "::error::Surefire wrote no test report" >&2; exit 1; } + for report in "${reports[@]}"; do + if ! grep -q "name=\"java.specification.version\" value=\"${JAVA_LINE}\"" "$report"; then + grep -o 'name="java.specification.version" value="[^"]*"' "$report" || true + echo "::error::${report} was not run on Java ${JAVA_LINE}" >&2 + exit 1 + fi + done + echo "${#reports[@]} test report(s), each run on Java ${JAVA_LINE}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..78f7e1a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,509 @@ +name: Release + +# Publishes this library (pom.xml's groupId and artifactId) to Maven Central, +# through the Central Portal, when a version tag is pushed, and only when +# four things hold at the tagged commit: it is on the default branch, the tag +# is `v` followed by the version pom.xml states there (the project's own +# , which must not be a -SNAPSHOT), Maven Central does not already +# list that version, and the full quality gate is green there. Nothing else +# triggers it - no branch push, no pull request, no manual dispatch. +# +# "Maven Central lists the version" means the version's POM file answers 200 +# at repo1.maven.org, the repository Maven resolves from; 404 means it is not +# listed, and any other answer stops the step. The search pages at +# central.sonatype.com are not read: they can lag repo1.maven.org. +# +# Two jobs, each checking out the tagged commit (github.sha): +# +# - `verify` (contents: read, and no other permission; it reads no secret) +# runs the first three checks before anything is installed, then the full +# quality gate, then builds the three release jars (the main jar, the +# sources jar and the javadoc jar) with `mvn -DskipTests clean package`, +# the tests having just passed in the gate. It records the SHA-256 of each +# jar in a SHA256SUMS file, uploads the jars and SHA256SUMS as one workflow +# artifact, and records the SHA-256 of SHA256SUMS itself as this job's +# output. +# - `publish` (contents: read, and no other permission) starts only when +# `verify` succeeded. It runs the version check and the Maven Central check +# again rather than trusting `verify`'s result, because a re-run of this +# job alone can come long after `verify` ran. It downloads the artifact and +# stops unless SHA256SUMS has the SHA-256 `verify` recorded and every jar +# in it has the SHA-256 SHA256SUMS lists. mise then installs the JDK and +# Maven that mise.toml pins (the ones `verify` built with) and nothing +# else. One Maven invocation with the `release` profile (pom.xml) then +# rebuilds the three jars without running the tests (`-DskipTests clean +# deploy`), refuses unless each one's SHA-256 equals the one SHA256SUMS +# lists (the build is reproducible: pom.xml fixes +# project.build.outputTimestamp), signs every file it deploys with the +# maven-gpg-plugin's Bouncy Castle signer, and deploys them through the +# central-publishing-maven-plugin with autoPublish true and waitUntil +# published: the signed files go to the Central Portal as one deployment, +# the Portal validates it and publishes it to Maven Central, and the +# plugin waits, up to sixty minutes, until the Portal reports it +# published. The last step prints the version's Central address. +# +# Secrets. `publish` is the one job that reads stored credentials, because +# the Central Portal offers no tokenless (OIDC) publishing and accepts only +# signed files. It reads four repository secrets: MAVEN_CENTRAL_USERNAME and +# MAVEN_CENTRAL_PASSWORD (a Central Portal user token) and +# MAVEN_GPG_PRIVATE_KEY and MAVEN_GPG_PASSPHRASE (the ASCII-armored signing +# key and its passphrase). The `secrets` expressions that read them appear in +# this file only in the `env` of `publish`'s deploy step, so their values +# exist only in that step's environment, the environment of the one Maven +# invocation above; every plugin that invocation runs could read them. The +# settings file the step before it writes holds two `${env.NAME}` +# references, never a value; Maven fills them in when it reads the file. No +# step prints a value; GitHub also masks a secret's value in the log, a +# safeguard rather than a guarantee. The checkouts use GitHub's automatic +# per-job GITHUB_TOKEN, limited to that job's `permissions`, and the +# artifact upload and download use the credentials the runner provides for +# the run's own artifact storage. +# +# Every action this workflow uses, in both jobs, is pinned to a full commit +# SHA, with its version in a trailing comment: in `publish` the steps share +# a runner with the secrets, and in `verify` the actions check out the +# source the build reads, install the toolchain that builds it and store the +# jars whose sums `publish` checks, so a moved tag in either job could change +# what is published. +# +# What a failure leaves behind. A failure in `verify` publishes nothing: +# `publish` does not start and no secret is read. A failure in `publish` +# before the upload publishes nothing either: a check, a sum that does not +# match, a signing step without a usable key, or the Portal refusing the +# token before it accepts the deployment. Once the upload is done the +# deployment is in the Portal, whatever happens after it. A failure after +# the upload - in the plugin's wait (a network error, or the sixty minutes +# running out), the Portal reporting that the deployment failed validation, +# or the address step - leaves the run red and the deployment in the +# Portal: one that passed validation goes on to publish, and one that failed +# validation stays there, unpublished, until the maintainer drops it. No +# failure moves or deletes the tag. +# +# Re-running. The manual act this workflow needs is a re-run from the run's +# page in the Actions tab, for a run that failed for a reason outside the +# tagged commit, such as a registry or network error, Maven Central +# answering the listing check with a status other than 200 or 404, or a gate +# step that failed on the runner rather than on the code. A run that failed +# before the upload goes green on a re-run once the cause has passed. For a +# run whose `publish` job failed after the upload, the maintainer first +# looks at the deployment in the Portal: a deployment still validating or +# publishing is waited for, and one that failed validation is dropped there. +# "Re-run failed jobs" then re-runs `publish` alone, with the artifact and +# the SHA-256 `verify` recorded (while the artifact is kept). If Maven +# Central lists the version by then, `publish` reports it, downloads the +# three listed jars from repo1.maven.org and passes when each has the +# SHA-256 `verify` recorded, without building, signing or uploading +# anything, so that re-run goes green; listed jars with other sums fail it. +# If Maven Central does not list the version, `publish` builds, checks, +# signs and uploads again. Re-running every job instead re-runs `verify`, +# which refuses a version Maven Central lists, so that re-run stays red. A +# failure the tagged commit itself causes (it is not on the default branch, +# the tag and pom.xml name different versions, the gate is red) fails the +# same way on every re-run. +# +# Remedies. A version Maven Central lists is permanent: Maven Central never +# deletes or replaces a published file, so a broken release is followed by +# a new patch version, through a new release prep and the tag that names it, +# never by publishing the same version again. A deployment that failed +# validation is never published; the maintainer drops it in the Portal +# (Deployments), and a re-run then uploads it afresh. This workflow never +# drops, deletes or publishes a deployment in the Portal by any other path. +# +# Before the first publish, the maintainer verifies the systems.riddler +# namespace on the Central Portal, publishes the signing key's public half to +# a public keyserver, creates a Portal user token, and sets the four secrets +# on this repository; until then the signing step or the upload fails and +# nothing is published. +# +# This file records the publish decision for this repository, which keeps no +# decision records (CLAUDE.md, "Agent authority in this repo": the "Release +# preps" paragraph and the paragraphs after it, which name the secrets and +# say where a release decision is recorded). This workflow is the only +# publisher: it publishes on the push of the tag that names a merged release +# prep. An agent or a session never deploys to Maven Central, never signs a +# release and never reads a secret, and a failed run is re-run from its +# Actions page, never worked round by a local publish. + +on: + push: + tags: + - "v*.*.*" + +# The default for every job: contents: read, every other permission none. +# Each job states its own permissions below; neither adds any. The tag +# already exists when this runs, and nothing here creates or pushes one. +permissions: + contents: read + +# One run per tag, and a run in progress is never cancelled: a publish cut +# off part way is worse than one that finishes. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Verify, gate and build the jars + runs-on: ubuntu-latest + timeout-minutes: 45 + + # contents: read for the checkout and the default branch fetch. + permissions: + contents: read + + # The SHA-256 of SHA256SUMS, which `publish` matches before it builds. + outputs: + sums_sha256: ${{ steps.build.outputs.sums_sha256 }} + + steps: + # The tagged commit, by SHA. Full history: the ancestry check below + # walks it. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + # The default branch is read from the push event itself, never written + # here, so a renamed default branch needs no edit to this file. + - name: Fetch the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + test -n "$DEFAULT_BRANCH" || { echo "::error::the push event names no default branch" >&2; exit 1; } + git fetch --no-tags origin "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + + - name: Check the tagged commit is on the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + commit="$(git rev-parse 'HEAD^{commit}')" + if ! git merge-base --is-ancestor "$commit" "origin/${DEFAULT_BRANCH}"; then + echo "::error::${GITHUB_REF_NAME} is on ${commit}, which is not on ${DEFAULT_BRANCH}; nothing is published" >&2 + exit 1 + fi + echo "${GITHUB_REF_NAME} is on ${commit}, which is on ${DEFAULT_BRANCH}" + + # pom.xml is read with Python's XML reader (on the runner already), not + # with Maven, so this check runs before any toolchain is installed. Only + # the project's own is read, never a parent's, a plugin's or + # a dependency's. `publish` runs the same check again. + - name: Check the tag names the version in pom.xml + run: | + set -euo pipefail + pom_field() { python3 -c 'import sys, xml.etree.ElementTree as ET; ns = {"m": "http://maven.apache.org/POM/4.0.0"}; print(ET.parse("pom.xml").getroot().findtext("m:" + sys.argv[1], default="", namespaces=ns).strip())' "$1"; } + tag_version="${GITHUB_REF_NAME#v}" + pom_version="$(pom_field version)" + test -n "$pom_version" || { echo "::error::no project in pom.xml at the tagged commit" >&2; exit 1; } + case "$pom_version" in + *-SNAPSHOT) echo "::error::pom.xml at the tagged commit says ${pom_version}, a snapshot; nothing is published" >&2; exit 1 ;; + esac + if [ "$tag_version" != "$pom_version" ]; then + echo "::error::${GITHUB_REF_NAME} names ${tag_version} but pom.xml at the tagged commit says ${pom_version}; nothing is published" >&2 + exit 1 + fi + echo "${GITHUB_REF_NAME} matches pom.xml version ${pom_version}" + + # A version Maven Central lists is reported, never published again + # (Maven Central would refuse it anyway). The coordinates are read from + # pom.xml, so the version checked is the version of the library this + # run would publish. This read needs no credential. `publish` runs the + # same check again. + - name: Check Maven Central does not list this version + run: | + set -euo pipefail + pom_field() { python3 -c 'import sys, xml.etree.ElementTree as ET; ns = {"m": "http://maven.apache.org/POM/4.0.0"}; print(ET.parse("pom.xml").getroot().findtext("m:" + sys.argv[1], default="", namespaces=ns).strip())' "$1"; } + group="$(pom_field groupId)" + artifact="$(pom_field artifactId)" + version="${GITHUB_REF_NAME#v}" + url="https://repo1.maven.org/maven2/${group//.//}/${artifact}/${version}/${artifact}-${version}.pom" + status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 60 "$url")" || status=000 + echo "${url} answered ${status}" + case "$status" in + 404) echo "${group}:${artifact}:${version} is not on Maven Central yet" ;; + 200) echo "::error::${group}:${artifact}:${version} is already on Maven Central; reported, not published again" >&2; exit 1 ;; + *) echo "::error::Maven Central answered ${status} for ${group}:${artifact}:${version}; nothing is published" >&2; exit 1 ;; + esac + + # --- the toolchain and the full quality gate, exactly as ci.yml's gate + # job runs them. + + # The toolchain comes from mise.toml, provisioned by mise itself: the + # Temurin JDK and Maven at the versions pinned there. No tool cache is + # restored in either job of this workflow: a release installs its tools + # fresh rather than from a cache another run wrote. + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.8.2 + cache: false + + # The command is read out of .claude/wurk.json (gate.full), the gate an + # agent must have green before committing. It is read and executed + # inside the step rather than interpolated into `run:`, so a repository + # file never reaches the shell as expression text. + - name: Full quality gate + run: | + set -euo pipefail + mapfile -t cmd < <(jq -er '.gate.full[]' .claude/wurk.json) + test "${#cmd[@]}" -gt 0 || { echo "::error::gate.full is empty" >&2; exit 1; } + echo "gate command (.claude/wurk.json gate.full): ${cmd[*]}" + "${cmd[@]}" + + # --- end of the gate. + + # The gate's build output lands under target/, which .gitignore covers. + # A gate that left any changed file, or any untracked file that is not + # ignored, stops the release here, with the files named. + - name: Check the gate left the checkout clean + run: | + set -euo pipefail + if [ -n "$(git status --porcelain)" ]; then + git status --short + echo "::error::the gate left changed or untracked files; nothing is published" >&2 + exit 1 + fi + + # The release jars, built by the same command `publish` runs without + # the release profile, so the two builds produce the same bytes. The + # step stops unless the three jars exist; SHA256SUMS lists the SHA-256 + # of each, and the SHA-256 of SHA256SUMS is this job's output. + - name: Build the release jars and record their SHA-256 + id: build + run: | + set -euo pipefail + pom_field() { python3 -c 'import sys, xml.etree.ElementTree as ET; ns = {"m": "http://maven.apache.org/POM/4.0.0"}; print(ET.parse("pom.xml").getroot().findtext("m:" + sys.argv[1], default="", namespaces=ns).strip())' "$1"; } + artifact="$(pom_field artifactId)" + version="${GITHUB_REF_NAME#v}" + mise exec -- mvn -B -ntp -DskipTests clean package + release="${RUNNER_TEMP}/release" + rm -rf "$release" + mkdir -p "${release}/jars" + for suffix in "" "-sources" "-javadoc"; do + jar="target/${artifact}-${version}${suffix}.jar" + test -f "$jar" || { echo "::error::the build left no ${jar}; nothing is published" >&2; exit 1; } + cp "$jar" "${release}/jars/" + done + cd "$release" + sha256sum "jars/${artifact}-${version}.jar" "jars/${artifact}-${version}-sources.jar" "jars/${artifact}-${version}-javadoc.jar" > SHA256SUMS + cat SHA256SUMS + sums_sha256="$(sha256sum SHA256SUMS | cut -d ' ' -f 1)" + echo "SHA256SUMS sha256 ${sums_sha256}" + echo "sums_sha256=${sums_sha256}" >> "$GITHUB_OUTPUT" + + # The three jars and SHA256SUMS, as one artifact `publish` downloads. + - name: Upload the jars and their SHA-256 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-jars + path: ${{ runner.temp }}/release/ + if-no-files-found: error + + publish: + name: Sign and publish to Maven Central + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 90 + + # contents: read for the checkout, and no other permission. + permissions: + contents: read + + # mise installs only the JDK and Maven in this job (install_args below); + # with auto-install off, `mise exec` never installs anything else. + env: + MISE_EXEC_AUTO_INSTALL: "false" + + steps: + # The same commit `verify` checked out. Nothing here fetches again, so + # the checkout keeps no credential in the repository's git config. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.sha }} + persist-credentials: false + + # The same check as in `verify`, run again here: a re-run of this job + # alone does not re-run `verify`. + - name: Check the tag names the version in pom.xml + run: | + set -euo pipefail + pom_field() { python3 -c 'import sys, xml.etree.ElementTree as ET; ns = {"m": "http://maven.apache.org/POM/4.0.0"}; print(ET.parse("pom.xml").getroot().findtext("m:" + sys.argv[1], default="", namespaces=ns).strip())' "$1"; } + tag_version="${GITHUB_REF_NAME#v}" + pom_version="$(pom_field version)" + test -n "$pom_version" || { echo "::error::no project in pom.xml at the tagged commit" >&2; exit 1; } + case "$pom_version" in + *-SNAPSHOT) echo "::error::pom.xml at the tagged commit says ${pom_version}, a snapshot; nothing is published" >&2; exit 1 ;; + esac + if [ "$tag_version" != "$pom_version" ]; then + echo "::error::${GITHUB_REF_NAME} names ${tag_version} but pom.xml at the tagged commit says ${pom_version}; nothing is published" >&2 + exit 1 + fi + echo "${GITHUB_REF_NAME} matches pom.xml version ${pom_version}" + + # The same read as in `verify`, run again here, where a listed version + # does not fail the step: it sends this job down the path that checks + # the listed jars instead of publishing (the header's "Re-running"). + - name: Check whether Maven Central lists this version + id: central + run: | + set -euo pipefail + pom_field() { python3 -c 'import sys, xml.etree.ElementTree as ET; ns = {"m": "http://maven.apache.org/POM/4.0.0"}; print(ET.parse("pom.xml").getroot().findtext("m:" + sys.argv[1], default="", namespaces=ns).strip())' "$1"; } + group="$(pom_field groupId)" + artifact="$(pom_field artifactId)" + version="${GITHUB_REF_NAME#v}" + url="https://repo1.maven.org/maven2/${group//.//}/${artifact}/${version}/${artifact}-${version}.pom" + status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 60 "$url")" || status=000 + echo "${url} answered ${status}" + case "$status" in + 404) + echo "${group}:${artifact}:${version} is not on Maven Central yet; this job builds, signs and publishes it" + echo "listed=false" >> "$GITHUB_OUTPUT" + ;; + 200) + echo "::notice::${group}:${artifact}:${version} is already on Maven Central; this job checks the listed jars against the ones verify built and publishes nothing" + echo "listed=true" >> "$GITHUB_OUTPUT" + ;; + *) echo "::error::Maven Central answered ${status} for ${group}:${artifact}:${version}; nothing is published" >&2; exit 1 ;; + esac + + - name: Download the jars verify built + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-jars + path: ${{ runner.temp }}/release + + # SHA256SUMS has the SHA-256 `verify` recorded, each jar it lists has + # the SHA-256 it lists, and the artifact holds those jars and no other + # file, or nothing is published. + - name: Match the jars to the ones verify built + env: + VERIFIED_SUMS_SHA256: ${{ needs.verify.outputs.sums_sha256 }} + run: | + set -euo pipefail + test -n "$VERIFIED_SUMS_SHA256" || { echo "::error::verify recorded no SHA256SUMS SHA-256; nothing is published" >&2; exit 1; } + cd "${RUNNER_TEMP}/release" + test -f SHA256SUMS || { echo "::error::the artifact holds no SHA256SUMS; nothing is published" >&2; exit 1; } + sums_sha256="$(sha256sum SHA256SUMS | cut -d ' ' -f 1)" + if [ "$sums_sha256" != "$VERIFIED_SUMS_SHA256" ]; then + echo "::error::SHA256SUMS has sha256 ${sums_sha256}, but the one verify wrote has ${VERIFIED_SUMS_SHA256}; nothing is published" >&2 + exit 1 + fi + sha256sum --check --strict SHA256SUMS || { echo "::error::a jar does not match SHA256SUMS; nothing is published" >&2; exit 1; } + listed="$(cut -d ' ' -f 3 SHA256SUMS | sort)" + present="$(find jars -type f | sort)" + if [ "$listed" != "$present" ]; then + printf 'listed:\n%s\npresent:\n%s\n' "$listed" "$present" + echo "::error::the artifact holds files SHA256SUMS does not list; nothing is published" >&2 + exit 1 + fi + echo "the jars match the SHA256SUMS verify wrote (sha256 ${sums_sha256})" + + # The re-run path: the version is already listed, so nothing is built, + # signed or uploaded. The step passes only when each of the three jars + # Maven Central lists has the SHA-256 `verify` recorded. + - name: Match the jars Maven Central lists to the ones verify built + if: steps.central.outputs.listed == 'true' + run: | + set -euo pipefail + pom_field() { python3 -c 'import sys, xml.etree.ElementTree as ET; ns = {"m": "http://maven.apache.org/POM/4.0.0"}; print(ET.parse("pom.xml").getroot().findtext("m:" + sys.argv[1], default="", namespaces=ns).strip())' "$1"; } + group="$(pom_field groupId)" + artifact="$(pom_field artifactId)" + version="${GITHUB_REF_NAME#v}" + base="https://repo1.maven.org/maven2/${group//.//}/${artifact}/${version}" + listed="${RUNNER_TEMP}/listed" + rm -rf "$listed" + mkdir -p "$listed" + for suffix in "" "-sources" "-javadoc"; do + name="${artifact}-${version}${suffix}.jar" + status="$(curl -sS -o "${listed}/${name}" -w '%{http_code}' --max-time 120 "${base}/${name}")" || status=000 + echo "${base}/${name} answered ${status}" + test "$status" = 200 || { echo "::error::Maven Central answered ${status} for ${name}; re-run this job once it lists every jar" >&2; exit 1; } + want="$(awk -v f="jars/${name}" '$2 == f { print $1 }' "${RUNNER_TEMP}/release/SHA256SUMS")" + got="$(sha256sum "${listed}/${name}" | cut -d ' ' -f 1)" + test -n "$want" || { echo "::error::SHA256SUMS lists no ${name}" >&2; exit 1; } + if [ "$got" != "$want" ]; then + echo "::error::the ${name} Maven Central lists has sha256 ${got}, but the one verify built has ${want}" >&2 + exit 1 + fi + echo "${name} on Maven Central has sha256 ${got}, the one verify built" + done + echo "${group}:${artifact}:${version} is on Maven Central with the jars verify built; nothing was published by this run" + + # The JDK and Maven mise.toml pins, the ones `verify` built with, and + # nothing else: `install_args` names the two tools mise installs. + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + if: steps.central.outputs.listed == 'false' + with: + version: 2026.8.2 + cache: false + install_args: java maven + + # A settings file naming the Central Portal server the publishing + # plugin authenticates to (pom.xml's publishingServerId). It holds two + # `${env.NAME}` references, never a value: Maven reads them from the + # deploy step's environment. Written outside the checkout, through a + # quoted here-document, so the shell expands nothing in it. + - name: Write the Maven settings for the Central Portal + if: steps.central.outputs.listed == 'false' + run: | + set -euo pipefail + cat > "${RUNNER_TEMP}/settings.xml" <<'SETTINGS' + + + + central + ${env.MAVEN_CENTRAL_USERNAME} + ${env.MAVEN_CENTRAL_PASSWORD} + + + + SETTINGS + cat "${RUNNER_TEMP}/settings.xml" + + # The only step that reads the secrets (the only `secrets` expressions + # in this file), into its own environment. The `release` profile rebuilds + # the three jars without the tests, refuses unless their SHA-256 values + # equal the ones SHA256SUMS lists, signs them and the POM with the + # Bouncy Castle signer (which reads MAVEN_GPG_PRIVATE_KEY and + # MAVEN_GPG_PASSPHRASE from the environment), then uploads the signed + # files to the Central Portal as one deployment, publishes it + # (autoPublish) and waits until the Portal reports it published. No git + # tag is created or pushed. + - name: Rebuild, check, sign and publish + if: steps.central.outputs.listed == 'false' + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: | + set -euo pipefail + pom_field() { python3 -c 'import sys, xml.etree.ElementTree as ET; ns = {"m": "http://maven.apache.org/POM/4.0.0"}; print(ET.parse("pom.xml").getroot().findtext("m:" + sys.argv[1], default="", namespaces=ns).strip())' "$1"; } + artifact="$(pom_field artifactId)" + version="${GITHUB_REF_NAME#v}" + sum_of() { awk -v f="jars/$1" '$2 == f { print $1 }' "${RUNNER_TEMP}/release/SHA256SUMS"; } + jar_sum="$(sum_of "${artifact}-${version}.jar")" + sources_sum="$(sum_of "${artifact}-${version}-sources.jar")" + javadoc_sum="$(sum_of "${artifact}-${version}-javadoc.jar")" + for sum in "$jar_sum" "$sources_sum" "$javadoc_sum"; do + test -n "$sum" || { echo "::error::SHA256SUMS does not list all three jars; nothing is published" >&2; exit 1; } + done + mise exec -- java -version + mise exec -- mvn -B -ntp -s "${RUNNER_TEMP}/settings.xml" -P release -DskipTests \ + -Duxid.release.sha256.jar="$jar_sum" \ + -Duxid.release.sha256.sources="$sources_sum" \ + -Duxid.release.sha256.javadoc="$javadoc_sum" \ + clean deploy + + # The search page at central.sonatype.com and the repo1.maven.org + # directory each read from the Portal's publish on their own schedule, + # so either may lag the publish by some minutes. + - name: Print the published version's addresses + run: | + set -euo pipefail + pom_field() { python3 -c 'import sys, xml.etree.ElementTree as ET; ns = {"m": "http://maven.apache.org/POM/4.0.0"}; print(ET.parse("pom.xml").getroot().findtext("m:" + sys.argv[1], default="", namespaces=ns).strip())' "$1"; } + group="$(pom_field groupId)" + artifact="$(pom_field artifactId)" + version="${GITHUB_REF_NAME#v}" + echo "https://central.sonatype.com/artifact/${group}/${artifact}/${version}" + echo "https://repo1.maven.org/maven2/${group//.//}/${artifact}/${version}/" diff --git a/CLAUDE.md b/CLAUDE.md index cf2b980..47cfa55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,9 +95,10 @@ owns the release bead tags that merged commit with the new version (`vX.Y.Z`) and pushes the tag. An agent or a session never deploys to Maven Central: the release workflow (`.github/workflows/release.yml`, the file the release publishes from) publishes on that tag push, and a failed workflow is re-run -from its Actions page, never worked round by a local publish. That workflow -arrives with a later change; until it is on `main`, nothing publishes this -package. +from its Actions page, never worked round by a local publish. Its header +says what each step checks, what a failure leaves behind and which re-run +can go green; until the prerequisites in the next paragraph are in place, +its signing or upload step fails and nothing is published. Maven Central's publisher, the Central Portal, offers no tokenless (OIDC) publishing and requires a GPG signature on every file it accepts, so this @@ -157,10 +158,12 @@ is `systems.riddler:uxid`, and its module and package are The tree on `main` today holds these agent instructions, the wurk manifest (`.claude/wurk.json`), the tracker files under `.beads/`, the build (`pom.xml`, `mise.toml` and `.mvn/jvm.config`), the module descriptor, the -package it exports (no public type yet), `LICENSE` and `CHANGELOG.md`. The -library, CI, the release workflow and the vendored corpus arrive with later -changes; until they do, the sentences in this file about the 1.0.0 package, -its corpus and its workflows describe the target, not the current tree. +package it exports (no public type yet), `LICENSE`, `CHANGELOG.md` with the +fragment rule in `changelog.d/README.md`, and the two workflows: CI +(`.github/workflows/ci.yml`) and the release workflow +(`.github/workflows/release.yml`). The library and the vendored corpus +arrive with later changes; until they do, the sentences in this file about +the 1.0.0 package and its corpus describe the target, not the current tree. - **The package is `systems.riddler:uxid`.** Version 1.0.0 is its first release on Maven Central. @@ -195,8 +198,11 @@ test`: the compile and the tests. The gate runs on the JDK `mise.toml` pins (Temurin 25, with Maven 3.9). The toolchain floor is declared separately: `maven.compiler.release` 17 in -`pom.xml`, so the jars run on Java 17. CI, once it lands, proves the floor by -running the tests on each supported JDK; the gate JDK alone does not. +`pom.xml`, so the jars run on Java 17. CI (`.github/workflows/ci.yml`) runs +the full gate on the `mise.toml` JDK and proves the floor by running the +tests on Temurin 17, 21 and 25: the code is compiled on the gate JDK, with +Error Prone, and only the test JVM moves to each JDK; the gate JDK alone does +not prove the floor. ### This repo's own gate rules @@ -256,7 +262,7 @@ satellite conventions rather than inventing a second set. test-only seam lives in the library source (`src/main/java/`). - **Java 17 or later** (`maven.compiler.release` 17), built with Maven, with Spotless (google-java-format), Error Prone, JUnit 5 and JaCoCo in the gate, - and the tests run on Java 17, 21 and 25 in CI once CI lands (ruled by the + and the tests run on Java 17, 21 and 25 in CI (ruled by the operator, 2026-10-21). The coverage bar is 90 percent of lines (decided by the conductor under a standing consent, 2026-10-21). - **One module.** The library's `module-info.java` declares the module @@ -269,7 +275,8 @@ satellite conventions rather than inventing a second set. form the gate rules above give. - Changelog: a change a user of the API could notice adds a fragment under `changelog.d/`, named for the bead (`changelog.d/uxjv-abc.md`), in the same - request; tests, docs, tooling and gate changes take none. New version + request; tests, docs, tooling, CI and gate changes take none + (`changelog.d/README.md` states the rule and the format). New version sections in `CHANGELOG.md` keep the file's own heading shape; existing history is not rewritten. - Commit messages: simple present tense ("Adds ...", "Fixes ..."), body diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000..a3633d6 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,80 @@ +# Changelog fragments + +Changelog entries for unreleased work live here, one file per issue, not as +edits to `CHANGELOG.md`. At release the fragments are assembled into one +version section of `CHANGELOG.md` and deleted. + +## Why fragments + +Work happens in one worktree per issue, so several branches are usually open +at once. If each appended to the top of `CHANGELOG.md`, every branch would +touch the same lines of the same file and nearly every pull request would +conflict with every other. A fragment is named for its issue, so no two +branches write the same file. + +## When a change needs a fragment + +The changelog serves people who use the library. Write a fragment for a +change a user of the API could notice: + +- a public class, interface, record, enum, method, constructor or error code + added, changed or removed +- a change in observable behavior, including what an id looks like and what + generation, decoding or the validity checks accept or refuse +- a change in the supported Java versions, the module name, the exported + package, the Maven coordinate or the runtime dependencies +- a bug fix a user could have noticed +- anything breaking + +Write none for tests, documentation, tooling, CI, the release workflow or +gate changes. If you are unsure, ask whether someone who only calls the +public API could tell the difference; if not, skip it. + +## Format + +One file per issue, named for its beads issue id, in the same pull request as +the change: + + changelog.d/uxjv-abc.md + +The file holds a Keep a Changelog kind heading and one bullet per change; an +illustration of the shape, not a change this library has made: + +```markdown +### Fixed + +- The validity check answers `false` for a `usr` id when the caller requires the `cus` prefix. +``` + +- The kind headings are `### Added`, `### Changed`, `### Deprecated`, + `### Removed`, `### Fixed` and `### Security`. A file may carry more than + one when the issue genuinely spans them. +- Bullets are `- `, one per change, in the present tense, saying what the + change means to a user. No nested bullets: detail belongs in the pull + request and the commit body. +- A breaking change opens its bullet with `Breaking:` and says what a caller + does about it, not only what broke. +- A change in the vendored conformance corpus that changes an id this library + produces or accepts is visible to every consumer that stores ids: say what + changes on the consumer's side. +- Examples stay domain-free: neutral resource prefixes such as `usr` or + `cus`, and fictional `@example.com` addresses. + +## At release + +The release prep assembles the fragments into a new version section at the +top of `CHANGELOG.md`, at the `##` level the file's own `## Unreleased` +heading uses: `## X.Y.Z - YYYY-MM-DD`. The entries are grouped under the kind +headings, `### Added` and the rest, in the order Added, Changed, Deprecated, +Removed, Fixed, Security; within one kind, fragments go in file-name order and +each fragment's bullets keep their order and their `- ` marker. A `Breaking:` +bullet keeps that word. The sections already released in `CHANGELOG.md` are +not rewritten. The fragments are deleted in the same commit. + +The prep (the version bump in `pom.xml`, the version in the README's +dependency lines, the promoted section and the deleted fragments) merges on +its own, and then its merged commit is tagged `vX.Y.Z`. The release workflow, +`.github/workflows/release.yml`, publishes on that tag push (CLAUDE.md, +"Release preps"). Because the prep consumes every fragment, it merges last: +a branch still holding an unmerged fragment when the prep lands waits for the +next release. diff --git a/pom.xml b/pom.xml index 556c39a..4b9c16d 100644 --- a/pom.xml +++ b/pom.xml @@ -144,6 +144,18 @@ spotless-maven-plugin 3.10.4 + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + org.sonatype.central + central-publishing-maven-plugin + 0.12.0 + @@ -313,4 +325,95 @@ + + + + + release + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + release-jar-sums + verify + + enforce + + + + + ${project.build.directory}/${project.build.finalName}.jar + sha256 + ${uxid.release.sha256.jar} + The main jar differs from the one the release workflow verified; nothing is signed or published. + + + ${project.build.directory}/${project.build.finalName}-sources.jar + sha256 + ${uxid.release.sha256.sources} + The sources jar differs from the one the release workflow verified; nothing is signed or published. + + + ${project.build.directory}/${project.build.finalName}-javadoc.jar + sha256 + ${uxid.release.sha256.javadoc} + The javadoc jar differs from the one the release workflow verified; nothing is signed or published. + + + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign + verify + + sign + + + bc + true + MAVEN_GPG_PRIVATE_KEY + MAVEN_GPG_PASSPHRASE + + + + + + + org.sonatype.central + central-publishing-maven-plugin + true + + central + true + published + 3600 + ${project.groupId}:${project.artifactId}:${project.version} + + + + + +