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
81 changes: 70 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,25 +58,62 @@ jobs:
echo "already_tagged=false" >> "$GITHUB_OUTPUT"
fi

# Pick the release version. Three sources in priority order:
#
# 1. `workflow_dispatch` input — for ad-hoc manual releases.
# Wins over everything because it's the most explicit.
# 2. A `.release-version` file committed at repo root — for
# pre-committing the next minor/major on a push-to-master
# release without needing manual dispatch coordination. The
# consuming PR lands the file in the same commit that wants
# the non-default version; this workflow picks it up, tags
# that version, then deletes the file in a follow-up commit
# so subsequent pushes fall back to auto-patch-increment.
# 3. Neither — axion-release auto-increments the patch number
# off the latest tag. Normal ongoing behaviour.
#
# The file path is validated as a SemVer string before use so a
# malformed value blocks the release at config time rather than
# cutting a bogus tag.
- name: Resolve explicit release version
id: version_override
run: |
if [[ -n "$RELEASE_VERSION_INPUT" ]]; then
echo "Using workflow_dispatch input: $RELEASE_VERSION_INPUT"
echo "source=input" >> "$GITHUB_OUTPUT"
echo "value=$RELEASE_VERSION_INPUT" >> "$GITHUB_OUTPUT"
elif [[ -f .release-version ]]; then
V=$(tr -d '[:space:]' < .release-version)
if [[ -z "$V" ]]; then
echo "::error::.release-version file exists but is empty"
exit 1
fi
if ! [[ "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then
echo "::error::.release-version value '$V' is not SemVer (MAJOR.MINOR.PATCH[-prerelease])"
exit 1
fi
echo "Using .release-version file: $V"
echo "source=file" >> "$GITHUB_OUTPUT"
echo "value=$V" >> "$GITHUB_OUTPUT"
else
echo "No explicit version — axion-release will auto-increment patch."
echo "source=auto" >> "$GITHUB_OUTPUT"
fi

# Only create a new tag if HEAD is not already tagged. If a previous run
# tagged successfully but failed during publish, re-running this workflow
# will skip tagging and proceed straight to the "publish if missing" step
# below — that's what makes the pipeline idempotent.
#
# Version override: on workflow_dispatch with a non-empty `version`
# input, pass `-Prelease.version=<VERSION>` so axion-release tags that
# exact version instead of auto-incrementing the patch. On push events,
# `RELEASE_VERSION_INPUT` is empty and the call stays identical to the
# pre-override form.
- name: Release (tag and push)
if: steps.tag_check.outputs.already_tagged == 'false'
env:
VERSION_OVERRIDE: ${{ steps.version_override.outputs.value }}
VERSION_OVERRIDE_SOURCE: ${{ steps.version_override.outputs.source }}
run: |
VERSION_ARG=()
if [[ -n "$RELEASE_VERSION_INPUT" ]]; then
echo "Explicit release version requested: $RELEASE_VERSION_INPUT"
VERSION_ARG=("-Prelease.version=$RELEASE_VERSION_INPUT")
else
echo "No explicit version — axion-release will auto-increment patch."
if [[ "$VERSION_OVERRIDE_SOURCE" != "auto" ]]; then
echo "Explicit release version requested (source=$VERSION_OVERRIDE_SOURCE): $VERSION_OVERRIDE"
VERSION_ARG=("-Prelease.version=$VERSION_OVERRIDE")
fi
./gradlew release -Prelease.pushTagsOnly \
"${VERSION_ARG[@]}" \
Expand Down Expand Up @@ -140,3 +177,25 @@ jobs:
else
echo "GitHub Release $TAG already exists — skipping"
fi

# If the release version came from a `.release-version` file, delete
# the file now that the tag has been pushed, the plugin has been
# published, and the GitHub Release exists. Leaving the file in place
# would either re-ship the same version on the next push (rejected
# by Plugin Portal → failed job) or starve auto-patch-increment.
#
# The cleanup commit is tagged `[skip ci]` so this workflow does
# not trigger itself in a loop. Only runs when we actually tagged
# (already_tagged == 'false') AND the version came from the file —
# a workflow_dispatch input override must not consume the file, and
# a rerun against an already-tagged HEAD must not touch the tree.
- name: Consume .release-version override
if: steps.version_override.outputs.source == 'file' && steps.tag_check.outputs.already_tagged == 'false'
env:
TAG: ${{ steps.released.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git rm .release-version
git commit -m "chore(release): consume .release-version after shipping $TAG [skip ci]"
git push origin "HEAD:$GH_REF_NAME"
1 change: 1 addition & 0 deletions .release-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.1.0
101 changes: 101 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,107 @@ adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [v2.1.0] — DSL polish on top of the v2 breaking release

v2.1.0 is the **first publicly tagged v2 release**. It bundles everything
the v2.0 branch landed in master (the legacy-knob removal below) with two
small-but-sharp polish fixes that surfaced during a real-world pilot of
v2.0 against a Modulr micro-service. The polish fixes are strictly
additive on top of v2.0 behaviour — operators already on the v2 DSL see
no functional change, only earlier and clearer errors when something is
misconfigured.

### Changed — targeted error messages when v1 knobs appear in build.gradle

Before v2.1, a v1 user who dropped `runAllIfNoMatches = false` into their
`affectedTests { }` block got Gradle's default unknown-property error:

```
> Could not set unknown property 'runAllIfNoMatches' for extension
'affectedTests' of type io.affectedtests.gradle.AffectedTestsExtension.
```

Correct, but unhelpful — it names the removed knob without naming the v2
replacement, forcing the operator to grep the CHANGELOG to find the fix.

v2.1 intercepts the assignment on the extension and swaps that generic
error for a targeted migration hint pointing at the exact `onXxx` knob
that took over each responsibility:

```
> 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.
```

Matching shims for `runAllOnNonJavaChange` and `excludePaths` point at
`onUnmappedFile` and at the `ignorePaths` vs `outOfScopeTestDirs`
distinction respectively. The v1 names are still rejected — v2 does not
re-introduce the knobs — but the rejection is now actionable.

Kotlin DSL callers already got a compile error naming the removed
property, so these shims only fire in Groovy DSL, which is where the
generic error lived.

Regression coverage:

- `AffectedTestsPluginTest.legacyKnobAssignmentThrowsWithV2MigrationHint_runAllIfNoMatches`
- `AffectedTestsPluginTest.legacyKnobAssignmentThrowsWithV2MigrationHint_runAllOnNonJavaChange`
- `AffectedTestsPluginTest.legacyKnobAssignmentThrowsWithV2MigrationHint_excludePaths`

Each pins both the v1 knob name *and* the v2 replacement name in the
error text, so a well-intentioned "tidy up the error message" refactor
that drops either half gets caught.

### Added — `.release-version` override file for merge-to-master minor/major releases

Until v2.1, the only way to ship a minor/major bump instead of an
auto-patch-increment was to run the release workflow via
`workflow_dispatch` with an explicit `version` input. That meant every
minor release required coordination between "merge the PR" and
"manually trigger the workflow" — and if the auto-release on the merge
push got there first, it would mint an unwanted patch tag the operator
then had to work around.

v2.1 teaches `.github/workflows/release.yml` a third version source: a
`.release-version` file committed at repo root. When the merge-to-master
release workflow finds the file, it reads the SemVer string, validates
it, and tags that exact version instead of auto-incrementing the patch.
After a successful publish the workflow deletes the file in a follow-up
commit tagged `[skip ci]` (so the cleanup push doesn't re-trigger the
release), which keeps ongoing patch releases on auto-increment.

Priority order on `./gradlew release`:

1. `workflow_dispatch` `version` input (manual dispatch — unchanged).
2. `.release-version` file at repo root (new in v2.1).
3. Auto-patch-increment (default — unchanged).

This release itself uses the file mechanism to ship as `v2.1.0` on
top of the auto-patch-increment line that would otherwise have cut
`v1.9.24`. See README §Versioning for the decision matrix.

### Changed — `gradlewTimeoutSeconds` range check moved to configuration time

The `gradlewTimeoutSeconds >= 0` check added in v1.9.22 lived on the
core config builder, which is only invoked at task-execution time. That
meant an invalid value like `gradlewTimeoutSeconds = -5` passed through
configuration silently and only blew up when someone actually ran
`./gradlew affectedTest`. IDE sync, `./gradlew help`, and
`./gradlew tasks` all ran green against a misconfigured build.

v2.1 adds a mirror check in `AffectedTestsPlugin#apply` via
`project.afterEvaluate`, so the same error now fires at configuration
completion. IDE sync and any dry Gradle invocation now surface the
misconfiguration immediately. The builder-side check stays in place as
belt-and-braces for programmatic callers that bypass the DSL extension.

Regression coverage:
`AffectedTestsPluginTest.negativeGradlewTimeoutFailsAtConfigurationTime`
walks the thrown exception chain end-to-end and pins the knob name, the
rejected value, and the `>= 0` range bound in the error message.

### Removed — v2.0 breaking release: legacy v1 knobs are gone

The three v1 configuration knobs that were deprecated across the v1.9.x
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ affectedTests {
// 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 ----------------
Expand Down Expand Up @@ -340,12 +345,23 @@ The `onUnmappedFile = "full_suite"` default follows the "run more, never run les

### Migrating from v1 config

**v2.0.0 removed the three v1 legacy knobs.** If any of these still appear in your `build.gradle`, Gradle configuration will fail with an unknown-property error before the `affectedTest` task runs:
**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:

- `runAllIfNoMatches`
- `runAllOnNonJavaChange`
- `excludePaths`

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.

#### Deprecation timeline

| Release | What happens |
Expand Down Expand Up @@ -482,6 +498,7 @@ Versions are managed automatically via [axion-release](https://github.com/allegr
| 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 |

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package io.affectedtests.gradle;

import java.util.List;

import org.gradle.api.GradleException;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.Property;

Expand Down Expand Up @@ -244,10 +247,82 @@ public abstract class AffectedTestsExtension {
*
* <p>Recommended values: {@code 1800} (30 min) for merge-gate
* unit-test runs, {@code 3600} (1 hour) for suites that include
* integration tests. Must be {@code >= 0}; the core config
* builder rejects negative values at build-config time.
* integration tests. Must be {@code >= 0}; the plugin rejects
* negative values at {@code afterEvaluate} (configuration) time
* and again at task-execution time as belt-and-braces.
*
* @return the gradlew timeout property in seconds
*/
public abstract Property<Long> getGradlewTimeoutSeconds();

// ------------------------------------------------------------------
// v2 migration hints for the three legacy knobs removed in v2.0.
//
// The properties themselves are gone, so Gradle's default behaviour
// for `affectedTests.runAllIfNoMatches = false` is a terse
// "Could not set unknown property 'runAllIfNoMatches'" — correct
// but unhelpful for operators reading a v1 build.gradle.
//
// These shim setters are invoked by Groovy DSL assignment
// (`ext.foo = bar` maps to `setFoo(bar)` on the managed type) and
// replace the terse error with an actionable v2 replacement
// pointing at the exact `onXxx` knob to use. Kotlin DSL callers
// get a compile error referencing the removed property, which is
// already clearer than Groovy's runtime error, so no shim needed
// there.
//
// If v2 ever adds real `runAllIfNoMatches` / `runAllOnNonJavaChange`
// / `excludePaths` semantics back (unlikely — the v2 names are
// strictly richer), delete these shims and re-introduce the
// abstract Property<T> getters.
// ------------------------------------------------------------------

/**
* Migration shim: {@code runAllIfNoMatches} was removed in v2.0.
* Intercepts a v1-style assignment in Groovy DSL and raises a
* targeted migration error instead of Gradle's generic
* "unknown property" failure.
*
* @param ignored v1 boolean value (never read)
* @throws GradleException always, with a v2 replacement hint
*/
@SuppressWarnings("unused")
public void setRunAllIfNoMatches(Object ignored) {
throw new GradleException(
"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.");
}

/**
* Migration shim: {@code runAllOnNonJavaChange} was removed in v2.0.
*
* @param ignored v1 boolean value (never read)
* @throws GradleException always, with a v2 replacement hint
*/
@SuppressWarnings("unused")
public void setRunAllOnNonJavaChange(Object ignored) {
throw new GradleException(
"affectedTests.runAllOnNonJavaChange was removed in v2.0.0. "
+ "Use onUnmappedFile = \"full_suite\" (v1 true) or "
+ "onUnmappedFile = \"selected\" (v1 false) instead. "
+ "See CHANGELOG.md v2.0 for the full migration table.");
}

/**
* Migration shim: {@code excludePaths} was removed in v2.0.
*
* @param ignored v1 list value (never read)
* @throws GradleException always, with a v2 replacement hint
*/
@SuppressWarnings("unused")
public void setExcludePaths(List<?> ignored) {
throw new GradleException(
"affectedTests.excludePaths was removed in v2.0.0. "
+ "Use ignorePaths (glob patterns that bypass test selection) "
+ "or outOfScopeTestDirs (test dirs the affectedTest task never dispatches) "
+ "depending on intent. See CHANGELOG.md v2.0 for the full migration table.");
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.affectedtests.gradle;

import org.gradle.api.GradleException;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.file.Directory;
Expand Down Expand Up @@ -88,6 +89,21 @@ public void apply(Project project) {
rootProject.allprojects(p -> p.getPluginManager().withPlugin("java", unused ->
task.dependsOn(p.getTasks().named("testClasses"))));
});

// Validate scalar-range constraints at configuration completion so
// operators get feedback during IDE sync / a dry `./gradlew help`
// run instead of having to execute the task to see a negative
// timeout get rejected. The task-side builder keeps its own
// range check as belt-and-braces for programmatic callers that
// bypass the extension.
project.afterEvaluate(p -> {
Long timeout = extension.getGradlewTimeoutSeconds().getOrNull();
if (timeout != null && timeout < 0L) {
throw new GradleException(
"affectedTests.gradlewTimeoutSeconds must be >= 0 "
+ "(0 disables the timeout); got " + timeout);
}
});
}

/**
Expand Down
Loading