Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
75 changes: 73 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = <semver base from gradle.properties>-<date from the tag>, 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-<date>` < `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 <major>.<minor>.<patch> 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 <major>.<minor>.<patch>-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 <major>.<minor>.<patch>-<date>."
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 }}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/snapshot-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
63 changes: 46 additions & 17 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") {
Expand All @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions docs/android_on_device.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>")
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`.
Expand Down
69 changes: 69 additions & 0 deletions gradle/maven-version-guard.gradle.kts
Original file line number Diff line number Diff line change
@@ -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 `<major>.<minor>.<patch>[-<qualifier>]`, 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 '<major>.<minor>.<patch>' 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()) }
}
}
8 changes: 5 additions & 3 deletions trailblaze-android-gradle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion trailblaze-android-gradle/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "..." }
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading