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
144 changes: 144 additions & 0 deletions .github/workflows/end-to-end.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# The end-to-end suite is its own Gradle build, so the core build never reaches it and
# `./gradlew build` at the root is unaffected.
#
# It resolves Grails from the artifacts the core build publishes rather than by project
# substitution - that is what makes the tests end-to-end. The repository is the same
# build/local-maven that grails-forge points its generated applications at, populated by
# publishAllPublicationsToTestCaseMavenRepoRepository, so the suite exercises real poms and
# module metadata including the CLI companion artifacts.
#
# It also needs two JDKs, which is the other reason it gets its own workflow: the Grails 7
# fixture must be compiled on Java 17 (the minimum for a Grails 7 app, so the binary matches
# what a real Grails 7 plugin is built with), while the core build and the Grails 8
# application consuming the fixture need 21. The Grails 8 side simply tracks the repository's
# root .sdkmanrc - it has to run on whatever the core build it consumes runs on - and only the
# fixture carries its own pin. The steps below read both out of those files rather than relying
# on Gradle toolchain auto-detection.
name: "End to End"
on:
push:
branches:
- '[0-9]+.[0-9]+.x'
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
endToEnd:
name: "End to End Tests (end-to-end build only)"
if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
runs-on: ubuntu-24.04
steps:
- name: "📥 Checkout repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "☕️ Determine JDKs from .sdkmanrc"
# Read both pins out of the files that already declare them. Only the major is kept:
# `sdk env` gives a developer the exact Liberica patch, but the fixture and the suite are
# test builds outside the reproducible-build surface, so CI deliberately takes the
# runner's current release of each major rather than pinning patch versions here.
id: jdks
run: |
set -euo pipefail
fixture_java=$(grep -E '^java=' end-to-end/legacy-g7-command-plugin/.sdkmanrc | cut -d= -f2)
build_java=$(grep -E '^java=' .sdkmanrc | cut -d= -f2)
echo "fixture-java=${fixture_java%%.*}" >> "$GITHUB_OUTPUT"
echo "build-java=${build_java%%.*}" >> "$GITHUB_OUTPUT"
echo "Grails 7 fixture JDK: ${fixture_java}"
echo "end-to-end build JDK (from root .sdkmanrc): ${build_java}"
- name: "☕️ Setup JDKs"
# Both, in one step. The last version listed becomes the default JAVA_HOME (the Grails 8
# side); the fixture step below switches to the matching JAVA_HOME_<major>_X64 for its
# single invocation.
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: liberica
java-version: |
${{ steps.jdks.outputs.fixture-java }}
${{ steps.jdks.outputs.build-java }}
- name: "🗄️ Restore dependency jar cache"
uses: actions/cache@v4
with:
# Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
# Keyed by branch version so each release branch maintains its own warm cache.
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-${{ hashFiles('**/dependencies.gradle', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-
- name: "🐘 Setup Gradle"
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
cache-disabled: true # dependency jars are cached by the explicit branch-keyed step above
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
- name: "📦 Setup: publish grails-gradle to the local repository the tests resolve from"
# Both builds publish into the same build/local-maven, and both are needed: the Grails BOM
# constrains org.apache.grails.gradle artifacts. grails-forge depends on this same pair of
# publish tasks for the applications its tests generate.
working-directory: 'grails-gradle'
run: ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository -PskipTests --stacktrace
- name: "📦 Setup: publish Grails to the local repository the tests resolve from"
run: ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository -PskipTests --stacktrace
- name: "📦 Setup: build the precompiled Grails 7 / Groovy 4 fixture"
working-directory: 'end-to-end/legacy-g7-command-plugin'
env:
# Derived from the major the jdks step read out of the fixture's .sdkmanrc, so a fixture
# JDK bump changes this lookup with it instead of leaving a stale literal behind.
JAVA_HOME: ${{ env[format('JAVA_HOME_{0}_X64', steps.jdks.outputs.fixture-java)] }}
run: |
set -euo pipefail
if [ -z "${JAVA_HOME:-}" ]; then
echo "setup-java did not provision JDK ${{ steps.jdks.outputs.fixture-java }}; refusing to build the fixture on the default JDK" >&2
exit 1
fi
./gradlew jar --stacktrace
- name: "🔍 Verify the fixture really was built by the Grails 7 toolchain"
working-directory: 'end-to-end/legacy-g7-command-plugin'
# A fixture silently built by the wrong toolchain would still pass the suite while
# proving nothing, so fail loudly here instead. Grails-/Groovy-Compile-Version come from
# the resolved BOM and Build-Jdk-Spec from the JVM that ran Gradle (all stamped into the
# manifest at jar time); the class-file major version is the ground truth for what the
# bytecode actually targets, since the fixture deliberately sets no toolchain or release.
run: |
set -euo pipefail
fixture_java='${{ steps.jdks.outputs.fixture-java }}'
jar=$(ls build/libs/*.jar)
unzip -p "$jar" META-INF/MANIFEST.MF | tr -d '\r' > /tmp/fixture-manifest
cat /tmp/fixture-manifest
grep -q '^Grails-Compile-Version: 7\.' /tmp/fixture-manifest
grep -q '^Groovy-Compile-Version: 4\.' /tmp/fixture-manifest
Comment on lines +98 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems that compound, and together they defeat the stated purpose of the verify step.

JAVA_HOME_17_X64 hardcodes the very version the step above just derived. The 17 in that variable name is a literal. If legacy-g7-command-plugin/.sdkmanrc ever moves off 17, steps.jdks.outputs.fixture-java follows it and setup-java provisions the new major - but env.JAVA_HOME_17_X64 is then unset, JAVA_HOME resolves to empty, and the fixture builds on the default JDK (21) instead. Nothing fails. Deriving the variable name would fix it:

env:
  JAVA_HOME: ${{ env[format('JAVA_HOME_{0}_X64', steps.jdks.outputs.fixture-java)] }}

And the verify step cannot catch that. Its comment says "a fixture silently built by the wrong toolchain would still pass the suite while proving nothing, so fail loudly here instead" - but it only greps Grails-Compile-Version and Groovy-Compile-Version, which come from the resolved BOM, not from the JDK. A fixture compiled on 21 against Grails 7.0.14 / Groovy 4.0.32 still prints 7. and 4. and sails through.

That matters more than usual here because legacy-g7-command-plugin/build.gradle sets no release, sourceCompatibility or toolchain (deliberately - that was the point of the move), so the class file version is determined entirely by JAVA_HOME. The check that would actually be loud is the bytecode major:

unzip -p "$jar" legacy/g7/commands/HelloG7PrecompiledCommand.class | od -An -t u1 -j 6 -N 2
# expect major 61 for Java 17

Stamp the JDK into the manifest at jar time the same way Grails/Groovy are, and assert it here.

grep -q "^Build-Jdk-Spec: ${fixture_java}\$" /tmp/fixture-manifest
expected_major=$((fixture_java + 44))
actual_major=$(unzip -p "$jar" legacy/g7/commands/HelloG7PrecompiledCommand.class \
| od -An -t u1 -j 6 -N 2 | awk 'NF { print $1 * 256 + $2; exit }')
echo "class-file major version: ${actual_major} (expected ${expected_major} for Java ${fixture_java})"
[ "$actual_major" -eq "$expected_major" ]
- name: "🔍 Setup TestLens"
uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
- name: "🧪 Run the end-to-end tests"
# Only the end-to-end build. grails-core's own unit and functional suites are the CI
# workflow's job; nothing here re-runs them. The publish steps above are setup, not tests.
working-directory: 'end-to-end'
run: ./gradlew check --continue --stacktrace
Comment on lines +134 to +138

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check alone leaves the moved sources outside the project's violation gate. end-to-end applies neither GrailsCodeStylePlugin nor GrailsCodeAnalysisPlugin, and the root aggregateStyleViolations / aggregateAnalysisViolations only walk root subprojects - which these no longer are. So CodeNarc/Checkstyle/PMD/SpotBugs no longer see legacy-commands, legacy-commands-plugin, or the fixture, and ./gradlew clean aggregateViolations at the root will report clean regardless of what is in them.

That is a real regression from the move rather than a pre-existing gap: before this PR these projects were in that graph. Either apply the style/analysis convention plugins in end-to-end/build.gradle and run ./gradlew check codeStyle here, or state in end-to-end/README.md that the suite is deliberately outside the violation surface and why.

- name: "📤 Upload test reports"
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: end-to-end-test-reports
path: end-to-end/**/build/reports/tests/**
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ This repository contains multiple independent Gradle projects:
| **build-logic/** | Gradle convention plugins for the build | `cd build-logic && ../gradlew build` |
| **grails-gradle/** | Grails Gradle plugins | `cd grails-gradle && ./gradlew build` |
| **grails-forge/** | Application generator (like Spring Initializr) | `cd grails-forge && ./gradlew build` |
| **end-to-end/** | End-to-end tests consuming published Grails artifacts (see `end-to-end/README.md` for required setup) | `cd end-to-end && ./gradlew check` |

Each project has its own `settings.gradle` and independent build. When working on a specific project, run Gradle commands from that project's directory.

Expand Down
111 changes: 111 additions & 0 deletions end-to-end/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<!--
SPDX-License-Identifier: Apache-2.0

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

# End-to-end tests

Tests that exercise Grails from the outside, where doing so needs something the core build
cannot provide — a different JDK, a different Grails major, or a real published artifact.

This is its own Gradle build, so the core build never reaches these projects and `./gradlew build`
at the repository root is unaffected by anything here.

It resolves Grails from the artifacts the core build **publishes**, not by project substitution.
That is what makes these tests end-to-end: they consume grails-core the way an application does,
through real poms and Gradle module metadata, including the CLI companion artifacts. The repository
is `<repository root>/build/local-maven` — the same one `grails-forge` points its generated
applications at via `GRAILS_REPO_URL`.

## Projects

| Project | What it is |
|---|---|
| `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. |
| `legacy-commands-plugin` | A Grails 8 plugin whose legacy commands are recompiled under Groovy 5. |
| `legacy-commands` | A Grails 8 application that consumes both and runs their commands through the registry. |

`legacy-g7-command-plugin` is deliberately excluded from `settings.gradle`. An included build would
substitute `org.apache.grails:grails-core` for this repository's Groovy 5 project, which is exactly
the substitution the fixture exists to avoid — it must be compiled by a real Grails 7 toolchain for
its trait-woven bytecode to prove anything.

## JDKs

The Grails 7 half declares the JDK it needs in a `.sdkmanrc`, rather than a Gradle toolchain, so that
neither the core build nor a contributor's default environment inherits a second JDK requirement:

| Where | JDK | Why |
|---|---|---|
| `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. |
| 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. |

## Running locally

Three steps, in order. Each fails with an actionable message if a prior one was skipped.

Publish Grails to the repository this build resolves from. Both builds publish into the same
directory and both are needed — the BOM constrains `org.apache.grails.gradle` artifacts too:

```shell
(cd grails-gradle && ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository)
./gradlew publishAllPublicationsToTestCaseMavenRepoRepository
```

Build the Grails 7 fixture — it is consumed as a prebuilt jar:

```shell
cd end-to-end/legacy-g7-command-plugin
sdk env
./gradlew jar
```

Run the suite, on the root JDK:

```shell
sdk env # from the repository root
cd end-to-end
./gradlew check
```

Re-run the publish whenever you change something in the core build that these tests exercise;
nothing here can detect that for you, because the whole point is that the build boundary is real.

CI does the same three steps, reading both JDK majors out of the `.sdkmanrc` files. Only the major
is honoured there: `sdk env` gives a developer the exact Liberica patch, but these are test builds
outside the reproducible-build surface, so CI deliberately takes the runner's current release of
each major. See `.github/workflows/end-to-end.yml`.

## Style and analysis checks

These projects are deliberately outside the violation gate — no `grails-code-style` or
`grails-code-analysis` plugin is applied here, matching every other test-example application in the
repository (none of the `grails-test-examples/*` projects apply them either, and the root
`aggregateViolations` reports only collect from projects that do). They are fixtures: the
application and plugin sources exist to exercise the command registry, and the Grails 7 fixture
must stay compilable by a real Grails 7 toolchain, which the current convention plugins do not
target.

## Why not `includeBuild('..')`

Composite substitution would win over the local repository and put us back to resolving projects
instead of artifacts, which is the thing these tests exist not to do. It also cannot express the CLI
companions: `grails-core-cli` is a secondary capability of `:grails-core` rather than a project, so
substituting it hits a capability self-conflict.

Published metadata has that solved already — the companion is a first-class module with its own
publication, and `CliPublishingSupport` rewrites capability requests out of what gets published so
that external consumers resolve the plain coordinate. Resolving from the repository gets that for
free; `settings.gradle` uses `exclusiveContent` so every `org.apache.grails` artifact must come from
the local build and a remote snapshot cannot quietly satisfy the request instead.
41 changes: 41 additions & 0 deletions end-to-end/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

def localMavenRepo = rootProject.layout.projectDirectory.dir('../build/local-maven')

subprojects {
configurations.configureEach {
// Snapshots in the local repository are timestamped and rewritten on every core publish,
// so cached metadata would serve a stale one.
resolutionStrategy {
cacheChangingModulesFor(0, 'seconds')
cacheDynamicVersionsFor(0, 'seconds')
}
}

tasks.withType(AbstractCompile).configureEach {
doFirst {
if (!localMavenRepo.asFile.directory) {
throw new GradleException('Grails has not been published to the repository this ' +
"build resolves from (${localMavenRepo.asFile}). Run this first, from the " +
'repository root: ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository')
}
}
}
}
6 changes: 6 additions & 0 deletions end-to-end/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
projectVersion=8.0.0-SNAPSHOT

org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx5G
org.gradle.configuration-cache=false
org.gradle.caching=true
org.gradle.parallel=true
Binary file added end-to-end/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
9 changes: 9 additions & 0 deletions end-to-end/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading