Skip to content

License updates #184

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jun 9, 2025
Merged

License updates #184

merged 20 commits into from
Jun 9, 2025

Conversation

jamesfredley
Copy link
Contributor

No description provided.

@jamesfredley jamesfredley self-assigned this Jun 9, 2025
@jamesfredley jamesfredley moved this to In Progress in Apache Grails Jun 9, 2025
@jamesfredley jamesfredley added this to the grails:7.0.0-M4 milestone Jun 9, 2025
@jamesfredley jamesfredley marked this pull request as ready for review June 9, 2025 18:53
jamesfredley and others added 3 commits June 9, 2025 14:55
diff --git c/.asf.yaml i/.asf.yaml
new file mode 100644
index 0000000..c971dc3
--- /dev/null
+++ i/.asf.yaml
@@ -0,0 +1,17 @@
+github:
+  environments:
+    source:
+      required_reviewers:
+        - id: grails-committers
+          type: Team
+      wait_timer: 0
+    release:
+      required_reviewers:
+        - id: grails-committers
+          type: Team
+      wait_timer: 0
+    docs:
+      required_reviewers:
+        - id: grails-committers
+          type: Team
+      wait_timer: 0
diff --git c/.github/workflows/gradle.yml i/.github/workflows/gradle.yml
index bd7c396..3011091 100644
--- c/.github/workflows/gradle.yml
+++ i/.github/workflows/gradle.yml
@@ -24,9 +24,13 @@ on:
   workflow_dispatch:
 permissions:
   packages: read
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: false
 jobs:
-  build:
-    name: "Build (Redis ${{ matrix.redis-version }})"
+  tests:
+    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
+    name: "Test (Redis ${{ matrix.redis-version }})"
     runs-on: ubuntu-24.04
     strategy:
       matrix:
@@ -60,8 +64,8 @@ jobs:
           REDIS_PORT: 6379
         run: ./gradlew build --continue
   publish_snapshot:
-    if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
-    needs: build
+    needs: tests
+    if: ${{ always() && github.repository_owner == 'apache' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (needs.tests.result == 'success' || needs.tests.result == 'skipped') }}
     runs-on: ubuntu-24.04
     permissions:
       contents: write
@@ -80,11 +84,10 @@ jobs:
       - name: "📤 Publish Snapshot Artifacts"
         id: publish
         env:
-          GITHUB_MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
           GRAILS_PUBLISH_RELEASE: 'false'
-          MAVEN_PUBLISH_USERNAME: ${{ secrets.NEXUS_USER }}
-          MAVEN_PUBLISH_PASSWORD: ${{ secrets.NEXUS_PW }}
           MAVEN_PUBLISH_URL: ${{ secrets.GRAILS_NEXUS_PUBLISH_SNAPSHOT_URL }}
+          MAVEN_PUBLISH_USERNAME: ${{ secrets.NEXUS_USER }}
+          MAVEN_PUBLISH_PASSWORD: ${{ secrets.NEXUS_PW  }}
         run: ./gradlew --no-build-cache publish
       - name: "🔨 Generate Snapshot Documentation"
         run: ./gradlew :grails-redis:groovydoc
diff --git c/.github/workflows/rat.yml i/.github/workflows/rat.yml
index 041bf8e..11a11af 100644
--- c/.github/workflows/rat.yml
+++ i/.github/workflows/rat.yml
@@ -17,13 +17,11 @@ name: RAT Report
 on:
   push:
     branches:
-      - '[4-9]+.[0-9]+.x'
-      - '[3-9]+.[3-9]+.x'
+      - '[3-9]+.[0-9]+.x'
       - license-audit
   pull_request:
     branches:
-      - '[4-9]+.[0-9]+.x'
-      - '[3-9]+.[3-9]+.x'
+      - '[3-9]+.[0-9]+.x'
       - license-audit
   workflow_dispatch:
 # queue jobs and only allow 1 run per branch due to the likelihood of hitting GitHub resource limits
diff --git c/.github/workflows/release.yml i/.github/workflows/release.yml
index b3bf198..775884c 100644
--- c/.github/workflows/release.yml
+++ i/.github/workflows/release.yml
@@ -20,17 +20,43 @@ on:
 permissions:
   contents: write
   packages: read
+env:
+  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 jobs:
-  release:
+  publish:
+    permissions:
+      packages: read  #  pre-release workflow
+      contents: write  #  to create release
+      issues: write  #  to modify milestones
     runs-on: ubuntu-24.04
+    outputs:
+      release_version: ${{ steps.release_version.outputs.value }}
+      extract_repository_name: ${{ steps.extract_repository_name.outputs.repository_name }}
     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: "Extract repository name"
+        id: extract_repository_name
+        run: |
+          echo "repository_name=${GITHUB_REPOSITORY##*/}" >> $GITHUB_OUTPUT
       - name: "📥 Checkout repository"
         uses: actions/checkout@v4
+      - name: 'Ensure Common Build Date' # to ensure a reproducible build
+        run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV"
+      - name: "Ensure source files use common date"
+        run: |
+          find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
+      - name: '🔐 Set up GPG'
+        run: |
+          echo "${{ secrets.GRAILS_GPG_KEY }}" | gpg --batch --import
+          gpg --list-keys
+        env:
+          GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
       - name: "☕️ Setup JDK"
         uses: actions/setup-java@v4
         with:
-          java-version: 17
           distribution: liberica
+          java-version: '17.0.15' # this must be a specific version for reproducible builds
       - name: "🐘 Setup Gradle"
         uses: gradle/actions/setup-gradle@v4
         with:
@@ -40,33 +66,200 @@ jobs:
         run: echo "release_version=${GITHUB_REF:11}" >> $GITHUB_OUTPUT
       - name: "⚙️ Run pre-release"
         uses: apache/grails-github-actions/pre-release@asf
-      - name: "🔐 Generate key file for artifact signing"
         env:
-          SECRING_FILE: ${{ secrets.SECRING_FILE }}
-        run: echo $SECRING_FILE | base64 -d > ${{ github.workspace }}/secring.gpg
-      - name: "📤 Publish to Sonatype - close and release staging repository"
+          RELEASE_VERSION: ${{ steps.release_version.outputs.value }}
+      - name: "📤 Publish to staging repository"
         env:
-          GITHUB_MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
           GRAILS_PUBLISH_RELEASE: 'true'
-          NEXUS_PUBLISH_USERNAME: ${{ secrets.NEXUS_USER }}
-          NEXUS_PUBLISH_PASSWORD: ${{ secrets.NEXUS_PW }}
-          NEXUS_PUBLISH_URL: ${{ secrets.GRAILS_NEXUS_PUBLISH_RELEASE_URL }}
-          NEXUS_PUBLISH_STAGING_PROFILE_ID: ${{ secrets.NEXUS_PUBLISH_STAGING_PROFILE_ID }} # TODO: What about this secret?
-          SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
-          SIGNING_PASSPHRASE: ${{ secrets.SIGNING_PASSPHRASE }}
+          NEXUS_PUBLISH_USERNAME: ${{ secrets.NEXUS_STAGE_DEPLOYER_USER }}
+          NEXUS_PUBLISH_PASSWORD: ${{ secrets.NEXUS_STAGE_DEPLOYER_PW }}
+          NEXUS_PUBLISH_URL: 'https://repository.apache.org/service/local/'
+          NEXUS_PUBLISH_STAGING_PROFILE_ID: ${{ secrets.STAGING_PROFILE_ID }}
+          NEXUS_PUBLISH_DESCRIPTION: '${{ steps.extract_repository_name.outputs.repository_name }}:${{ steps.release_version.outputs.value }}'
+          SIGNING_KEY: ${{ secrets.GPG_KEY_ID }}
         run: >
-          ./gradlew --refresh-dependencies
-          -Psigning.secretKeyRingFile=${{ github.workspace }}/secring.gpg
+          ./gradlew
           publishToSonatype
-          closeAndReleaseSonatypeStagingRepository
+          closeSonatypeStagingRepository
+          aggregateChecksums
+          aggregatePublishedArtifacts
+      - name: "Upload checksums"
+        uses: softprops/action-gh-release@v2
+        with:
+          files: build/CHECKSUMS.txt
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: "Upload published artifacts"
+        uses: softprops/action-gh-release@v2
+        with:
+          files: build/PUBLISHED_ARTIFACTS.txt
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: "Generate Build Date file"
+        run: echo "$SOURCE_DATE_EPOCH" >> build/BUILD_DATE.txt
+      - name: "Upload Build Date file"
+        uses: softprops/action-gh-release@v2
+        with:
+          files: build/BUILD_DATE.txt
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+  source:
+    # to ensure we never publish any build artifacts, run the source distribution as a separate build workflow
+    environment: source
+    name: "Source Distribution"
+    needs: publish
+    permissions:
+      contents: write
+    runs-on: ubuntu-latest
+    outputs:
+      extract_repository_name: ${{ steps.extract_repository_name.outputs.repository_name }}
+    steps:
+      - name: "Extract repository name"
+        id: extract_repository_name
+        run: |
+          echo "repository_name=${GITHUB_REPOSITORY##*/}" >> $GITHUB_OUTPUT
+      - name: "📥 Checkout repository"
+        uses: actions/checkout@v4
+        with:
+          repository: ${{ github.repository }}
+          ref: ${{ github.ref_name }}
+          path: project
+      - name: "🗑️ Remove unnecessary files"
+        run: |
+          rm -f project/gradle/wrapper/gradle-wrapper.jar
+          rm -f project/gradle/wrapper/gradle-wrapper.properties
+          rm -f project/gradlew
+          rm -f project/.asf.yaml
+      - name: "Download CHECKSUMS.txt and rename to CHECKSUMS"
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          cd project
+          release_url=$(gh release view ${{ github.ref_name }} --json assets --repo ${{ github.repository }} --jq '.assets[] | select(.name == "CHECKSUMS.txt") | .url')
+          curl -L -H "Authorization: token $GH_TOKEN" -o CHECKSUMS "$release_url"
+      - name: "Download PUBLISHED_ARTIFACTS.txt and rename to PUBLISHED_ARTIFACTS"
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          cd project
+          release_url=$(gh release view ${{ github.ref_name }} --json assets --repo ${{ github.repository }} --jq '.assets[] | select(.name == "PUBLISHED_ARTIFACTS.txt") | .url')
+          curl -L -H "Authorization: token $GH_TOKEN" -o PUBLISHED_ARTIFACTS "$release_url"
+      - name: "Download BUILD_DATE.txt and rename to BUILD_DATE"
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          cd project
+          release_url=$(gh release view ${{ github.ref_name }} --json assets --repo ${{ github.repository }} --jq '.assets[] | select(.name == "BUILD_DATE.txt") | .url')
+          curl -L -H "Authorization: token $GH_TOKEN" -o BUILD_DATE "$release_url"
+      - name: "Ensure source files use common date"
+        run: |
+          SOURCE_DATE_EPOCH=$(cat project/BUILD_DATE)
+          find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
+      - name: "📦 Create source distribution ZIP"
+        run: |
+          version="${{ github.ref_name }}"
+          version="${version#v}"  # Strip 'v' prefix
+          zip -r "apache-${{ steps.extract_repository_name.outputs.repository_name }}-${version}-incubating-src.zip" project -x 'project/.git/*' -x 'project/.github/*'
+      - name: '🔐 Set up GPG'
+        run: |
+          echo "${{ secrets.GRAILS_GPG_KEY }}" | gpg --batch --import
+          gpg --list-keys
+        env:
+          GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
+      - name: "🔏 Sign source distribution ZIP"
+        env:
+          GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
+        run: |
+          version="${{ github.ref_name }}"
+          version="${version#v}"  # Strip 'v' prefix
+          gpg --default-key "${GPG_KEY_ID}" --batch --yes --pinentry-mode loopback --armor --detach-sign apache-${{ steps.extract_repository_name.outputs.repository_name }}-${version}-incubating-src.zip
+      - name: "📦 Create source distribution checksum"
+        run: |
+          version="${{ github.ref_name }}"
+          version="${version#v}"  # Strip 'v' prefix
+          sha512sum apache-${{ steps.extract_repository_name.outputs.repository_name }}-${version}-incubating-src.zip > "apache-${{ steps.extract_repository_name.outputs.repository_name }}-${version}-incubating-src.zip.sha512"
+      - name: "🚀 Upload ZIP and Signature to GitHub Release"
+        uses: softprops/action-gh-release@v2
+        with:
+          tag_name: ${{ github.ref_name }}
+          files: |
+            apache-${{ steps.extract_repository_name.outputs.repository_name }}-*-incubating-src.zip
+            apache-${{ steps.extract_repository_name.outputs.repository_name }}-*-incubating-src.zip.sha512
+            apache-${{ steps.extract_repository_name.outputs.repository_name }}-*-incubating-src.zip.asc
+      - name: "Remove CHECKSUMS.txt asset from release"
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -e
+          cd project
+          gh release --repo ${{ github.repository }} delete-asset ${{ github.ref_name }} CHECKSUMS.txt --yes
+      - name: "Remove BUILD_DATE.txt asset from release"
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -e
+          cd project
+          gh release --repo ${{ github.repository }} delete-asset ${{ github.ref_name }} BUILD_DATE.txt --yes
+      - name: "Remove PUBLISHED_ARTIFACTS.txt asset from release"
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -e
+          cd project
+          gh release --repo ${{ github.repository }} delete-asset ${{ github.ref_name }} PUBLISHED_ARTIFACTS.txt --yes
+  release:
+    environment: release
+    needs: [publish, source]
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+      issues: write
+    steps:
+      - name: "📥 Checkout repository"
+        uses: actions/checkout@v4
+        with:
+          ref: v${{ needs.publish.outputs.release_version }}
+      - name: "☕️ Setup JDK"
+        uses: actions/setup-java@v4
+        with:
+          distribution: liberica
+          java-version: '17.0.15'
+      - name: "🐘 Setup Gradle"
+        uses: gradle/actions/setup-gradle@v4
+        with:
+          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY  }}
+      - name: "⚙️ Run post-release"
+        uses: apache/grails-github-actions/post-release@asf
+  docs:
+    environment: docs
+    name: "Publish Documentation"
+    needs: publish
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write # required for gradle.properties revert
+      issues: write # required for milestone closing
+    steps:
+      - name: "📥 Checkout repository"
+        uses: actions/checkout@v4
+        with:
+          ref: v${{ needs.publish.outputs.release_version }}
+      - name: "☕️ Setup JDK"
+        uses: actions/setup-java@v4
+        with:
+          java-version: '17.0.15'
+          distribution: liberica
+      - name: "🐘 Setup Gradle"
+        uses: gradle/actions/setup-gradle@v4
+        with:
+          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
       - name: "🔨 Build Documentation"
         run: ./gradlew :grails-redis:groovydoc
+        env:
+          GRAILS_PUBLISH_RELEASE: 'true'
       - name: "🚀 Publish to Github Pages"
         uses: apache/grails-github-actions/deploy-github-pages@asf
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GRADLE_PUBLISH_RELEASE: 'true'
           SOURCE_FOLDER: plugin/build/docs
-          VERSION: ${{ steps.release_version.outputs.release_version }}
-      - name: "⚙️ Run post-release"
-        uses: apache/grails-github-actions/post-release@asf
+          VERSION: ${{ needs.publish.outputs.release_version }}
\ No newline at end of file
diff --git c/.gitignore i/.gitignore
index c37e68c..7105fd0 100644
--- c/.gitignore
+++ i/.gitignore
@@ -13,3 +13,5 @@ build
 *.iws
 *.swp
 out
+!etc/bin
+etc/bin/results
\ No newline at end of file
diff --git c/.sdkmanrc i/.sdkmanrc
index 4c173c1..4d12ff3 100644
--- c/.sdkmanrc
+++ i/.sdkmanrc
@@ -1,3 +1,3 @@
 # Enable auto-env through the sdkman_auto_env config - https://sdkman.io/usage#env
-java=17.0.14-librca
-gradle=8.14.2
\ No newline at end of file
+java=17.0.15-librca
+gradle=8.14.2
diff --git c/CODE_OF_CONDUCT.md i/CODE_OF_CONDUCT.md
index 329203e..9af3eb8 100644
--- c/CODE_OF_CONDUCT.md
+++ i/CODE_OF_CONDUCT.md
@@ -1,8 +1,6 @@
 # Code of Conduct

-Apache Grails (Incubating) follows the ASF [Code of
-Conduct](https://www.apache.org/foundation/policies/conduct).
+Apache Grails (Incubating) follows the ASF [Code of Conduct](https://www.apache.org/foundation/policies/conduct).

 If you observe behavior that violates those rules please follow the
-[ASF reporting
-guidelines](https://www.apache.org/foundation/policies/conduct#reporting-guidelines).
+[ASF reporting guidelines](https://www.apache.org/foundation/policies/conduct#reporting-guidelines).
diff --git c/README.md i/README.md
index e20fecb..a6effc1 100644
--- c/README.md
+++ i/README.md
@@ -19,9 +19,26 @@ limitations under the License.
 Grails Redis Plugin
 ===================

-For integration between [Redis][redis] and Grails GORM layer, see the [Redis GORM plugin][redisgorm].
+Building
+--------------

-That plugin was originally called "redis" (the name of this plugin), but it has since been refactored to "redis-gorm" and now relies on this plugin for connectivity.
+To build this project from source, first bootstrap gradle:
+
+     cd gradle-bootstrap
+     gradle
+     cd -
+
+After bootstrap the project, you can build it with the command:
+
+     ./gradlew build
+
+To run the build only, and skip the tests, run:
+
+     ./gradlew build -PskipTests
+
+Then publish the jar files to mavenLocal for usage:
+
+    ./gradlew publishToMavenLocal

 What is Redis?
 --------------
@@ -46,22 +63,19 @@ What is Jedis?
 Installation
 ------------

-# Grails 3
+# Grails 7+

 Add the following dependency to build.gradle:

 ```
 dependencies {
   ...
-  implementation 'org.grails.plugins:grails-redis:3.0.0'
+  implementation 'org.apache.grails:grails-redis:5.0.0'

 }
 ```

-NOTE: The 2.X is compatible only with Grails 3.
-
-
 Out of the box, the plugin expects that Redis is running on `localhost:6379`.  You can modify this (as well as any other pool config options) by adding a stanza like this to your `grails-app/conf/Config.groovy` file:

     grails {
@@ -643,7 +657,6 @@ Release Notes Grails 2.x
 * 1.6.0 - released 11/04/2014 - Changed how `RedisService` is spring injected so that it's easier to mock out for tests by clients.  Upgraded to Jedis 2.6.0.
 * 1.6.2 - released 02/06/2015 - Port and timeout properties injected by external properties file are now converted to Integer.  If not integer, then defaults used.

-[redisgorm]: http://grails.github.com/inconsequential/redis/
 [redis]: http://redis.io
 [redisgroovy]: http://naleid.com/blog/2010/12/28/intro-to-using-redis-with-groovy/
 [slideshareggr]: http://naleid.com/blog/2011/06/27/redis-groovy-and-grails-presentation-at-gr8conf-2011-and-gum/
diff --git c/build.gradle i/build.gradle
index 2154873..f900e9f 100644
--- c/build.gradle
+++ i/build.gradle
@@ -17,28 +17,57 @@
  *  under the License.
  */

-// Workaround needed for nexus publishing bug
-// version and group must be specified in the root project
-// https://github.com/gradle-nexus/publish-plugin/issues/310
-version = projectVersion
-group = 'this.will.be.overridden'
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter

+subprojects {
+
+ext {
+    isReproducibleBuild = System.getenv("SOURCE_DATE_EPOCH") != null
+    buildInstant = java.util.Optional.ofNullable(System.getenv("SOURCE_DATE_EPOCH"))
+            .filter(s -> !s.isEmpty())
+            .map(Long::parseLong)
+            .map(Instant::ofEpochSecond)
+            .orElseGet(Instant::now)
+    formattedBuildDate = DateTimeFormatter.ISO_INSTANT.format(buildInstant)
+    buildDate = (buildInstant as Instant).atZone(ZoneOffset.UTC) // for reproducible builds
+    isCiBuild = System.getenv().get('CI') as Boolean
+}

 allprojects {
     repositories {
         mavenCentral()
-        maven { url = 'https://repo.grails.org/grails/core' }
-        maven { url = 'https://repository.apache.org/content/repositories/snapshots' }
+        maven { url = 'https://repo.grails.org/grails/restricted' }
+        maven { url = 'https://repository.apache.org/content/groups/snapshots' }
+        maven {
+            url = 'https://repository.apache.org/content/groups/staging'
+            content {
+                includeGroupByRegex 'org[.]apache[.]grails.*'
+            }
+        }
+    }
+
+    configurations.configureEach {
+        resolutionStrategy.eachDependency { DependencyResolveDetails details ->
+            if (details.requested.group == 'org.seleniumhq.selenium') {
+                details.useVersion('4.25.0')
+                details.because('Temporary workaround because of https://issues.chromium.org/issues/42323769')
+            }
+        }
     }
-}

 subprojects {
-    if (name == 'grails-redis') {
-        // This has to be applied here
-        apply plugin: 'org.apache.grails.gradle.grails-publish'
+    configurations.configureEach {
+        resolutionStrategy {
+            def cacheHours = isCiBuild || isReproducibleBuild ? 0 : 24
+            cacheDynamicVersionsFor(cacheHours, 'hours')
+            cacheChangingModulesFor(cacheHours, 'hours')
+        }
     }
 }

 apply {
-    from rootProject.layout.projectDirectory.file('gradle/rat-root-config.gradle')
+    from layout.projectDirectory.file('gradle/publish-root-config.gradle')
+    from layout.projectDirectory.file('gradle/rat-root-config.gradle')
 }
\ No newline at end of file
diff --git c/buildSrc/build.gradle i/buildSrc/build.gradle
index 97fdd9d..4d08e0c 100644
--- c/buildSrc/build.gradle
+++ i/buildSrc/build.gradle
@@ -28,13 +28,20 @@ file('../gradle.properties').withInputStream {

 repositories {
     mavenCentral()
-    maven { url = 'https://repo.grails.org/grails/core' }
-    maven { url = 'https://repository.apache.org/content/repositories/snapshots' }
+    maven { url = 'https://repo.grails.org/grails/restricted' }
+    maven { url = 'https://repository.apache.org/content/groups/snapshots' }
+    maven {
+        url = 'https://repository.apache.org/content/groups/staging'
+        content {
+            includeGroupByRegex 'org[.]apache[.]grails.*'
+        }
+    }
 }

 dependencies {
     implementation platform("org.apache.grails:grails-bom:${versions.get('grailsVersion')}")
-    implementation 'org.apache.grails:grails-gradle-plugins'
     implementation 'com.bertramlabs.plugins:asset-pipeline-gradle'
+    implementation 'org.apache.grails:grails-gradle-plugins'
     implementation "org.nosphere.apache.rat:org.nosphere.apache.rat.gradle.plugin:${versions.get('ratVersion')}"
+    implementation "org.gradle.crypto.checksum:org.gradle.crypto.checksum.gradle.plugin:${versions.get('gradleCryptoChecksumVersion')}"
 }
\ No newline at end of file
diff --git c/etc/bin/extract-build-artifact.sh i/etc/bin/extract-build-artifact.sh
new file mode 100755
index 0000000..992392b
--- /dev/null
+++ i/etc/bin/extract-build-artifact.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+#
+#  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.
+#
+set -e
+
+ARTIFACT_NAME=$1
+
+if [ -z "${ARTIFACT_NAME}" ]; then
+  echo "Usage: $0 <artifact-name>"
+  exit 1
+fi
+
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+
+if [ -z "${SCRIPT_DIR}/results/first/${ARTIFACT_NAME}" ]; then
+    echo "First Artifact Not found: $ARTIFACT_NAME could not be found under ${SCRIPT_DIR}/results/first/${ARTIFACT_NAME}"
+    exit 1;
+else
+  echo "First Artifact Found @ ${SCRIPT_DIR}/results/first/${ARTIFACT_NAME}"
+fi
+if [ -z "${SCRIPT_DIR}/results/second/${ARTIFACT_NAME}" ]; then
+    echo "Second Artifact Not found: $ARTIFACT_NAME could not be found under ${SCRIPT_DIR}/results/second/${ARTIFACT_NAME}"
+    exit 1;
+else
+  echo "Second Artifact Found @ ${SCRIPT_DIR}/results/first/${ARTIFACT_NAME}"
+fi
+
+rm -rf "${SCRIPT_DIR}/results/firstArtifact" || true
+rm -rf "${SCRIPT_DIR}/results/secondArtifact" || true
+
+unzip "${SCRIPT_DIR}/results/first/${ARTIFACT_NAME}" -d "${SCRIPT_DIR}/results/firstArtifact"
+unzip "${SCRIPT_DIR}/results/second/${ARTIFACT_NAME}" -d "${SCRIPT_DIR}/results/secondArtifact"
diff --git c/etc/bin/generate-build-artifact-hashes.groovy i/etc/bin/generate-build-artifact-hashes.groovy
new file mode 100755
index 0000000..43a148f
--- /dev/null
+++ i/etc/bin/generate-build-artifact-hashes.groovy
@@ -0,0 +1,84 @@
+#!/usr/bin/env groovy
+/*
+ *  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.
+ */
+import java.nio.file.*
+import java.security.MessageDigest
+
+// ---------------------------------------------------------------------------
+String sha512(Path file) {
+    MessageDigest md = MessageDigest.getInstance('SHA-512')
+    file.withInputStream { is ->
+        byte[] buf = new byte[8192]
+        for (int r = is.read(buf); r > 0; r = is.read(buf))
+            md.update(buf, 0, r)
+    }
+    md.digest().collect { String.format('%02x', it) }.join()
+}
+
+Path scriptDir = Paths.get(getClass()
+        .protectionDomain
+        .codeSource
+        .location
+        .toURI())
+        .toAbsolutePath()
+        .parent
+
+
+Path root = scriptDir.resolve('..').resolve('..').normalize()
+if(args && args.length > 0) {
+    System.out.println("Finding jars in: ${args[0]}" as String)
+    root = Paths.get(args[0]).toAbsolutePath().normalize()
+}
+
+// ---------------------------------------------------------------------------
+// Decide where to search: project root by default, or user-supplied path
+// (absolute or relative to project root) when an argument is given.
+Path scanRoot
+if (this.args && this.args.length > 0) {
+    Path argPath = Paths.get(this.args[0])
+    scanRoot = argPath.isAbsolute() ? argPath : root.resolve(argPath).normalize()
+    if (!Files.exists(scanRoot)) {
+        System.err.println "❌  Path '${scanRoot}' does not exist."
+        System.exit(1)
+    }
+} else {
+    scanRoot = root
+}
+List<Path> artifacts = []
+Files.walk(scanRoot)
+            .filter {
+                Files.isRegularFile(it) &&
+                        !it.toString().contains("buildSrc") &&
+                        !it.toString().contains("etc") &&
+                        it.toString().endsWith('.jar') &&
+                        it.toString().contains("${File.separator}build${File.separator}libs${File.separator}" as String)
+            }
+            .forEach { artifacts << it }
+
+artifacts.findAll {
+    !it.toString().contains("${File.separator}buildSrc${File.separator}" as String) // build src jars aren't published
+    !it.toString().contains("${File.separator}examples${File.separator}" as String) // test examples aren't published
+}.sort { a, b -> a.toString() <=> b.toString()
+}.collect { Path jar ->
+    String hash = sha512(jar)
+    String relative = root.relativize(jar).toString()
+    "${relative} ${hash}"
+}.sort().each {
+    println it
+}
\ No newline at end of file
diff --git c/etc/bin/test-reproducible-builds.sh i/etc/bin/test-reproducible-builds.sh
new file mode 100755
index 0000000..dca48ff
--- /dev/null
+++ i/etc/bin/test-reproducible-builds.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+#
+#  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.
+#
+
+# This file assumes the gnu version of coreutils is installed, which is not installed by default on a mac
+set -e
+
+export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
+
+CWD=$(pwd)
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+cd "${SCRIPT_DIR}/../.."
+
+rm -rf "${SCRIPT_DIR}/results" || true
+mkdir -p "${SCRIPT_DIR}/results"
+
+git clean -xdf --exclude='etc/bin' --exclude='.idea' --exclude='.gradle'
+killall -e java || true
+./gradlew build --rerun-tasks -PskipTests --no-build-cache
+"${SCRIPT_DIR}/generate-build-artifact-hashes.groovy" > "${SCRIPT_DIR}/results/first.txt"
+mkdir -p "${SCRIPT_DIR}/results/first"
+find . -path ./etc -prune -o -type f -path '*/build/libs/*.jar' -print0 | xargs -0 cp --parents -t "${SCRIPT_DIR}/results/first/"
+
+git clean -xdf --exclude='etc/bin' --exclude='.idea' --exclude='.gradle'
+killall -e java || true
+./gradlew build --rerun-tasks -PskipTests --no-build-cache
+"${SCRIPT_DIR}/generate-build-artifact-hashes.groovy" > "${SCRIPT_DIR}/results/second.txt"
+mkdir -p "${SCRIPT_DIR}/results/second"
+find . -path ./etc -prune -o -type f -path '*/build/libs/*.jar' -print0 | xargs -0 cp --parents -t "${SCRIPT_DIR}/results/second/"
+
+cd "${SCRIPT_DIR}/results"
+
+# diff -u first.txt second.txt
+DIFF_RESULTS=$(comm -3 first.txt second.txt | cut -d' ' -f1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | uniq | sort)
+echo "Differing artifacts:"
+echo "$DIFF_RESULTS" > diff.txt
+cat diff.txt
+
+printf '%s\n' "$DIFF_RESULTS" | sed 's|^etc/bin/results/||' > toPurge.txt
+find first -type f -name '*.jar' -print | sed 's|^first/||' | grep -F -x -v -f toPurge.txt |
+  while IFS= read -r f; do
+    rm -f "./first/$f"
+  done
+find second -type f -name '*.jar' -print | sed 's|^second/||' | grep -F -x -v -f toPurge.txt |
+  while IFS= read -r f; do
+    rm -f "./second/$f"
+  done
+rm toPurge.txt
+find . -type d -empty -delete
+cd "$CWD"
diff --git c/gradle-bootstrap/build.gradle i/gradle-bootstrap/build.gradle
new file mode 100644
index 0000000..38b2cea
--- /dev/null
+++ i/gradle-bootstrap/build.gradle
@@ -0,0 +1,60 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+def props = new Properties()
+project.rootProject.layout.projectDirectory.file('../.sdkmanrc').asFile.withInputStream {
+    props.load(it)
+}
+tasks.withType(Wrapper).configureEach {
+    gradleVersion = props.gradle
+}
+
+defaultTasks 'bootstrap'
+tasks.register('bootstrap') {
+    dependsOn 'wrapper'
+    doLast {
+        ant.copy file: "${projectDir}/gradlew", todir: "${projectDir}/../grails-forge"
+        ant.copy file: "${projectDir}/gradlew.bat", todir: "${projectDir}/../grails-forge"
+        ant.copy(todir: "${projectDir}/../grails-forge/gradle/wrapper") {
+            fileset(dir: "${projectDir}/gradle/wrapper") {
+                include(name: 'gradle-wrapper.jar')
+                include(name: 'gradle-wrapper.properties')
+            }
+        }
+        ant.chmod(file: "${projectDir}/../grails-forge/gradlew", perm: '755')
+        ant.chmod(file: "${projectDir}/../grails-forge/gradlew.bat", perm: '755')
+
+        ant.copy file: "${projectDir}/gradlew", todir: "${projectDir}/../grails-gradle"
+        ant.copy file: "${projectDir}/gradlew.bat", todir: "${projectDir}/../grails-gradle"
+        ant.copy(todir: "${projectDir}/../grails-gradle/gradle/wrapper") {
+            fileset(dir: "${projectDir}/gradle/wrapper") {
+                include(name: 'gradle-wrapper.jar')
+                include(name: 'gradle-wrapper.properties')
+            }
+        }
+        ant.chmod(file: "${projectDir}/../grails-gradle/gradlew", perm: '755')
+        ant.chmod(file: "${projectDir}/../grails-gradle/gradlew.bat", perm: '755')
+
+        ant.move file: "${projectDir}/gradlew", todir: "${projectDir}/../"
+        ant.move file: "${projectDir}/gradlew.bat", todir: "${projectDir}/../"
+        ant.move file: "${projectDir}/gradle/wrapper", todir: "${projectDir}/../gradle"
+        ant.chmod(file: "${projectDir}/../gradlew", perm: '755')
+        ant.chmod(file: "${projectDir}/../gradlew.bat", perm: '755')
+    }
+}
diff --git c/gradle-bootstrap/settings.gradle i/gradle-bootstrap/settings.gradle
new file mode 100644
index 0000000..7b9c8b3
--- /dev/null
+++ i/gradle-bootstrap/settings.gradle
@@ -0,0 +1,19 @@
+/*
+ *  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.
+ */
+
diff --git c/gradle.properties i/gradle.properties
index af0a723..21220f7 100644
--- c/gradle.properties
+++ i/gradle.properties
@@ -15,8 +15,9 @@

 projectVersion=5.0.0-SNAPSHOT

-grailsVersion=7.0.0-SNAPSHOT
+grailsVersion=7.0.0-M4
 javaVersion=17
+gradleCryptoChecksumVersion=1.4.0
 ratVersion=0.8.1

 # This prevents the Grails Gradle Plugin from unnecessarily excluding slf4j-simple in the generated POMs
diff --git c/gradle/docs-config.gradle i/gradle/docs-config.gradle
index c07671a..14765a7 100644
--- c/gradle/docs-config.gradle
+++ i/gradle/docs-config.gradle
@@ -18,13 +18,31 @@
  */

 configurations.register('documentation') {
-    extendsFrom(configurations.compileClasspath)
+    canBeConsumed = false
+    canBeResolved = true
+    attributes {
+        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
+        attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
+        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
+    }
 }

 dependencies {
+    add('documentation', platform("org.apache.grails:grails-bom:$grailsVersion"))
     add('documentation', 'com.github.javaparser:javaparser-core')
+    add('documentation', 'org.apache.groovy:groovy')
+    add('documentation', 'org.apache.groovy:groovy-groovydoc')
+    add('documentation', 'org.apache.groovy:groovy-ant')
+    add('documentation', 'org.apache.groovy:groovy-docgenerator')
+    add('documentation', 'org.apache.groovy:groovy-templates')
 }

-tasks.named('groovydoc', Groovydoc) {
-    groovyClasspath += configurations.documentation
-}
\ No newline at end of file
+tasks.withType(Groovydoc).configureEach {
+    it.access = GroovydocAccess.PROTECTED
+    it.processScripts = false
+    it.includeMainForScripts = false
+    it.includeAuthor = false
+    it.noTimestamp = true
+    it.noVersionStamp = false
+    it.groovyClasspath += configurations.documentation
+}
diff --git c/gradle/examples-config.gradle i/gradle/examples-config.gradle
index 8e1169e..e256f6d 100644
--- c/gradle/examples-config.gradle
+++ i/gradle/examples-config.gradle
@@ -19,4 +19,16 @@

 tasks.withType(Groovydoc).configureEach {
     enabled = false
+}
+
+// Workaround for parallel builds due to https://github.com/bertramdev/asset-pipeline/issues/177
+if ('assetCompile' in tasks.names) {
+    tasks.named('assetCompile') {
+        outputs.dir(rootProject.layout.buildDirectory.dir('asset-serialize'))
+    }
+}
+
+grails {
+    // turn off dependency management so we *know* we're testing with the right versions and not from a remote repository
+    springDependencyManagement = false
 }
\ No newline at end of file
diff --git c/gradle/java-config.gradle i/gradle/java-config.gradle
index 297dbd1..d21715c 100644
--- c/gradle/java-config.gradle
+++ i/gradle/java-config.gradle
@@ -17,4 +17,25 @@
  *  under the License.
  */

-compileJava.options.release = javaVersion.toInteger()
\ No newline at end of file
+compileJava.options.release = javaVersion.toInteger()
+
+extensions.configure(JavaPluginExtension)  {
+    it.withJavadocJar()
+    it.withSourcesJar()
+}
+
+tasks.withType(Javadoc).configureEach { Javadoc it ->
+    it.options.noTimestamp true // prevent the file header with the date
+    it.options.bottom "Generated ${formattedBuildDate} (UTC)"
+}
+
+// JavaCompile is not configured because we put java files inside of the groovy source sets
+
+tasks.withType(GroovyCompile).configureEach {
+    groovyOptions.encoding = 'UTF-8' // encoding needs to be the same since it's different across platforms
+    // Preserve method parameter names in Groovy classes for IDE parameter hints.
+    groovyOptions.parameters = true
+    options.encoding = 'UTF-8' // encoding needs to be the same since it's different across platforms
+    options.fork = true
+    options.forkOptions.jvmArgs = ['-Xms128M', '-Xmx2G']
+}
\ No newline at end of file
diff --git c/gradle/publish-config.gradle i/gradle/publish-config.gradle
index 2c35d3f..bc2afc2 100644
--- c/gradle/publish-config.gradle
+++ i/gradle/publish-config.gradle
@@ -17,6 +17,7 @@
  *  under the License.
  */

+import org.gradle.crypto.checksum.Checksum
 import org.grails.gradle.plugin.publishing.GrailsPublishExtension

 extensions.configure(GrailsPublishExtension) {
@@ -41,3 +42,82 @@ extensions.configure(GrailsPublishExtension) {
             puneetbehl: 'Puneet Behl'
     ]
 }
+
+
+afterEvaluate {
+    if (project.plugins.hasPlugin('signing')) {
+        if(System.getenv('TEST_BUILD_REPRODUCIBLE')) {
+            project.logger.lifecycle("Signing is disabled for this build to test build reproducibility.")
+            project.tasks.withType(Sign).configureEach {
+                it.enabled = false
+            }
+        }
+    }
+    if (project.plugins.hasPlugin("maven-publish")) {
+        def checksumTask = tasks.register("publishedChecksums", Checksum)
+        checksumTask.configure { Checksum check ->
+            check.checksumAlgorithm = Checksum.Algorithm.SHA512
+            check.outputDirectory.set(project.layout.buildDirectory.dir("checksums"))
+            check.dependsOn(tasks.withType(Jar))
+        }
+
+        def artifactsDir = project.layout.buildDirectory.dir("artifacts")
+        def artifactsTask = tasks.register("savePublishedArtifacts") {
+            it.outputs.dir(artifactsDir)
+            it.dependsOn(tasks.withType(Jar))
+        }
+
+        gradle.taskGraph.whenReady {
+            List filesToChecksum = []
+            publishing.publications.withType(MavenPublication).all { MavenPublication publication ->
+                publication.artifacts.each { MavenArtifact artifact ->
+                    if(artifact.file.name == 'grails-plugin.xml' || artifact.file.name == 'profile.yml') {
+                        return
+                    }
+                    filesToChecksum << artifact.file
+                }
+            }
+
+            checksumTask.configure { Checksum check ->
+                check.inputFiles.setFrom(filesToChecksum.unique())
+                check.finalizedBy(artifactsTask)
+
+            }
+
+            artifactsTask.configure {
+                doLast {
+                    Map<String, String> artifacts = [:]
+                    project.publishing.publications.withType(MavenPublication).all { MavenPublication publication ->
+                        publication.artifacts.each { MavenArtifact artifact ->
+                            if(!artifact.file.exists() || artifact.file.name == 'grails-plugin.xml' || artifact.file.name == 'profile.yml') {
+                                return
+                            }
+
+                            if(artifact.classifier) {
+                                artifacts[artifact.file.name] = "${publication.groupId}:${publication.artifactId}:${publication.version}:${artifact.classifier}" as String
+                            }
+                            else {
+                                artifacts[artifact.file.name] = "${publication.groupId}:${publication.artifactId}:${publication.version}" as String
+                            }
+                        }
+                    }
+
+                    File artifactsFile = artifactsDir.get().asFile
+                    artifactsFile.mkdirs()
+
+                    artifacts.entrySet().each { Map.Entry<String, String> entry ->
+                        File published = new File(artifactsFile, "${entry.key}.txt" as String)
+                        published.text = entry.value
+                    }
+                }
+            }
+        }
+
+        Set<String> publishTasks = tasks.names.findAll { it.startsWith('publishMavenPublication') }
+        publishTasks.each { taskName ->
+            tasks.named(taskName).configure { publishTask ->
+                publishTask.finalizedBy checksumTask
+            }
+        }
+    }
+}
diff --git c/gradle/publish-root-config.gradle i/gradle/publish-root-config.gradle
new file mode 100644
index 0000000..675e8ba
--- /dev/null
+++ i/gradle/publish-root-config.gradle
@@ -0,0 +1,107 @@
+/*
+ *  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.
+ */
+
+// Workaround needed for nexus publishing bug
+// version and group must be specified in the root project
+// https://github.com/gradle-nexus/publish-plugin/issues/310
+version = projectVersion
+group = 'this.will.be.overridden'
+
+def publishedProjects = [
+        'grails-redis'
+]
+subprojects {
+    version = projectVersion
+    if (name in publishedProjects) {
+        apply plugin: 'org.apache.grails.gradle.grails-publish'
+    }
+}
+
+def aggregatePublishedArtifacts = tasks.register('aggregatePublishedArtifacts')
+
+tasks.register("aggregateChecksums").configure {
+    group = "publishing"
+    description = "Aggregates all SHA-256 checksums from subprojects into a single file."
+
+    def outputFileProvider = rootProject.layout.buildDirectory.file("CHECKSUMS.txt")
+    outputs.file(outputFileProvider)
+
+    dependsOn(subprojects.findResults {it.tasks.names.contains('publishedChecksums') ? "${it.path}:publishedChecksums" : null })
+    finalizedBy(aggregatePublishedArtifacts)
+
+    outputs.upToDateWhen { false } // not worth caching
+
+    doLast {
+        def outputFile = outputFileProvider.get().asFile
+        outputFile.withPrintWriter { writer ->
+            subprojects.each { sub ->
+                def checksumDir = sub.layout.buildDirectory.dir("checksums").get().asFile
+                if (checksumDir.exists()) {
+                    checksumDir.listFiles(new FilenameFilter() {
+                        boolean accept(File dir, String name) {
+                            return name.endsWith(".sha512")
+                        }
+                    })?.each { checksumFile ->
+                        def jarName = checksumFile.name - ".sha512"
+                        def checksumLine = checksumFile.text.trim()
+                        def checksum = checksumLine.tokenize()[0]
+                        writer.println("${jarName} ${checksum}")
+                    }
+                }
+            }
+        }
+
+        println "Checksum manifest written to ${outputFile}"
+    }
+}
+
+aggregatePublishedArtifacts.configure {
+    group = "publishing"
+    description = "Aggregates all published artifacts from subprojects into a single file."
+
+    def outputFileProvider = rootProject.layout.buildDirectory.file("PUBLISHED_ARTIFACTS.txt")
+    outputs.file(outputFileProvider)
+
+    outputs.upToDateWhen { false } // not worth caching
+
+    dependsOn(subprojects.findResults {it.tasks.names.contains('savePublishedArtifacts') ? "${it.path}:savePublishedArtifacts" : null })
+
+    doLast {
+        def outputFile = outputFileProvider.get().asFile
+        outputFile.text = "" // clear previous
+        outputFile.withPrintWriter { writer ->
+            subprojects.each { sub ->
+                def artifactsDir = sub.layout.buildDirectory.dir("artifacts").get().asFile
+                if (artifactsDir.exists()) {
+                    artifactsDir.listFiles(new FilenameFilter() {
+                        boolean accept(File dir, String name) {
+                            return name.endsWith(".txt")
+                        }
+                    })?.each { checksumFile ->
+                        def artifactName = checksumFile.name - ".txt"
+                        def coordinates = checksumFile.text.trim()
+                        writer.println("${artifactName} ${coordinates}")
+                    }
+                }
+            }
+        }
+
+        println "Published artifacts written to ${outputFile}"
+    }
+}
\ No newline at end of file
diff --git c/gradle/rat-root-config.gradle i/gradle/rat-root-config.gradle
index 99be184..47e23a7 100644
--- c/gradle/rat-root-config.gradle
+++ i/gradle/rat-root-config.gradle
@@ -17,8 +17,8 @@

 apply plugin: 'org.nosphere.apache.rat'

-tasks.named('rat') {
-    excludes = [
+tasks.named('rat').configure {
+    it.excludes = [
             '.asf.yaml', // ASF metadata for github integration excluded from src zip
             'CODE_OF_CONDUCT.md',
             'LICENSE',
@@ -30,7 +30,6 @@ tasks.named('rat') {
             '**/build/**', // Gradle generated build directories
             '**/.gitattributes', // git configuration isn't code
             '**/.gradle/**', '**/wrapper/**', 'gradlew*',  // gradle wrapper files excluded from src zip
-            '**/*.html', // html files are only in test
             '**/resources/*', // exclude test artifacts
             'out/**', '*.ipr', '**/*.iml', '*.iws', '.idea/**', // Intellij generated files
             'src/*', // exclude build artifacts
@@ -39,6 +38,8 @@ tasks.named('rat') {
             '**/.gitkeep', // git configuration isn't code
             'etc/bin/results/**', // exclude build directories
             '**/*.png', '**/*.svg', '**/*.ico', '**/*.eps', '**/*.icns', '**/*.jpg', '**/*.jpeg', '**/*.gif', // Image files
+            '**/*.db', // H2 database test files
+            '**/*.gitkeep', // Empty Gitkeep file
     ]
     // never cache license audits
     it.outputs.upToDateWhen { false }
diff --git c/gradle/reproducible-config.gradle i/gradle/reproducible-config.gradle
new file mode 100644
index 0000000..b0f891c
--- /dev/null
+++ i/gradle/reproducible-config.gradle
@@ -0,0 +1,32 @@
+/*
+ *  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.
+ */
+
+// Any jar, zip, or archive should be reproducible
+// No longer needed after https://github.com/gradle/gradle/issues/30871
+tasks.withType(AbstractArchiveTask).configureEach {
+    preserveFileTimestamps = false // to prevent timestamp mismatches
+    reproducibleFileOrder = true // to keep the same ordering
+    // to avoid platform specific defaults, set the permissions consistently
+    filePermissions { permissions ->
+        permissions.unix(0644)
+    }
+    dirPermissions { permissions ->
+        permissions.unix(0755)
+    }
+}
\ No newline at end of file
diff --git c/gradle/test-config.gradle i/gradle/test-config.gradle
index 4d0eb56..b3c0a93 100644
--- c/gradle/test-config.gradle
+++ i/gradle/test-config.gradle
@@ -18,13 +18,24 @@
  */

 dependencies {
-    add('testRuntimeOnly', 'org.junit.platform:junit-platform-launcher') // Gradle 9+ requires this
+    // In Gradle 9, this needs to be declared
+    // https://docs.gradle.org/8.3/userguide/upgrading_version_8.html#test_framework_implementation_dependencies
+    add('testRuntimeOnly', 'org.junit.platform:junit-platform-launcher')
 }

 tasks.withType(Test).configureEach {
+    onlyIf {
+        ![
+                'skipTests'
+        ].find {
+            project.hasProperty(it)
+        }
+    }
+
     useJUnitPlatform()
     testLogging {
         exceptionFormat = 'full'
         events 'passed', 'skipped', 'failed'//, 'standardOut', 'standardError'
     }
+    beforeTest { descriptor -> logger.quiet(" -- $descriptor") }
 }
diff --git c/plugin/build.gradle i/plugin/build.gradle
index ad3d455..e43bdcc 100644
--- c/plugin/build.gradle
+++ i/plugin/build.gradle
@@ -69,4 +69,5 @@ apply {
     from rootProject.layout.projectDirectory.file('gradle/java-config.gradle')
     from rootProject.layout.projectDirectory.file('gradle/publish-config.gradle')
     from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
+    from rootProject.layout.projectDirectory.file('gradle/reproducible-config.gradle')
 }
\ No newline at end of file
diff --git c/settings.gradle i/settings.gradle
index 299c972..4d11dee 100644
--- c/settings.gradle
+++ i/settings.gradle
@@ -24,6 +24,13 @@ plugins {

 def isCI = System.getenv().containsKey('CI')
 def isLocal = !isCI
+def isReproducibleBuild = System.getenv("SOURCE_DATE_EPOCH") != null
+if(isReproducibleBuild) {
+    gradle.settingsEvaluated {
+        logger.warn("*************** Remote Build Cache Disabled due to Reproducible Build ********************")
+        logger.warn("Build date will be set to (SOURCE_DATE_EPOCH=${System.getenv("SOURCE_DATE_EPOCH")})")
+    }
+}

 develocity {
     server = 'https://ge.grails.org'
@@ -36,10 +43,10 @@ develocity {
 }

 buildCache {
-    local { enabled = isLocal }
+    local { enabled = (isLocal && !isReproducibleBuild) || (isCI && isReproducibleBuild) }
     remote(develocity.buildCache) {
         push = isCI
-        enabled = true
+        enabled = !isReproducibleBuild
     }
 }
@jamesfredley jamesfredley changed the title Licence updates License updates Jun 9, 2025
@jamesfredley jamesfredley merged commit f589b46 into 5.0.x Jun 9, 2025
6 checks passed
@jamesfredley jamesfredley deleted the licence-updates branch June 9, 2025 23:08
@github-project-automation github-project-automation bot moved this from In Progress to Done in Apache Grails Jun 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

2 participants