diff --git a/.github/workflows/end-to-end.yml b/.github/workflows/end-to-end.yml new file mode 100644 index 00000000000..ba6bb2206c7 --- /dev/null +++ b/.github/workflows/end-to-end.yml @@ -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__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 + 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 + - 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/** diff --git a/AGENTS.md b/AGENTS.md index 5ec9ac09512..598b244892c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/end-to-end/README.md b/end-to-end/README.md new file mode 100644 index 00000000000..ff0f0c77b94 --- /dev/null +++ b/end-to-end/README.md @@ -0,0 +1,111 @@ + + +# 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 `/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. diff --git a/end-to-end/build.gradle b/end-to-end/build.gradle new file mode 100644 index 00000000000..236237fd03c --- /dev/null +++ b/end-to-end/build.gradle @@ -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') + } + } + } +} diff --git a/end-to-end/gradle.properties b/end-to-end/gradle.properties new file mode 100644 index 00000000000..9532df04559 --- /dev/null +++ b/end-to-end/gradle.properties @@ -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 diff --git a/end-to-end/gradle/wrapper/gradle-wrapper.jar b/end-to-end/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..b1b8ef56b44 Binary files /dev/null and b/end-to-end/gradle/wrapper/gradle-wrapper.jar differ diff --git a/end-to-end/gradle/wrapper/gradle-wrapper.properties b/end-to-end/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..eb84db68da7 --- /dev/null +++ b/end-to-end/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/end-to-end/gradlew b/end-to-end/gradlew new file mode 100755 index 00000000000..249efbb032c --- /dev/null +++ b/end-to-end/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/end-to-end/gradlew.bat b/end-to-end/gradlew.bat new file mode 100755 index 00000000000..a51ec4f5886 --- /dev/null +++ b/end-to-end/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/grails-test-examples/legacy-commands-plugin/build.gradle b/end-to-end/legacy-commands-plugin/build.gradle similarity index 87% rename from grails-test-examples/legacy-commands-plugin/build.gradle rename to end-to-end/legacy-commands-plugin/build.gradle index d57c0a1d9b9..cbcfc739eb7 100644 --- a/grails-test-examples/legacy-commands-plugin/build.gradle +++ b/end-to-end/legacy-commands-plugin/build.gradle @@ -29,7 +29,7 @@ version = '0.0.1' group = 'legacy.commands.plugin' dependencies { - implementation platform(project(':grails-bom')) + implementation platform("org.apache.grails:grails-bom:$projectVersion") // This fixture recompiles legacy command sources against Grails 8's grails-core-cli-legacy to // validate discovery, adapter, registry, and runner wiring end-to-end. It does not @@ -40,6 +40,5 @@ dependencies { } apply { - from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') - from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') + from rootProject.layout.projectDirectory.file('../gradle/grails-extension-gradle-config.gradle') } diff --git a/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/GreetingService.groovy b/end-to-end/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/GreetingService.groovy similarity index 100% rename from grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/GreetingService.groovy rename to end-to-end/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/GreetingService.groovy diff --git a/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy b/end-to-end/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy similarity index 100% rename from grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy rename to end-to-end/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy diff --git a/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy b/end-to-end/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy similarity index 100% rename from grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy rename to end-to-end/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy diff --git a/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy b/end-to-end/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy similarity index 100% rename from grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy rename to end-to-end/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy diff --git a/grails-test-examples/legacy-commands-plugin/src/main/resources/META-INF/grails.factories b/end-to-end/legacy-commands-plugin/src/main/resources/META-INF/grails.factories similarity index 100% rename from grails-test-examples/legacy-commands-plugin/src/main/resources/META-INF/grails.factories rename to end-to-end/legacy-commands-plugin/src/main/resources/META-INF/grails.factories diff --git a/grails-test-examples/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy b/end-to-end/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy similarity index 100% rename from grails-test-examples/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy rename to end-to-end/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy diff --git a/grails-test-examples/legacy-commands/build.gradle b/end-to-end/legacy-commands/build.gradle similarity index 57% rename from grails-test-examples/legacy-commands/build.gradle rename to end-to-end/legacy-commands/build.gradle index e146ee5d81c..7ff244dba08 100644 --- a/grails-test-examples/legacy-commands/build.gradle +++ b/end-to-end/legacy-commands/build.gradle @@ -17,34 +17,43 @@ * under the License. */ plugins { + id 'groovy' id 'org.apache.grails.buildsrc.properties' id 'org.apache.grails.buildsrc.dependency-validator' id 'org.apache.grails.buildsrc.compile' id 'org.apache.grails.buildsrc.vulnerability-scan' + id 'org.apache.grails.gradle.grails-web' } version = '0.1' group = 'legacycommands' -apply plugin: 'groovy' -apply plugin: 'org.apache.grails.gradle.grails-web' +// The precompiled Grails 7 / Groovy 4 binary. Its build is standalone and runs under its own JDK +// (see legacy-g7-command-plugin/.sdkmanrc), so it is consumed here as a prebuilt jar rather than +// driven from this build. Build it first with: +// +// cd ../legacy-g7-command-plugin && sdk env && ./gradlew jar +// +// The version is not hard-coded here: a stale pin would silently drop the jar from the classpath, +// because Gradle tolerates non-existent files in a `files(...)` dependency. +def legacyG7FixtureJars = fileTree( + dir: rootProject.layout.projectDirectory.dir('legacy-g7-command-plugin/build/libs'), + include: '*.jar') -// Standalone Grails 7 / Groovy 4 fixture build (not part of the monorepo dependency graph). -def legacyG7FixtureDir = rootProject.layout.projectDirectory.dir('grails-test-examples/legacy-g7-command-plugin') -def legacyG7FixtureJar = legacyG7FixtureDir.file('build/libs/legacy-g7-command-plugin-0.0.1.jar') -tasks.register('buildLegacyG7CommandFixture', GradleBuild) { - dir = legacyG7FixtureDir.asFile - tasks = ['clean', 'jar'] +tasks.named('compileGroovy').configure { + doFirst { + if (legacyG7FixtureJars.empty) { + throw new GradleException('The precompiled Grails 7 command fixture has not been built. ' + + 'Run: cd end-to-end/legacy-g7-command-plugin && sdk env && ./gradlew jar') + } + } } dependencies { - implementation platform(project(':grails-bom')) + implementation platform("org.apache.grails:grails-bom:$projectVersion") - implementation project(':grails-test-examples-legacy-commands-plugin') - // Real Grails 7 / Groovy 4 precompiled ApplicationCommand binary. - implementation files(legacyG7FixtureJar) { - builtBy tasks.named('buildLegacyG7CommandFixture') - } + implementation project(':legacy-commands-plugin') + implementation files(legacyG7FixtureJars) implementation 'org.apache.grails:grails-core' implementation 'org.apache.grails:grails-logging' implementation 'org.apache.grails:grails-databinding' @@ -72,9 +81,18 @@ dependencies { testImplementation 'org.spockframework:spock-core' } +// The core build's gradle/functional-test-config.gradle is deliberately not applied here. Its +// dependency substitution enumerates rootProject.subprojects, which only makes sense inside the +// core build; here the org.apache.grails coordinates resolve from the published artifacts in +// build/local-maven instead (see settings.gradle). Its remaining job - the per-suite skip flags +// keyed off grails-test-examples-* project names - has no meaning in this build, which is driven +// by its own workflow. apply { - from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') - from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') + from rootProject.layout.projectDirectory.file('../gradle/grails-extension-gradle-config.gradle') +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() } // Opt in to the Grails 7 application-command bridge (off by default on Grails 8). diff --git a/grails-test-examples/legacy-commands/grails-app/conf/application.yml b/end-to-end/legacy-commands/grails-app/conf/application.yml similarity index 100% rename from grails-test-examples/legacy-commands/grails-app/conf/application.yml rename to end-to-end/legacy-commands/grails-app/conf/application.yml diff --git a/grails-test-examples/legacy-commands/grails-app/conf/logback.xml b/end-to-end/legacy-commands/grails-app/conf/logback.xml similarity index 100% rename from grails-test-examples/legacy-commands/grails-app/conf/logback.xml rename to end-to-end/legacy-commands/grails-app/conf/logback.xml diff --git a/grails-test-examples/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy b/end-to-end/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy similarity index 100% rename from grails-test-examples/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy rename to end-to-end/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy diff --git a/grails-test-examples/legacy-commands/grails-app/init/legacycommands/Application.groovy b/end-to-end/legacy-commands/grails-app/init/legacycommands/Application.groovy similarity index 100% rename from grails-test-examples/legacy-commands/grails-app/init/legacycommands/Application.groovy rename to end-to-end/legacy-commands/grails-app/init/legacycommands/Application.groovy diff --git a/grails-test-examples/legacy-commands/grails-app/services/legacycommands/GreetingService.groovy b/end-to-end/legacy-commands/grails-app/services/legacycommands/GreetingService.groovy similarity index 100% rename from grails-test-examples/legacy-commands/grails-app/services/legacycommands/GreetingService.groovy rename to end-to-end/legacy-commands/grails-app/services/legacycommands/GreetingService.groovy diff --git a/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy b/end-to-end/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy similarity index 85% rename from grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy rename to end-to-end/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy index 237d664bfa7..7d11a4604af 100644 --- a/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy +++ b/end-to-end/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy @@ -154,12 +154,12 @@ class LegacyCommandCompatibilityIntegrationSpec extends Specification { } markerFile.delete() - expect: 'the adapter targets the included-build Grails 7 / Groovy 4 binary and its resolved compile versions' + expect: 'the adapter targets the standalone Grails 7 / Groovy 4 binary and its resolved compile versions' applicationCommand != null applicationCommand instanceof ApplicationCommandTargetAware legacyCommand.class.name == 'legacy.g7.commands.HelloG7PrecompiledCommand' publishedArtifact.name.contains('legacy-g7-command-plugin') - grailsCompileVersion == '7.0.14' + grailsCompileVersion.startsWith('7.') groovyCompileVersion.startsWith('4.') when: 'the precompiled command runs through the public Grails 8 adapter' @@ -204,4 +204,33 @@ class LegacyCommandCompatibilityIntegrationSpec extends Specification { outputDirectory.delete() } + def "registers a precompiled command that relies on the trait to derive its name"() { + given: 'the command shape create-command generated on Grails 7, declaring no name of its own' + ApplicationCommand applicationCommand = ApplicationContextCommandRegistry.instance.findCommand('hello-derived-name') + ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine)) + File outputDirectory = new File(executionContext.baseDir, 'build/hello-derived-name') + File renderedFile = new File(outputDirectory, 'rendered.txt') + renderedFile.delete() + outputDirectory.delete() + + expect: 'the trait default resolved the registration key from the class name across the version boundary' + applicationCommand != null + applicationCommand instanceof ApplicationCommandTargetAware + ((ApplicationCommandTargetAware) applicationCommand).target.class.name == + 'legacy.g7.commands.HelloDerivedNameCommand' + applicationCommand.name == 'hello-derived-name' + + when: 'the command runs and reads its own derived name from inside the precompiled binary' + applicationCommand.applicationContext = applicationContext + boolean result = applicationCommand.handle(executionContext) + + then: 'the derivation is the same one the Groovy 4 bytecode observes at runtime' + result + renderedFile.text == 'G7-DERIVED-NAME-hello-derived-name' + + cleanup: + renderedFile.delete() + outputDirectory.delete() + } + } diff --git a/end-to-end/legacy-g7-command-plugin/.sdkmanrc b/end-to-end/legacy-g7-command-plugin/.sdkmanrc new file mode 100644 index 00000000000..b5d28f593f9 --- /dev/null +++ b/end-to-end/legacy-g7-command-plugin/.sdkmanrc @@ -0,0 +1,4 @@ +# Matches Grails 7: JDK 17 and Gradle 8.14.5 are what the 7.0.x branch pins. +# Deliberately not synced with the repository root, which tracks Grails 8. +java=17.0.18-librca +gradle=8.14.5 diff --git a/grails-test-examples/legacy-g7-command-plugin/build.gradle b/end-to-end/legacy-g7-command-plugin/build.gradle similarity index 66% rename from grails-test-examples/legacy-g7-command-plugin/build.gradle rename to end-to-end/legacy-g7-command-plugin/build.gradle index 746be2f460e..43981d6547b 100644 --- a/grails-test-examples/legacy-g7-command-plugin/build.gradle +++ b/end-to-end/legacy-g7-command-plugin/build.gradle @@ -17,9 +17,17 @@ * under the License. */ -// Standalone composite-build fixture compiled against published Grails 7 / Groovy 4. -// Included by the monorepo root so grails-test-examples-legacy-commands can consume a real -// precompiled Grails 7 ApplicationCommand binary instead of recompiling sources under Grails 8. +// Standalone build compiled against published Grails 7 / Groovy 4, producing a real precompiled +// Grails 7 ApplicationCommand binary for legacy-commands to consume instead of recompiling the +// sources under Grails 8. +// +// This is deliberately not part of the end-to-end build and is not composed with includeBuild: +// substitution would rewrite org.apache.grails:grails-core to this repo's Groovy 5 project, which +// is exactly what the fixture exists to avoid. +// +// It builds under the JDK declared in .sdkmanrc (17, the minimum for a Grails 7 app) rather than a +// Gradle toolchain, so the surrounding builds never inherit a second JDK requirement. Run it with +// `sdk env` from this directory, or see .github/workflows/end-to-end.yml for how CI provisions it. plugins { id 'java-library' id 'groovy' @@ -28,12 +36,6 @@ plugins { group = 'legacy.g7.commands' version = '0.0.1' -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - repositories { mavenCentral() } @@ -61,15 +63,21 @@ def groovyCompileVersion = providers.provider { resolvedCompileVersion('org.apache.groovy', 'groovy') } +// With no toolchain or release set (deliberate - see above), the JVM running Gradle is what +// determines the class-file version, so record it for the CI verify step to assert against. +def buildJdkSpec = providers.systemProperty('java.specification.version') + tasks.named('jar', Jar).configure { duplicatesStrategy = DuplicatesStrategy.EXCLUDE inputs.property('grailsCompileVersion', grailsCompileVersion) inputs.property('groovyCompileVersion', groovyCompileVersion) + inputs.property('buildJdkSpec', buildJdkSpec) doFirst { manifest.attributes( 'Grails-Legacy-Command-Fixture': 'true', 'Grails-Compile-Version': grailsCompileVersion.get(), - 'Groovy-Compile-Version': groovyCompileVersion.get() + 'Groovy-Compile-Version': groovyCompileVersion.get(), + 'Build-Jdk-Spec': buildJdkSpec.get() ) } } diff --git a/end-to-end/legacy-g7-command-plugin/gradle/wrapper/gradle-wrapper.jar b/end-to-end/legacy-g7-command-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..b1b8ef56b44 Binary files /dev/null and b/end-to-end/legacy-g7-command-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/end-to-end/legacy-g7-command-plugin/gradle/wrapper/gradle-wrapper.properties b/end-to-end/legacy-g7-command-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..a498622a5a9 --- /dev/null +++ b/end-to-end/legacy-g7-command-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/end-to-end/legacy-g7-command-plugin/gradlew b/end-to-end/legacy-g7-command-plugin/gradlew new file mode 100755 index 00000000000..249efbb032c --- /dev/null +++ b/end-to-end/legacy-g7-command-plugin/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/end-to-end/legacy-g7-command-plugin/gradlew.bat b/end-to-end/legacy-g7-command-plugin/gradlew.bat new file mode 100755 index 00000000000..a51ec4f5886 --- /dev/null +++ b/end-to-end/legacy-g7-command-plugin/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/end-to-end/legacy-g7-command-plugin/settings.gradle b/end-to-end/legacy-g7-command-plugin/settings.gradle new file mode 100644 index 00000000000..9ed07de4aa0 --- /dev/null +++ b/end-to-end/legacy-g7-command-plugin/settings.gradle @@ -0,0 +1,60 @@ +/* + * 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. + */ +// Plugin resolution only - this affects no dependency of the fixture itself, whose classpath must +// stay pinned to published Grails 7 / Groovy 4. Note the repo-wide settings plugin +// (org.apache.grails.buildsrc.repo) is deliberately not applied: it sets FAIL_ON_PROJECT_REPOS, +// which would reject this build's own `repositories { mavenCentral() }`. +pluginManagement { + apply from: file('../../gradle/plugin-repositories.gradle') + repositories { + configurePluginRepositories(delegate) + } +} + +plugins { + id 'com.gradle.develocity' version '4.3.2' + id 'com.gradle.common-custom-user-data-gradle-plugin' version '2.4.0' +} + +def isCI = System.getenv().containsKey('CI') +def isLocal = !isCI +def isReproducibleBuild = System.getenv('SOURCE_DATE_EPOCH') != null + +// Same shape as the root and end-to-end builds, so this leg of the suite shows up in Develocity +// alongside them rather than being the one invocation with no scan. +develocity { + server = 'https://develocity.apache.org' + buildScan { + tag('grails') + tag('grails-end-to-end') + tag('grails-7-fixture') + publishing.onlyIf { it.authenticated } + uploadInBackground = isLocal + } +} + +buildCache { + local { enabled = (isLocal && !isReproducibleBuild) || (isCI && isReproducibleBuild) } + remote(develocity.buildCache) { + push = isCI + enabled = !isReproducibleBuild + } +} + +rootProject.name = 'legacy-g7-command-plugin' diff --git a/grails-test-examples/legacy-g7-command-plugin/settings.gradle b/end-to-end/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloDerivedNameCommand.groovy similarity index 50% rename from grails-test-examples/legacy-g7-command-plugin/settings.gradle rename to end-to-end/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloDerivedNameCommand.groovy index 427b4a38abe..9ce43886ed6 100644 --- a/grails-test-examples/legacy-g7-command-plugin/settings.gradle +++ b/end-to-end/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloDerivedNameCommand.groovy @@ -16,4 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -rootProject.name = 'legacy-g7-command-plugin' +package legacy.g7.commands + +import grails.dev.commands.GrailsApplicationCommand + +/** + * Mirrors the command shape {@code create-command} generated on Grails 7: it implements the trait + * and declares neither {@code getName()} nor {@code getDescription()}, so the registered command + * name is whatever the trait derives from the class name. Reading {@code name} inside + * {@code handle()} exercises that derivation from the precompiled Groovy 4 binary rather than + * from the recompiled trait alone. + */ +class HelloDerivedNameCommand implements GrailsApplicationCommand { + + @Override + boolean handle() { + File outputDirectory = file('build/hello-derived-name') + mkdir(outputDirectory) + render("G7-DERIVED-NAME-${name}", new File(outputDirectory, 'rendered.txt')) + true + } +} diff --git a/grails-test-examples/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledCommand.groovy b/end-to-end/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledCommand.groovy similarity index 100% rename from grails-test-examples/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledCommand.groovy rename to end-to-end/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledCommand.groovy diff --git a/grails-test-examples/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledGrailsCommand.groovy b/end-to-end/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledGrailsCommand.groovy similarity index 100% rename from grails-test-examples/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledGrailsCommand.groovy rename to end-to-end/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledGrailsCommand.groovy diff --git a/grails-test-examples/legacy-g7-command-plugin/src/main/resources/META-INF/grails.factories b/end-to-end/legacy-g7-command-plugin/src/main/resources/META-INF/grails.factories similarity index 71% rename from grails-test-examples/legacy-g7-command-plugin/src/main/resources/META-INF/grails.factories rename to end-to-end/legacy-g7-command-plugin/src/main/resources/META-INF/grails.factories index 85a6a132182..5d3bc422314 100644 --- a/grails-test-examples/legacy-g7-command-plugin/src/main/resources/META-INF/grails.factories +++ b/end-to-end/legacy-g7-command-plugin/src/main/resources/META-INF/grails.factories @@ -1 +1 @@ -grails.dev.commands.ApplicationCommand=legacy.g7.commands.HelloG7PrecompiledCommand,legacy.g7.commands.HelloG7PrecompiledGrailsCommand +grails.dev.commands.ApplicationCommand=legacy.g7.commands.HelloG7PrecompiledCommand,legacy.g7.commands.HelloG7PrecompiledGrailsCommand,legacy.g7.commands.HelloDerivedNameCommand diff --git a/end-to-end/settings.gradle b/end-to-end/settings.gradle new file mode 100644 index 00000000000..baa921cd5ca --- /dev/null +++ b/end-to-end/settings.gradle @@ -0,0 +1,112 @@ +/* + * 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. + */ + +pluginManagement { + includeBuild('../grails-gradle') { + name = 'grails-gradle' + } + includeBuild('../build-logic') { + name = 'build-logic-root' + } + apply from: file('../gradle/plugin-repositories.gradle') + repositories { + configurePluginRepositories(delegate) + } +} + +plugins { + id 'com.gradle.develocity' version '4.3.2' + id 'com.gradle.common-custom-user-data-gradle-plugin' version '2.4.0' + id 'org.apache.grails.buildsrc.repo' +} + +def isCI = System.getenv().containsKey('CI') +def isLocal = !isCI +def isReproducibleBuild = System.getenv('SOURCE_DATE_EPOCH') != null + +develocity { + server = 'https://develocity.apache.org' + buildScan { + tag('grails') + tag('grails-end-to-end') + publishing.onlyIf { it.authenticated } + uploadInBackground = isLocal + } +} + +// Same shape as the root build's settings.gradle, so scans and cache behave identically here. +buildCache { + local { enabled = (isLocal && !isReproducibleBuild) || (isCI && isReproducibleBuild) } + remote(develocity.buildCache) { + push = isCI + enabled = !isReproducibleBuild + } +} + +// Resolve Grails from the artifacts the core build publishes, not from project substitution. That +// is what makes these tests end-to-end: they consume grails-core exactly as an application does, +// through real poms and Gradle module metadata. +// +// The repository is TestCaseMavenRepo, which every published project declares via the publish +// plugin's testRepositoryPath (build-logic .../PublishPlugin.groovy) and which resolves to +// /build/local-maven. Populate it before running this build: +// +// ./gradlew publishAllPublicationsToTestCaseMavenRepoRepository +// +// grails-forge consumes the same directory for the applications its tests generate +// (grails-forge/gradle/test-config.gradle passes it as GRAILS_REPO_URL). +// +// Deliberately NOT includeBuild('..'): composite substitution would win over this repository and +// take us back to resolving projects instead of artifacts. It also cannot express the CLI +// companions - grails-core-cli is a secondary capability of :grails-core rather than a project, so +// substitution hits a capability self-conflict. Published metadata has that already solved: the +// companion is a first-class module with its own publication, and CliPublishingSupport rewrites +// capability requests out of what gets published precisely so external consumers resolve the plain +// coordinate. +dependencyResolutionManagement { + repositories { + // exclusiveContent rather than ordering: every org.apache.grails artifact must come from + // the local build. Without it a remote 8.0.0-SNAPSHOT could satisfy the request and the + // suite would quietly test something other than this working tree. + exclusiveContent { + forRepository { + maven { + name = 'GrailsLocalMaven' + url = rootDir.toPath().resolve('../build/local-maven').normalize().toUri() + } + } + filter { + includeGroupByRegex('org\\.apache\\.grails.*') + } + } + } +} + +rootProject.name = 'grails-end-to-end' + +include( + 'legacy-commands', + 'legacy-commands-plugin', +) + +// legacy-g7-command-plugin is deliberately NOT included here. It compiles against published +// Grails 7 / Groovy 4 artifacts, and an included build would substitute +// org.apache.grails:grails-core for this repo's Groovy 5 project - which is exactly the +// substitution the fixture exists to avoid. It stays a standalone build, run under its own JDK +// (legacy-g7-command-plugin/.sdkmanrc), and legacy-commands consumes the jar it produces. diff --git a/gradle-bootstrap/build.gradle b/gradle-bootstrap/build.gradle index 38b2cea2ebd..e244b761d5e 100644 --- a/gradle-bootstrap/build.gradle +++ b/gradle-bootstrap/build.gradle @@ -17,17 +17,38 @@ * under the License. */ -def props = new Properties() -project.rootProject.layout.projectDirectory.file('../.sdkmanrc').asFile.withInputStream { - props.load(it) +def loadSdkmanrc = { String relativePath -> + def loaded = new Properties() + project.rootProject.layout.projectDirectory.file(relativePath).asFile.withInputStream { + loaded.load(it) + } + loaded } -tasks.withType(Wrapper).configureEach { + +def props = loadSdkmanrc('../.sdkmanrc') + +// Scoped to the default wrapper task on purpose. legacyG7Wrapper below pins a different version, +// which a `tasks.withType(Wrapper)` block here would silently overwrite. +tasks.named('wrapper', Wrapper) { gradleVersion = props.gradle } +// The Grails 7 fixture is built by the Gradle version Grails 7 uses, not the one this repository +// builds with, so that it stands in for a plugin the way one was actually published. It therefore +// gets its own wrapper generated from its own .sdkmanrc, written straight into the fixture rather +// than copied from the shared one below. +def legacyG7Dir = project.rootProject.layout.projectDirectory.dir('../end-to-end/legacy-g7-command-plugin') +def legacyG7Props = loadSdkmanrc('../end-to-end/legacy-g7-command-plugin/.sdkmanrc') +tasks.register('legacyG7Wrapper', Wrapper) { + description = "Generates the Grails 7 fixture's wrapper at the Gradle version Grails 7 pins." + gradleVersion = legacyG7Props.gradle + scriptFile = legacyG7Dir.file('gradlew').asFile + jarFile = legacyG7Dir.file('gradle/wrapper/gradle-wrapper.jar').asFile +} + defaultTasks 'bootstrap' tasks.register('bootstrap') { - dependsOn 'wrapper' + dependsOn 'wrapper', 'legacyG7Wrapper' doLast { ant.copy file: "${projectDir}/gradlew", todir: "${projectDir}/../grails-forge" ant.copy file: "${projectDir}/gradlew.bat", todir: "${projectDir}/../grails-forge" @@ -51,6 +72,22 @@ tasks.register('bootstrap') { ant.chmod(file: "${projectDir}/../grails-gradle/gradlew", perm: '755') ant.chmod(file: "${projectDir}/../grails-gradle/gradlew.bat", perm: '755') + // end-to-end/legacy-g7-command-plugin is deliberately absent from these copies: its + // wrapper comes from legacyG7Wrapper at the Grails 7 Gradle version instead. + ant.chmod(file: "${projectDir}/../end-to-end/legacy-g7-command-plugin/gradlew", perm: '755') + ant.chmod(file: "${projectDir}/../end-to-end/legacy-g7-command-plugin/gradlew.bat", perm: '755') + + ant.copy file: "${projectDir}/gradlew", todir: "${projectDir}/../end-to-end" + ant.copy file: "${projectDir}/gradlew.bat", todir: "${projectDir}/../end-to-end" + ant.copy(todir: "${projectDir}/../end-to-end/gradle/wrapper") { + fileset(dir: "${projectDir}/gradle/wrapper") { + include(name: 'gradle-wrapper.jar') + include(name: 'gradle-wrapper.properties') + } + } + ant.chmod(file: "${projectDir}/../end-to-end/gradlew", perm: '755') + ant.chmod(file: "${projectDir}/../end-to-end/gradlew.bat", perm: '755') + ant.move file: "${projectDir}/gradlew", todir: "${projectDir}/../" ant.move file: "${projectDir}/gradlew.bat", todir: "${projectDir}/../" ant.move file: "${projectDir}/gradle/wrapper", todir: "${projectDir}/../gradle" diff --git a/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy b/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy index caeef4c19e2..bff5d438b8a 100644 --- a/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy +++ b/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy @@ -49,8 +49,8 @@ class GrailsApplicationContextCommandRunner extends DevelopmentGrailsApplication if (command) { Object autowireTarget = resolveAutowireTarget(command) - Object skipBootstrap = autowireTarget.hasProperty('skipBootstrap')?.getProperty(autowireTarget) - if (skipBootstrap instanceof Boolean && !System.getProperty(Settings.SETTING_SKIP_BOOTSTRAP)) { + Boolean skipBootstrap = resolveSkipBootstrap(command) + if (skipBootstrap != null && !System.getProperty(Settings.SETTING_SKIP_BOOTSTRAP)) { System.setProperty(Settings.SETTING_SKIP_BOOTSTRAP, skipBootstrap.toString()) } @@ -139,6 +139,21 @@ class GrailsApplicationContextCommandRunner extends DevelopmentGrailsApplication ((ApplicationCommandTargetAware) command).target : command } + /** + * Reads the optional {@code skipBootstrap} flag declared by a command. The flag is looked up on + * the autowire target rather than on {@code command} itself, so a command reached through the + * deprecated Grails 7 compatibility layer keeps its flag: the adapter forwards {@code handle} + * but not the arbitrary properties a command declares. + * + * @param command the command resolved from the registry + * @return the declared flag, or {@code null} when the target declares no {@code skipBootstrap} property or declares one that is not a {@link Boolean} + */ + static Boolean resolveSkipBootstrap(Object command) { + Object autowireTarget = resolveAutowireTarget(command) + Object skipBootstrap = autowireTarget.hasProperty('skipBootstrap')?.getProperty(autowireTarget) + skipBootstrap instanceof Boolean ? (Boolean) skipBootstrap : null + } + /** * Main method to run an existing Application class * diff --git a/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy b/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy index 72b0e4fadeb..583b56ca676 100644 --- a/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy +++ b/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy @@ -174,4 +174,63 @@ class GrailsApplicationContextCommandRunnerSpec extends Specification { then: autowireTarget.is(command) } + + def "resolveSkipBootstrap reads the flag declared by a command"() { + expect: + GrailsApplicationContextCommandRunner.resolveSkipBootstrap(new SkipBootstrapCommand()) == true + } + + def "resolveSkipBootstrap returns null for a command without the flag"() { + expect: + GrailsApplicationContextCommandRunner.resolveSkipBootstrap(new PlainCommand()) == null + } + + def "resolveSkipBootstrap reads the flag from the target of a legacy command adapter"() { + given: 'an adapter that forwards handle() but not the properties its target declares' + Object adapter = new TargetAwareAdapter(new SkipBootstrapCommand()) + + expect: 'the flag is invisible on the adapter itself' + !adapter.hasProperty('skipBootstrap') + + and: 'so it can only be found by going through the target' + GrailsApplicationContextCommandRunner.resolveSkipBootstrap(adapter) == true + } + + def "resolveSkipBootstrap ignores a non-Boolean flag declared by the target"() { + expect: + GrailsApplicationContextCommandRunner.resolveSkipBootstrap(new TargetAwareAdapter(new TextSkipBootstrapCommand())) == null + } + + /** + * A command opting out of BootStrap execution, like the schema export and database migration commands. + */ + static class SkipBootstrapCommand { + Boolean skipBootstrap = true + } + + /** + * A command declaring a {@code skipBootstrap} property of an unusable type. + */ + static class TextSkipBootstrapCommand { + String skipBootstrap = 'true' + } + + /** + * A command leaving BootStrap execution alone. + */ + static class PlainCommand { + String description = 'runs with BootStrap' + } + + /** + * Stands in for the adapter the registry returns for a Grails 7 command: it exposes the target + * but none of the properties that target declares. + */ + static class TargetAwareAdapter implements ApplicationCommandTargetAware { + final Object target + + TargetAwareAdapter(Object target) { + this.target = target + } + } } diff --git a/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy b/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy index 2480e0fd929..4012b6d6282 100644 --- a/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy +++ b/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy @@ -24,18 +24,22 @@ import org.apache.grails.core.cli.ApplicationCommand import org.apache.grails.core.cli.ApplicationCommandTargetAware import org.apache.grails.core.cli.ExecutionContext import org.springframework.context.ConfigurableApplicationContext +import org.springframework.core.Ordered +import org.springframework.core.annotation.OrderUtils /** * Adapts a Grails 7 command contract to the Grails 8 CLI contract. */ @SuppressWarnings('deprecation') @CompileStatic -class LegacyApplicationCommandAdapter implements ApplicationCommand, ApplicationCommandTargetAware { +class LegacyApplicationCommandAdapter implements ApplicationCommand, ApplicationCommandTargetAware, Ordered { private final grails.dev.commands.ApplicationCommand legacyCommand + private final int order LegacyApplicationCommandAdapter(grails.dev.commands.ApplicationCommand legacyCommand) { this.legacyCommand = legacyCommand + this.order = resolveOrder(legacyCommand) } @Override @@ -43,6 +47,15 @@ class LegacyApplicationCommandAdapter implements ApplicationCommand, Application legacyCommand } + /** + * The order declared by the adapted Grails 7 command, so that commands ordered through the + * Spring conventions keep deciding duplicate command names as they did on Grails 7. + */ + @Override + int getOrder() { + order + } + @Override String getName() { legacyCommand.name @@ -67,4 +80,11 @@ class LegacyApplicationCommandAdapter implements ApplicationCommand, Application boolean handle(ExecutionContext executionContext) { legacyCommand.handle(new grails.dev.commands.ExecutionContext(executionContext.commandLine)) } + + private static int resolveOrder(grails.dev.commands.ApplicationCommand legacyCommand) { + if (legacyCommand instanceof Ordered) { + return ((Ordered) legacyCommand).order + } + OrderUtils.getOrder(legacyCommand.class, Ordered.LOWEST_PRECEDENCE) + } } diff --git a/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandProvider.groovy b/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandProvider.groovy index a00eecbe967..c41ba02e221 100644 --- a/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandProvider.groovy +++ b/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandProvider.groovy @@ -24,6 +24,7 @@ import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import grails.dev.commands.ApplicationCommand as LegacyApplicationCommand +import org.springframework.core.OrderComparator import org.springframework.util.ClassUtils import org.apache.grails.core.cli.ApplicationCommand @@ -58,15 +59,11 @@ class LegacyApplicationCommandProvider implements ApplicationCommandFactoryKeyPr addLegacyCommandClasses(legacyClasses, contextClassLoader) } + List adapters = [] for (Class legacyClass : legacyClasses) { try { LegacyApplicationCommand legacyCommand = instantiate(legacyClass) - ApplicationCommand command = new LegacyApplicationCommandAdapter(legacyCommand) - String installedName = registrar.register(command) - if (installedName != null && !warningLogged) { - log.warn('Command \'{}\' from a Grails 7 plugin was loaded through the deprecated grails.dev.commands compatibility layer. Ask the plugin author to migrate to the org.apache.grails.core.cli command API and publish a -cli companion artifact; this compatibility path will be removed in a future major release.', installedName) - warningLogged = true - } + adapters.add(new LegacyApplicationCommandAdapter(legacyCommand)) } catch (LinkageError e) { rethrowIfFatal(e) @@ -79,6 +76,21 @@ class LegacyApplicationCommandProvider implements ApplicationCommandFactoryKeyPr legacyClass.name, e) } } + + // Registration is first-wins, so the adapters are ordered the same way the Grails 8 commands are: by class + // name for a stable baseline, then by the order the adapted command declares. Without this, two Grails 7 + // plugins shipping the same command name would be resolved by jar scan order. + adapters.sort { LegacyApplicationCommandAdapter first, LegacyApplicationCommandAdapter second -> + first.target.class.name <=> second.target.class.name + } + OrderComparator.sort(adapters) + for (ApplicationCommand command : adapters) { + String installedName = registrar.register(command) + if (installedName != null && !warningLogged) { + log.warn('Command \'{}\' from a Grails 7 plugin was loaded through the deprecated grails.dev.commands compatibility layer. Ask the plugin author to migrate to the org.apache.grails.core.cli command API and publish a -cli companion artifact; this compatibility path will be removed in a future major release.', installedName) + warningLogged = true + } + } } private static void addLegacyCommandClasses( @@ -141,9 +153,6 @@ class LegacyApplicationCommandProvider implements ApplicationCommandFactoryKeyPr if (current instanceof VirtualMachineError) { throw (VirtualMachineError) current } - if (current instanceof ThreadDeath) { - throw (ThreadDeath) current - } current = current.cause } } diff --git a/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy b/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy index 29d0c6a473a..a2b9be08b88 100644 --- a/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy +++ b/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy @@ -29,6 +29,8 @@ import org.apache.grails.core.cli.compat.LegacyApplicationCommandProvider import org.grails.build.parsing.CommandLine import org.slf4j.LoggerFactory import org.springframework.context.ConfigurableApplicationContext +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order import spock.lang.Specification import spock.lang.TempDir import spock.lang.Unroll @@ -119,6 +121,35 @@ class LegacyCommandRegistryLoadingSpec extends Specification { !(command instanceof LegacyApplicationCommandAdapter) } + def "prefers the ordered legacy command when two legacy commands share a name"() { + given: + useFactoryResources('grails.dev.commands.ApplicationCommand=' + + "${AUnorderedLegacyCollisionCommand.name},${ZOrderedLegacyCollisionCommand.name}") + + when: + ApplicationCommand command = new ApplicationContextCommandRegistry().findCommand('legacy-order-collision') + + then: + ((ApplicationCommandTargetAware) command).target instanceof ZOrderedLegacyCollisionCommand + } + + @Unroll + def "resolves unordered legacy command name collisions by class name when declared #declarationOrder"() { + given: + useFactoryResources("grails.dev.commands.ApplicationCommand=${declaredClasses.join(',')}") + + when: + ApplicationCommand command = new ApplicationContextCommandRegistry().findCommand('legacy-stable-collision') + + then: + ((ApplicationCommandTargetAware) command).target instanceof AStableLegacyCollisionCommand + + where: + declarationOrder | declaredClasses + 'in class order' | [AStableLegacyCollisionCommand.name, BStableLegacyCollisionCommand.name] + 'in reverse order' | [BStableLegacyCollisionCommand.name, AStableLegacyCollisionCommand.name] + } + def "continues loading commands when a legacy command constructor fails"() { given: useFactoryResources( @@ -236,7 +267,7 @@ class LegacyCommandRegistryLoadingSpec extends Specification { thrown.is(failure) where: - failure << [new StackOverflowError(), new ThreadDeath()] + failure << [new StackOverflowError(), new OutOfMemoryError()] } @Unroll @@ -253,7 +284,7 @@ class LegacyCommandRegistryLoadingSpec extends Specification { thrown.is(failure) where: - failure << [new StackOverflowError(), new ThreadDeath()] + failure << [new StackOverflowError(), new OutOfMemoryError()] } @Unroll @@ -270,7 +301,7 @@ class LegacyCommandRegistryLoadingSpec extends Specification { thrown.is(failure) where: - failure << [new StackOverflowError(), new ThreadDeath()] + failure << [new StackOverflowError(), new OutOfMemoryError()] } private void attachProviderAppender() { @@ -385,6 +416,79 @@ class LegacyRegistryCollisionCommand implements grails.dev.commands.ApplicationC } } +class AUnorderedLegacyCollisionCommand implements grails.dev.commands.ApplicationCommand { + + @Override + String getName() { + 'legacy-order-collision' + } + + @Override + String getDescription() { + 'Unordered legacy collision command' + } + + @Override + boolean handle(LegacyExecutionContext executionContext) { + true + } +} + +@Order(Ordered.HIGHEST_PRECEDENCE) +class ZOrderedLegacyCollisionCommand implements grails.dev.commands.ApplicationCommand { + + @Override + String getName() { + 'legacy-order-collision' + } + + @Override + String getDescription() { + 'Ordered legacy collision command' + } + + @Override + boolean handle(LegacyExecutionContext executionContext) { + true + } +} + +class AStableLegacyCollisionCommand implements grails.dev.commands.ApplicationCommand { + + @Override + String getName() { + 'legacy-stable-collision' + } + + @Override + String getDescription() { + 'First stable legacy collision command' + } + + @Override + boolean handle(LegacyExecutionContext executionContext) { + true + } +} + +class BStableLegacyCollisionCommand implements grails.dev.commands.ApplicationCommand { + + @Override + String getName() { + 'legacy-stable-collision' + } + + @Override + String getDescription() { + 'Second stable legacy collision command' + } + + @Override + boolean handle(LegacyExecutionContext executionContext) { + true + } +} + class ThrowingLegacyCommand implements grails.dev.commands.ApplicationCommand { ThrowingLegacyCommand() { diff --git a/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy b/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy index 8229762c6bf..00b10773dc2 100644 --- a/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy +++ b/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy @@ -23,6 +23,8 @@ import grails.dev.commands.ExecutionContext as LegacyExecutionContext import org.apache.grails.core.cli.ExecutionContext import org.grails.build.parsing.CommandLine import org.springframework.context.ConfigurableApplicationContext +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order import spock.lang.Specification class LegacyApplicationCommandAdapterSpec extends Specification { @@ -49,6 +51,26 @@ class LegacyApplicationCommandAdapterSpec extends Specification { legacyCommand.executionContext.commandLine.is(commandLine) } + def "propagates the order of a legacy command that implements Ordered"() { + expect: + new LegacyApplicationCommandAdapter(new OrderedInterfaceLegacyCommand()).order == 42 + } + + def "resolves the order of a legacy command declared with the Order annotation"() { + expect: + new LegacyApplicationCommandAdapter(new OrderAnnotatedLegacyCommand()).order == 7 + } + + def "prefers the Ordered interface over the Order annotation when a legacy command has both"() { + expect: + new LegacyApplicationCommandAdapter(new DoublyOrderedLegacyCommand()).order == 5 + } + + def "defaults an unordered legacy command to lowest precedence"() { + expect: + new LegacyApplicationCommandAdapter(new TestLegacyCommand()).order == Ordered.LOWEST_PRECEDENCE + } + private static class TestLegacyCommand implements ApplicationCommand { String name = 'legacy-command' @@ -61,4 +83,25 @@ class LegacyApplicationCommandAdapterSpec extends Specification { true } } + + private static class OrderedInterfaceLegacyCommand extends TestLegacyCommand implements Ordered { + + @Override + int getOrder() { + 42 + } + } + + @Order(7) + private static class OrderAnnotatedLegacyCommand extends TestLegacyCommand { + } + + @Order(9) + private static class DoublyOrderedLegacyCommand extends TestLegacyCommand implements Ordered { + + @Override + int getOrder() { + 5 + } + } } diff --git a/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandDiagnostics.groovy b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandDiagnostics.groovy index c66de63e45b..5ab4b3ca998 100644 --- a/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandDiagnostics.groovy +++ b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandDiagnostics.groovy @@ -81,9 +81,6 @@ class ApplicationCommandDiagnostics { if (current instanceof VirtualMachineError) { throw (VirtualMachineError) current } - if (current instanceof ThreadDeath) { - throw (ThreadDeath) current - } current = current.cause } } diff --git a/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy index 17d695492c3..987efb91959 100644 --- a/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy +++ b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy @@ -43,22 +43,56 @@ class ApplicationContextCommandRegistry { ClassLoader registryClassLoader = ApplicationContextCommandRegistry.classLoader ClassLoader contextClassLoader = Thread.currentThread().contextClassLoader - addApplicationCommands(registryClassLoader) + addApplicationCommands(registryClassLoader, contextClassLoader) + + Set handledFactoryKeys = loadCommandProviders(registryClassLoader, contextClassLoader) + missingCommandHint = ApplicationCommandDiagnostics.detectMissingCommandHint( + registryClassLoader, contextClassLoader, handledFactoryKeys) + } + + private void addApplicationCommands(ClassLoader registryClassLoader, ClassLoader contextClassLoader) { + Map, String> commandOrigins = new LinkedHashMap<>() + addApplicationCommandClasses(commandOrigins, registryClassLoader) // If this is reflectively loaded from the delegating cli, we need to make sure the context class loader is - // also used to pull any commands that are loaded from the gradle classpath. Only when it is a distinct - // classloader: repeating the scan for the same classloader would re-instantiate every command (whose - // constructor may have side effects) just to discard it on the name-collision check below. + // also used to pull any commands that are loaded from the gradle classpath. The classes are collected before + // any of them is instantiated: a class reachable through both classloaders would otherwise be instantiated + // twice (its constructor may have side effects) just to be discarded on the name-collision check below. if (contextClassLoader != null && contextClassLoader != registryClassLoader) { - addApplicationCommands(contextClassLoader) + addApplicationCommandClasses(commandOrigins, contextClassLoader) } - Set handledFactoryKeys = loadCommandProviders(registryClassLoader, contextClassLoader) - missingCommandHint = ApplicationCommandDiagnostics.detectMissingCommandHint( - registryClassLoader, contextClassLoader, handledFactoryKeys) + List discoveredCommands = [] + for (Map.Entry, String> entry : commandOrigins) { + try { + discoveredCommands.add(instantiate(entry.key)) + } + catch (LinkageError e) { + rethrowIfFatal(e) + log.error('Unable to link application command \'{}\' declared in \'{}\'. This is a Grails binary-compatibility issue; please report it to the Grails framework. The command is unavailable.', + entry.key.name, entry.value, e) + } + catch (Throwable e) { + rethrowIfFatal(e) + log.warn('Failed to load application command \'{}\' declared in \'{}\'; skipping it.', + entry.key.name, entry.value, e) + } + } + + discoveredCommands.sort { ApplicationCommand first, ApplicationCommand second -> + first.class.name <=> second.class.name + } + OrderComparator.sort(discoveredCommands) + for (ApplicationCommand command : discoveredCommands) { + if (!commands.containsKey(command.name)) { + commands[command.name] = command + } + } } - private void addApplicationCommands(ClassLoader classLoader) { + private static void addApplicationCommandClasses( + Map, String> commandOrigins, + ClassLoader classLoader) { Map> declarations try { declarations = GrailsFactoriesLoader.loadFactoryDeclarations( @@ -70,22 +104,21 @@ class ApplicationContextCommandRegistry { return } - Map commandOrigins = new LinkedHashMap<>() + Map declaredOrigins = new LinkedHashMap<>() for (Map.Entry> entry : declarations) { for (String commandName : entry.value) { - commandOrigins.putIfAbsent(commandName, entry.key) + declaredOrigins.putIfAbsent(commandName, entry.key) } } - List discoveredCommands = [] - for (Map.Entry entry : commandOrigins) { + for (Map.Entry entry : declaredOrigins) { try { Class commandClass = ClassUtils.forName(entry.key, classLoader) if (!ApplicationCommand.isAssignableFrom(commandClass)) { throw new IllegalArgumentException( "Class [${entry.key}] is not assignable to [${ApplicationCommand.name}]") } - discoveredCommands.add(instantiateCommand((Class) commandClass)) + commandOrigins.putIfAbsent((Class) commandClass, entry.value) } catch (LinkageError e) { rethrowIfFatal(e) @@ -98,16 +131,6 @@ class ApplicationContextCommandRegistry { entry.key, entry.value, e) } } - - discoveredCommands.sort { ApplicationCommand first, ApplicationCommand second -> - first.class.name <=> second.class.name - } - OrderComparator.sort(discoveredCommands) - for (ApplicationCommand command : discoveredCommands) { - if (!commands.containsKey(command.name)) { - commands[command.name] = command - } - } } private Set loadCommandProviders(ClassLoader registryClassLoader, ClassLoader contextClassLoader) { @@ -190,23 +213,9 @@ class ApplicationContextCommandRegistry { } } - private static ApplicationCommandProvider instantiate(Class providerClass) { - try { - providerClass.getDeclaredConstructor().newInstance() - } - catch (InvocationTargetException e) { - Throwable cause = e.cause - rethrowIfFatal(cause) - if (cause instanceof LinkageError) { - throw (LinkageError) cause - } - throw e - } - } - - private static ApplicationCommand instantiateCommand(Class commandClass) { + private static T instantiate(Class type) { try { - commandClass.getDeclaredConstructor().newInstance() + type.getDeclaredConstructor().newInstance() } catch (InvocationTargetException e) { Throwable cause = e.cause @@ -225,9 +234,6 @@ class ApplicationContextCommandRegistry { if (current instanceof VirtualMachineError) { throw (VirtualMachineError) current } - if (current instanceof ThreadDeath) { - throw (ThreadDeath) current - } current = current.cause } } diff --git a/grails-core/src/main/groovy/org/grails/core/io/support/GrailsFactoriesLoader.groovy b/grails-core/src/main/groovy/org/grails/core/io/support/GrailsFactoriesLoader.groovy index 8974d97fc07..5d7c6e1dda7 100644 --- a/grails-core/src/main/groovy/org/grails/core/io/support/GrailsFactoriesLoader.groovy +++ b/grails-core/src/main/groovy/org/grails/core/io/support/GrailsFactoriesLoader.groovy @@ -20,6 +20,7 @@ package org.grails.core.io.support import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j import org.springframework.core.OrderComparator import org.springframework.util.Assert @@ -35,6 +36,7 @@ import org.grails.io.support.FactoriesLoaderSupport * @since 2.4 * @author Graeme Rocher */ +@Slf4j @CompileStatic class GrailsFactoriesLoader extends FactoriesLoaderSupport { @@ -130,7 +132,9 @@ class GrailsFactoriesLoader extends FactoriesLoaderSupport { properties.load(input) } } - catch (IOException | IllegalArgumentException ignored) { + catch (IOException | IllegalArgumentException e) { + log.warn('Unable to read factory declarations from \'{}\'; skipping it. Any factories declared in that resource are unavailable.', + resource.toExternalForm(), e) continue } diff --git a/grails-core/src/test/groovy/org/apache/grails/core/cli/ApplicationCommandProviderSpec.groovy b/grails-core/src/test/groovy/org/apache/grails/core/cli/ApplicationCommandProviderSpec.groovy index 3f5f191f053..120b3e87a6d 100644 --- a/grails-core/src/test/groovy/org/apache/grails/core/cli/ApplicationCommandProviderSpec.groovy +++ b/grails-core/src/test/groovy/org/apache/grails/core/cli/ApplicationCommandProviderSpec.groovy @@ -38,10 +38,12 @@ class ApplicationCommandProviderSpec extends Specification { private ClassLoader originalContextClassLoader private URLClassLoader factoryClassLoader + private URLClassLoader registryDefiningClassLoader def cleanup() { Thread.currentThread().contextClassLoader = originalContextClassLoader factoryClassLoader?.close() + registryDefiningClassLoader?.close() } def "continues loading providers when one provider constructor fails"() { @@ -80,9 +82,6 @@ class ApplicationCommandProviderSpec extends Specification { def "instantiates a modern command once when distinct resources declare the same class"() { given: ConstructorCountingApplicationCommand.constructorCalls = 0 - new ApplicationContextCommandRegistry() - int registryClassLoaderConstructions = ConstructorCountingApplicationCommand.constructorCalls - ConstructorCountingApplicationCommand.constructorCalls = 0 URL firstPlugin = createFactoryJar( 'first-counting-command.jar', 'example.FirstFactory=example.FirstImplementation', @@ -98,7 +97,32 @@ class ApplicationCommandProviderSpec extends Specification { then: registry.findCommand('counting-modern') instanceof ConstructorCountingApplicationCommand - ConstructorCountingApplicationCommand.constructorCalls == registryClassLoaderConstructions + 1 + ConstructorCountingApplicationCommand.constructorCalls == 1 + } + + def "instantiates the same command class once across registry and context classloaders"() { + given: 'a factories declaration reachable from the registry classloader and the context classloader' + SharedCountingApplicationCommand.constructorCalls = 0 + URL plugin = createFactoryJar( + 'shared-counting-command.jar', + 'example.OtherFactory=example.OtherImplementation', + "${ApplicationCommand.name}=${SharedCountingApplicationCommand.name}") + // The registry scans its own defining classloader and then the thread context classloader. + // Define the registry class in a loader that also carries the factories jar so the first + // scan finds the declaration, and hand the same jar to the context classloader for the + // second. The command class itself stays on the shared parent test classpath, so both + // scans resolve the identical Class - the case that was previously constructed twice. + registryDefiningClassLoader = new RegistryDefiningClassLoader([plugin] as URL[], getClass().classLoader) + useFactoryResources([plugin]) + + when: 'the singleton instance is read, since the eager @Singleton initializer constructs it' + def registry = registryDefiningClassLoader + .loadClass(ApplicationContextCommandRegistry.name) + .instance + + then: + registry.findCommand('counting-shared') instanceof SharedCountingApplicationCommand + SharedCountingApplicationCommand.constructorCalls == 1 } def "ordered modern commands win duplicate-name collisions"() { @@ -167,7 +191,7 @@ class ApplicationCommandProviderSpec extends Specification { thrown.is(failure) where: - failure << [new StackOverflowError(), new ThreadDeath()] + failure << [new StackOverflowError(), new OutOfMemoryError()] } def "reports a provider load-time linkage error with its factory origin and continues discovery"() { @@ -209,7 +233,7 @@ class ApplicationCommandProviderSpec extends Specification { thrown.is(failure) where: - failure << [new StackOverflowError(), new ThreadDeath()] + failure << [new StackOverflowError(), new OutOfMemoryError()] } @Unroll @@ -230,7 +254,7 @@ class ApplicationCommandProviderSpec extends Specification { thrown.is(failure) where: - failure << [new StackOverflowError(), new ThreadDeath()] + failure << [new StackOverflowError(), new OutOfMemoryError()] } @Unroll @@ -251,7 +275,7 @@ class ApplicationCommandProviderSpec extends Specification { thrown.is(failure) where: - failure << [new StackOverflowError(), new ThreadDeath()] + failure << [new StackOverflowError(), new OutOfMemoryError()] } def "reports each legacy command plugin once without loading its command classes"() { @@ -298,18 +322,60 @@ class ApplicationCommandProviderSpec extends Specification { !errorOutput.toString('UTF-8').contains('ships Grails 7 commands') } - def "continues command discovery when a factory resource is malformed"() { + def "reports a malformed factory resource and continues command discovery"() { given: String malformedFactory = 'grails.dev.commands.ApplicationCommand=' + '\\' + 'uInvalid' URL plugin = createFactoryJar('malformed-plugin.jar', malformedFactory) useFactoryResources([plugin]) + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream() when: - ApplicationContextCommandRegistry registry = new ApplicationContextCommandRegistry() + ApplicationContextCommandRegistry registry = captureStandardError(errorOutput) { + new ApplicationContextCommandRegistry() + } then: registry.missingCommandHint == null noExceptionThrown() + String captured = errorOutput.toString('UTF-8') + captured.contains('Unable to read factory declarations') + captured.contains('malformed-plugin.jar') + captured.contains('META-INF/grails.factories') + + and: 'the cause is reported, since it is what tells the user why the resource was dropped' + captured.contains(IllegalArgumentException.name) + captured.contains('Malformed') + } + + def "reports a malformed cli factory resource without losing another plugin's modern commands"() { + given: + String malformedCliFactory = ApplicationCommand.name + '=' + '\\' + 'uInvalid' + URL malformedPlugin = createFactoryJar( + 'malformed-cli-plugin.jar', + 'example.OtherFactory=example.OtherImplementation', + malformedCliFactory) + URL modernPlugin = createFactoryJar( + 'modern-command-plugin.jar', + 'example.OtherFactory=example.OtherImplementation', + "${ApplicationCommand.name}=${ModernTestCommand.name}") + useFactoryResources([malformedPlugin, modernPlugin]) + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream() + + when: + ApplicationContextCommandRegistry registry = captureStandardError(errorOutput) { + new ApplicationContextCommandRegistry() + } + + then: + registry.findCommand('modern-test') instanceof ModernTestCommand + String captured = errorOutput.toString('UTF-8') + captured.contains('Unable to read factory declarations') + captured.contains('malformed-cli-plugin.jar') + captured.contains('META-INF/grails-cli.factories') + + and: 'the cause is reported, since it is what tells the user why the resource was dropped' + captured.contains(IllegalArgumentException.name) + captured.contains('Malformed') } def "continues command discovery when factory resources cannot be enumerated"() { @@ -326,10 +392,10 @@ class ApplicationCommandProviderSpec extends Specification { noExceptionThrown() } - def "uses the resource URL when plugin origin resolution fails"() { + def "reports the factory resource URL as the plugin origin when the resource is not a jar"() { given: - URL factoryResource = new URL(null, 'memory:broken-origin/META-INF/grails.factories', - new FailingOriginUrlStreamHandler()) + URL factoryResource = new URL(null, 'memory:legacy-plugin/META-INF/grails.factories', + new FixedFactoryResourceUrlStreamHandler()) originalContextClassLoader = Thread.currentThread().contextClassLoader factoryClassLoader = new FixedFactoryResourceClassLoader(factoryResource, getClass().classLoader) Thread.currentThread().contextClassLoader = factoryClassLoader @@ -342,7 +408,7 @@ class ApplicationCommandProviderSpec extends Specification { then: registry.missingCommandHint != null - errorOutput.toString('UTF-8').contains('Plugin memory:broken-origin/META-INF/grails.factories ships Grails 7 commands') + errorOutput.toString('UTF-8').contains('Plugin memory:legacy-plugin/META-INF/grails.factories ships Grails 7 commands') } def "does not report legacy command diagnostics when a provider handles the factory key"() { @@ -407,6 +473,12 @@ class ApplicationCommandProviderSpec extends Specification { originalContextClassLoader = Thread.currentThread().contextClassLoader factoryClassLoader = new TrackingFactoryClassLoader( resources as URL[], getClass().classLoader, duplicateResources, loadFailures) + // Bootstrap loadClass before the loader becomes the context classloader: the JDK bean + // introspector resolves *BeanInfo classes through the context classloader, and a + // first-ever invocation of this Groovy-compiled loadClass in the middle of that lookup + // triggers Groovy class initialization that recurses into the same lookup and dies with + // a ClassCircularityError. + factoryClassLoader.loadClass(Object.name) Thread.currentThread().contextClassLoader = factoryClassLoader } @@ -491,6 +563,25 @@ class ConstructorCountingApplicationCommand implements ApplicationCommand { } } +class SharedCountingApplicationCommand implements ApplicationCommand { + + static int constructorCalls + + SharedCountingApplicationCommand() { + constructorCalls++ + } + + @Override + String getName() { + 'counting-shared' + } + + @Override + boolean handle(ExecutionContext executionContext) { + true + } +} + class AUnorderedCollisionCommand implements ApplicationCommand { @Override @@ -632,6 +723,65 @@ class TrackingFactoryClassLoader extends URLClassLoader { } } +/** + * Defines {@link ApplicationContextCommandRegistry} (and its generated inner classes) itself, so + * that the registry's own classloader sees the factory jars handed to this loader. The + * package-scoped {@link ApplicationCommandDiagnostics} collaborator must be defined alongside it, + * since package-private access does not cross classloaders. Everything else - including the + * command classes the factories declare - delegates to the parent test classpath, which is what + * makes the same command Class reachable through two distinct classloaders. + */ +@CompileStatic +class RegistryDefiningClassLoader extends URLClassLoader { + + private static final List CHILD_FIRST_CLASSES = + [ApplicationContextCommandRegistry.name, ApplicationCommandDiagnostics.name].asImmutable() + + RegistryDefiningClassLoader(URL[] urls, ClassLoader parent) { + super(urls, parent) + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + // Deliberately free of closures and other Groovy runtime dispatch: this runs in the middle + // of classloading, where triggering metaclass initialization recurses into this method. + if (childFirst(name)) { + Class loaded = findLoadedClass(name) + if (loaded == null) { + byte[] classBytes = readParentClassBytes(name) + loaded = defineClass(name, classBytes, 0, classBytes.length) + } + if (resolve) { + resolveClass(loaded) + } + return loaded + } + super.loadClass(name, resolve) + } + + private byte[] readParentClassBytes(String name) throws ClassNotFoundException { + InputStream input = parent.getResourceAsStream(name.replace('.', '/') + '.class') + if (input == null) { + throw new ClassNotFoundException(name) + } + try { + input.readAllBytes() + } + finally { + input.close() + } + } + + private static boolean childFirst(String name) { + for (String childFirstClass : CHILD_FIRST_CLASSES) { + if (name == childFirstClass || name.startsWith(childFirstClass + '$')) { + return true + } + } + false + } +} + @CompileStatic class ThrowingFactoryResourcesClassLoader extends URLClassLoader { @@ -666,16 +816,10 @@ class FixedFactoryResourceClassLoader extends URLClassLoader { } @CompileStatic -class FailingOriginUrlStreamHandler extends URLStreamHandler { - - private int connectionCount +class FixedFactoryResourceUrlStreamHandler extends URLStreamHandler { @Override protected URLConnection openConnection(URL url) throws IOException { - connectionCount++ - if (connectionCount > 1) { - throw new AssertionError('origin unavailable') - } new URLConnection(url) { @Override void connect() { diff --git a/grails-forge/grails-forge-cli/src/test/groovy/org/grails/forge/cli/CommandSpec.groovy b/grails-forge/grails-forge-cli/src/test/groovy/org/grails/forge/cli/CommandSpec.groovy index 7aeabecc167..fb77f2dfff0 100644 --- a/grails-forge/grails-forge-cli/src/test/groovy/org/grails/forge/cli/CommandSpec.groovy +++ b/grails-forge/grails-forge-cli/src/test/groovy/org/grails/forge/cli/CommandSpec.groovy @@ -56,13 +56,57 @@ class CommandSpec extends Specification { executeCommand(gradleCommand) } + // The specs that call executeGradleCommand('build') build a whole generated application - + // asset compilation, bootWar, test and integrationTest - so this budget covers a full Grails + // build on a shared CI runner, not a single task. At 240s it was marginal rather than + // generous: on the Java 25 lane that build measured 217s when it passed, and a runner roughly + // 10% slower than average was enough to push it over and fail the lane on timing alone. + protected static final int POLL_TIMEOUT_SECONDS = 600 + + private static final int POLL_INITIAL_DELAY_MILLIS = 3000 + private static final int POLL_DELAY_MILLIS = 1000 + + // Once the process has exited the output can no longer grow, so allow the consumer thread + // started by consumeProcessOutputStream a moment to drain before deciding the value is absent. + private static final int OUTPUT_DRAIN_MILLIS = 2000 + PollingConditions getDefaultPollingConditions() { - new PollingConditions(timeout: 240, initialDelay: 3, delay: 1, factor: 1) + new PollingConditions(timeout: POLL_TIMEOUT_SECONDS, initialDelay: 3, delay: 1, factor: 1) } + /** + * Waits for the generated build to emit {@code value}, giving up as soon as the build process + * exits without having produced it. + * + *

Deliberately not {@link PollingConditions}: that retries on any {@code Throwable}, so a + * build which has already finished cannot short-circuit the wait. Waiting out the full budget + * after the process is gone is how a generated build that simply failed came to look like a + * hang, reported only as a bare unsatisfied condition with no cause. Keying the early exit on + * process liveness rather than on a {@code BUILD FAILED} marker matters, because several specs + * legitimately wait for text from a build that is expected to fail.

+ */ void testOutputContains(String value) { - defaultPollingConditions.eventually { - assert output.toString().contains(value) + long deadline = System.currentTimeMillis() + (POLL_TIMEOUT_SECONDS * 1000L) + sleep(POLL_INITIAL_DELAY_MILLIS) + while (true) { + if (output.toString().contains(value)) { + return + } + if (process != null && !process.alive) { + sleep(OUTPUT_DRAIN_MILLIS) + String finalOutput = output.toString() + if (finalOutput.contains(value)) { + return + } + throw new AssertionError("The generated build exited with code ${process.exitValue()} " + + "without producing the expected output [${value}].\nBuild output:\n${finalOutput}" as Object) + } + if (System.currentTimeMillis() >= deadline) { + throw new AssertionError("Timed out after ${POLL_TIMEOUT_SECONDS}s waiting for the generated " + + "build to produce [${value}]; the build was still running.\nBuild output:\n" + + "${output}" as Object) + } + sleep(POLL_DELAY_MILLIS) } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/ApplicationContextCommandFactory.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/ApplicationContextCommandFactory.groovy index de97212315f..28a6360810a 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/ApplicationContextCommandFactory.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/ApplicationContextCommandFactory.groovy @@ -73,9 +73,6 @@ class ApplicationContextCommandFactory implements CommandFactory { if (current instanceof VirtualMachineError) { throw (VirtualMachineError) current } - if (current instanceof ThreadDeath) { - throw (ThreadDeath) current - } current = current.cause } } diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/profile/AbstractProfileSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/AbstractProfileSpec.groovy new file mode 100644 index 00000000000..fd56924895e --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/AbstractProfileSpec.groovy @@ -0,0 +1,119 @@ +/* + * 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. + */ +package org.grails.cli.profile + +import java.nio.file.Path + +import grails.build.logging.GrailsConsole +import org.grails.build.parsing.CommandLine +import org.grails.cli.profile.commands.factory.StubRegistryClassLoader +import org.grails.io.support.FileSystemResource +import spock.lang.Specification +import spock.lang.TempDir + +class AbstractProfileSpec extends Specification { + + private static final String LEGACY_COMMAND_HINT = + 'Grails 7 commands were detected; set grails { legacyCommandSupport = true } or upgrade the plugin.' + + @TempDir + Path tempDir + + private ClassLoader originalContextClassLoader + + def setup() { + originalContextClassLoader = Thread.currentThread().contextClassLoader + } + + def cleanup() { + Thread.currentThread().contextClassLoader = originalContextClassLoader + } + + void "Test an unknown command reports the legacy command hint from the runtime registry"() { + given: "a profile and a runtime registry that detected Grails 7 commands" + Thread.currentThread().contextClassLoader = new StubRegistryClassLoader( + getClass().classLoader, HintingCommandRegistry) + GrailsConsole console = Mock() + Profile profile = createProfile() + + when: "a command that does not exist is handled" + boolean handled = profile.handleCommand(executionContext(console, 'zzz-unknown-command')) + + then: "the reported message carries the hint read from the registry" + !handled + 1 * console.error('Command not found zzz-unknown-command\n' + LEGACY_COMMAND_HINT) + } + + void "Test an unknown command reports no hint when the runtime registry has none"() { + given: "a profile and a runtime registry without a hint" + Thread.currentThread().contextClassLoader = new StubRegistryClassLoader( + getClass().classLoader, SilentCommandRegistry) + GrailsConsole console = Mock() + Profile profile = createProfile() + + when: "a command that does not exist is handled" + boolean handled = profile.handleCommand(executionContext(console, 'zzz-unknown-command')) + + then: "the reported message is left unchanged" + !handled + 1 * console.error('Command not found zzz-unknown-command') + } + + private Profile createProfile() { + File profileDir = new File(tempDir.toFile(), 'profile') + assert profileDir.mkdirs() + new File(profileDir, 'profile.yml').text = 'name: web\n' + ResourceProfile.create(Stub(ProfileRepository), 'web', new FileSystemResource("$profileDir/")) + } + + private ExecutionContext executionContext(GrailsConsole console, String commandName) { + CommandLine commandLine = Stub(CommandLine) { + getCommandName() >> commandName + getRemainingArgs() >> [] + } + Stub(ExecutionContext) { + getConsole() >> console + getCommandLine() >> commandLine + getBaseDir() >> tempDir.toFile() + } + } +} + +class HintingCommandRegistry { + + static final HintingCommandRegistry instance = new HintingCommandRegistry() + + final String missingCommandHint = + 'Grails 7 commands were detected; set grails { legacyCommandSupport = true } or upgrade the plugin.' + + Collection findCommands() { + [] + } +} + +class SilentCommandRegistry { + + static final SilentCommandRegistry instance = new SilentCommandRegistry() + + final String missingCommandHint = null + + Collection findCommands() { + [] + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/ApplicationContextCommandFactorySpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/ApplicationContextCommandFactorySpec.groovy index 322f105242b..b183b5a41df 100644 --- a/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/ApplicationContextCommandFactorySpec.groovy +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/ApplicationContextCommandFactorySpec.groovy @@ -70,7 +70,7 @@ class ApplicationContextCommandFactorySpec extends Specification { where: errorType | registryType StackOverflowError | StackOverflowRegistry - ThreadDeath | ThreadDeathRegistry + OutOfMemoryError | OutOfMemoryRegistry } } @@ -105,9 +105,9 @@ class StackOverflowRegistry { } } -class ThreadDeathRegistry { +class OutOfMemoryRegistry { static Object getInstance() { - throw new ThreadDeath() + throw new OutOfMemoryError() } } diff --git a/settings.gradle b/settings.gradle index 2449ba89bf7..1b218ba6ced 100644 --- a/settings.gradle +++ b/settings.gradle @@ -566,8 +566,6 @@ include( 'grails-test-examples-undertow', 'grails-test-examples-views-functional-tests', 'grails-test-examples-views-functional-tests-plugin', - 'grails-test-examples-legacy-commands', - 'grails-test-examples-legacy-commands-plugin', ) project(':grails-test-examples-async-events-pubsub-demo').projectDir = file('grails-test-examples/async-events-pubsub-demo') project(':grails-test-examples-app1').projectDir = file('grails-test-examples/app1') @@ -606,8 +604,6 @@ project(':grails-test-examples-scaffolding').projectDir = file('grails-test-exam project(':grails-test-examples-scaffolding-fields').projectDir = file('grails-test-examples/scaffolding-fields') project(':grails-test-examples-views-functional-tests').projectDir = file('grails-test-examples/views-functional-tests') project(':grails-test-examples-views-functional-tests-plugin').projectDir = file('grails-test-examples/views-functional-tests-plugin') -project(':grails-test-examples-legacy-commands').projectDir = file('grails-test-examples/legacy-commands') -project(':grails-test-examples-legacy-commands-plugin').projectDir = file('grails-test-examples/legacy-commands-plugin') project(':grails-test-examples-test-phases').projectDir = file('grails-test-examples/test-phases') project(':grails-test-examples-jetty').projectDir = file('grails-test-examples/jetty') project(':grails-test-examples-undertow').projectDir = file('grails-test-examples/undertow')