Skip to content

Fix Maven Central publishing and version artifacts as <semver>-<date> - #235

Merged
handstandsam merged 5 commits into
mainfrom
fix-maven-central-publishing
Aug 11, 2026
Merged

Fix Maven Central publishing and version artifacts as <semver>-<date>#235
handstandsam merged 5 commits into
mainfrom
fix-maven-central-publishing

Conversation

@handstandsam

@handstandsam handstandsam commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #226.

After this PR: pushing a vYYYY.MM.DD tag publishes every library module and the trailblaze-android-gradle plugin to Maven Central as 0.1.0-YYYY.MM.DD, fully automatically. Merges to main keep publishing 0.1.0-SNAPSHOT to the Central snapshot repository, unchanged. Today the last consumable release is 0.0.2 (July 2025) and the Gradle plugin has never been published, despite every release run since reporting success.

Why nothing landed since 0.0.2

Three independent defects:

1. Deployments uploaded but never published. publishToMavenCentral() defaults to automaticRelease = false: under the Central Portal API the deployment uploads as USER_MANAGED and waits for a manual Publish click in the portal — while Gradle prints BUILD SUCCESSFUL. (The v2026.08.07 job log ends with Skipping deployment validation!, emitted from inside that upload block.) Now automaticRelease = true, and the default validateDeployment = true blocks the job until Central reaches a terminal state — a rejected deployment turns the job red instead of green-but-published-nothing.

2. The Gradle plugin was never in the release task graph. The aggregator delegating into the composite-included trailblaze-android-gradle build hung off tasks.matching { name == "publishAllPublicationsToMavenCentralRepository" } in the root project, which applies no publish plugin — that container is empty, so nothing ever depended on the aggregator. Registering the delegating task under that name (plus vanniktech's publishToMavenCentral / publishAndReleaseToMavenCentral aliases) is what makes the release command reach the included build.

3. The included build would have released its own SNAPSHOT default. It reads its own gradle.properties and never sees the root's git-tag version override. The workflow now passes -Pversion explicitly, which propagates into the composite (verified with an init-script probe across all three builds), putting the plugin on the same coordinate as the libraries.

Versioning: <semver base>-<tag date>

The tag stays vYYYY.MM.DD, but the Maven coordinate is not the bare tag. 2026.08.11 sorts above any future 1.0.0 in both Gradle's conflict resolution and Maven's ComparableVersion (2026 beats 1 on the first numeric component), so one bare-CalVer release would permanently foreclose a stable semver line — Maven coordinates have no epoch escape hatch. Only 0.0.1/0.0.2 exist on Central, so the door is still open; this PR is what keeps it open.

The version is <semver base from gradle.properties>-<date from the tag>, e.g. 0.1.0-2026.08.14:

  • the base carries the API-compatibility signal and is bumped by hand (in both gradle.properties files, root and plugin) only when the contract moves — it is not bumped per release;
  • the date records when the build was cut.

Ordering verified against both Gradle 8.14's real conflict resolution and Maven's ComparableVersion — they agree on every case:

A B winner
0.0.2 (current Central max) 0.1.0-2026.08.14 the new release
0.1.0-2026.08.11 0.1.0-2026.09.01 later date
0.1.0-2026.08.11 0.2.0-2026.08.01 higher base — base dominates date
0.9.0-2026.12.01 1.0.0 1.0.0 — graduating to a bare stable release works
1.0.0 2026.08.11 2026.08.11 — the trap this scheme avoids

One rule the shape imposes, documented at the derivation site: a dated build of the same base outranks the bare version (1.0.0-2026.11.01 > 1.0.0), so never ship dated builds of a version you intend to release bare — numbered -rc.N qualifiers (1.0.0-rc.1) sort below the final in both tools, and the dot keeps rc.10 above rc.2.

Guardrails: bare CalVer can never ship

Nothing structurally prevented the mistake: gradle/git-version.gradle.kts sets version = 2026.08.14 on any build whose HEAD carries a v* tag — exactly the state a release runs in — so one workflow line was the only defense. Now:

  • gradle/maven-version-guard.gradle.kts hard-fails any Maven-Central publish whose version's leading component is a calendar year, hooked into prepareMavenCentralPublishing (before anything is staged, signed, or uploaded) and every PublishToMavenRepository as backstop. It splits on ., -, and _ — Gradle and Maven treat all three as part separators, so 2026-08-11 is as poisonous as 2026.08.11. Applied by the root build and (separately — the root's apply() can't reach a composite build) by trailblaze-android-gradle. Ordinary builds, publishToMavenLocal, and SNAPSHOT publishing are untouched.
  • Tag triggers tightened to v[0-9]* (release + build-desktop; ** previously matched backup/… tags), and release.yml asserts the full vYYYY.MM.DD[.N] shape plus a strict <major>.<minor>.<patch> base — with automaticRelease there is no portal gate left, so a stray vendor-x tag would otherwise ship immutable 0.1.0-endor-x clutter to Central. The base check also catches a duplicated version= line.
  • No tag-derived text is interpolated into the publish script: the computed version reaches Gradle via env: rather than ${{ }} inside the run: that holds the publish secrets, and the workflow declares permissions: contents: read.

Docs

docs/android_on_device.md now names the trailblaze-android coordinate (nothing in the docs ever did), including the androidx.test:runner line that is not part of the published graph, and explains the version format. The plugin README links its Central artifact.

Verification

  • Release-command --dry-run before/after: the included build was absent from the graph before, present after, alongside all 14 subproject publish tasks.
  • -Pversion and ORG_GRADLE_PROJECT_* propagation into all three composite builds probed directly.
  • Version-ordering matrix run against both Gradle and Maven (table above).
  • Guard exercised live in both builds: 2026.08.11 and 2026-08-11 blocked before any staging; 0.1.0-2026.08.11 passes through to the real upload; SNAPSHOT unaffected.
  • Merge-then-tag race checked: the snapshot job's checkout (depth 1, no tags) cannot see a release tag, so it can never build at the CalVer version — and the guard would block it anyway.
  • ./gradlew -p trailblaze-android-gradle publishToMavenLocal produces jar, sources jar, .module, POM, and the xyz.block.trailblaze.android-gradle.gradle.plugin marker POM.
  • mkdocs build --strict passes. No javadoc-jar risk: 0.0.2 published sources-only under the same JavadocJar.None() config.
  • Adversarially reviewed (Gradle semantics + CI flow); all findings fixed and folded in above.

Operational notes

  • A release creates two Portal deployments (the main build and the included build each run their own build service) that upload and publish independently — a half-red release is possible and would be visible as a failed job.
  • The Portal account holds a backlog of unpublished USER_MANAGED deployments, one per tag since the Portal migration, each at a bare CalVer version. They are invisible to consumers and block nothing — but whoever has portal access should drop them (not publish: publishing any one of them springs the CalVer trap permanently).
  • --dry-run does not extend into the included build — delegated tasks execute for real. Don't sanity-check the publish aggregators locally with credentials exported (now warned in a comment at the aggregator).

The last consumable release is 0.0.2 (July 2025). Since the vanniktech plugin
moved to the Central Portal API, every tagged release has uploaded its
deployment as USER_MANAGED — it parks in the Portal waiting for a manual
Publish click while Gradle reports BUILD SUCCESSFUL. `automaticRelease = true`
releases it, and the default `validateDeployment` turns a Central rejection
into a red job instead of a green one that published nothing.

`trailblaze-android-gradle` has never published at all. The aggregator that
delegates into the composite-included build hung off `tasks.matching { name ==
"publishAllPublicationsToMavenCentralRepository" }` in the root project, which
applies no publish plugin and therefore has no task of that name — nothing ever
depended on it. Registering the aggregator *under* that name is what makes the
release command reach the included build.

That included build reads its own gradle.properties and never saw the git-tag
version override, so it would have released its SNAPSHOT default. release.yml
now passes `-Pversion=<tag minus v>`, which propagates into the composite and
puts the plugin on the same YYYY.MM.DD coordinate as the libraries.

Release triggers narrow from `**` to `v*`: throwaway tags like `backup/…`
matched and would have run a publish at the SNAPSHOT version. Snapshot
publishing on `main` is unchanged — SNAPSHOT versions bypass the deployment
flow entirely, so `automaticRelease` is inert there.

Fixes #226
A bare `2026.08.11` coordinate sorts ABOVE any future `1.0.0` in both Gradle's
conflict resolution and Maven's ComparableVersion — 2026 beats 1 on the first
numeric part — so publishing CalVer would foreclose a stable semver line
permanently, with no epoch escape hatch short of renaming the artifacts. Since
only 0.0.1/0.0.2 have ever been released, the door is still open.

The Maven version is now `<semver base from gradle.properties>-<tag date>`, e.g.
`0.1.0-2026.08.11`. The base carries the API-compatibility signal and is bumped
by hand; the date says when the build was cut. Verified in both Gradle and Maven:
the date orders within a base, the base dominates the date, and `0.9.0-<date>`
< `1.0.0`, so graduating to a bare stable release works.

One rule this shape imposes, documented at the derivation site: a dated build of
the same base outranks the bare version (`1.0.0-2026.11.01` > `1.0.0`), so don't
ship dated builds of a version you intend to release bare — `-rc-<date>` sorts
below the final in both tools.
@handstandsam
handstandsam force-pushed the fix-maven-central-publishing branch from 004484d to 39db0cf Compare August 11, 2026 14:25
Comment thread .github/workflows/release.yml Fixed
Nothing structurally prevented a CalVer coordinate from shipping. The release
tag is `vYYYY.MM.DD`, so the tag name is the obvious thing to feed to
`-Pversion`, and `gradle/git-version.gradle.kts` already sets
`version = 2026.08.11` on any build whose HEAD carries a release tag — which is
exactly the state a release runs in. One correct line in release.yml was the
only thing standing between a tagged build and an irreversible mistake.

`gradle/maven-version-guard.gradle.kts` fails the publish if the version's
leading component is a calendar year, wired into `prepareMavenCentralPublishing`
(the earliest task on the Central path, so nothing is staged, signed, or
uploaded) and every PublishToMavenRepository as a backstop. Applied by both the
root build and the trailblaze-android-gradle composite build, which needs its
own copy — a separate Gradle build the root's apply() doesn't reach, and the one
most exposed since its version arrives via propagated `-Pversion`.

Scoped to the Central publish path on purpose: the git-version override puts a
CalVer version on every ordinary build made from a tagged commit, and those must
keep working. publishToMavenLocal is untouched.

release.yml asserts the same rule before Gradle starts, so the constraint stays
visible where the version is composed.

Verified: bare CalVer blocked on both builds; `0.1.0-2026.08.11` passes the
guard and proceeds to the real upload; SNAPSHOT builds unaffected.
Both tools sort -rc-<date> and -rc.N below the bare release, but numbered
rc qualifiers are the established convention and the dot is what keeps
rc.10 sorting above rc.2.
Four fixes from a pre-merge review pass:

- The CalVer guard split only on '.', so a dash/underscore CalVer version
  (2026-08-11) passed it while still outranking 1.0.0 — Gradle and Maven split
  version parts on '.', '-', and '_' equivalently. The guard now splits on all
  three.
- Tag triggers tightened from v* to v[0-9]* (release.yml and build-desktop.yml),
  and release.yml now asserts the full vYYYY.MM.DD[.N] shape plus a strict
  <major>.<minor>.<patch> check on the extracted base. With automaticRelease
  there is no portal gate left, so a stray tag like 'vendor-x' would otherwise
  have shipped immutable 0.1.0-endor-x clutter to Central; the base check also
  catches a duplicate version= line in gradle.properties.
- The computed version now reaches the publish step via env instead of ${{ }}
  interpolation into the run script — it derives from the tag name, and inlining
  it into the step that holds the publish secrets is the standard
  template-injection shape. Also: permissions: contents: read.
- The root build delegates publishToMavenCentral and
  publishAndReleaseToMavenCentral into the included build too; those vanniktech
  alias names would otherwise reintroduce the missing-plugin bug on manual runs.
  A comment warns that --dry-run executes delegated included-build tasks for
  real.

Docs: the "one coordinate" claim was wrong — androidx.test:runner is not in
trailblaze-android's published graph (only monitor, as implementation), so the
snippet now includes it, and custom-llmClient consumers are pointed at the koog
client dependency.
@handstandsam handstandsam changed the title Fix Maven Central publishing so tagged releases actually land Fix Maven Central publishing and version artifacts as <semver>-<date> Aug 11, 2026
@handstandsam
handstandsam merged commit 822156b into main Aug 11, 2026
13 checks passed
@handstandsam
handstandsam deleted the fix-maven-central-publishing branch August 11, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish trailblaze-android-gradle & (and a current trailblaze-android) so the instrumentation route is consumable

2 participants