diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 186c599f0..2534e82a7 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -11,7 +11,9 @@ on: push: branches: [ "main" ] tags: - - 'v*' + # Digit required so a non-release tag that merely starts with `v` (`vendor-x`) can't cut a + # GitHub Release + Homebrew bump. Matches release.yml's trigger. + - 'v[0-9]*' # Allows manual triggering from Actions tab workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 619166de5..d137a63a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,17 @@ name: release on: push: tags: - - '**' + # Release tags only. `**` also matched throwaway tags (`backup/…`), which would have run a + # publish at the SNAPSHOT version from `gradle.properties` — a Maven Central release happens + # when, and only when, a `vYYYY.MM.DD` release tag is pushed. The digit in the pattern keeps + # non-release tags that merely start with `v` (`vendor-x`) from triggering at all; the + # compute step below enforces the full tag shape. + - 'v[0-9]*' + +# The job publishes to Maven Central via its own secrets; the GITHUB_TOKEN needs nothing beyond +# checkout. +permissions: + contents: read env: GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" @@ -33,9 +43,70 @@ jobs: - name: Generate WASM Report Template run: ./gradlew :trailblaze-report:generateReportTemplate -Ptrailblaze.wasm=true + - name: Compute Maven release version + id: maven_version + # Maven coordinate = -, e.g. + # `0.1.0-2026.08.11`. Two independent jobs, deliberately: + # + # * the semver base carries the API-compatibility signal and is bumped by hand in + # gradle.properties when the contract moves; + # * the tag's date records when the build was cut. + # + # The semver prefix is load-bearing, not decoration. A bare CalVer coordinate + # (`2026.08.11`) sorts ABOVE any future `1.0.0` in both Gradle's conflict resolution and + # Maven's ComparableVersion — 2026 > 1 on the first numeric part — which would foreclose + # a stable semver line permanently, with no epoch escape hatch short of renaming the + # artifacts. `0.9.0-` < `1.0.0`, so this shape keeps that door open. + # + # Corollary worth knowing before changing this: 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 the + # version you intend to release bare — use numbered `-rc.N` qualifiers (`1.0.0-rc.1`, + # `1.0.0-rc.2`), which both tools sort below the final release. The dot is load-bearing: + # it's what keeps `rc.10` above `rc.2`. + run: | + # Only vYYYY.MM.DD[.N] tags may publish. With `automaticRelease = true` there is no + # portal gate after this step, and Maven Central is immutable — a stray tag that merely + # starts with `v` (e.g. `vendor-x`) would otherwise ship `0.1.0-endor-x` forever. This + # also neutralizes shell-metacharacter tag names before the version reaches any shell. + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]]; then + echo "::error::Tag '$GITHUB_REF_NAME' does not match vYYYY.MM.DD[.N]; refusing to publish." + exit 1 + fi + base="$(sed -n 's/^version=\(.*\)-SNAPSHOT$/\1/p' gradle.properties)" + # Strict .. only. Also catches a duplicate version= line (sed then + # emits two lines) and a malformed base that neither CalVer check would spot. + if [[ ! "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version= in gradle.properties must be ..-SNAPSHOT; extracted base '$base'" + exit 1 + fi + version="${base}-${GITHUB_REF_NAME#v}" + # Belt and braces with `gradle/maven-version-guard.gradle.kts`, which fails the publish + # tasks themselves. Asserting here too means a bad version dies before Gradle starts, + # and keeps the rule visible to anyone editing this file rather than only enforced three + # directories away. + case "$version" in + [0-9][0-9][0-9][0-9].*) + echo "::error::Refusing to publish '$version': a bare CalVer coordinate sorts above any future 1.0.0 in Gradle and Maven. Expected ..-." + exit 1 + ;; + esac + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Publishing Maven artifacts as $version" + - name: Publish Artifacts - run: ./gradlew publishAllPublicationsToMavenCentralRepository -Ptrailblaze.wasm=true + # `-Pversion` is the one input that reaches BOTH this build and the composite-included + # `trailblaze-android-gradle` build, so the Gradle plugin ships under the same version as + # the libraries instead of re-publishing the SNAPSHOT default from its own + # `gradle.properties`. It also outranks `gradle/git-version.gradle.kts`'s git-describe + # tag override, which would otherwise set the bare CalVer version. + # + # The version rides in via env rather than `${{ }}` interpolation into the script text: + # it derives from the tag name, and inlining untrusted input into a `run:` that holds the + # publish secrets is the classic template-injection shape (zizmor). The strict tag check + # above already constrains it, but the env route costs nothing. + run: ./gradlew publishAllPublicationsToMavenCentralRepository -Pversion="$MAVEN_VERSION" -Ptrailblaze.wasm=true env: + MAVEN_VERSION: ${{ steps.maven_version.outputs.version }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_CENTRAL_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_CENTRAL_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} diff --git a/.github/workflows/snapshot-release.yml b/.github/workflows/snapshot-release.yml index bef79c80b..d1d15b0d7 100644 --- a/.github/workflows/snapshot-release.yml +++ b/.github/workflows/snapshot-release.yml @@ -35,6 +35,9 @@ jobs: run: ./gradlew :trailblaze-report:generateReportTemplate -Ptrailblaze.wasm=true - name: Publish Artifacts + # No `-Pversion` here — snapshots publish at the `gradle.properties` SNAPSHOT version, which + # goes straight to the snapshot repository. Only a `v*` tag (see release.yml) produces a + # Maven Central release. run: ./gradlew publishAllPublicationsToMavenCentralRepository -Ptrailblaze.wasm=true env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_CENTRAL_USERNAME }} diff --git a/build.gradle.kts b/build.gradle.kts index c010dc2d6..5d1ec4343 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -81,24 +81,37 @@ subprojects { } // The OSS release workflow on `block/trailblaze` runs -// `./gradlew publishAllPublicationsToMavenCentralRepository` from this root. That command -// finds matching tasks across all subprojects in this build, but composite-included builds -// (registered via `settings.gradle.kts`'s `pluginManagement.includeBuild(...)`) are isolated -// — their publish tasks must be delegated explicitly or external consumers never receive the -// published artifact + plugin marker. Register a root-level aggregator per included plugin -// build so the existing release command transitively picks them up. -val trailblazeAndroidGradlePluginPublishMavenCentral = - tasks.register("trailblazeAndroidGradlePluginPublishMavenCentral") { +// `./gradlew publishAllPublicationsToMavenCentralRepository` from this root. Gradle resolves that +// name against every project in *this* build, but composite-included builds (registered via +// `settings.gradle.kts`'s `pluginManagement.includeBuild(...)`) are isolated — their publish tasks +// must be delegated explicitly or external consumers never receive the published artifact + plugin +// marker. +// +// Registering the delegating aggregator under the SAME name the release command already asks for +// is what pulls the included build in. The root project applies no `maven-publish`/vanniktech +// plugin, so it contributes no task of that name to collide with; the name match is the whole +// mechanism. (A previous `tasks.matching { name == ... }.configureEach { dependsOn(...) }` hook +// here never fired for exactly that reason — the root task container it filtered was empty — so +// `trailblaze-android-gradle` was silently absent from every release.) +// +// vanniktech also registers `publishToMavenCentral` / `publishAndReleaseToMavenCentral` aliases +// on every publishing subproject, so those names are delegated too — otherwise a manual +// `./gradlew publishToMavenCentral` would silently reintroduce the missing-plugin bug for that +// invocation. +// +// NOTE: `--dry-run` does NOT extend into the included build — Gradle executes delegated +// included-build tasks for REAL even under `-m`. Don't "sanity check" these aggregators with +// publish credentials exported. +listOf( + "publishAllPublicationsToMavenCentralRepository", + "publishToMavenCentral", + "publishAndReleaseToMavenCentral", +).forEach { publishTaskName -> + tasks.register(publishTaskName) { group = "publishing" - description = - "Publishes the included trailblaze-android-gradle build to Maven Central." - dependsOn( - gradle.includedBuild("trailblaze-android-gradle") - .task(":publishAllPublicationsToMavenCentralRepository"), - ) + description = "Publishes the included trailblaze-android-gradle build to Maven Central." + dependsOn(gradle.includedBuild("trailblaze-android-gradle").task(":$publishTaskName")) } -tasks.matching { it.name == "publishAllPublicationsToMavenCentralRepository" }.configureEach { - dependsOn(trailblazeAndroidGradlePluginPublishMavenCentral) } // Apply shared dependency-resolution forces (version pins) so every configuration resolves the @@ -108,6 +121,12 @@ apply(from = "gradle/dependency-resolution.gradle.kts") // Apply shared git version computation apply(from = "gradle/git-version.gradle.kts") +// Hard-fail any Maven Central publish attempted at a bare CalVer version. Applied AFTER +// git-version.gradle.kts, which is the thing most likely to produce one: it sets +// `version = 2026.08.11` on every build made from a `v*` tag, which is exactly the state a +// release runs in. +apply(from = "gradle/maven-version-guard.gradle.kts") + subprojects .forEach { it.plugins.withId("com.android.library") { @@ -126,7 +145,17 @@ subprojects it.afterEvaluate { if (it.plugins.hasPlugin("com.vanniktech.maven.publish.base")) { it.extensions.getByType(MavenPublishBaseExtension::class.java).also { publishing -> - publishing.publishToMavenCentral() + // `automaticRelease = true` is what actually makes a tagged release land on Maven + // Central. Without it the Central Portal deployment is uploaded as USER_MANAGED and + // parks in https://central.sonatype.com/publishing/deployments waiting for someone to + // click Publish — the Gradle build still reports BUILD SUCCESSFUL, which is how every + // release between 0.0.2 and now produced no consumable artifact. `validateDeployment` + // stays at its default `true` so the build blocks on the deployment reaching a terminal + // state and goes red on FAILED instead of succeeding into the void. + // + // SNAPSHOT builds are unaffected: they publish straight to the snapshot repository and + // never create a deployment, so the flag is inert on the `main` snapshot workflow. + publishing.publishToMavenCentral(automaticRelease = true) publishing.signAllPublications() publishing.pom { url.set("https://www.github.com/block/trailblaze") diff --git a/docs/android_on_device.md b/docs/android_on_device.md index c5650e209..c92055acd 100644 --- a/docs/android_on_device.md +++ b/docs/android_on_device.md @@ -6,6 +6,30 @@ Install `trailblaze` on your `PATH` first — `brew install block/tap/trailblaze quickest path. The instructions below assume the CLI is reachable so on-device instrumentation tests can be authored and run against the sample project under `examples/`. +### Add the instrumentation dependency +`trailblaze-android` carries the on-device driver and `AndroidTrailblazeRule`, and exposes what +the rule's API surface touches (JUnit, UiAutomator, `trailblaze-common`) as `api` dependencies. +Add `androidx.test:runner` alongside it — the `AndroidJUnitRunner` that executes the tests is +not part of the published graph: +```kotlin +// build.gradle.kts +dependencies { + androidTestImplementation("xyz.block.trailblaze:trailblaze-android:") + androidTestRuntimeOnly("androidx.test:runner:1.7.0") +} +``` +(If you pass a custom `llmClient` to the rule, also declare the matching +`ai.koog:prompt-executor-*-client` dependency — the client implementations are not re-exported.) +Releases are published to Maven Central under the +[`xyz.block.trailblaze`](https://central.sonatype.com/namespace/xyz.block.trailblaze) group, and +versioned `MAJOR.MINOR.PATCH-YYYY.MM.DD` — for example `0.1.0-2026.08.11`. The semver prefix is +the API-compatibility signal; the date records when that build was cut. While the prefix is +`0.x`, treat the API as unstable: any release may change it. + +Builds off `main` are published as `0.1.0-SNAPSHOT` to +`https://central.sonatype.com/repository/maven-snapshots/` if you want to track unreleased work. +Maven timestamps each snapshot deployment, so you can also pin an exact build +(`0.1.0-20260811.010555-1`) rather than the moving `-SNAPSHOT`. ### Pass your LLM Provider API Key to Instrumentation 1. Set up your provider API key on the development machine in your shell environment. The environment variable names are defined in `LlmProviderEnvVarUtil`. diff --git a/gradle/maven-version-guard.gradle.kts b/gradle/maven-version-guard.gradle.kts new file mode 100644 index 000000000..62948c7f9 --- /dev/null +++ b/gradle/maven-version-guard.gradle.kts @@ -0,0 +1,69 @@ +/** + * Hard gate: refuse to publish a bare CalVer coordinate (`2026.08.11`) to Maven Central. + * + * Publishing one is a one-way door. `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 part — + * so once a CalVer release exists, a later semver line is silently out-ranked by every older + * build: a transitive dependency on the CalVer artifact wins over a direct `1.0.0`. Maven + * coordinates have no epoch, so the only exits are renaming the artifacts or staying on CalVer + * forever. + * + * This is easy to do by accident here, which is why it is a build failure rather than a + * convention. Releases are tagged `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. Publishing from a tagged checkout without an explicit + * `-Pversion` would ship CalVer with nothing else to stop it. + * + * The rule: the leading component of a published version must not be a calendar year. A published + * version is `..[-]`, and the build date — when present — rides in + * the qualifier (`0.1.0-2026.08.11`), never in front. `.github/workflows/release.yml` derives + * exactly that shape. + * + * Deliberately scoped to the Maven Central publish path, not applied at configuration time: the + * git-version override sets a CalVer version on every ordinary build made from a tagged commit, + * and those must keep working. `publishToMavenLocal` is likewise left alone. + */ + +// No realistic major version reaches four digits; every calendar year does. +val calVerLeadingComponent = 1000 + +fun assertNotCalendarVersion(target: String, version: String) { + // Gradle and Maven both split version parts on `.`, `-`, AND `_`, so a dash/underscore CalVer + // (`2026-08-11`) outranks `1.0.0` exactly like the dotted form and must be caught too. A plain + // `substringBefore('.')` would let it through: "2026-08-11".toIntOrNull() is null. + val leading = version.split('.', '-', '_').first().toIntOrNull() ?: return + if (leading < calVerLeadingComponent) return + throw GradleException( + """ + Refusing to publish $target at version '$version'. + + Its leading component ($leading) is a calendar year, making this a bare CalVer coordinate. + A CalVer version sorts ABOVE any future semver release — '$version' beats '1.0.0' in both + Gradle and Maven — which permanently forecloses a stable semver line for these artifacts. + There is no epoch escape hatch in Maven coordinates. + + Publish '..' with the build date in the qualifier instead, e.g. + '0.1.0-$version'. That is what .github/workflows/release.yml derives from the release tag; + a publish run that bypasses it (or a local `-Pversion=`) has to supply the same shape. + """.trimIndent(), + ) +} + +allprojects { + // `prepareMavenCentralPublishing` is the earliest task on the Central path — it runs before the + // signing and publish tasks and is what registers the project with the upload build service, so + // failing here means nothing is staged, signed, or uploaded. The PublishToMavenRepository gate + // below is the backstop for any path that reaches a remote repository without it. + tasks.matching { it.name == "prepareMavenCentralPublishing" }.configureEach { + val path = project.displayName + val versionAtExecution = project.provider { project.version.toString() } + doFirst { assertNotCalendarVersion(path, versionAtExecution.get()) } + } + + tasks.withType(org.gradle.api.publish.maven.tasks.PublishToMavenRepository::class.java) + .configureEach { + val path = project.displayName + val versionAtExecution = project.provider { project.version.toString() } + doFirst { assertNotCalendarVersion(path, versionAtExecution.get()) } + } +} diff --git a/trailblaze-android-gradle/README.md b/trailblaze-android-gradle/README.md index 5b9bfb40b..28697ad06 100644 --- a/trailblaze-android-gradle/README.md +++ b/trailblaze-android-gradle/README.md @@ -40,9 +40,11 @@ pure-boilerplate ones. ## Apply -The plugin is published to Maven Central. The plugin marker is not on the -Gradle Plugin Portal, so add Maven Central to your `pluginManagement` block -first (skip this step if your `settings.gradle.kts` already lists it): +The plugin is published to Maven Central as +[`xyz.block.trailblaze:trailblaze-android-gradle`](https://central.sonatype.com/artifact/xyz.block.trailblaze/trailblaze-android-gradle), +versioned in lockstep with the rest of the `xyz.block.trailblaze` artifacts. The plugin marker is +not on the Gradle Plugin Portal, so add Maven Central to your `pluginManagement` block first +(skip this step if your `settings.gradle.kts` already lists it): ```kotlin // settings.gradle.kts diff --git a/trailblaze-android-gradle/build.gradle.kts b/trailblaze-android-gradle/build.gradle.kts index e9d21b067..bfac768eb 100644 --- a/trailblaze-android-gradle/build.gradle.kts +++ b/trailblaze-android-gradle/build.gradle.kts @@ -7,6 +7,11 @@ plugins { alias(libs.plugins.vanniktech.maven.publish) } +// Same bare-CalVer publish gate the including build applies. This is a separate Gradle build, so +// the root's `apply(from = ...)` does not reach it — and it is the build most exposed to the +// mistake, since it takes its release version from a `-Pversion` that propagates in from outside. +apply(from = "../gradle/maven-version-guard.gradle.kts") + gradlePlugin { // The public-facing OSS plugin id. External consumers reach this via: // plugins { id("xyz.block.trailblaze.android-gradle") version "..." } @@ -55,7 +60,11 @@ dependencies { // jar is on; javadoc jar is off because the only public API is the plugin's id + extension // (documented in this module's README). mavenPublishing { - publishToMavenCentral() + // `automaticRelease = true` matches the root `opensource/build.gradle.kts` subprojects block. + // Without it the Central Portal deployment uploads as USER_MANAGED and waits for a manual + // Publish click while the build reports success — see the comment there. Inert for SNAPSHOT + // versions, which bypass the deployment flow entirely. + publishToMavenCentral(automaticRelease = true) // Signing only kicks in when signing credentials are present (`signing.signingInMemoryKey` // etc.). `build` / `check` still work in CI without secrets — only the `publish` task // requires them. Matches the posture every other vanniktech-published module in this repo diff --git a/trailblaze-android-gradle/gradle.properties b/trailblaze-android-gradle/gradle.properties index 1fc0e8378..da7dadd0f 100644 --- a/trailblaze-android-gradle/gradle.properties +++ b/trailblaze-android-gradle/gradle.properties @@ -6,10 +6,15 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Dorg.slf4j.simpleLogger.defaultLogLevel=off kotlin.code.style=official -# Maven artifact coordinate. `group` mirrors every other published OSS Trailblaze artifact; -# `version` is bumped manually alongside coordinated Trailblaze releases (the open-source -# version cadence is `vYYYY.MM.DD`, but the published library/plugin artifact version uses -# semver — `0.x` while the public API is still settling). +# Maven artifact coordinate. `group` mirrors every other published OSS Trailblaze artifact. +# `version` here is only the SNAPSHOT default that `main` merges publish under; a release run +# overrides it with `-Pversion=-` from `.github/workflows/release.yml`, +# which propagates from the including build into this composite-included one. That keeps the +# plugin on the same coordinate as the libraries it generates test shells for. +# +# Keep the semver base in lockstep with the root `opensource/gradle.properties`: the release +# version derives its base from the root file, so a divergence here only shows up as mismatched +# SNAPSHOT coordinates on `main`. group=xyz.block.trailblaze version=0.1.0-SNAPSHOT