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/10/14 18:41:10 UTC

(grails-core) branch 8.0.x updated (8b7b316af9 -> 77b8a0029f)

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

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


    from 8b7b316af9 Merge pull request #16617 from apache/fix/hibernate7-embedded-hasmany-collection-8.0.x
     add b13072430f Use the Grails Develocity access key for the remote build cache
     add 0868ee4187 Cache the Gradle User Home through setup-gradle instead of actions/cache
     add 33de4d48f0 Write the Gradle User Home cache from one core build job per OS
     add 9ac5e38869 Merge pull request #16645 from jdaugherty/ci/gradle-cache-single-writer
     add 947726325a Merge branch '7.0.x' into 7.1.x
     add 47d4cef9fc Merge branch '7.1.x' into 7.2.x
     new 77b8a0029f Merge branch '7.2.x' into 8.0.x

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .github/workflows/benchmark.yml              |  10 +--
 .github/workflows/build-health.yml           |  15 ++--
 .github/workflows/codeanalysis.yml           |  10 +--
 .github/workflows/codestyle.yml              |  15 ++--
 .github/workflows/coverage.yml               |  10 +--
 .github/workflows/end-to-end.yml             |   5 +-
 .github/workflows/forge-deploy-aws.yml       |   5 +-
 .github/workflows/gradle.yml                 | 102 ++++++++++++++++-----------
 .github/workflows/groovy-snapshot-canary.yml |  12 ++--
 .github/workflows/rat.yml                    |   5 +-
 .github/workflows/release-publish-docs.yml   |   5 +-
 .github/workflows/release.yml                |  12 ++--
 .github/workflows/sitemesh2.yml              |  10 +--
 .github/workflows/validate-actions.yml       |   5 +-
 .github/workflows/vulnerability-scan.yml     |  10 +--
 15 files changed, 137 insertions(+), 94 deletions(-)


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

Posted by jd...@apache.org.
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 77b8a0029f964c1b3016bc61a5c70deee1988ca3
Merge: 8b7b316af9 47d4cef9fc
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Wed Oct 14 19:41:04 2026 +0100

    Merge branch '7.2.x' into 8.0.x
    
    8.0.x already carries the Develocity key and setup-gradle caching changes
    that #16645 backported to 7.0.x, so every workflow keeps the 8.0.x
    version, and the 7.x-only Groovy joint workflow stays deleted. The single
    writer from #16645 is applied to the 8.0.x workflows: the core build's
    Ubuntu JDK 21 entry and Windows JDK 25 shard 0, which runs the full
    build, are the cache writers, and every other caching setup-gradle step,
    including the jobs and workflows 7.x does not have, is read-only.

 .github/workflows/benchmark.yml              |  10 +--
 .github/workflows/build-health.yml           |  15 ++--
 .github/workflows/codeanalysis.yml           |  10 +--
 .github/workflows/codestyle.yml              |  15 ++--
 .github/workflows/coverage.yml               |  10 +--
 .github/workflows/end-to-end.yml             |   5 +-
 .github/workflows/forge-deploy-aws.yml       |   5 +-
 .github/workflows/gradle.yml                 | 102 ++++++++++++++++-----------
 .github/workflows/groovy-snapshot-canary.yml |  12 ++--
 .github/workflows/rat.yml                    |   5 +-
 .github/workflows/release-publish-docs.yml   |   5 +-
 .github/workflows/release.yml                |  12 ++--
 .github/workflows/sitemesh2.yml              |  10 +--
 .github/workflows/validate-actions.yml       |   5 +-
 .github/workflows/vulnerability-scan.yml     |  10 +--
 15 files changed, 137 insertions(+), 94 deletions(-)

diff --cc .github/workflows/benchmark.yml
index 74ca4531f2,0000000000..b356903df3
mode 100644,000000..100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@@ -1,396 -1,0 +1,398 @@@
 +# 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@v7.0.1
 +        with:
 +          fetch-depth: 0
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_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@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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ“ฅ Download JMH artifacts"
 +        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/build-health.yml
index 840c1aa872,e740fcc2e7..ec21dfc365
--- a/.github/workflows/build-health.yml
+++ b/.github/workflows/build-health.yml
@@@ -39,12 -39,13 +39,13 @@@ jobs
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿฉบ Check Core Projects"
          # build-logic is an included build, so its own health task is invoked through the root build.
@@@ -84,12 -80,13 +85,13 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿฉบ Check Gradle Plugin Projects"
          working-directory: grails-gradle
@@@ -122,12 -119,13 +124,13 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿฉบ Check Forge Projects"
          working-directory: grails-forge
diff --cc .github/workflows/codeanalysis.yml
index d7c1e90e38,0000000000..7e6517eaa7
mode 100644,000000..100644
--- a/.github/workflows/codeanalysis.yml
+++ b/.github/workflows/codeanalysis.yml
@@@ -1,140 -1,0 +1,142 @@@
 +# 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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ”Ž Check Core Projects"
 +        run: ./gradlew aggregateAnalysisViolations --continue
 +      - name: "๐Ÿ“ค Upload Reports"
 +        if: always()
 +        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
 +      - name: "๐Ÿ”Ž Run Core SpotBugs Advisory"
 +        if: success() || failure()
 +        continue-on-error: true
 +        id: core_spotbugs_advisory
 +        run: |
 +          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
 +          ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
 +      - name: "๐Ÿ“ค Upload SpotBugs Advisory Reports"
 +        if: success() || failure()
 +        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: core-spotbugs-advisory-reports
 +          path: build/reports/violations/SPOTBUGS_VIOLATIONS.md
 +      - name: "๐Ÿ“‹ Publish SpotBugs Advisory Report in Job Summary"
 +        if: success() || failure()
 +        run: |
 +          echo "## ๐Ÿ”Ž SpotBugs Advisory Report - Core Projects" >> $GITHUB_STEP_SUMMARY
 +          echo "Advisory run outcome: ${{ steps.core_spotbugs_advisory.outcome }}" >> $GITHUB_STEP_SUMMARY
 +          file="build/reports/violations/SPOTBUGS_VIOLATIONS.md"
 +          [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
 +  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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_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.ignoreFailures=true
 +      - name: "๐Ÿ“ค Upload Reports"
 +        if: always()
 +        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
 +      - name: "๐Ÿ”Ž Run Gradle Plugin SpotBugs Advisory"
 +        if: success() || failure()
 +        continue-on-error: true
 +        id: gradle_plugin_spotbugs_advisory
 +        working-directory: grails-gradle
 +        run: |
 +          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
 +          ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
 +      - name: "๐Ÿ“ค Upload Gradle Plugin SpotBugs Advisory Reports"
 +        if: success() || failure()
 +        uses: actions/upload-artifact@v7.0.1
 +        with:
 +          name: gradle-plugin-spotbugs-advisory-reports
 +          path: grails-gradle/build/reports/violations/SPOTBUGS_VIOLATIONS.md
 +      - name: "๐Ÿ“‹ Publish Gradle Plugin SpotBugs Advisory Report in Job Summary"
 +        if: success() || failure()
 +        run: |
 +          echo "## ๐Ÿ”Ž SpotBugs Advisory Report - Gradle Plugin Projects" >> $GITHUB_STEP_SUMMARY
 +          echo "Advisory run outcome: ${{ steps.gradle_plugin_spotbugs_advisory.outcome }}" >> $GITHUB_STEP_SUMMARY
 +          file="grails-gradle/build/reports/violations/SPOTBUGS_VIOLATIONS.md"
 +          [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
diff --cc .github/workflows/codestyle.yml
index 3f9b7a84fd,9a4ff1c526..676e9c2bbe
--- a/.github/workflows/codestyle.yml
+++ b/.github/workflows/codestyle.yml
@@@ -38,16 -37,17 +38,17 @@@ jobs
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ”Ž Check Core Projects"
 -        run: ./gradlew codeStyle
 -      - name: "๐Ÿ“ค Upload Failure Reports"
 +        run: ./gradlew aggregateStyleViolations validateRepositoryConventions --continue
 +      - name: "๐Ÿ“ค Upload Reports"
          if: always()
          uses: actions/upload-artifact@v7.0.1
          with:
@@@ -73,15 -77,16 +74,16 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 -      - name: "๐Ÿ”Ž Check Gradle Plugin Projects"
 -        working-directory: grails-gradle
 +      - name: "๐Ÿ”Ž Check Forge Projects"
 +        working-directory: grails-forge
          run: ./gradlew codeStyle
        - name: "๐Ÿ“ค Upload Failure Reports"
          if: always()
@@@ -113,17 -118,18 +115,18 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_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@v7.0.1
          with:
diff --cc .github/workflows/coverage.yml
index 466e7b5a0f,0000000000..d7f0bb16bd
mode 100644,000000..100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@@ -1,121 -1,0 +1,123 @@@
 +# 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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐ŸŒก๏ธ Run tests with coverage"
 +        run: >
 +          ./gradlew jacocoAggregateReport
 +          --continue
 +          --stacktrace
 +          -PskipCodeStyle
 +      - name: "๐Ÿ“ค Upload coverage artifact"
 +        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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_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@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@v7.0.1
 +      - name: "๐Ÿ“ฅ Download all coverage artifacts"
 +        uses: actions/download-artifact@v8.0.1
 +        with:
 +          path: coverage-reports
 +      - name: "๐Ÿ“Š Upload coverage to Codecov"
 +        continue-on-error: true
 +        uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
 +        with:
 +          token: ${{ secrets.CODECOV_TOKEN }}
 +          directory: coverage-reports
 +          verbose: true
diff --cc .github/workflows/end-to-end.yml
index d4a86ee4cb,0000000000..a6d4c29be1
mode 100644,000000..100644
--- a/.github/workflows/end-to-end.yml
+++ b/.github/workflows/end-to-end.yml
@@@ -1,139 -1,0 +1,140 @@@
 +# 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@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@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: |
 +            ${{ steps.jdks.outputs.fixture-java }}
 +            ${{ steps.jdks.outputs.build-java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_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@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
 +      - 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@v7.0.1
 +        with:
 +          name: end-to-end-test-reports
 +          path: end-to-end/**/build/reports/tests/**
diff --cc .github/workflows/forge-deploy-aws.yml
index 80307aaf6f,be4c0bc7ff..5f2126cca8
--- a/.github/workflows/forge-deploy-aws.yml
+++ b/.github/workflows/forge-deploy-aws.yml
@@@ -81,10 -81,11 +81,11 @@@ jobs
            java-version: ${{ steps.jdk.outputs.version }}
  
        - name: "Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # A dispatched run is on a branch and writes like a push; a pull request only reads its base branch's entries.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
  
        - name: "Map deployment slot"
diff --cc .github/workflows/gradle.yml
index 43b545505e,1bce8a0774..649e12ab90
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@@ -35,12 -35,13 +35,13 @@@ jobs
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Validate grails-core dependency versions"
          run: >
@@@ -58,58 -54,6 +59,59 @@@
            ./gradlew validateDependencyVersions
            --continue
            --stacktrace
 +  validateBomProperties:
 +    name: 'Validate BOM Properties'
 +    runs-on: ubuntu-24.04
 +    steps:
 +      - name: "๐Ÿ“ฅ Checkout repository"
 +        uses: actions/checkout@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ” Validate grails-core BOM properties"
 +        run: >
 +          ./gradlew validateBomProperties
 +          --continue
 +          --stacktrace
 +      - name: "๐Ÿ” Validate grails-gradle BOM properties"
 +        working-directory: 'grails-gradle'
 +        run: >
 +          ./gradlew validateBomProperties
 +          --continue
 +          --stacktrace
 +  validateGithubBranch:
 +    name: 'Validate githubBranch'
 +    runs-on: ubuntu-24.04
 +    steps:
 +      - name: "๐Ÿ“ฅ Checkout repository"
 +        uses: actions/checkout@v7.0.1
 +      - name: "๐Ÿ” Verify githubBranch names the maintenance branch"
 +        # The documentation links every page to its source on githubBranch, and a release fails unless githubBranch names
 +        # the branch it targets, so a maintenance branch must set it to itself from the moment it is created. A pull
 +        # request is checked against the branch it merges into. GitHub expressions have no regular expressions, so the
 +        # step skips any branch that is not named X.Y.x.
 +        env:
 +          BRANCH: ${{ github.base_ref || github.ref_name }}
 +        run: |
 +          if [[ ! "${BRANCH}" =~ ^[0-9]+\.[0-9]+\.x$ ]]; then
 +            echo "Skipping, ${BRANCH} is not a maintenance branch"
 +            exit 0
 +          fi
 +          githubBranch=$(sed -n 's/^githubBranch=//p' gradle.properties)
 +          if [[ "${githubBranch}" != "${BRANCH}" ]]; then
 +            echo "โŒ githubBranch in gradle.properties is '${githubBranch}', not ${BRANCH}." >&2
 +            exit 1
 +          fi
 +          echo "โœ… gradle.properties sets githubBranch=${BRANCH}"
    buildGradle:
      name: 'Build Gradle Plugins'
      strategy:
@@@ -128,13 -72,14 +130,14 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿ”จ Build project without tests"
          if: ${{ contains(github.event.head_commit.message, '[skip tests]') }}
          working-directory: 'grails-gradle'
@@@ -153,78 -98,25 +156,81 @@@
            --stacktrace
            --rerun-tasks
            -PskipCodeStyle
 +  buildLogic:
 +    # The build logic is an included build: the root build uses its plugins but never runs its tests
 +    name: 'Build Logic'
 +    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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
 +      - name: "๐Ÿ”จ Build and test the build logic"
 +        if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 +        working-directory: 'build-logic'
 +        run: >
 +          ./gradlew check
 +          --continue
 +          --stacktrace
    build:
      if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
 -    name: 'Build Grails-Core'
 +    name: 'Build Grails-Core (${{ matrix.job_name }})'
      strategy:
        fail-fast: false
        matrix:
          include:
 -          - os: ubuntu-latest
 -            java: 17
 -            cache_writer: true
            - os: ubuntu-latest
              java: 21
 +            job_name: Ubuntu JDK 21
++            cache_writer: true
 +            gradle_task: 'build :grails-shell-cli:installDist groovydoc'
            - os: ubuntu-latest
              java: 25
 +            job_name: Ubuntu JDK 25
 +            gradle_task: 'build :grails-shell-cli:installDist groovydoc'
            - os: macos-latest
              java: 21
 +            job_name: macOS JDK 21
 +            gradle_task: 'build :grails-shell-cli:installDist groovydoc'
 +            # The macOS runner has ~7 GB of RAM and 3 CPUs, against ~16 GB and 4 CPUs on the
 +            # Linux and Windows runners, so it has the least room for concurrent JVMs.
 +            # Capping only maxTestParallel would not help: that is a per-Test-task limit, and
 +            # with org.gradle.parallel=true several projects' test tasks run at once, so the
 +            # number of live forks is bounded by Gradle's global worker pool - which defaults
 +            # to the 3 CPUs here. --max-workers is therefore the setting that actually limits
 +            # concurrent test, compiler and groovydoc JVMs; maxTestParallel is kept alongside
 +            # it so no single task exceeds that cap either.
 +            runner_arguments: '--max-workers=2 -PmaxTestParallel=2'
            - os: windows-latest
              java: 25
 +            job_name: Windows JDK 25 shard 0
+             cache_writer: true
 +            gradle_task: 'build :grails-shell-cli:installDist groovydoc'
 +            shard_arguments: '-PtestShardCount=3 -PtestShardIndex=0'
 +          - os: windows-latest
 +            java: 25
 +            job_name: Windows JDK 25 shard 1
 +            gradle_task: testShard
 +            shard_arguments: '-PtestShardCount=3 -PtestShardIndex=1'
 +          - os: windows-latest
 +            java: 25
 +            job_name: Windows JDK 25 shard 2
 +            gradle_task: testShard
 +            shard_arguments: '-PtestShardCount=3 -PtestShardIndex=2'
      runs-on: ${{ matrix.os }}
      steps:
        - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it
@@@ -237,13 -129,14 +243,14 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # On a branch push only the matrix entries flagged cache_writer save, one per OS, which keeps every
+           # release branch's entries within GitHub's 10 GB cache; a pull request only reads its base branch's.
+           cache-read-only: ${{ github.event_name == 'pull_request' || !matrix.cache_writer }}
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿ”จ Build project"
          # build-logic is an included build, so the specs of its vulnerability-scan plugin are run through the root build.
          run: >
@@@ -293,16 -166,17 +300,17 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿ”จ Build project"
          run: >
 -          ./gradlew build :grails-shell-cli:installDist groovydoc :build-logic-root:vulnerability-scan:check
 +          ./gradlew ${{ matrix.gradle_task }} :build-logic-root:vulnerability-scan:check
            --continue
            --rerun-tasks
            --stacktrace
@@@ -331,13 -204,14 +339,14 @@@
            distribution: 'liberica'
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿ”จ Build project without tests"
          if: ${{ contains(github.event.head_commit.message, '[skip tests]') }}
          working-directory: 'grails-forge'
@@@ -433,16 -271,17 +442,17 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿƒ Run Functional Tests"
          run: >
 -          ./gradlew bootJar check
 +          ./gradlew ${{ matrix.gradle_task }}
            --continue
            --rerun-tasks
            --stacktrace
@@@ -494,16 -318,17 +504,17 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿƒ Run Functional Tests"
          run: >
 -          ./gradlew bootJar check
 +          ./gradlew ${{ matrix.gradle_task }}
            --continue
            --rerun-tasks
            --stacktrace
@@@ -533,15 -356,16 +544,16 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿƒ Run Spring Security Functional Tests (TESTCONFIG=${{ matrix.test-config }})"
          run: >
            ./gradlew
@@@ -551,49 -375,6 +563,50 @@@
            --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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
 +      - 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]') }}
@@@ -615,13 -395,14 +628,14 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿƒ Run Redis Tests"
          run: >
            ./gradlew bootJar check
@@@ -656,22 -436,12 +670,23 @@@
          with:
            distribution: liberica
            java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ—„๏ธ Cache the mongod the embedded server runs"
 +        uses: actions/cache@v6.1.0
 +        with:
 +          # Outside the Gradle User Home that setup-gradle caches: 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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿƒ Run Functional Tests"
          env:
@@@ -709,10 -478,11 +724,11 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿƒ Run Functional Tests"
          env:
@@@ -725,118 -495,6 +741,120 @@@
            -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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_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 ]
@@@ -850,15 -508,16 +868,16 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿ“ค Publish Gradle Snapshot Artifacts"
          env:
            GRAILS_PUBLISH_RELEASE: 'false'
@@@ -907,15 -564,16 +926,16 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿ“ค Publish Grails-Core Snapshot Artifacts"
          uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
          env:
@@@ -963,15 -621,16 +983,16 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿ“ค Publish Gradle Snapshot Artifacts"
          env:
            GRAILS_PUBLISH_RELEASE: 'false'
@@@ -1064,12 -723,13 +1085,13 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ”จ Build Snapshot Documentation"
          run: >
diff --cc .github/workflows/groovy-snapshot-canary.yml
index 361e2668bf,0000000000..ae0894ffa2
mode 100644,000000..100644
--- a/.github/workflows/groovy-snapshot-canary.yml
+++ b/.github/workflows/groovy-snapshot-canary.yml
@@@ -1,180 -1,0 +1,180 @@@
 +# 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@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@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@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Groovy is rebuilt every run, but its build dependencies are not: the Gradle User Home of this job
-           # holds them, and the entry is keyed by job, so it never mixes with the Grails jobs below.
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
 +      - 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@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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          java-version: 21
 +          distribution: liberica
 +      - name: "๐Ÿ—„๏ธ Cache the mongod the embedded server runs"
 +        uses: actions/cache@v6.1.0
 +        with:
 +          # Outside the Gradle User Home that setup-gradle caches, 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@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
 +      - name: "๐Ÿ“ฅ Fetch the locally built Groovy artifacts"
 +        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
diff --cc .github/workflows/rat.yml
index 084db0d6ae,c05aa02c40..217aeac933
--- a/.github/workflows/rat.yml
+++ b/.github/workflows/rat.yml
@@@ -38,15 -40,16 +38,16 @@@ jobs
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Setup TestLens"
 -        uses: testlens-app/setup-testlens@d96a555133c275a00949d2cc77b70fe9a4242ebf # v1.9.2
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
        - name: "๐Ÿง Apache License - Release Audit Tool"
          run: ./gradlew rat
        - name: Upload RAT HTML report
diff --cc .github/workflows/release-publish-docs.yml
index 9dab3f7d3b,6a388259c3..e4ed3858fa
--- a/.github/workflows/release-publish-docs.yml
+++ b/.github/workflows/release-publish-docs.yml
@@@ -78,15 -63,14 +78,16 @@@ jobs
            distribution: ${{ env.JAVA_DISTRIBUTION }}
            java-version: ${{ env.JAVA_VERSION }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # A dispatched run is on a branch and writes like a push; a pull request only reads its base branch's entries.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ“– Generate Documentation"
 -        run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH}
 +        # githubBranch comes from gradle.properties, so the source links point at the same branch as the voted
 +        # documentation instead of the temporary correction branch
 +        run: ./gradlew grails-doc:build
        - name: "๐Ÿš€ Publish to GitHub Pages"
          uses: apache/grails-github-actions/deploy-github-pages@asf
          env:
diff --cc .github/workflows/release.yml
index 8e29d82c6a,966cba11a4..72ecaa8b8e
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@@ -98,11 -81,11 +98,11 @@@ jobs
            distribution: ${{ env.JAVA_DISTRIBUTION }}
            java-version: ${{ env.JAVA_VERSION }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # A release runs on its tag, which GitHub lets read the default branch's entries but not its release branch's;
-           # what it writes is scoped to that tag, so it serves only a re-run of the same release.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # A release runs on its tag, which reads the default branch's entries; what it wrote would serve only
+           # a re-run of the same release, so it writes nothing.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "โš™๏ธ Run pre-release"
          uses: apache/grails-github-actions/pre-release@asf
@@@ -717,11 -645,11 +717,11 @@@
            distribution: ${{ env.JAVA_DISTRIBUTION }}
            java-version: ${{ env.JAVA_VERSION }}
        - name: "๐Ÿ˜ Setup Gradle"
 -        uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # A release runs on its tag, which GitHub lets read the default branch's entries but not its release branch's;
-           # what it writes is scoped to that tag, so it serves only a re-run of the same release.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # A release runs on its tag, which reads the default branch's entries; what it wrote would serve only
+           # a re-run of the same release, so it writes nothing.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ”Ž Determine SDKMAN release type"
          id: sdkman-release
diff --cc .github/workflows/sitemesh2.yml
index 00ce82f709,0000000000..bad9354d0b
mode 100644,000000..100644
--- a/.github/workflows/sitemesh2.yml
+++ b/.github/workflows/sitemesh2.yml
@@@ -1,128 -1,0 +1,130 @@@
 +# 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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
 +      - 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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: ${{ matrix.java }}
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
 +      - name: "๐Ÿ” Setup TestLens"
 +        uses: testlens-app/setup-testlens@d29a2be31512b3aeda3adcef09fbe0d55af90c51 # v1.9.4
 +      - 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 8946b56a9d,0000000000..b8dde8e144
mode 100644,000000..100644
--- a/.github/workflows/validate-actions.yml
+++ b/.github/workflows/validate-actions.yml
@@@ -1,60 -1,0 +1,61 @@@
 +# 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@v7.0.1
 +      - name: "โ˜•๏ธ Setup JDK"
 +        uses: actions/setup-java@v6.0.1
 +        with:
 +          distribution: liberica
 +          java-version: 21
 +      - name: "๐Ÿ˜ Setup Gradle"
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
 +        with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
++          # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
++          # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
++          cache-read-only: true
 +          develocity-access-key: ${{ secrets.GRAILS_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 692b0c5c59,be8ba6e460..6fa6dd3a97
--- a/.github/workflows/vulnerability-scan.yml
+++ b/.github/workflows/vulnerability-scan.yml
@@@ -55,12 -40,13 +55,13 @@@ jobs
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Run Vulnerability Scan"
          # Looks up every resolved dependency in the OSV database (https://osv.dev) and in Sonatype Guide.
@@@ -108,12 -79,13 +109,13 @@@
          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
 +        uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
          with:
-           # Every push writes, so each release branch keeps its own entries; a pull request only reads its base branch's.
-           cache-read-only: ${{ github.event_name == 'pull_request' }}
+           # Only the core build writes, one job per OS, which keeps every release branch's entries within GitHub's
+           # 10 GB cache; this job restores the newest entry for its OS and downloads whatever that lacks.
+           cache-read-only: true
            develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
        - name: "๐Ÿ” Run Vulnerability Scan"
          # GitHub withholds secrets from a pull request raised by a fork, so its scan consults OSV only,