You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/09/25 15:36:01 UTC

(grails-core) 01/01: Merge branch '7.2.x' into 8.0.x

This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch 8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 0dab8a726376f819375a95f2a90c70ad6d19d00a
Merge: e4cde3c99b eddc4ee8dc
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Fri Sep 25 11:30:34 2026 -0400

    Merge branch '7.2.x' into 8.0.x
    
    # Conflicts:
    #       .github/workflows/codestyle.yml
    #       .github/workflows/gradle.yml
    #       .github/workflows/groovy-joint-workflow.yml
    #       .github/workflows/vulnerability-scan.yml
    #       AGENTS.md
    #       gradle.properties
    #       grails-core/src/test/groovy/grails/util/GrailsUtilTests.java
    #       grails-data-hibernate5/docs/build.gradle
    #       grails-doc/src/en/guide/upgrading/upgrading60x.adoc
    #       grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GrailsGspSpec.groovy
    #       grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java
    #       grails-gradle/model/src/test/groovy/org/grails/io/support/SpringIOUtilsSpec.groovy
    #       grails-interceptors/src/main/groovy/grails/artefact/Interceptor.groovy
    #       grails-interceptors/src/main/groovy/org/grails/plugins/web/interceptors/UrlMappingMatcher.groovy
    #       grails-interceptors/src/test/groovy/grails/artefact/InterceptorSpec.groovy
    #       grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy
    #       grails-testing-support-http-client/src/test/groovy/org/apache/grails/testing/http/client/TestHttpResponseSpec.groovy
    #       grails-testing-support-http-client/src/test/groovy/org/apache/grails/testing/http/client/utils/XmlUtilsSpec.groovy

 .github/workflows/benchmark.yml                    |  12 +-
 .github/workflows/codeanalysis.yml                 |  12 +-
 .github/workflows/codeql.yml                       |   4 +-
 .github/workflows/codestyle.yml                    |  18 +--
 .github/workflows/coverage.yml                     |  16 +--
 .github/workflows/end-to-end.yml                   |   8 +-
 .github/workflows/forge-deploy-aws.yml             |   4 +-
 .github/workflows/gradle.yml                       | 130 ++++++++++-----------
 .github/workflows/groovy-snapshot-canary.yml       |  18 +--
 .github/workflows/rat.yml                          |   6 +-
 .github/workflows/release-close.yml                |   2 +-
 .github/workflows/release-publish-docs.yml         |   4 +-
 .github/workflows/release-verify.yml               |   4 +-
 .github/workflows/release.yml                      |  20 ++--
 .github/workflows/sitemesh2.yml                    |   8 +-
 .github/workflows/validate-actions.yml             |   4 +-
 .github/workflows/vulnerability-scan.yml           |  10 +-
 AGENTS.md                                          |  10 +-
 .../grails/doc/asciidoc/AsciiDocEngine.groovy      |   6 +-
 .../grails/doc/asciidoc/AsciiDocEngineSpec.groovy  |  85 ++++++++++++++
 .../tests/ValidationErrorToOneReadOnlySpec.groovy  | 107 +++++++++++++++++
 grails-data-hibernate5/docs/build.gradle           |   4 +
 .../src/docs/asciidoc/databaseMigration/index.adoc |   2 +-
 .../docs/src/docs/asciidoc/index.adoc              |   1 +
 .../docs/src/docs/asciidoc/persistenceBasics.adoc  |   2 +-
 .../docs/asciidoc/persistenceBasics/cascades.adoc  |   5 +
 .../docs/asciidoc/programmaticTransactions.adoc    |   4 +-
 .../docs/src/docs/asciidoc/querying.adoc           |   4 +-
 .../src/en/guide/testing/functionalTesting.adoc    |  17 +++
 .../org/grails/forge/rocker/plugin/RockerTask.java |  11 +-
 .../grails/forge/api/PreviewControllerSpec.groovy  |   2 +-
 .../build/gradle/templates/buildGradle.rocker.raw  |  10 +-
 .../grails/forge/feature/other/GrailsQuartz.java   |   2 +-
 .../org/grails/forge/feature/test/GebFeature.java  |  47 ++++++++
 ...iverBinaries.java => GebWithLocalBrowsers.java} |  56 +++------
 .../forge/feature/test/GebWithTestcontainers.java  |  30 ++---
 ...pock.rocker.raw => containerGebSpec.rocker.raw} |   0
 .../{groovyJunit.rocker.raw => gebSpec.rocker.raw} |  32 ++---
 .../template/webdriverBinariesPlugin.rocker.raw    |  29 -----
 .../grails-forge-core/src/main/resources/pom.xml   |  10 --
 .../forge/feature/other/GrailsQuartzSpec.groovy    |  60 ++++++++++
 ...Spec.groovy => GebWithLocalBrowsersSpec.groovy} |  83 +++++++++----
 .../feature/test/GebWithTestcontainersSpec.groovy  |  25 ++++
 .../features/geb/GebWithLocalBrowsersSpec.groovy   |  43 +++++++
 44 files changed, 671 insertions(+), 296 deletions(-)

diff --cc .github/workflows/benchmark.yml
index 68488b337a,0000000000..cf5ce77353
mode 100644,000000..100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@@ -1,394 -1,0 +1,394 @@@
 +# 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.
 +
 +name: "JMH Benchmark Comparison"
 +
 +# SECURITY: This workflow deliberately uses pull_request, never pull_request_target.
 +# Pull requests run untrusted code, and Apache Infra policy forbids exposing tokens
 +# to that code through a privileged pull_request_target workflow.
 +on:
 +  pull_request:
 +    types: [opened, synchronize, reopened, labeled]
 +    paths-ignore:
 +      - '**/*.md'
 +      - '**/*.adoc'
 +      - 'grails-doc/**'
 +
 +concurrency:
 +  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.action == 'labeled' && github.event.label.name != 'performance' && github.run_id || 'benchmark' }}
 +  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 +
 +permissions:
 +  contents: read
 +
 +jobs:
 +  # Each shard builds both revisions before measuring either one on the same runner.
 +  # Building first avoids CPU, IO, thermal, cache, and frequency state biasing a measurement.
 +  # Shard a measures BASE then HEAD, while shard b measures HEAD then BASE.
 +  # Alternating the order cancels first-versus-second ordering bias without doubling runtime.
 +  # Both shards use the PR merge commit as HEAD, so they measure what would actually land.
 +  # Results are advisory: a detected regression never fails this workflow.
 +  benchmark:
 +    name: "Paired JMH benchmarks (${{ matrix.shard }})"
 +    if: >-
 +      contains(github.event.pull_request.labels.*.name, 'performance') &&
 +      (github.event.action != 'labeled' || github.event.label.name == 'performance')
 +    runs-on: ubuntu-24.04
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        shard: [a, b]
 +    env:
 +      BASE_SHA: ${{ github.event.pull_request.base.sha }}
 +      HEAD_SHA: ${{ github.sha }}
 +      JMH_INCLUDE: '.*'
 +      RESULT_DIR: ${{ github.workspace }}/jmh-results/${{ matrix.shard }}
 +      REPORT_DIR: ${{ github.workspace }}/jmh-reports/${{ matrix.shard }}
 +      SHARD: ${{ matrix.shard }}
 +    steps:
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +        with:
 +          fetch-depth: 0
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      # Deliberately allowed to fail the job, and placed before the expensive work: a broken
 +      # comparison tool makes every number produced here untrustworthy. That is a tooling
 +      # failure rather than a performance finding, so regressions themselves stay advisory.
 +      - name: "๐Ÿงช Verify JMH comparison tool"
 +        run: ./gradlew :grails-test-examples-benchmarks:test --max-workers=4
 +      - name: "๐ŸŒณ Prepare paired worktrees"
 +        run: |
 +          WORKTREE_ROOT="$RUNNER_TEMP/jmh-worktrees/$SHARD"
 +          mkdir -p "$WORKTREE_ROOT" "$RESULT_DIR" "$REPORT_DIR"
 +          if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
 +            RESOLVED_BASE_SHA="$BASE_SHA"
 +          elif RESOLVED_BASE_SHA="$(git merge-base "$HEAD_SHA^1" "$HEAD_SHA^2" 2>/dev/null)"; then
 +            echo "Configured base commit is unreachable; using merge-base $RESOLVED_BASE_SHA."
 +          elif RESOLVED_BASE_SHA="$(git rev-parse "$HEAD_SHA^" 2>/dev/null)"; then
 +            echo "Configured base commit is unreachable; using HEAD parent $RESOLVED_BASE_SHA."
 +          else
 +            RESOLVED_BASE_SHA=""
 +            echo "No base commit could be resolved; comparison will use HEAD-only mode."
 +          fi
 +          git worktree add --detach "$WORKTREE_ROOT/head" "$HEAD_SHA"
 +          {
 +            printf 'HEAD_DIR=%s\n' "$WORKTREE_ROOT/head"
 +            printf 'WORKTREE_ROOT=%s\n' "$WORKTREE_ROOT"
 +          } >> "$GITHUB_ENV"
 +          if [ -n "$RESOLVED_BASE_SHA" ] && git worktree add --detach "$WORKTREE_ROOT/base" "$RESOLVED_BASE_SHA"; then
 +            {
 +              printf 'BASE_DIR=%s\n' "$WORKTREE_ROOT/base"
 +              printf 'RESOLVED_BASE_SHA=%s\n' "$RESOLVED_BASE_SHA"
 +            } >> "$GITHUB_ENV"
 +            echo "Using base commit $RESOLVED_BASE_SHA."
 +          else
 +            echo "BASE_BENCHMARKS_AVAILABLE=false" >> "$GITHUB_ENV"
 +          fi
 +      # Build both JMH jars before measuring either revision. Builds are CPU- and IO-heavy,
 +      # so building and measuring one revision at a time would bias results with different
 +      # thermal, cache, and CPU-frequency state on the shared runner.
 +      - name: "๐Ÿ”จ Build paired JMH jars"
 +        timeout-minutes: 60
 +        run: |
 +          base_build_ok=false
 +          head_build_ok=false
 +          if [ "${BASE_BENCHMARKS_AVAILABLE:-true}" = "true" ] && [ -f "$BASE_DIR/grails-test-examples/benchmarks/build.gradle" ]; then
 +            if (
 +              cd "$BASE_DIR"
 +              ./gradlew :grails-test-examples-benchmarks:jmhJar --max-workers=4
 +            ); then
 +              base_build_ok=true
 +              echo "Built base benchmark at $RESOLVED_BASE_SHA."
 +            else
 +              echo "BASE benchmark JAR build failed."
 +            fi
 +          else
 +            echo "BASE does not contain the benchmarks project; comparison will use HEAD-only mode."
 +          fi
 +          if (
 +            cd "$HEAD_DIR"
 +            ./gradlew :grails-test-examples-benchmarks:jmhJar --max-workers=4
 +          ); then
 +            head_build_ok=true
 +            echo "Built HEAD benchmark at $HEAD_SHA."
 +          else
 +            echo "HEAD benchmark JAR build failed."
 +          fi
 +          {
 +            printf 'BASE_BUILD_OK=%s\n' "$base_build_ok"
 +            printf 'HEAD_BUILD_OK=%s\n' "$head_build_ok"
 +          } >> "$GITHUB_ENV"
 +      - name: "๐Ÿ›‘ Stop Gradle daemons before measurement"
 +        if: always()
 +        run: |
 +          ./gradlew --stop || true
 +          if [ -n "${BASE_DIR:-}" ] && [ -d "$BASE_DIR" ]; then (cd "$BASE_DIR" && ./gradlew --stop) || true; fi
 +          if [ -n "${HEAD_DIR:-}" ] && [ -d "$HEAD_DIR" ]; then (cd "$HEAD_DIR" && ./gradlew --stop) || true; fi
 +      # Two forks, three warmup iterations, and five measurement iterations balance PR latency
 +      # against confidence. Reversing shard order cancels first-versus-second runner-state bias.
 +      - name: "๐ŸŒก๏ธ Run paired JMH benchmarks"
 +        timeout-minutes: 60
 +        run: |
 +          # JMH writes results incrementally, so a run that dies partway can leave a file that
 +          # parses perfectly while describing only some of the benchmarks. The report job decides
 +          # completeness from which files exist, so a partial file would be indistinguishable from
 +          # a good one. Write to a staging path and publish it only on success, so a failed run
 +          # leaves NO file rather than a plausible one.
 +          run_benchmark() {
 +            local revision_dir="$1"
 +            local result_file="$2"
 +            local staging_file="$result_file.partial"
 +            rm -f "$staging_file" "$result_file"
 +            if (
 +              cd "$revision_dir" && ./gradlew --no-daemon :grails-test-examples-benchmarks:jmh \
 +                -Pjmh.include="$JMH_INCLUDE" \
 +                -Pjmh.forks=2 \
 +                -Pjmh.warmupIterations=3 \
 +                -Pjmh.iterations=5 \
 +                -Pjmh.resultFile="$staging_file" \
 +                -Pjmh.profilers=gc \
 +                --max-workers=4
 +            ) && [ -s "$staging_file" ] && mv "$staging_file" "$result_file"; then
 +              return 0
 +            fi
 +            rm -f "$staging_file"
 +            return 1
 +          }
 +
 +          base_run_failed=false
 +          head_run_failed=false
 +          run_base_benchmark() {
 +            if [ "${BASE_BUILD_OK:-false}" != "true" ]; then
 +              echo "BASE benchmark execution skipped because its JAR was not built."
 +            elif ! run_benchmark "$BASE_DIR" "$RESULT_DIR/base.json"; then
 +              base_run_failed=true
 +              echo "BASE benchmark execution failed."
 +            fi
 +          }
 +          run_head_benchmark() {
 +            if [ "${HEAD_BUILD_OK:-false}" != "true" ]; then
 +              echo "HEAD benchmark execution skipped because its JAR was not built."
 +            elif ! run_benchmark "$HEAD_DIR" "$RESULT_DIR/head.json"; then
 +              head_run_failed=true
 +              echo "HEAD benchmark execution failed."
 +            fi
 +          }
 +
 +          if [ "$SHARD" = "a" ]; then
 +            run_base_benchmark
 +            run_head_benchmark
 +          else
 +            run_head_benchmark
 +            run_base_benchmark
 +          fi
 +          {
 +            printf 'BASE_RUN_FAILED=%s\n' "$base_run_failed"
 +            printf 'HEAD_RUN_FAILED=%s\n' "$head_run_failed"
 +          } >> "$GITHUB_ENV"
 +      # The comparison is advisory. The comparison tool exits successfully for regressions, and this
 +      # step is non-blocking even if results are incomplete because a benchmark execution failed.
 +      # No --pr-number is passed here on purpose: this job renders the report only. The separate
 +      # report job owns comment posting, so a two-shard matrix cannot produce duplicate comments.
 +      - name: "๐Ÿ“Š Compare JMH results"
 +        if: always()
 +        continue-on-error: true
 +        run: |
 +          report_file="$REPORT_DIR/comparison.md"
 +          {
 +            printf '### JMH shard `%s`\n\n' "$SHARD"
 +            if [ "${BASE_RUN_FAILED:-false}" = "true" ]; then
 +              printf 'BASE benchmark execution failed.\n\n'
 +            fi
 +            if [ "${BASE_BUILD_OK:-false}" != "true" ]; then
 +              printf 'BASE benchmark JAR was not built.\n\n'
 +            fi
 +            if [ "${HEAD_RUN_FAILED:-false}" = "true" ]; then
 +              printf 'HEAD benchmark execution failed.\n\n'
 +            fi
 +            if [ "${HEAD_BUILD_OK:-false}" != "true" ]; then
 +              printf 'HEAD benchmark JAR was not built.\n\n'
 +            fi
 +          } > "$report_file"
 +          comparison_file="$RUNNER_TEMP/jmh-comparison-$SHARD.md"
 +          if [ ! -f "$RESULT_DIR/head.json" ]; then
 +            printf 'HEAD benchmark result was not produced.\n' >> "$report_file"
 +          elif [ "${BASE_BUILD_OK:-false}" = "true" ] && [ -f "$RESULT_DIR/base.json" ]; then
 +            ./gradlew -q --console=plain :grails-test-examples-benchmarks:jmhCompare --args="--head $RESULT_DIR/head.json --base $RESULT_DIR/base.json --output $comparison_file"
 +            cat "$comparison_file" >> "$report_file"
 +          else
 +            ./gradlew -q --console=plain :grails-test-examples-benchmarks:jmhCompare --args="--head $RESULT_DIR/head.json --output $comparison_file"
 +            cat "$comparison_file" >> "$report_file"
 +          fi
 +      - name: "๐Ÿ“‹ Publish JMH report in job summary"
 +        if: always()
 +        run: |
 +          if [ -f "$REPORT_DIR/comparison.md" ]; then
 +            cat "$REPORT_DIR/comparison.md" >> "$GITHUB_STEP_SUMMARY"
 +          else
 +            printf '## JMH benchmark comparison\n\nNo comparison report was produced.\n' >> "$GITHUB_STEP_SUMMARY"
 +          fi
 +      - name: "๐Ÿ“ค Upload JMH artifacts"
 +        if: always()
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
++        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: jmh-results-${{ matrix.shard }}
 +          path: |
 +            jmh-results/${{ matrix.shard }}/
 +            jmh-reports/${{ matrix.shard }}/
 +          if-no-files-found: warn
 +      - name: "๐Ÿงน Remove paired worktrees"
 +        if: always()
 +        run: |
 +          WORKTREE_ROOT="$RUNNER_TEMP/jmh-worktrees/$SHARD"
 +          git worktree remove --force "$WORKTREE_ROOT/base" || true
 +          git worktree remove --force "$WORKTREE_ROOT/head" || true
 +
 +  report:
 +    name: "Publish JMH benchmark comparison"
 +    needs: benchmark
 +    if: >-
 +      always() && github.event_name == 'pull_request' &&
 +      contains(github.event.pull_request.labels.*.name, 'performance') &&
 +      (github.event.action != 'labeled' || github.event.label.name == 'performance') &&
 +      github.event.pull_request.head.repo.full_name == github.repository
 +    runs-on: ubuntu-24.04
 +    permissions:
 +      contents: read
 +      pull-requests: write
 +    env:
 +      PR_NUMBER: ${{ github.event.pull_request.number }}
 +      REPOSITORY: ${{ github.repository }}
 +      EXPECTED_SHARDS: "a b"
 +    steps:
 +      # This job runs only for same-repository pull requests, whose authors already hold push
 +      # access, so checking out the merge commit to run the Gradle-based comparison tool is not a privilege
 +      # escalation. Fork pull requests never reach this job: their pull_request token is
 +      # read-only and commenting would fail with 403, so they receive the per-shard job summary
 +      # and the uploaded artifacts instead.
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ“ฅ Download JMH artifacts"
-         uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
++        uses: actions/download-artifact@v8.0.1
 +        with:
 +          path: benchmark-artifacts
 +      # Both shards measured the same two revisions in opposite orders. Only complete same-shard
 +      # base/head pairs are pooled, preserving runner identity while letting the alternating order
 +      # cancel ordering bias. Incomplete shards are reported and never cross-paired.
 +      - name: "๐Ÿ“Š Pool shard results and compare"
 +        id: compare
 +        run: |
 +          collected_base="$RUNNER_TEMP/collected-base"
 +          collected_head="$RUNNER_TEMP/collected-head"
 +          pooled_base="$RUNNER_TEMP/pooled-base"
 +          pooled_head="$RUNNER_TEMP/pooled-head"
 +          head_only="$RUNNER_TEMP/head-only"
 +          mkdir -p "$collected_base" "$collected_head" "$pooled_base" "$pooled_head" "$head_only"
 +          # Seeded from the matrix rather than from discovered files: a shard that failed before
 +          # uploading anything has no file to discover, and would otherwise be omitted from the
 +          # report entirely instead of being named as missing.
 +          declare -A observed_shards=()
 +          expected_shard_files=""
 +          for shard in $EXPECTED_SHARDS; do
 +            observed_shards["$shard"]=true
 +            expected_shard_files="${expected_shard_files:+$expected_shard_files,}shard-$shard.json"
 +          done
 +          while IFS= read -r -d '' result_file; do
 +            shard=""
 +            if [[ "$result_file" =~ /jmh-results-([^/]+)/ ]]; then
 +              shard="${BASH_REMATCH[1]}"
 +            elif [[ "$result_file" =~ /jmh-results/([^/]+)/ ]]; then
 +              shard="${BASH_REMATCH[1]}"
 +            else
 +              printf 'Unable to identify shard for result %s; ignoring it.\n' "$result_file"
 +              continue
 +            fi
 +            observed_shards["$shard"]=true
 +            case "$(basename "$result_file")" in
 +              base.json) cp "$result_file" "$collected_base/shard-$shard.json" ;;
 +              head.json) cp "$result_file" "$collected_head/shard-$shard.json" ;;
 +            esac
 +          done < <(find benchmark-artifacts -type f \( -name 'base.json' -o -name 'head.json' \) -print0)
 +
 +          complete_shards=()
 +          dropped_shards=()
 +          if [ "${#observed_shards[@]}" -gt 0 ]; then
 +            for shard in "${!observed_shards[@]}"; do
 +              if [ -f "$collected_base/shard-$shard.json" ] && [ -f "$collected_head/shard-$shard.json" ]; then
 +                cp "$collected_base/shard-$shard.json" "$pooled_base/shard-$shard.json"
 +                cp "$collected_head/shard-$shard.json" "$pooled_head/shard-$shard.json"
 +                complete_shards+=("$shard")
 +              else
 +                dropped_shards+=("$shard")
 +              fi
 +              if [ -f "$collected_head/shard-$shard.json" ]; then
 +                cp "$collected_head/shard-$shard.json" "$head_only/shard-$shard.json"
 +              fi
 +            done
 +          fi
 +
 +          report_file="$RUNNER_TEMP/pooled-comparison.md"
 +          comparison_file="$RUNNER_TEMP/jmh-comparison.md"
 +          {
 +            printf '### JMH Shard Pairing\n\n'
 +            printf '**Complete shard pairs used:** %s\n' "${#complete_shards[@]}"
 +            if [ "${#dropped_shards[@]}" -gt 0 ]; then
 +              printf '**Dropped incomplete shards:**'
 +              printf ' `%s`' "${dropped_shards[@]}"
 +              printf '\n'
 +            fi
 +            printf '\n'
 +          } > "$report_file"
 +          should_post=false
 +          if [ "${#complete_shards[@]}" -gt 0 ]; then
 +            should_post=true
 +            ./gradlew -q --console=plain :grails-test-examples-benchmarks:jmhCompare --args="--head $pooled_head --base $pooled_base --expected-shards $expected_shard_files --output $comparison_file"
 +            cat "$comparison_file" >> "$report_file"
 +          elif compgen -G "$head_only/*.json" > /dev/null; then
 +            should_post=true
 +            printf 'No complete same-runner base and HEAD shard pair was available. Reporting available HEAD results only.\n\n' >> "$report_file"
 +            ./gradlew -q --console=plain :grails-test-examples-benchmarks:jmhCompare --args="--head $head_only --output $comparison_file"
 +            cat "$comparison_file" >> "$report_file"
 +          else
 +            printf '### JMH Benchmark Report\n\nNo HEAD benchmark results were produced for this run. The previous JMH report comment on this PR (if any) was left unchanged.\n' >> "$report_file"
 +          fi
 +          cat "$report_file" >> "$GITHUB_STEP_SUMMARY"
 +          printf 'report_file=%s\n' "$report_file" >> "$GITHUB_OUTPUT"
 +          printf 'should_post=%s\n' "$should_post" >> "$GITHUB_OUTPUT"
 +      - name: "๐Ÿ’ฌ Post or update JMH PR comment"
 +        if: steps.compare.outputs.should_post == 'true'
 +        continue-on-error: true
 +        env:
 +          REPORT_FILE: ${{ steps.compare.outputs.report_file }}
 +          GITHUB_TOKEN: ${{ github.token }}
 +        run: ./gradlew -q --console=plain :grails-test-examples-benchmarks:jmhCompare --args="--post-file $REPORT_FILE --repo $REPOSITORY --pr-number $PR_NUMBER"
diff --cc .github/workflows/codeanalysis.yml
index f5e0180047,0000000000..ba99bae0cf
mode 100644,000000..100644
--- a/.github/workflows/codeanalysis.yml
+++ b/.github/workflows/codeanalysis.yml
@@@ -1,97 -1,0 +1,97 @@@
 +# 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.
 +
 +name: "Code Analysis"
 +on:
 +  push:
 +    branches:
 +      - '[0-9]+.[0-9]+.x'
 +      - '8.0.x-hibernate7.*'
 +  pull_request:
 +  workflow_dispatch:
 +# queue jobs and only allow 1 run per branch due to the likelihood of hitting GitHub resource limits
 +concurrency:
 +  group: ${{ github.workflow }}-${{ github.ref }}
 +  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 +jobs:
 +  check_core_projects:
 +    name: "Core Projects"
 +    runs-on: ubuntu-24.04
 +    steps:
 +      - name: "๐ŸŒ Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
 +        run: curl -s https://api.ipify.org
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ”Ž Check Core Projects"
 +        run: ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.pmd=true -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
 +      - name: "๐Ÿ“ค Upload Reports"
 +        if: always()
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
++        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: core-reports
 +          path: build/reports/violations/
 +      - name: "๐Ÿ“‹ Publish Code Analysis Report in Job Summary"
 +        if: always()
 +        run: |
 +          echo "## ๐Ÿ”Ž Code Analysis Report - Core Projects" >> $GITHUB_STEP_SUMMARY
 +          for report in PMD_VIOLATIONS.md SPOTBUGS_VIOLATIONS.md; do
 +            file="build/reports/violations/$report"
 +            [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
 +          done
 +  check_gradle_plugin_projects:
 +    name: "Gradle Plugin Projects"
 +    runs-on: ubuntu-24.04
 +    steps:
 +      - name: "๐ŸŒ Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
 +        run: curl -s https://api.ipify.org
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ”Ž Check Gradle Plugin Projects"
 +        working-directory: grails-gradle
 +        run: ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.pmd=true -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
 +      - name: "๐Ÿ“ค Upload Reports"
 +        if: always()
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
++        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: gradle-plugin-reports
 +          path: grails-gradle/build/reports/violations/
 +      - name: "๐Ÿ“‹ Publish Code Analysis Report in Job Summary"
 +        if: always()
 +        run: |
 +          echo "## ๐Ÿ”Ž Code Analysis Report - Gradle Plugin Projects" >> $GITHUB_STEP_SUMMARY
 +          for report in PMD_VIOLATIONS.md SPOTBUGS_VIOLATIONS.md; do
 +            file="grails-gradle/build/reports/violations/$report"
 +            [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
 +          done
diff --cc .github/workflows/codeql.yml
index 8fc297a210,bc1430f8c7..1f8572fec4
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@@ -58,19 -56,17 +58,19 @@@ jobs
  
      steps:
      - name: Checkout repository
-       uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+       uses: actions/checkout@v7.0.1
      - name: "โ˜•๏ธ Setup JDK"
-       uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+       uses: actions/setup-java@v6.0.1
        with:
          distribution: liberica
 -        java-version: 17
 +        java-version: 21
      # Initializes the CodeQL tools for scanning.
      - name: Initialize CodeQL
 -      uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
 +      uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
        with:
          languages: ${{ matrix.language }}
 +        # The autobuild action runs `./gradlew testClasses`, which compiles Groovy sample apps that are covered by regular CI.
 +        build-mode: none
          # If you wish to specify custom queries, you can do so here or in a config file.
          # By default, queries listed here will override any specified in a config file.
          # Prefix the list here with "+" to use these queries and those in the config file.
diff --cc .github/workflows/codestyle.yml
index a82a9d12a9,772ad775f5..62d100c515
--- a/.github/workflows/codestyle.yml
+++ b/.github/workflows/codestyle.yml
@@@ -33,25 -32,25 +33,25 @@@ jobs
        - name: "๐ŸŒ Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
          run: curl -s https://api.ipify.org
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ˜ Setup Gradle"
          uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ”Ž Check Core Projects"
 -        run: ./gradlew codeStyle
 -      - name: "๐Ÿ“ค Upload Failure Reports"
 +        run: ./gradlew aggregateStyleViolations --continue
 +      - name: "๐Ÿ“ค Upload Reports"
          if: always()
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+         uses: actions/upload-artifact@v7.0.1
          with:
            name: core-reports
 -          path: build/reports/codestyle/
 +          path: build/reports/violations/
        - name: "๐Ÿ“‹ Publish Code Style Report in Job Summary"
          if: always()
          run: |
@@@ -67,12 -70,12 +67,12 @@@
        - name: "๐ŸŒ Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
          run: curl -s https://api.ipify.org
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ˜ Setup Gradle"
          uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
@@@ -83,10 -86,10 +83,10 @@@
          run: ./gradlew codeStyle
        - name: "๐Ÿ“ค Upload Failure Reports"
          if: always()
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+         uses: actions/upload-artifact@v7.0.1
          with:
 -          name: gradle-plugin-reports
 -          path: grails-gradle/build/reports/codestyle/
 +          name: forge-reports
 +          path: grails-forge/build/reports/code-style/
        - name: "๐Ÿ“‹ Publish Code Style Report in Job Summary"
          if: always()
          run: |
@@@ -106,26 -109,26 +106,26 @@@
        - name: "๐ŸŒ Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
          run: curl -s https://api.ipify.org
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ˜ Setup Gradle"
          uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 -      - name: "๐Ÿ”Ž Check Forge Projects"
 -        working-directory: grails-forge
 -        run: ./gradlew codeStyle
 -      - name: "๐Ÿ“ค Upload Failure Reports"
 +      - name: "๐Ÿ”Ž Check Gradle Plugin Projects"
 +        working-directory: grails-gradle
 +        run: ./gradlew aggregateStyleViolations --continue
 +      - name: "๐Ÿ“ค Upload Reports"
          if: always()
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+         uses: actions/upload-artifact@v7.0.1
          with:
 -          name: forge-reports
 -          path: grails-forge/build/reports/codestyle/
 +          name: gradle-plugin-reports
 +          path: grails-gradle/build/reports/violations/
        - name: "๐Ÿ“‹ Publish Code Style Report in Job Summary"
          if: always()
          run: |
diff --cc .github/workflows/coverage.yml
index 0afea8f632,0000000000..99e9ab4b8f
mode 100644,000000..100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@@ -1,119 -1,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.
 +
 +name: "Coverage"
 +
 +on:
 +  push:
 +    branches:
 +      - '[0-9]+.[0-9]+.x'
 +      - '8.0.x-hibernate7.*'
 +  pull_request:
 +  workflow_dispatch:
 +
 +concurrency:
 +  group: ${{ github.workflow }}-${{ github.ref }}
 +  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 +
 +jobs:
 +  coverage-core:
 +    name: "Coverage - grails-core (${{ matrix.os }})"
 +    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        os: [ubuntu-24.04]
 +    runs-on: ${{ matrix.os }}
 +    steps:
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐ŸŒก๏ธ Run tests with coverage"
 +        run: >
 +          ./gradlew jacocoAggregateReport
 +          --continue
 +          --stacktrace
 +          -PskipCodeStyle
 +      - name: "๐Ÿ“ค Upload coverage artifact"
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
++        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: coverage-core-${{ matrix.os }}
 +          path: build/reports/jacoco/aggregate/jacocoAggregateReport.xml
 +          if-no-files-found: warn
 +
 +  coverage-gradle:
 +    name: "Coverage - grails-gradle (${{ matrix.os }})"
 +    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        os: [ubuntu-24.04]
 +    runs-on: ${{ matrix.os }}
 +    steps:
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐ŸŒก๏ธ Run tests with coverage"
 +        working-directory: grails-gradle
 +        run: >
 +          ./gradlew jacocoAggregateReport
 +          --continue
 +          --stacktrace
 +          -PskipCodeStyle
 +      - name: "๐Ÿ“ค Upload coverage artifact"
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
++        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: coverage-gradle-${{ matrix.os }}
 +          path: grails-gradle/build/reports/jacoco/aggregate/jacocoAggregateReport.xml
 +          if-no-files-found: warn
 +
 +  upload-coverage:
 +    name: "Upload Coverage to Codecov"
 +    needs: [coverage-core, coverage-gradle]
 +    # Run even if some matrix legs fail so partial coverage is still uploaded
 +    if: always()
 +    runs-on: ubuntu-24.04
 +    steps:
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "๐Ÿ“ฅ Download all coverage artifacts"
-         uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
++        uses: actions/download-artifact@v8.0.1
 +        with:
 +          path: coverage-reports
 +      - name: "๐Ÿ“Š Upload coverage to Codecov"
 +        continue-on-error: true
 +        uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
 +        with:
 +          token: ${{ secrets.CODECOV_TOKEN }}
 +          directory: coverage-reports
 +          verbose: true
diff --cc .github/workflows/end-to-end.yml
index 22b5e04531,0000000000..7a987d327b
mode 100644,000000..100644
--- a/.github/workflows/end-to-end.yml
+++ b/.github/workflows/end-to-end.yml
@@@ -1,149 -1,0 +1,149 @@@
 +# 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. That is a hard requirement for some of
 +# these projects rather than a nicety: spring-dependency-management imports grails-bom as a Maven
 +# BOM through io.spring.dependency-management, which resolves it in its own detached configuration
 +# and so can only ever consume a published pom.
 +#
 +# 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
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Determine JDKs from .sdkmanrc"
 +        # Read both pins out of the files that already declare them. Only the major is kept:
 +        # `sdk env` gives a developer the exact Liberica patch, but the fixture and the suite are
 +        # test builds outside the reproducible-build surface, so CI deliberately takes the
 +        # runner's current release of each major rather than pinning patch versions here.
 +        id: jdks
 +        run: |
 +          set -euo pipefail
 +          fixture_java=$(grep -E '^java=' end-to-end/legacy-g7-command-plugin/.sdkmanrc | cut -d= -f2)
 +          build_java=$(grep -E '^java=' .sdkmanrc | cut -d= -f2)
 +          echo "fixture-java=${fixture_java%%.*}" >> "$GITHUB_OUTPUT"
 +          echo "build-java=${build_java%%.*}" >> "$GITHUB_OUTPUT"
 +          echo "Grails 7 fixture JDK: ${fixture_java}"
 +          echo "end-to-end build JDK (from root .sdkmanrc): ${build_java}"
 +      - name: "โ˜•๏ธ Setup JDKs"
 +        # Both, in one step. The last version listed becomes the default JAVA_HOME (the Grails 8
 +        # side); the fixture step below switches to the matching JAVA_HOME_<major>_X64 for its
 +        # single invocation.
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: |
 +            ${{ steps.jdks.outputs.fixture-java }}
 +            ${{ steps.jdks.outputs.build-java }}
 +      - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
-         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache@v6.1.0
 +        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@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # v1.9.3
 +      - 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'
 +        env:
 +          DO_NOT_CACHE_TESTS: '1'
 +        run: ./gradlew check --continue --stacktrace
 +      - name: "๐Ÿ“ค Upload test reports"
 +        if: failure()
-         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
++        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: end-to-end-test-reports
 +          path: end-to-end/**/build/reports/tests/**
diff --cc .github/workflows/gradle.yml
index 7c3e935c89,d95e4abce3..d1fbadc5cb
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@@ -30,14 -30,14 +30,14 @@@ jobs
      runs-on: ubuntu-24.04
      steps:
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
-         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+         uses: actions/cache@v6.1.0
          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.
@@@ -182,8 -145,7 +182,8 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
 -        uses: actions/cache@v6.1.0
 +        id: dependency-cache
-         uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache/restore@v6.1.0
          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.
@@@ -207,19 -169,9 +207,19 @@@
            --stacktrace
            -PonlyCoreTests
            -PskipCodeStyle
 +          ${{ matrix.shard_arguments }}
 +          ${{ matrix.runner_arguments }}
 +      - name: "๐Ÿ—„๏ธ Save dependency jar cache"
 +        if: ${{ success() && matrix.cache_writer && steps.dependency-cache.outputs.cache-hit != 'true' }}
-         uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache/save@v6.1.0
 +        with:
 +          path: |
 +            ~/.gradle/caches/modules-2
 +            ~/.gradle/wrapper
 +          key: gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-${{ hashFiles('**/dependencies.gradle', '**/gradle-wrapper.properties') }}
    buildRerunTasks:
      if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 -    name: 'Build Grails-Core Rerunning all Tasks'
 +    name: 'Build Grails-Core Rerunning all Tasks (${{ matrix.job_name }})'
      strategy:
        fail-fast: false
        matrix:
@@@ -260,8 -190,7 +260,8 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
 -        uses: actions/cache@v6.1.0
 +        id: dependency-cache
-         uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache/restore@v6.1.0
          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.
@@@ -286,15 -215,6 +286,15 @@@
            --stacktrace
            -PonlyCoreTests
            -PskipCodeStyle
 +          ${{ matrix.extra_arguments }}
 +      - name: "๐Ÿ—„๏ธ Save dependency jar cache"
 +        if: ${{ success() && matrix.cache_writer && steps.dependency-cache.outputs.cache-hit != 'true' }}
-         uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache/save@v6.1.0
 +        with:
 +          path: |
 +            ~/.gradle/caches/modules-2
 +            ~/.gradle/wrapper
 +          key: gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-${{ hashFiles('**/dependencies.gradle', '**/gradle-wrapper.properties') }}
    buildForge:
      name: "Build Grails Forge (Java ${{ matrix.java }}, indy=${{ matrix.indy }})"
      strategy:
@@@ -363,8 -283,8 +363,8 @@@
            ./tmp1/cli/bin/grails --version
            ./tmp1/cli/bin/grails-forge-cli --version
        - name: "๐Ÿ“ค Upload CLI Zip to Workflow Summary Page"
 -        if: ${{ matrix.java == '17' && matrix.indy == false }}
 +        if: ${{ matrix.java == '21' && matrix.indy == false }}
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+         uses: actions/upload-artifact@v7.0.1
          with:
            name: 'apache-grails-SNAPSHOT-bin.zip'
            include-hidden-files: true
@@@ -435,8 -313,7 +435,8 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
 -        uses: actions/cache@v6.1.0
 +        id: dependency-cache
-         uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache/restore@v6.1.0
          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.
@@@ -463,25 -340,14 +463,25 @@@
            -PonlyFunctionalTests
            -PskipCodeStyle
            -PskipHibernate5Tests
 +          -PskipHibernate7Tests
            -PskipMongodbTests
 +          -PskipNeo4jTests
            -PskipSpringSecurityTests
            -PskipRedisTests
 +          ${{ matrix.shard_arguments }}
 +      - name: "๐Ÿ—„๏ธ Save dependency jar cache"
 +        if: ${{ success() && matrix.cache_writer && steps.dependency-cache.outputs.cache-hit != 'true' }}
-         uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache/save@v6.1.0
 +        with:
 +          path: |
 +            ~/.gradle/caches/modules-2
 +            ~/.gradle/wrapper
 +          key: gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-${{ hashFiles('**/dependencies.gradle', '**/gradle-wrapper.properties') }}
        - name: "๐Ÿ“ค Upload Geb Reports"
          if: failure()
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+         uses: actions/upload-artifact@v7.0.1
          with:
 -          name: geb-reports-functional-java${{ matrix.java }}-indy${{ matrix.indy }}
 +          name: geb-reports-functional-${{ matrix.artifact_suffix }}
            path: '**/build/geb-reports/'
            if-no-files-found: ignore
    security:
@@@ -548,12 -400,12 +548,12 @@@
        - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
          run: curl -s https://api.ipify.org
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ˜ Setup Gradle"
          uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
@@@ -570,48 -422,6 +570,48 @@@
            --stacktrace
            -DTESTCONFIG=${{ matrix.test-config }}
            -PskipCodeStyle
 +  casSecurityConfig:
 +    name: "Spring Security CAS Functional Tests"
 +    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +    timeout-minutes: 90 # slowest run: 35 min
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        # The CAS test app authenticates against a real Apereo CAS server started via Testcontainers.
 +        # 'cas' leaves the proxy settings unset; 'casProxy' exercises the proxy-granting-ticket
 +        # callback; 'casNoSingleSignout' asserts the shipped default, where single signout is off.
 +        # TESTCONFIG is applied at app startup, so every config must be its own run. The specs are
 +        # gated on a config being selected, so the broad 'security' job skips them and no config is
 +        # run twice.
 +        # Carried over from the standalone grails-spring-security CI convention - add, don't remove.
 +        test-config: [ 'cas', 'casProxy', 'casNoSingleSignout' ]
 +    runs-on: ubuntu-24.04
 +    steps:
 +      - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
 +        run: curl -s https://api.ipify.org
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY  }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # v1.9.3
 +      - name: "๐Ÿƒ Run Spring Security CAS Functional Tests (TESTCONFIG=${{ matrix.test-config }})"
 +        run: >
 +          ./gradlew
 +          :grails-test-examples-spring-security-cas-test1:check
 +          --continue
 +          --rerun-tasks
 +          --stacktrace
 +          -DTESTCONFIG=${{ matrix.test-config }}
 +          -PskipCodeStyle
    redis:
      name: "Redis Tests"
      if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
@@@ -684,17 -492,6 +684,17 @@@
            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: "๐Ÿ—„๏ธ Cache the mongod the embedded server runs"
-         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache@v6.1.0
 +        with:
 +          # Kept apart from the dependency jars: this holds an immutable download from
 +          # fastdl.mongodb.org - the archive and the binary extracted out of it - and no database,
 +          # so it belongs to no branch and does not change when a dependency version does.
 +          # Keyed by the version this entry asks for, because the matrix runs more than one and a
 +          # single key would keep whichever job missed first and leave the other restoring 250MB
 +          # of a binary it cannot use. No restore-keys, for that same reason.
 +          path: ~/.embedmongo
 +          key: embedmongo-${{ runner.os }}-${{ matrix.mongodb-version }}
        - name: "๐Ÿ˜ Setup Gradle"
          uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
@@@ -762,116 -558,6 +762,116 @@@
            -PgrailsIndy=${{ matrix.indy }}
            -PonlyHibernate5Tests
            -PskipCodeStyle
 +  hibernate7Functional:
 +    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +    name: "Hibernate7 Functional Tests (${{ matrix.job_name }})"
 +    timeout-minutes: 210 # slowest run: 104 min, which failed; the slowest that passed took 86
 +    runs-on: ubuntu-24.04
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        include:
 +          - java: 21
 +            indy: false
 +            job_name: Java 21, indy=false
 +            gradle_task: 'bootJar check'
 +            shard_arguments: '-PtestShardCount=3 -PtestShardIndex=0'
 +          - java: 21
 +            indy: false
 +            job_name: Java 21, indy=false, shard 1
 +            gradle_task: testShard
 +            shard_arguments: '-PtestShardCount=3 -PtestShardIndex=1'
 +          - java: 21
 +            indy: false
 +            job_name: Java 21, indy=false, shard 2
 +            gradle_task: testShard
 +            shard_arguments: '-PtestShardCount=3 -PtestShardIndex=2'
 +          - java: 21
 +            indy: true
 +            job_name: Java 21, indy=true
 +            gradle_task: 'bootJar check'
 +            shard_arguments: '-PtestShardCount=2 -PtestShardIndex=0'
 +          - java: 21
 +            indy: true
 +            job_name: Java 21, indy=true, shard 1
 +            gradle_task: testShard
 +            shard_arguments: '-PtestShardCount=2 -PtestShardIndex=1'
 +          - java: 25
 +            indy: false
 +            job_name: Java 25, indy=false
 +            gradle_task: 'bootJar check'
 +            shard_arguments: '-PtestShardCount=2 -PtestShardIndex=0'
 +          - java: 25
 +            indy: false
 +            job_name: Java 25, indy=false, shard 1
 +            gradle_task: testShard
 +            shard_arguments: '-PtestShardCount=2 -PtestShardIndex=1'
 +    steps:
 +      - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
 +        run: curl -s https://api.ipify.org
 +      - name: "๐Ÿ“ฅ Checkout the repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿƒ Run Functional Tests"
 +        env:
 +          GITHUB_MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
 +        run: >
 +          ./gradlew ${{ matrix.gradle_task }}
 +          --continue
 +          --rerun-tasks
 +          --stacktrace
 +          -PgrailsIndy=${{ matrix.indy }}
 +          -PonlyHibernate7Tests
 +          -PskipCodeStyle
 +          ${{ matrix.shard_arguments }}
 +  neo4jFunctional:
 +    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +    name: "Neo4j Functional Tests (Java ${{ matrix.java }}, indy=${{ matrix.indy }})"
 +    timeout-minutes: 180 # slowest run: 90 min
 +    runs-on: ubuntu-24.04
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        java: [ 21, 25 ]
 +        indy: [ false ]
 +        include:
 +          - java: 21
 +            indy: true
 +    steps:
 +      - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
 +        run: curl -s https://api.ipify.org
 +      - name: "๐Ÿ“ฅ Checkout the repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿƒ Run Functional Tests"
 +        env:
 +          GITHUB_MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
 +        run: >
 +          ./gradlew bootJar check
 +          --continue
 +          --rerun-tasks
 +          --stacktrace
 +          -PgrailsIndy=${{ matrix.indy }}
 +          -PonlyNeo4jTests
 +          -PskipCodeStyle
    publishGradle:
      if: github.repository_owner == 'apache' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
      needs: [ buildGradle ]
@@@ -880,14 -566,14 +880,14 @@@
        - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
          run: curl -s https://api.ipify.org
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
-         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+         uses: actions/cache@v6.1.0
          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.
@@@ -947,14 -631,14 +947,14 @@@
        - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
          run: curl -s https://api.ipify.org
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
-         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+         uses: actions/cache@v6.1.0
          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.
@@@ -1013,14 -697,14 +1013,14 @@@
        - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
          run: curl -s https://api.ipify.org
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
-         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+         uses: actions/cache@v6.1.0
          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.
@@@ -1069,14 -753,14 +1069,14 @@@
        - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
          run: curl -s https://api.ipify.org
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ“ฅ Download Wrapper"
-         uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+         uses: actions/download-artifact@v8.0.1
          with:
            name: apache-grails-wrapper-SNAPSHOT-bin
            path: wrapper
@@@ -1126,12 -810,12 +1126,12 @@@
            echo "NOW=${NOW}" >> $GITHUB_ENV
            echo "Current date: ${NOW}"
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
-         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+         uses: actions/cache@v6.1.0
          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.
diff --cc .github/workflows/groovy-snapshot-canary.yml
index 27336d370a,0000000000..fb1c913f47
mode 100644,000000..100644
--- a/.github/workflows/groovy-snapshot-canary.yml
+++ b/.github/workflows/groovy-snapshot-canary.yml
@@@ -1,196 -1,0 +1,196 @@@
 +# 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.
 +
 +name: "Groovy Snapshot Canary Build"
 +on:
 +  push:
 +    branches:
 +      - '[0-9]+.[0-9]+.x'
 +  pull_request:
 +    branches:
 +      - '[0-9]+.[0-9]+.x'
 +  workflow_dispatch:
 +# queue jobs and only allow 1 run per branch due to the likelihood of hitting GitHub resource limits
 +concurrency:
 +  group: ${{ github.workflow }}-${{ github.ref }}
 +  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 +permissions:
 +  contents: read
 +jobs:
 +  build_groovy:
 +    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +    name: "Build Groovy Snapshot"
 +    runs-on: ubuntu-latest
 +    outputs:
 +      groovyVersion: ${{ steps.groovy-version.outputs.value }}
 +    steps:
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          java-version: 21
 +          distribution: liberica
 +      - name: "๐Ÿ“ฅ Checkout Grails Core to fetch Gradle Plugin versions it uses"
 +        # Sparse-checkout fetches only the two paths this job actually needs:
 +        #   - dependencies.gradle: parsed below to derive the matching Apache
 +        #     Groovy branch (GROOVY_<major>_<minor>_X) for this Grails branch.
 +        #   - .github/scripts/groovy-snapshot-canary-build.init.gradle: passed to
 +        #     ./gradlew --init-script when publishing Groovy to local Maven so
 +        #     the canary build reuses our Develocity / build-cache settings.
 +        # Both paths must be listed here; otherwise the init-script step fails
 +        # with "The specified initialization script ... does not exist."
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +        with:
 +          sparse-checkout-cone-mode: false
 +          sparse-checkout: |
 +            dependencies.gradle
 +            .github/scripts/groovy-snapshot-canary-build.init.gradle
 +      - name: "๐Ÿ“ Derive matching Apache Groovy branch from dependencies.gradle"
 +        id: groovy-branch
 +        run: |
 +          # Extract the major and minor version of `groovy.version` declared in this
 +          # branch's dependencies.gradle (e.g. '5.0.5' -> '5_0', '4.0.31' -> '4_0')
 +          # and use it to pick the matching Apache Groovy development branch
 +          # (GROOVY_<major>_<minor>_X).
 +          # This keeps `7.0.x` PRs validating against Groovy 4 and `8.0.x` PRs
 +          # validating against Groovy 5 without hard-coding the branch here.
 +          GROOVY_MAJOR_MINOR=$(sed -nE "s/.*'groovy\.version'.*'([0-9]+)\.([0-9]+)\.[0-9]+'.*/\1_\2/p" dependencies.gradle | head -n 1)
 +          if [ -z "$GROOVY_MAJOR_MINOR" ]; then
 +            echo "::error::Could not determine Apache Groovy major/minor version from dependencies.gradle"
 +            exit 1
 +          fi
 +          GROOVY_BRANCH="GROOVY_${GROOVY_MAJOR_MINOR}_X"
 +          echo "Validating against Apache Groovy branch: $GROOVY_BRANCH"
 +          echo "value=$GROOVY_BRANCH" >> $GITHUB_OUTPUT
 +          rm dependencies.gradle
 +      - name: "๐Ÿ“ฅ Checkout Apache Groovy (${{ steps.groovy-branch.outputs.value }})"
 +        run: git clone --depth 1 https://github.com/apache/groovy.git -b ${{ steps.groovy-branch.outputs.value }} --single-branch
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-disabled: true # this workflow rebuilds Groovy each run; a Gradle home cache is not useful here
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY  }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # v1.9.3
 +      - name: "๐Ÿ“ Store Groovy version to use when building Grails"
 +        id: groovy-version
 +        run: |
 +          cd groovy
 +          GROOVY_VERSION=$(cat gradle.properties | grep groovyVersion | cut -d\= -f2 |  tr -d '[:space:]')
 +          echo "Groovy version $GROOVY_VERSION stored"
 +          echo "value=$GROOVY_VERSION" >> $GITHUB_OUTPUT
 +      - name: "๐Ÿ”จ Publish Groovy to local maven repository (no docs)"
 +        run: |
 +          cd groovy
 +          ./gradlew pTML -x groovydoc -x javadoc -x javadocAll -x groovydocAll -x asciidoc -x docGDK \
 +            --init-script $GITHUB_WORKSPACE/.github/scripts/groovy-snapshot-canary-build.init.gradle
 +      - name: "๐Ÿ“ค Share locally built Groovy artifacts with the build_grails job"
-         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
++        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: local-groovy-maven-repo
 +          path: ~/.m2/repository/org/apache/groovy/
 +          if-no-files-found: 'error'
 +          retention-days: 1
 +  build_grails:
 +    needs: [build_groovy]
 +    name: "Build Grails (shard ${{ matrix.shard_index }})"
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        include:
 +          - shard_index: 0
 +            gradle_task: build
 +          - shard_index: 1
 +            gradle_task: testShard
 +          - shard_index: 2
 +            gradle_task: testShard
 +    runs-on: ubuntu-latest
 +    steps:
 +      - name: "โš™๏ธ Maximize build space"
 +        run: |
 +          echo "-- Dotnet"
 +          sudo rm -rf /usr/share/dotnet
 +          echo "-- Android"
 +          sudo rm -rf /usr/local/lib/android
 +          echo "-- Haskell"
 +          sudo rm -rf /opt/ghc
 +          echo "-- CodeQL"
 +          sudo rm -rf /opt/hostedtoolcache/CodeQL
 +
 +          df -h
 +      - name: "๐Ÿ“ฅ Checkout project"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          java-version: 21
 +          distribution: liberica
 +      - name: "๐Ÿ—„๏ธ Restore dependency jar cache"
 +        id: dependency-cache
-         uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache/restore@v6.1.0
 +        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: "๐Ÿ—„๏ธ Cache the mongod the embedded server runs"
-         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache@v6.1.0
 +        with:
 +          # Separate from the dependency jars, for the reasons given on the same step in
 +          # gradle.yml. The version is the one -PmongodbContainerVersion asks for below.
 +          path: ~/.embedmongo
 +          key: embedmongo-${{ runner.os }}-8.0
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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 TestLens"
 +        uses: testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # v1.9.3
 +      - name: "๐Ÿ“ฅ Fetch the locally built Groovy artifacts"
-         uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
++        uses: actions/download-artifact@v8.0.1
 +        with:
 +          name: local-groovy-maven-repo
 +          path: ~/.m2/repository/org/apache/groovy
 +      - name: 'Swap Groovy Version'
 +        run: |
 +          sed -i "/['\"]groovy\.version['\"][[:space:]]*:/c\'groovy.version':'${{ needs.build_groovy.outputs.groovyVersion }}'," dependencies.gradle
 +      - name: "๐Ÿ”จ Build and test Grails using the locally built Groovy snapshot"
 +        # -PmongodbContainerVersion names the MongoDB the specifications bring themselves: a
 +        # container for most of them, an embedded server for the ones that need a replica set. It
 +        # keeps those two on the same release. This workflow validates Grails against a Groovy
 +        # snapshot rather than against a range of MongoDB releases, which is gradle.yml's matrix.
 +        run: >
 +          ./gradlew ${{ matrix.gradle_task }}
 +          --continue
 +          --stacktrace
 +          -PskipCodeStyle
 +          -PmaxTestParallel=3
 +          -PtestShardCount=3
 +          -PtestShardIndex=${{ matrix.shard_index }}
 +          -PmongodbContainerVersion=8.0
 +        env:
 +          GRAILS_INCLUDE_MAVEN_LOCAL: true
 +      - name: "๐Ÿ—„๏ธ Save dependency jar cache"
 +        if: ${{ success() && matrix.shard_index == 0 && steps.dependency-cache.outputs.cache-hit != 'true' }}
-         uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
++        uses: actions/cache/save@v6.1.0
 +        with:
 +          path: |
 +            ~/.gradle/caches/modules-2
 +            ~/.gradle/wrapper
 +          key: gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-${{ hashFiles('**/dependencies.gradle', '**/gradle-wrapper.properties') }}
diff --cc .github/workflows/rat.yml
index 1aeb13732d,46e90f38a4..15523bfc30
--- a/.github/workflows/rat.yml
+++ b/.github/workflows/rat.yml
@@@ -33,12 -35,12 +33,12 @@@ jobs
      runs-on: ubuntu-latest
      steps:
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "๐Ÿ˜ Setup Gradle"
          uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
diff --cc .github/workflows/sitemesh2.yml
index 8b597672ec,0000000000..404be46dee
mode 100644,000000..100644
--- a/.github/workflows/sitemesh2.yml
+++ b/.github/workflows/sitemesh2.yml
@@@ -1,126 -1,0 +1,126 @@@
 +# 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.
 +
 +# SiteMesh 3 is the default GSP layout engine. This separate lane exercises the
 +# legacy SiteMesh 2 (grails-layout) path so that regressions affecting apps that
 +# still rely on it are caught. Setting SITEMESH2_TESTING_ENABLED=true flips the
 +# starter, the uber unit suite and the functional test examples back onto
 +# :grails-layout instead of :grails-sitemesh3.
 +name: "SiteMesh 2 Compatibility"
 +on:
 +  workflow_dispatch:
 +  schedule:
 +    - cron: '0 6 * * 1' # Weekly, Mondays 06:00 UTC
 +  push:
 +    branches:
 +      - '[0-9]+.[0-9]+.x'
 +      - '8.0.x-hibernate7.*'
 +    paths:
 +      - 'grails-gsp/**'
 +      - 'grails-test-suite-uber/**'
 +      - 'grails-test-examples/**'
 +      - 'grails-dependencies/**'
 +      - '.github/workflows/sitemesh2.yml'
 +  pull_request:
 +    paths:
 +      - 'grails-gsp/**'
 +      - 'grails-test-suite-uber/**'
 +      - 'grails-test-examples/**'
 +      - 'grails-dependencies/**'
 +      - '.github/workflows/sitemesh2.yml'
 +# Queue jobs - cancel in-progress PR runs when new commits pushed, but allow branch builds to complete
 +concurrency:
 +  group: ${{ github.workflow }}-${{ github.ref }}
 +  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 +jobs:
 +  coreTests:
 +    name: "SiteMesh 2 Core Tests (Java ${{ matrix.java }})"
 +    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        java: [ 21 ]
 +    runs-on: ubuntu-24.04
 +    env:
 +      SITEMESH2_TESTING_ENABLED: 'true'
 +    steps:
 +      - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
 +        run: curl -s https://api.ipify.org
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY  }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # v1.9.3
 +      - name: "๐Ÿ”จ Run Core Tests against SiteMesh 2"
 +        # --rerun-tasks forces the tests to execute against the grails-layout
 +        # classpath instead of reusing outputs cached from a SiteMesh 3 build.
 +        run: >
 +          ./gradlew build
 +          --continue
 +          --rerun-tasks
 +          --stacktrace
 +          -PonlyCoreTests
 +          -PskipCodeStyle
 +  functionalTests:
 +    name: "SiteMesh 2 Functional Tests (Java ${{ matrix.java }}, indy=${{ matrix.indy }})"
 +    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +    strategy:
 +      fail-fast: false
 +      matrix:
 +        java: [ 21 ]
 +        indy: [ false ]
 +    runs-on: ubuntu-24.04
 +    env:
 +      SITEMESH2_TESTING_ENABLED: 'true'
 +    steps:
 +      - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
 +        run: curl -s https://api.ipify.org
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY  }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # v1.9.3
 +      - name: "๐Ÿƒ Run Functional Tests against SiteMesh 2"
 +        # --rerun-tasks forces the tests to execute against the grails-layout
 +        # classpath instead of reusing outputs cached from a SiteMesh 3 build.
 +        run: >
 +          ./gradlew bootJar check
 +          --continue
 +          --rerun-tasks
 +          --stacktrace
 +          -PgrailsIndy=${{ matrix.indy }}
 +          -PonlyFunctionalTests
 +          -PskipCodeStyle
 +          -PskipHibernate5Tests
 +          -PskipHibernate7Tests
 +          -PskipMongodbTests
diff --cc .github/workflows/validate-actions.yml
index a459724633,0000000000..9891224f26
mode 100644,000000..100644
--- a/.github/workflows/validate-actions.yml
+++ b/.github/workflows/validate-actions.yml
@@@ -1,59 -1,0 +1,59 @@@
 +# 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.
 +
 +name: "Validate GitHub Actions"
 +on:
 +  push:
 +    branches:
 +      - '[0-9]+.[0-9]+.x'
 +      - '8.0.x-hibernate7.*'
 +    paths:
 +      - '.github/workflows/**'
 +      - 'build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ValidateActionsTask.groovy'
 +      - 'build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsValidateActionsPlugin.groovy'
 +  pull_request:
 +    paths:
 +      - '.github/workflows/**'
 +      - 'build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ValidateActionsTask.groovy'
 +      - 'build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsValidateActionsPlugin.groovy'
 +  schedule:
 +    - cron: '0 6 * * 1' # weekly Monday 06:00 UTC โ€” catches ASF delisting a pinned SHA
 +  workflow_dispatch:
 +permissions:
 +  contents: read
 +# queue jobs - cancel in-progress PR runs when new commits are pushed, but allow branch builds to complete
 +concurrency:
 +  group: ${{ github.workflow }}-${{ github.ref }}
 +  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 +jobs:
 +  validate:
 +    name: "Validate Actions"
 +    runs-on: ubuntu-24.04
 +    steps:
 +      - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
++        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
++        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        with:
 +          cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ”Ž Validate Actions against the ASF approved list"
 +        run: ./gradlew validateActions
 +        timeout-minutes: 10
diff --cc .github/workflows/vulnerability-scan.yml
index 4ae478ffe1,b5a715a283..c924e386aa
--- a/.github/workflows/vulnerability-scan.yml
+++ b/.github/workflows/vulnerability-scan.yml
@@@ -44,27 -44,12 +44,27 @@@ jobs
        contents: read
      steps:
        - name: "๐Ÿ“ฅ Checkout repository"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Determine Java version from .sdkmanrc"
 +        # Read the JDK the build targets out of the file that already declares it, so a
 +        # baseline bump does not leave a stale literal behind here. Only the major is kept:
 +        # a scan is outside the reproducible-build surface, so CI takes the runner's current
 +        # release of that major rather than pinning the patch version.
 +        id: sdkmanrc
 +        run: |
 +          set -euo pipefail
 +          sdkman_java=$(grep -E '^java=' .sdkmanrc | cut -d= -f2 || true)
 +          if [ -z "${sdkman_java}" ]; then
 +            echo "โŒ Could not determine the java version from .sdkmanrc" >&2
 +            exit 1
 +          fi
 +          echo "Scanning with JDK ${sdkman_java%%.*} (from .sdkmanrc: ${sdkman_java})"
 +          echo "java-version=${sdkman_java%%.*}" >> "$GITHUB_OUTPUT"
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: ${{ steps.sdkmanrc.outputs.java-version }}
        - name: "๐Ÿ˜ Setup Gradle"
          uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
@@@ -98,27 -83,12 +98,27 @@@
        pull-requests: write
      steps:
        - name: "๐Ÿ“ฅ Checkout pull request"
-         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+         uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Determine Java version from .sdkmanrc"
 +        # Read the JDK the build targets out of the file that already declares it, so a
 +        # baseline bump does not leave a stale literal behind here. Only the major is kept:
 +        # a scan is outside the reproducible-build surface, so CI takes the runner's current
 +        # release of that major rather than pinning the patch version.
 +        id: sdkmanrc
 +        run: |
 +          set -euo pipefail
 +          sdkman_java=$(grep -E '^java=' .sdkmanrc | cut -d= -f2 || true)
 +          if [ -z "${sdkman_java}" ]; then
 +            echo "โŒ Could not determine the java version from .sdkmanrc" >&2
 +            exit 1
 +          fi
 +          echo "Scanning with JDK ${sdkman_java%%.*} (from .sdkmanrc: ${sdkman_java})"
 +          echo "java-version=${sdkman_java%%.*}" >> "$GITHUB_OUTPUT"
        - name: "โ˜•๏ธ Setup JDK"
-         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+         uses: actions/setup-java@v6.0.1
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: ${{ steps.sdkmanrc.outputs.java-version }}
        - name: "๐Ÿ˜ Setup Gradle"
          uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
diff --cc AGENTS.md
index 6c7eaabb4e,4cb8a00a97..4fb8771ceb
--- a/AGENTS.md
+++ b/AGENTS.md
@@@ -51,11 -51,8 +51,11 @@@ export GRADLE_OPTS="-Dorg.gradle.jvmarg
  8. **No internal APIs in docs** - Only document public APIs; never reference internal or package-private classes and methods in user-facing documentation
  9. **Test via public APIs** - Tests must exercise behavior through the same APIs an end user calls; never invoke internal implementations, package-private methods, or bypass the public surface directly
  10. **Always review and extend tests** - Review existing unit and functional tests before making changes; every code change must include new or enhanced tests that cover the affected behavior
 -11. **The BOM must manage the latest version** - `validateDependencyVersions` enforces that the BOM (`dependencies.gradle`) manages a version `>=` every transitively-resolved version. When it fails, **bump the version in `dependencies.gradle`** so the BOM wins โ€” never silence it with `allowedBomOverrides` or an exclusion unless there is an explicit, documented conflict or an agreed-upon workaround. See [Dependency Management](#dependency-management).
 -12. **GitHub Actions must use ASF-approved references** - Every third-party action SHA must appear in the ASF allowlist; `actions/*` use the full tag of their latest release and `apache/*` use version or branch references, never SHAs. See [GitHub Actions](#github-actions).
 +11. **Every code touch must update all tests for the changed class** - When a class is modified, find and update every test that covers it โ€” unit, integration, and TCK. Do not leave any existing test out of sync with the new code.
 +12. **Clean violations before commit** - Before every automated commit, run `./gradlew clean aggregateViolations :grails-test-report:check --continue` from the root and ensure that `build/reports/violations/CHECKSTYLE_VIOLATIONS.md`, `build/reports/violations/CODENARC_VIOLATIONS.md`, `build/reports/violations/PMD_VIOLATIONS.md`, and `build/reports/violations/SPOTBUGS_VIOLATIONS.md` report no issues. Also review the test result reports under `grails-test-report/build/reports/tests/` and  [...]
 +13. **Mandatory test coverage** - Any class touched in a commit MUST be covered with tests that verify all behavior. You must run ALL tests in the affected module(s) and ensure they pass before committing.
 +14. **The BOM must manage the latest version** - `validateDependencyVersions` enforces that the BOM (`dependencies.gradle`) manages a version `>=` every transitively-resolved version. When it fails, **bump the version in `dependencies.gradle`** so the BOM wins โ€” never silence it with `allowedBomOverrides` or an exclusion unless there is an explicit, documented conflict or an agreed-upon workaround. See [Dependency Management](#dependency-management).
- 15. **GitHub Actions must use ASF-approved pins** - Every third-party action SHA must appear in the ASF allowlist. See [GitHub Actions](#github-actions).
++15. **GitHub Actions must use ASF-approved references** - Every third-party action SHA must appear in the ASF allowlist; `actions/*` use the full tag of their latest release and `apache/*` use version or branch references, never SHAs. See [GitHub Actions](#github-actions).
  
  ## Available Skills
  
diff --cc grails-data-hibernate5/docs/build.gradle
index 78cac8df9a,5ec29c3f35..f7556e25a4
--- a/grails-data-hibernate5/docs/build.gradle
+++ b/grails-data-hibernate5/docs/build.gradle
@@@ -71,18 -72,23 +71,22 @@@ tasks.named('asciidoctor', AsciidoctorT
      }
  
      it.attributes = [
 -            'experimental': 'true',
 -            'compat-mode': 'true',
 -            'toc': 'left',
 -            'icons': 'font',
 -            'reproducible': '',
 -            'version': projectVersion,
 -            'pluginVersion': projectVersion,
 -            'groupId': project.group,
 -            'artifactId': project.name,
 +            'experimental'              : 'true',
 +            'compat-mode'               : 'true',
 +            'toc'                       : 'left',
 +            'icons'                     : 'font',
 +            'reproducible'              : '',
 +            'version'                   : projectVersion,
 +            'pluginVersion'             : projectVersion,
 +            'groupId'                   : project.group,
 +            'artifactId'                : project.name,
              'migrationPluginExamplesDir': project.layout.projectDirectory.dir('src/docs/asciidoc/databaseMigration').asFile.relativePath(rootProject.findProject(':grails-data-hibernate5-dbmigration').layout.projectDirectory.asFile),
 -            'migrationPluginGroupId': rootProject.findProject(':grails-data-hibernate5-dbmigration').group,
 -            'migrationPluginArtifactId': rootProject.findProject(':grails-data-hibernate5-dbmigration').name,
 -            'liquibaseHibernate5Version': liquibaseHibernate5Version,
 +            'migrationPluginGroupId'    : rootProject.findProject(':grails-data-hibernate5-dbmigration').group,
 +            'migrationPluginArtifactId' : rootProject.findProject(':grails-data-hibernate5-dbmigration').name,
+             // Every file under the source directory is also converted on its own, including the ones that only
+             // exist to be included by index.adoc. Their headings start at the depth they are included at, so they
+             // are converted as fragments. The '@' lets index.adoc switch this back off to keep its own checks.
 -            'fragment': '@'
++            'fragment'                  : '@'
      ]
  }