Skip to content

Commit afa4d80

Browse files
committed
Move legacy command compatibility tests into an end-to-end build
Addresses the outstanding review feedback on the Grails 7 command compatibility layer. Build placement. The Grails 7 fixture needs Java 17 - the minimum for a Grails 7 app, so the binary matches what a real Grails 7 plugin is built with - but expressing that as a Gradle toolchain put an unprovisionable JDK requirement into the core build's task graph. Nothing in the repo provisions a 17 (no foojay resolver, no toolchainManagement, no org.gradle.java.installations.*), and because the fixture jar was an implementation dependency, `./gradlew build -PskipTests` reached it - so the build failed on the JDK the project documents in .sdkmanrc and in the reproducible-build container. CI only passed because the runner images happen to ship a 17 that Gradle auto-detects. The three projects now live in a new top-level end-to-end build, so the root build no longer reaches them. The fixture declares its JDK and Gradle version in its own .sdkmanrc, matching what Grails 7 pins (17 and 8.14.5) rather than what this repository builds with, and is excluded from the gradle-bootstrap wrapper propagation for that reason; the end-to-end build itself is added to that propagation and tracks the root. A dedicated workflow provisions both JDKs, reading the versions out of those files. Resolution goes through published artifacts rather than project substitution, which is what makes these tests end-to-end: they consume grails-core the way an application does, through real poms and module metadata. The repository is the same build/local-maven that grails-forge points its generated applications at, populated by publishAllPublicationsToTestCaseMavenRepoRepository in both the root and grails-gradle builds. settings.gradle scopes it with exclusiveContent so a remote snapshot cannot quietly satisfy an org.apache.grails request and leave the suite testing something other than the working tree. That also disposes of the CLI companion problem rather than working around it. grails-core-cli is a secondary capability of :grails-core, not a project, so composite substitution cannot express it and hits a capability self-conflict - but it is a first-class published module whose metadata CliPublishingSupport already rewrites for external consumers, so resolving from the repository gets it for free. Trait-derived command names. Every legacy command in the tree overrode getName()/getDescription(), so the trait's default derivation - what create-command generated on Grails 7, and therefore what most published Grails 7 commands rely on for their registration key - had no coverage. Adds a third precompiled command declaring neither getter. Factory resource failures are no longer silent. loadFactoryDeclarations dropped a malformed resource with no log line at any level, and it backs modern grails-cli.factories discovery, so one bad file lost every one of a plugin's Grails 8 commands and providers. It now warns with the resource URL and cause, keeping the per-resource isolation. skipBootstrap resolution is covered. The lookup moves into a static helper so the adapter-target case - where reading the flag off the adapter instead of its target would let BootStrap run during dbm-update - is guarded by a test. Commands are now deduplicated across the registry and context classloaders the way providers already were. A command declared in a resource visible through both was constructed twice and the second instance discarded, which the surrounding comment says the code exists to avoid; an existing assertion encoded that double construction and now asserts a single one. Legacy commands take part in ordering. Modern commands are sorted before first-wins registration, but legacy ones were registered in factory scan order and the adapter carried none of the target's ordering, so two Grails 7 plugins declaring the same command name resolved by jar order. The adapter now projects the target's Ordered/@order onto itself and the provider sorts before registering, restoring Grails 7 semantics. ThreadDeath handling is removed from the four fatal-error guards. Thread.stop() was removed in JDK 20, so on the 21 baseline the only instances that branch ever saw were the ones the tests constructed, and the type is deprecated for removal. VirtualMachineError handling and the wrapped-cause walk are unchanged, and the specs now prove rethrow across two VirtualMachineError subtypes instead. The duplicated rethrowIfFatal is deliberately left in place: grails-shell-cli cannot see grails-core at compile time, so consolidating would mean new public API on a general-purpose utility for a CLI-internal concern. Test quality. A spec named for a plugin-origin resolution failure described a code path that does not exist and whose failure branch was unreachable; it is renamed to what it verifies, with the dead branch removed. AbstractProfile's unknown-command path, changed by this work but uncovered, gets a spec driving the public Profile.handleCommand. Also collapses two byte-identical instantiate helpers, drops a duplicated fixture version pin in the integration spec, and corrects comments describing the fixture as an included composite build.
1 parent 75ed393 commit afa4d80

48 files changed

Lines changed: 1756 additions & 130 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/end-to-end.yml

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one or more
2+
# contributor license agreements. See the NOTICE file distributed with
3+
# this work for additional information regarding copyright ownership.
4+
# The ASF licenses this file to You under the Apache License, Version 2.0
5+
# (the "License"); you may not use this file except in compliance with
6+
# the License. You may obtain a copy of the License at
7+
#
8+
# https://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
# The end-to-end suite is its own Gradle build, so the core build never reaches it and
17+
# `./gradlew build` at the root is unaffected.
18+
#
19+
# It resolves Grails from the artifacts the core build publishes rather than by project
20+
# substitution - that is what makes the tests end-to-end. The repository is the same
21+
# build/local-maven that grails-forge points its generated applications at, populated by
22+
# publishAllPublicationsToTestCaseMavenRepoRepository, so the suite exercises real poms and
23+
# module metadata including the CLI companion artifacts.
24+
#
25+
# It also needs two JDKs, which is the other reason it gets its own workflow: the Grails 7
26+
# fixture must be compiled on Java 17 (the minimum for a Grails 7 app, so the binary matches
27+
# what a real Grails 7 plugin is built with), while the core build and the Grails 8
28+
# application consuming the fixture need 21. The Grails 8 side simply tracks the repository's
29+
# root .sdkmanrc - it has to run on whatever the core build it consumes runs on - and only the
30+
# fixture carries its own pin. The steps below read both out of those files rather than relying
31+
# on Gradle toolchain auto-detection.
32+
name: "End to End"
33+
on:
34+
workflow_dispatch:
35+
push:
36+
branches:
37+
- '[0-9]+.[0-9]+.x'
38+
# The legacy command compatibility work these tests cover is still in review. Run the suite
39+
# on its branch so the result is visible on the pull request; drop this entry once it merges.
40+
- 'feat/8.0.x-legacy-command-compat'
41+
paths:
42+
- 'end-to-end/**'
43+
- 'grails-core/**'
44+
- 'grails-core-cli-legacy/**'
45+
- 'grails-console/**'
46+
- 'grails-gradle/**'
47+
- '.github/workflows/end-to-end.yml'
48+
pull_request:
49+
paths:
50+
- 'end-to-end/**'
51+
- 'grails-core/**'
52+
- 'grails-core-cli-legacy/**'
53+
- 'grails-console/**'
54+
- 'grails-gradle/**'
55+
- '.github/workflows/end-to-end.yml'
56+
concurrency:
57+
group: ${{ github.workflow }}-${{ github.ref }}
58+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
59+
jobs:
60+
endToEnd:
61+
name: "End to End Tests (end-to-end build only)"
62+
if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
63+
runs-on: ubuntu-24.04
64+
steps:
65+
- name: "📥 Checkout repository"
66+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
67+
- name: "☕️ Determine JDKs from .sdkmanrc"
68+
# Read both pins out of the files that already declare them, so the workflow cannot
69+
# drift from what a developer gets with `sdk env`.
70+
id: jdks
71+
run: |
72+
set -euo pipefail
73+
fixture_java=$(grep -E '^java=' end-to-end/legacy-g7-command-plugin/.sdkmanrc | cut -d= -f2)
74+
build_java=$(grep -E '^java=' .sdkmanrc | cut -d= -f2)
75+
echo "fixture-java=${fixture_java%%.*}" >> "$GITHUB_OUTPUT"
76+
echo "build-java=${build_java%%.*}" >> "$GITHUB_OUTPUT"
77+
echo "Grails 7 fixture JDK: ${fixture_java}"
78+
echo "end-to-end build JDK (from root .sdkmanrc): ${build_java}"
79+
- name: "☕️ Setup JDKs"
80+
# Both, in one step. The last version listed becomes the default JAVA_HOME (the Grails 8
81+
# side); the fixture step below switches to JAVA_HOME_17_X64 for its single invocation.
82+
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
83+
with:
84+
distribution: liberica
85+
java-version: |
86+
${{ steps.jdks.outputs.fixture-java }}
87+
${{ steps.jdks.outputs.build-java }}
88+
- name: "🗄️ Restore dependency jar cache"
89+
uses: actions/cache@v4
90+
with:
91+
# Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
92+
# Keyed by branch version so each release branch maintains its own warm cache.
93+
path: |
94+
~/.gradle/caches/modules-2
95+
~/.gradle/wrapper
96+
key: gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-${{ hashFiles('**/dependencies.gradle', '**/gradle-wrapper.properties') }}
97+
restore-keys: |
98+
gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-
99+
- name: "🐘 Setup Gradle"
100+
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
101+
with:
102+
cache-disabled: true # dependency jars are cached by the explicit branch-keyed step above
103+
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
104+
- name: "📦 Setup: publish grails-gradle to the local repository the tests resolve from"
105+
# Both builds publish into the same build/local-maven, and both are needed: the Grails BOM
106+
# constrains org.apache.grails.gradle artifacts. grails-forge depends on this same pair of
107+
# publish tasks for the applications its tests generate.
108+
working-directory: 'grails-gradle'
109+
run: ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository -PskipTests --stacktrace
110+
- name: "📦 Setup: publish Grails to the local repository the tests resolve from"
111+
run: ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository -PskipTests --stacktrace
112+
- name: "📦 Setup: build the precompiled Grails 7 / Groovy 4 fixture"
113+
working-directory: 'end-to-end/legacy-g7-command-plugin'
114+
env:
115+
JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }}
116+
run: ./gradlew jar --stacktrace
117+
- name: "🔍 Verify the fixture really was built on Grails 7 / Groovy 4"
118+
working-directory: 'end-to-end/legacy-g7-command-plugin'
119+
# A fixture silently built by the wrong toolchain would still pass the suite while
120+
# proving nothing, so fail loudly here instead.
121+
run: |
122+
set -euo pipefail
123+
jar=$(ls build/libs/*.jar)
124+
unzip -p "$jar" META-INF/MANIFEST.MF | tr -d '\r' > /tmp/fixture-manifest
125+
cat /tmp/fixture-manifest
126+
grep -q '^Grails-Compile-Version: 7\.' /tmp/fixture-manifest
127+
grep -q '^Groovy-Compile-Version: 4\.' /tmp/fixture-manifest
128+
- name: "🔍 Setup TestLens"
129+
uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
130+
- name: "🧪 Run the end-to-end tests"
131+
# Only the end-to-end build. grails-core's own unit and functional suites are the CI
132+
# workflow's job; nothing here re-runs them. The publish steps above are setup, not tests.
133+
working-directory: 'end-to-end'
134+
run: ./gradlew check --continue --stacktrace
135+
- name: "📤 Upload test reports"
136+
if: failure()
137+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
138+
with:
139+
name: end-to-end-test-reports
140+
path: end-to-end/**/build/reports/tests/**

end-to-end/README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<!--
2+
SPDX-License-Identifier: Apache-2.0
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
-->
16+
17+
# End-to-end tests
18+
19+
Tests that exercise Grails from the outside, where doing so needs something the core build
20+
cannot provide — a different JDK, a different Grails major, or a real published artifact.
21+
22+
This is its own Gradle build, so the core build never reaches these projects and `./gradlew build`
23+
at the repository root is unaffected by anything here.
24+
25+
It resolves Grails from the artifacts the core build **publishes**, not by project substitution.
26+
That is what makes these tests end-to-end: they consume grails-core the way an application does,
27+
through real poms and Gradle module metadata, including the CLI companion artifacts. The repository
28+
is `<repository root>/build/local-maven` — the same one `grails-forge` points its generated
29+
applications at via `GRAILS_REPO_URL`.
30+
31+
## Projects
32+
33+
| Project | What it is |
34+
|---|---|
35+
| `legacy-g7-command-plugin` | A **standalone build**, not part of this one. Compiles against published Grails 7 / Groovy 4 to produce a genuine precompiled `grails.dev.commands.ApplicationCommand` binary. |
36+
| `legacy-commands-plugin` | A Grails 8 plugin whose legacy commands are recompiled under Groovy 5. |
37+
| `legacy-commands` | A Grails 8 application that consumes both and runs their commands through the registry. |
38+
39+
`legacy-g7-command-plugin` is deliberately excluded from `settings.gradle`. An included build would
40+
substitute `org.apache.grails:grails-core` for this repository's Groovy 5 project, which is exactly
41+
the substitution the fixture exists to avoid — it must be compiled by a real Grails 7 toolchain for
42+
its trait-woven bytecode to prove anything.
43+
44+
## JDKs
45+
46+
The Grails 7 half declares the JDK it needs in a `.sdkmanrc`, rather than a Gradle toolchain, so that
47+
neither the core build nor a contributor's default environment inherits a second JDK requirement:
48+
49+
| Where | JDK | Why |
50+
|---|---|---|
51+
| `legacy-g7-command-plugin/.sdkmanrc` | 17, Gradle 8.14.5 | What Grails 7 pins, so the fixture is built the way a Grails 7 plugin actually was. `gradle-bootstrap` generates this wrapper from that file via its `legacyG7Wrapper` task, rather than copying the shared one. |
52+
| the repository's root `.sdkmanrc` | 21 | The Grails 8 baseline. This build consumes artifacts from the core build, so it runs on whatever the core build runs on — it deliberately does not re-pin that. |
53+
54+
## Running locally
55+
56+
Three steps, in order. Each fails with an actionable message if a prior one was skipped.
57+
58+
Publish Grails to the repository this build resolves from. Both builds publish into the same
59+
directory and both are needed — the BOM constrains `org.apache.grails.gradle` artifacts too:
60+
61+
```shell
62+
(cd grails-gradle && ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository)
63+
./gradlew publishAllPublicationsToTestCaseMavenRepoRepository
64+
```
65+
66+
Build the Grails 7 fixture — it is consumed as a prebuilt jar:
67+
68+
```shell
69+
cd end-to-end/legacy-g7-command-plugin
70+
sdk env
71+
./gradlew jar
72+
```
73+
74+
Run the suite, on the root JDK:
75+
76+
```shell
77+
sdk env # from the repository root
78+
cd end-to-end
79+
./gradlew check
80+
```
81+
82+
Re-run the publish whenever you change something in the core build that these tests exercise;
83+
nothing here can detect that for you, because the whole point is that the build boundary is real.
84+
85+
CI does the same three steps, reading both JDK versions out of the `.sdkmanrc` files so the workflow
86+
cannot drift from them. See `.github/workflows/end-to-end.yml`.
87+
88+
## Why not `includeBuild('..')`
89+
90+
Composite substitution would win over the local repository and put us back to resolving projects
91+
instead of artifacts, which is the thing these tests exist not to do. It also cannot express the CLI
92+
companions: `grails-core-cli` is a secondary capability of `:grails-core` rather than a project, so
93+
substituting it hits a capability self-conflict.
94+
95+
Published metadata has that solved already — the companion is a first-class module with its own
96+
publication, and `CliPublishingSupport` rewrites capability requests out of what gets published so
97+
that external consumers resolve the plain coordinate. Resolving from the repository gets that for
98+
free; `settings.gradle` uses `exclusiveContent` so every `org.apache.grails` artifact must come from
99+
the local build and a remote snapshot cannot quietly satisfy the request instead.

end-to-end/build.gradle

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
def localMavenRepo = rootProject.layout.projectDirectory.dir('../build/local-maven')
21+
22+
subprojects {
23+
configurations.configureEach {
24+
// Snapshots in the local repository are timestamped and rewritten on every core publish,
25+
// so cached metadata would serve a stale one.
26+
resolutionStrategy {
27+
cacheChangingModulesFor(0, 'seconds')
28+
cacheDynamicVersionsFor(0, 'seconds')
29+
}
30+
}
31+
32+
tasks.withType(AbstractCompile).configureEach {
33+
doFirst {
34+
if (!localMavenRepo.asFile.directory) {
35+
throw new GradleException('Grails has not been published to the repository this ' +
36+
"build resolves from (${localMavenRepo.asFile}). Run this first, from the " +
37+
'repository root: ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository')
38+
}
39+
}
40+
}
41+
}

end-to-end/gradle.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
projectVersion=8.0.0-SNAPSHOT
2+
3+
org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx5G
4+
org.gradle.configuration-cache=false
5+
org.gradle.caching=true
6+
org.gradle.parallel=true
47.3 KB
Binary file not shown.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
distributionBase=GRADLE_USER_HOME
2+
distributionPath=wrapper/dists
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
4+
networkTimeout=10000
5+
retries=0
6+
retryBackOffMs=500
7+
validateDistributionUrl=true
8+
zipStoreBase=GRADLE_USER_HOME
9+
zipStorePath=wrapper/dists

0 commit comments

Comments
 (0)