A Gradle plugin that detects changes in the current branch and runs only the unit and integration tests relevant to those changes. No seed run required — it works immediately.
Target stack: Gradle 8+ (primary support: 9.x), Java 21+, JUnit 5
// build.gradle
plugins {
id 'io.github.vedanthvdev.affectedtests' version 'x.y.z'
}Check Gradle Plugin Portal for the latest version.
./gradlew affectedTest./gradlew affectedTest --explainPrints the full decision trace — bucket counts, situation, action, and the tier of the priority ladder (explicit onXxx / mode default) that picked each action — without running a single test. Useful when a CI run escalated to the full suite and the operator needs to know why before filing a bug.
When outOfScopeTestDirs / outOfScopeSourceDirs are configured but zero files in the diff land in the out-of-scope bucket and the situation is DISCOVERY_SUCCESS or DISCOVERY_EMPTY, the trace emits a one-line Hint: pointing at the configured knob. That's the silent-failure trap a real adopter hit: a perfectly valid-looking glob that never bit anything, which the plugin would otherwise only surface after a 30-minute full-suite CI run. The hint is suppressed on EMPTY_DIFF, ALL_FILES_IGNORED, ALL_FILES_OUT_OF_SCOPE, and UNMAPPED_FILE because those branches ran the way they did for reasons an out-of-scope pattern could not have influenced.
Sample output:
=== Affected Tests — decision trace (--explain) ===
Base ref: origin/master
Mode: AUTO (effective: LOCAL)
Changed files: 3
Buckets:
ignored 1
out-of-scope 0
production .java 1
test .java 0
unmapped 1
ignored sample: README.md
production sample: src/main/java/com/example/Foo.java
unmapped sample: build.gradle
Situation: UNMAPPED_FILE
Action: FULL_SUITE (source: mode default)
Outcome: FULL_SUITE — onUnmappedFile=FULL_SUITE — non-Java or unmapped file in diff
Action matrix (situation → action [source]):
EMPTY_DIFF SKIPPED [mode default]
ALL_FILES_IGNORED SKIPPED [mode default]
ALL_FILES_OUT_OF_SCOPE SKIPPED [mode default]
UNMAPPED_FILE FULL_SUITE [mode default]
DISCOVERY_INCOMPLETE SELECTED [mode default]
DISCOVERY_EMPTY SKIPPED [mode default]
DISCOVERY_SUCCESS SELECTED [explicit onXxx setting]
=== end --explain ===
That's it. With zero config, the plugin will:
- Diff against
origin/master(including uncommitted + staged changes). - Route each changed file through one of five buckets: ignored (
*.md, LICENSE, CHANGELOG, images,**/generated/**), out-of-scope, production.java, test.java, or unmapped (everything else, e.g.application.yml). - Pick a discovery strategy — naming, usage, impl, transitive — and merge their results into one test set.
- Follow 4 levels of transitive dependencies (tuned for typical controller → service → repository chains).
- Fall through to the full suite if it encounters an unmapped file, so a YAML/Gradle/Liquibase diff never ships without tests.
- run: ./gradlew affectedTest -PaffectedTestsBaseRef=${{ github.event.pull_request.base.sha }}./gradlew affectedTest -PaffectedTestsBaseRef=origin/mainMake sure to use
fetch-depth: 0sogit diffhas access to the full history.
Every invocation resolves to exactly one Situation and exactly one Action, both of which appear in the log line so an operator can tell — at a glance — why the plugin chose what it did.
Every affectedTest run prints exactly one summary line in the form Affected Tests: <OUTCOME> (<SITUATION>) — <details>:
Affected Tests: SELECTED (DISCOVERY_SUCCESS) — 3 changed file(s), 2 production class(es), 5 test class(es) affected
Affected Tests: FULL_SUITE (UNMAPPED_FILE) — 1 changed file(s); running full suite (onUnmappedFile=FULL_SUITE — non-Java or unmapped file in diff).
Affected Tests: SKIPPED (ALL_FILES_IGNORED) — 1 changed file(s); every changed file matched ignorePaths.
The outcome (SELECTED / FULL_SUITE / SKIPPED) and the situation that produced it are first-class fields on every branch, so CI dashboards can grep -E '^Affected Tests: (SELECTED|FULL_SUITE|SKIPPED)' and bucket runs without parsing the tail.
On a SELECTED outcome, the task also prints the first few FQNs per module at lifecycle level so a reviewer can sanity-check the dispatch from the default CI log without rerunning with --info:
Running 17 affected test classes across 2 module(s):
application:test (12 test classes)
com.example.auth.LoginControllerTest
com.example.auth.TokenServiceTest
com.example.auth.PasswordHasherTest
com.example.auth.SessionRepositoryTest
com.example.auth.RoleMapperTest
… and 7 more (use --info for full list)
api:test (5 test classes)
com.example.api.PublicEndpointsTest
com.example.api.RateLimitTest
com.example.api.VersionHeaderTest
com.example.api.ErrorFormatTest
com.example.api.HealthProbeTest
The preview caps at five FQNs per module; --info still surfaces the full per-FQN list. The cap exists so a utility change that ripples into hundreds of test classes can't blow past the 4 MiB GitHub Actions per-step log cap before the nested test output even starts.
| Situation | Fires when |
|---|---|
EMPTY_DIFF |
git diff produced no files at all. |
ALL_FILES_IGNORED |
Every file in the diff matched ignorePaths (e.g. a docs-only MR). |
ALL_FILES_OUT_OF_SCOPE |
Every file sat under outOfScopeTestDirs or outOfScopeSourceDirs (e.g. a Cucumber/api-test-only MR). |
UNMAPPED_FILE |
The diff contains at least one file the plugin cannot resolve to a Java class under sourceDirs/testDirs (e.g. application.yml, build.gradle, a Liquibase changelog). |
DISCOVERY_EMPTY |
Mapping succeeded but the discovery strategies returned zero tests. |
DISCOVERY_SUCCESS |
Mapping + discovery produced a non-empty test set. |
| Action | Meaning |
|---|---|
SELECTED |
Run only the discovered affected tests. |
FULL_SUITE |
Run the entire test suite (no --tests filter). |
SKIPPED |
Exit 0 without running tests. |
Every situation gets an independently-configurable action. The matrix is resolved in strict priority order: explicit onXxx setting → mode profile default. Zero-config installs always resolve to a concrete mode via Mode.AUTO detection, so nothing you configure today silently regresses tomorrow.
mode seeds the defaults for situations you haven't explicitly configured:
| Mode | EMPTY_DIFF |
ALL_FILES_IGNORED |
ALL_FILES_OUT_OF_SCOPE |
UNMAPPED_FILE |
DISCOVERY_EMPTY |
DISCOVERY_INCOMPLETE |
|---|---|---|---|---|---|---|
local |
SKIPPED | SKIPPED | SKIPPED | FULL_SUITE | SKIPPED | SELECTED |
ci |
SKIPPED | SKIPPED | SKIPPED | FULL_SUITE | FULL_SUITE | FULL_SUITE |
strict |
FULL_SUITE | FULL_SUITE | SKIPPED | FULL_SUITE | FULL_SUITE | FULL_SUITE |
auto |
Detects CI / GITHUB_ACTIONS / GITLAB_CI / JENKINS_HOME and resolves to ci or local. |
Leaving mode unset picks auto, which resolves to local or ci depending on the environment. The UNMAPPED_FILE → FULL_SUITE safety net is the default in every built-in mode, so a zero-config install still escalates on unmapped files without any DSL wiring.
All settings have sensible defaults. Override only what you need.
affectedTests {
// Git base ref to diff against (default: "origin/master")
baseRef = "origin/main"
// Include uncommitted/staged changes (default: false — committed-only).
// The plugin ships COMMITTED-ONLY so a local run matches the MR diff
// CI will pick up on the same HEAD, and running the task twice in a
// row produces the same test selection regardless of workstation state.
// Flip to `true` locally when you are iterating on tests and want
// WIP to seed the diff. Never enable these in CI — the tree is
// already clean after checkout there, so they are pure noise.
includeUncommitted = false
includeStaged = false
// v2 profile. "auto" is the recommended migration target.
// See the "Mode profiles" table above.
// (default: unset — preserves pre-v2 defaults)
mode = "auto"
// ---------------- Path buckets (v2) ----------------
// Files that must not influence test selection (docs, LICENSE,
// CHANGELOG, images, generated sources). When every file in the
// diff matches ignorePaths, the engine lands on ALL_FILES_IGNORED.
// The defaults already cover markdown/text/LICENSE/CHANGELOG/images
// at both the repo root and nested paths — you usually only extend this.
ignorePaths = ["**/*Dto.java"]
// Test source sets the plugin must not dispatch via the affectedTest
// task (e.g. Cucumber, Gatling). A diff entirely under these dirs
// routes to ALL_FILES_OUT_OF_SCOPE → SKIPPED by default.
//
// Entries may be either:
// • literal directory prefixes — "api-test/src/test/java" matches
// that path at the repo root or under any module
// (e.g. "services/orders/api-test/src/test/java/..."), and
// "api-test" (no source-dir suffix) never claims sibling names
// like "api-test-utils/...";
// • Ant-style globs — "api-test/**" or "**/api-test/**" — using
// the standard JVM glob syntax ("*", "**", "?", "[abc]", "{a,b}").
//
// Mix both shapes freely; the plugin picks the right semantics per
// entry. If you configure this knob but see "Hint:" on --explain
// saying zero files matched, your paths/globs don't bite — double
// check them (that's the silent-failure trap sanity testing caught).
outOfScopeTestDirs = ["api-test/**", "performance-test/**"]
// Production source sets the plugin must treat as out-of-scope.
// Accepts the same literal-prefix / glob shapes as outOfScopeTestDirs.
outOfScopeSourceDirs = []
// ---------------- Per-situation actions (v2) ----------------
// Each takes one of "selected" | "full_suite" | "skipped".
// Any value left unset falls back through mode → pre-v2 default.
onEmptyDiff = "skipped"
onAllFilesIgnored = "skipped"
onAllFilesOutOfScope = "skipped"
onUnmappedFile = "full_suite" // the key MR-safety knob
onDiscoveryEmpty = "full_suite" // belt-and-braces for CI
// Fires when any scanned Java file failed to parse (malformed source,
// half-committed refactor, encoding glitch). Mode defaults: CI / STRICT
// escalate to full_suite (selection is known to be under-reported so
// safety wins); LOCAL stays on selected (the developer sees the WARN
// and values iteration speed on a branch they're actively editing).
onDiscoveryIncomplete = "full_suite"
// ---------------- Child-process deadline (v1.9.22) ----------------
// Wall-clock timeout in seconds for the nested `./gradlew :module:test`
// invocation. 0 (the default) disables the watchdog and matches
// pre-v1.9.22 behaviour — the task waits forever. Positive values
// spawn the child under a ProcessBuilder watchdog: on timeout the
// task runs destroy() → 10s grace → destroyForcibly() → 5s reap and
// then fails the build so a hung test never holds the CI worker
// hostage. Note: the watchdog path uses inheritIO for the child's
// stdio, so Develocity build-scan stream capture of the nested
// output is not available when a timeout is set — leave at 0 and
// enforce the deadline at the CI-job level if you rely on scan
// ingestion of child-process output.
//
// Negative values are rejected. Since v2.1 the range check fires at
// configuration time (IDE sync / `./gradlew help`) rather than only
// when the task executes, so a typo like `-5` is caught without
// needing to actually launch `affectedTest`.
gradlewTimeoutSeconds = 1800 // 30 min; use 3600 for suites with integration tests
// ---------------- Discovery tuning ----------------
// Discovery strategies: "naming", "usage", "impl", "transitive" (default: all four)
strategies = ["naming", "usage", "impl", "transitive"]
// Transitive dependency depth — used when the "transitive" strategy is enabled.
// Raised from 2 → 4 in v2 because typical Spring controller→service→repo
// chains are 2–3 deep; 4 gives a margin without producing runaway sets.
// (default: 4, max: 5, 0 = disabled)
transitiveDepth = 4
// Test class suffixes (default: ["Test", "IT", "ITTest", "IntegrationTest"])
testSuffixes = ["Test", "IT", "ITTest", "IntegrationTest"]
// Source directories (default: ["src/main/java"])
sourceDirs = ["src/main/java"]
// Test directories (default: ["src/test/java"])
testDirs = ["src/test/java"]
// Include tests for implementations of changed interfaces (default: true)
includeImplementationTests = true
// Implementation naming prefixes/suffixes — "Impl" matches FooImpl for Foo;
// "Default" matches DefaultFoo for Foo, which is idiomatic in Spring code.
// (default: ["Impl", "Default"])
implementationNaming = ["Impl", "Default"]
}The pipeline is five stages: detect what changed, bucket each path (ignored / out-of-scope / production / test / unmapped), resolve the Situation, discover the tests impacted by the in-scope Java classes, and execute only that subset — or the full suite, or nothing at all — based on the Action the Situation maps to.
Source: docs/architecture.mmd · regenerate with npx --yes @mermaid-js/mermaid-cli -i docs/architecture.mmd -o docs/architecture.svg -b transparent
All four strategies run against every changed production class. Their results are merged (union), so a test is run if any strategy identifies it. The goal is maximum coverage — running a few extra tests is always preferable to missing one.
| Strategy | What it does | Example |
|---|---|---|
| naming | For each changed class Foo, looks for test files named FooTest, FooIT, FooITTest, FooIntegrationTest (configurable suffixes). Purely file-name based — no parsing required. |
FooService changed → finds FooServiceTest, FooServiceIT |
| usage | Parses every test file with JavaParser and checks whether it references any changed class. Uses a two-tier approach: (1) direct import match — if the test has import com.example.FooService;, it's affected regardless of how it uses the class; (2) type-reference scan for wildcard imports (import com.example.*) and same-package usage (no import needed). Catches fields, method parameters, return types, constructor calls, generics, and casts. |
BarModel changed → finds BarValidatorTest (imports it), BazMapperTest (same package, uses it as field type) |
| impl | When an interface or base class changes, scans all production source files to find classes that extends or implements the changed type (via AST) and classes following the *Impl or Default* naming convention. Then re-runs the naming and usage strategies on those implementations. |
FooService (interface) changed → finds FooServiceImpl and DefaultFooService → finds FooServiceImplTest / DefaultFooServiceTest |
| transitive | Builds a reverse dependency map of all production classes: for each class, which other classes depend on it (via field types). When a class changes, walks this "used-by" graph N levels deep (configurable, default 4, max 5) to find consumers. Then runs naming + usage on those consumers. | BazGateway changed → FooService uses it (depth 1) → OrdersController uses FooService (depth 2) → finds both tests via naming |
The plugin scans the project tree recursively at any depth to find source and test directories. It is completely project-structure agnostic — it does not assume any particular module layout. Whether your modules are flat (api/src/test/java), nested (services/orders/src/test/java), or deeply nested (platform/services/orders/src/test/java), all test files are discovered.
Directories like .git, build, .gradle, and node_modules are automatically skipped during the walk. outOfScopeTestDirs and outOfScopeSourceDirs are additionally filtered at index time so discovery never picks up tests living there.
Any test file that is itself modified in the diff is always included in the run, regardless of strategy results.
The plugin works out of the box with multi-module projects — it recursively scans all modules at any nesting depth, so no configuration is needed.
Internally, each discovered test FQN is traced back to the Gradle subproject that owns its file, and tests are then dispatched per module:
./gradlew :moduleA:test --tests com.example.FooTest \
:moduleB:test --tests com.example.BarTest
This makes --tests filters scope cleanly to their owning module, instead of being applied globally and failing on any subproject that doesn't happen to contain the FQN. Cross-module imports (e.g. a test in application that imports a class from api) are still detected correctly via the usage and impl strategies.
Every row below shows the situation the engine resolved, and the action applied with the default configuration (no mode set, no explicit onXxx).
| Diff contents | Resolved Situation |
Default Action |
Override knob |
|---|---|---|---|
Only mapped production/test .java files |
DISCOVERY_SUCCESS (or DISCOVERY_EMPTY if no tests map) |
SELECTED |
discovery tuning |
Only files matching ignorePaths (docs, LICENSE, CHANGELOG, images, generated) |
ALL_FILES_IGNORED |
SKIPPED |
onAllFilesIgnored or mode=strict |
Only files under outOfScopeTestDirs / outOfScopeSourceDirs (e.g. api-test only) |
ALL_FILES_OUT_OF_SCOPE |
SKIPPED |
onAllFilesOutOfScope |
Any YAML / Gradle / Liquibase / .java outside configured dirs |
UNMAPPED_FILE |
FULL_SUITE (via onUnmappedFile = "full_suite") |
onUnmappedFile = "selected" |
| No changed files at all | EMPTY_DIFF |
SKIPPED |
onEmptyDiff = "full_suite" / mode = strict |
| Mapping succeeds but discovery returns zero tests | DISCOVERY_EMPTY |
SKIPPED — or FULL_SUITE if mode=ci/strict |
onDiscoveryEmpty / mode |
At least one scanned .java file failed to parse (malformed / half-committed / encoding glitch) |
DISCOVERY_INCOMPLETE (takes precedence over both DISCOVERY_EMPTY and DISCOVERY_SUCCESS) |
SELECTED — or FULL_SUITE if mode=ci/strict |
onDiscoveryIncomplete / mode |
| Mixed diff: Java + unmapped file | UNMAPPED_FILE (takes precedence) |
FULL_SUITE |
onUnmappedFile — set to "selected" to fall through to discovery |
baseRef not resolvable |
FAILED |
Hard error (prevents silent test skipping in CI) | — |
| Not a git work tree / JGit I/O error | FAILED |
Hard error | — |
The onUnmappedFile = "full_suite" default follows the "run more, never run less" principle: a change to application.yml can alter production behaviour just as surely as a change to a .java file, so the plugin cannot safely pick a subset from an empty Java mapping.
v2.0.0 removed the three v1 legacy knobs. If any of these still appear in your build.gradle, Gradle configuration will fail before the affectedTest task runs:
runAllIfNoMatchesrunAllOnNonJavaChangeexcludePaths
Since v2.1.0, the error is targeted: instead of Gradle's generic "unknown property" message, you'll see a migration hint naming the exact v2 replacement knob. For example, runAllIfNoMatches = false now fails with:
> affectedTests.runAllIfNoMatches was removed in v2.0.0. Use
onEmptyDiff = "full_suite" and/or onDiscoveryEmpty = "full_suite"
instead (or set mode = "ci" / "strict" to get those defaults). See
CHANGELOG.md v2.0 for the full migration table.
Shims exist for all three legacy knobs; Kotlin DSL callers already get a compile error naming the removed property.
| Release | What happens |
|---|---|
| v1.9.x and earlier | Legacy knobs worked silently. No warnings. |
| v1.10.x – v1.11.x | Legacy knobs still worked. A per-run WARNING: [affected-tests] '<knob>' is deprecated… named each one and its replacement. Zero-config users saw nothing. |
| v2.0.0 (this release) | Legacy knobs removed. excludePaths, runAllIfNoMatches, runAllOnNonJavaChange are unknown properties — Gradle configuration fails. Migrate using the table below. |
| v1 config | v2 equivalent | Why |
|---|---|---|
runAllIfNoMatches = true |
onEmptyDiff = "full_suite" and onDiscoveryEmpty = "full_suite" |
The v1 flag conflated two different situations ("git diff is empty" vs "discovery found nothing"). v2 splits them so you can e.g. skip empty-diff runs but still fall back to full suite when discovery fails. |
runAllIfNoMatches = false (explicit) |
Set mode = "local", or pin onEmptyDiff = "skipped" + onDiscoveryEmpty = "skipped" explicitly. Do not just delete the line — in v2 the zero-config mode = "auto" resolves to ci in a CI runner, and ci escalates DISCOVERY_EMPTY to FULL_SUITE. A v1 pipeline that set runAllIfNoMatches = false specifically to prevent the discovery-empty branch from flipping to full suite will start running the full suite on every no-match MR unless one of these two knobs is pinned. |
|
runAllOnNonJavaChange = true |
onUnmappedFile = "full_suite" |
Single-situation knob, same semantics. |
runAllOnNonJavaChange = false |
onUnmappedFile = "selected" |
Plugin treats the unmapped file as if absent and continues to discovery. |
excludePaths = ["**/generated/**"] |
ignorePaths = ["**/generated/**"] |
Identical semantics — just a rename. |
excludePaths = [] (explicit empty) |
Delete the line | v2's default ignorePaths list is broader (markdown, licence, changelog, images, generated). Explicitly empty discards all of it. |
Before (v1):
affectedTests {
baseRef = "origin/master"
runAllIfNoMatches = true
runAllOnNonJavaChange = true
excludePaths = ["**/generated/**"]
transitiveDepth = 4
}After (v2):
affectedTests {
baseRef = "origin/master"
mode = "ci" // one line replaces both booleans in 95% of cases
// excludePaths / ignorePaths unset — v2 default already covers generated/
// transitiveDepth = 4 now the default, no need to set it
}Or if you want every situation explicit (recommended for production pipelines where you care about each edge case):
affectedTests {
baseRef = "origin/master"
onEmptyDiff = "full_suite"
onAllFilesIgnored = "skipped"
onAllFilesOutOfScope = "skipped"
onUnmappedFile = "full_suite"
onDiscoveryEmpty = "full_suite"
}Did you set runAllIfNoMatches?
├─ No → nothing to do for this knob
├─ runAllIfNoMatches = true
│ └─ set onEmptyDiff = "full_suite" AND onDiscoveryEmpty = "full_suite"
│ (or just set mode = "ci" / mode = "strict" — both imply it)
└─ runAllIfNoMatches = false
└─ DO NOT just delete the line. In v2, zero-config AUTO-in-CI
escalates DISCOVERY_EMPTY to FULL_SUITE. Either:
· pin mode = "local", OR
· pin onEmptyDiff = "skipped" + onDiscoveryEmpty = "skipped"
Did you set runAllOnNonJavaChange?
├─ No → nothing to do
├─ runAllOnNonJavaChange = true → set onUnmappedFile = "full_suite"
│ (or just delete the line — it's the default)
└─ runAllOnNonJavaChange = false → set onUnmappedFile = "selected"
Did you set excludePaths?
├─ No → nothing to do
├─ excludePaths = [] → delete the line (you probably want the broader v2 default)
└─ excludePaths = [...] → rename to ignorePaths with the same list
Every affectedTest run prints the outcome + situation + the v2 knob that fired on one line, so CI dashboards can key off a stable vocabulary:
Affected Tests: FULL_SUITE (UNMAPPED_FILE) — 1 changed file(s); running full suite (onUnmappedFile=FULL_SUITE — non-Java or unmapped file in diff).
Breaking-change note for grep-based alerting: v1 summary lines carried both the v1 name (runAllOnNonJavaChange=true) and the v2 name (onUnmappedFile=FULL_SUITE) to ease migration. v2.0 drops the v1 vocabulary. Any CI alert rules keyed on runAllIfNoMatches=true, runAllOnNonJavaChange=true, or the [affected-tests] '…' is deprecated warning must be updated to the v2 knob names.
affected-tests/
├── affected-tests-core/ # Git integration, change detection, test discovery
├── affected-tests-gradle/ # Gradle plugin (io.github.vedanthvdev.affectedtests)
├── docs/
│ ├── DESIGN-v2.md # v2 design document (situation/action/mode model)
│ ├── architecture.mmd # Mermaid source for the architecture diagram
│ └── architecture.svg # Rendered diagram embedded in README
├── build.gradle
├── settings.gradle
└── README.md
| Component | Version |
|---|---|
| Gradle | 8.x+, primary support on 9.x |
| Java | 21+ |
| JUnit | 5.x |
| Spring Boot | Compatible (no Boot-specific code) |
- JGit — Git change detection (no native git binary required)
- JavaParser — Source-level test discovery (usage + implementation strategies)
- SLF4J — Logging
See CHANGELOG.md for the release history. GitHub's auto-generated release notes (one entry per tag, backed by the merged PR titles) are on the Releases page.
Versions are managed automatically via axion-release — derived from git tags, never hardcoded in source. The release workflow (.github/workflows/release.yml) runs on every push to master and cuts a patch release by default. For minor or major bumps, trigger the workflow manually with a version input.
| Goal | How |
|---|---|
| Check what version this branch is | ./gradlew currentVersion |
Auto patch release (e.g. 1.9.12 → 1.9.13) |
Merge to master — the release workflow does the rest |
Force a minor or major release (e.g. 1.9.x → 1.10.0) |
GitHub → Actions → Release → Run workflow → fill version (e.g. 1.10.0), or run gh workflow run release.yml --ref master -f version=1.10.0 |
| Pin a minor/major on a merge-to-master release (no dispatch) | Commit a .release-version file at repo root containing the SemVer (e.g. 2.1.0) in the same PR whose merge should ship that version. The release workflow tags that version, publishes it, then auto-deletes the file in a [skip ci] follow-up commit so the next push falls back to auto-patch-increment. |
| Release-candidate / pre-release | Trigger Run workflow with version: 1.10.0-RC1, or locally: ./gradlew release -Prelease.versionIncrementer=incrementPrerelease |
| Manually re-run a failed publish | Re-trigger the workflow on the already-tagged commit — portal-check + release-check steps are idempotent |
Note: ./gradlew markNextVersion -Prelease.version=X.Y.0 influences only what currentVersion reports on that branch. It does not change what ./gradlew release picks, because the release task still follows the default patch incrementer unless -Prelease.version=X.Y.Z is passed at release time. Use the workflow dispatch above for minor/major bumps instead.
Apache 2.0