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 2025/08/04 19:23:26 UTC

(grails-core) 01/01: fix: #14951 - make groovy extension merge for shadowJar reproducible

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

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

commit 815a39be30c1a3326f200bb69b9c1a2f58d37df9
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Mon Aug 4 15:23:16 2025 -0400

    fix: #14951 - make groovy extension merge for shadowJar reproducible
---
 grails-common/build.gradle                         |   2 +
 .../common/properties/PropertyFileUtils.groovy     |  57 --------
 .../GlobalGrailsClassInjectorTransformation.groovy |   2 +-
 grails-forge/buildSrc/build.gradle                 |   1 +
 .../GrailsGroovyExtensionTransformer.groovy        |  96 ++++++++++++++
 grails-forge/grails-cli/build.gradle               |   8 +-
 .../common}/build.gradle                           |  37 +++---
 .../grails/gradle/common/PropertyFileUtils.groovy  |  80 ++++++++++++
 .../gradle/common/PropertyFileUtilsSpec.groovy     | 144 +++++++++++++++++++++
 grails-gradle/gradle/publish-root-config.gradle    |   3 +-
 grails-gradle/plugins/build.gradle                 |   1 +
 .../gradle/plugin/core/GrailsGradlePlugin.groovy   |  31 +----
 grails-gradle/settings.gradle                      |   3 +
 .../grails/gsp/compiler/GroovyPageCompiler.groovy  |   2 +-
 14 files changed, 358 insertions(+), 109 deletions(-)

diff --git a/grails-common/build.gradle b/grails-common/build.gradle
index 3dadf85c54..1823888a0b 100644
--- a/grails-common/build.gradle
+++ b/grails-common/build.gradle
@@ -27,6 +27,8 @@ group = 'org.apache.grails.common'
 dependencies {
     implementation platform(project(':grails-bom'))
 
+    api 'org.apache.grails.gradle.common:grails-gradle-common'
+
     // WARNING: this file is not to depend on grails-core; it is meant to be a shared library that can be used
     // independently of Grails.  Mainly to share data between Grails & GORM
 
diff --git a/grails-common/src/main/groovy/org/apache/grails/common/properties/PropertyFileUtils.groovy b/grails-common/src/main/groovy/org/apache/grails/common/properties/PropertyFileUtils.groovy
deleted file mode 100644
index 018add755c..0000000000
--- a/grails-common/src/main/groovy/org/apache/grails/common/properties/PropertyFileUtils.groovy
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- *  Licensed to the Apache Software Foundation (ASF) under one or more
- *  contributor license agreements.  See the NOTICE file distributed with
- *  this work for additional information regarding copyright ownership.
- *  The ASF licenses this file to You under the Apache License, Version 2.0
- *  (the "License"); you may not use this file except in compliance with
- *  the License.  You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing, software
- *  distributed under the License is distributed on an "AS IS" BASIS,
- *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- *  See the License for the specific language governing permissions and
- *  limitations under the License.
- */
-package org.apache.grails.common.properties
-
-import java.nio.charset.StandardCharsets
-import java.time.LocalDate
-import java.time.ZoneOffset
-import java.util.regex.Pattern
-
-final class PropertyFileUtils {
-
-    private PropertyFileUtils() {
-        // prevent  instantiation
-    }
-
-    // TODO: this is copied from grails-common, but we should consider moving grails-common up to grails-gradle once
-    // the publish plugin is moved out of grails-core
-    static void makePropertiesFileReproducible(File factoriesFile) {
-        String sourceDateEpoch = System.getenv('SOURCE_DATE_EPOCH')
-        if (!sourceDateEpoch) {
-            sourceDateEpoch = LocalDate.now(ZoneOffset.UTC)
-                    .atStartOfDay(ZoneOffset.UTC)
-                    .toEpochSecond().toString()
-        }
-
-        Pattern timeRegex = Pattern.compile('^#(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)(?:,|\\s).*$')
-
-        List<String> lines = factoriesFile.readLines(StandardCharsets.ISO_8859_1.name())
-
-        boolean dateReplaced = false
-        factoriesFile.withWriter { BufferedWriter writer ->
-            lines.each { String line ->
-                if (!dateReplaced && timeRegex.matcher(line).matches()) {
-                    dateReplaced = true
-                    writer.writeLine("# SOURCE_DATE_EPOCH = ${sourceDateEpoch}" as String)
-                    return
-                }
-
-                writer.writeLine(line)
-            }
-        }
-    }
-}
diff --git a/grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy b/grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy
index db424c91f2..4b71658b2c 100644
--- a/grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy
+++ b/grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy
@@ -34,7 +34,7 @@ import groovy.xml.StreamingMarkupBuilder
 import groovy.xml.XmlSlurper
 import groovy.xml.slurpersupport.GPathResult
 import org.apache.grails.common.compiler.GroovyTransformOrder
-import org.apache.grails.common.properties.PropertyFileUtils
+import org.apache.grails.gradle.common.PropertyFileUtils
 import org.codehaus.groovy.ast.ASTNode
 import org.codehaus.groovy.ast.AnnotationNode
 import org.codehaus.groovy.ast.ClassHelper
diff --git a/grails-forge/buildSrc/build.gradle b/grails-forge/buildSrc/build.gradle
index d43981885c..9446ec85f3 100644
--- a/grails-forge/buildSrc/build.gradle
+++ b/grails-forge/buildSrc/build.gradle
@@ -95,6 +95,7 @@ dependencies {
     implementation "org.antlr:antlr4-runtime:$antlr4Version"
     implementation "org.gradle.crypto.checksum:org.gradle.crypto.checksum.gradle.plugin:$gradleChecksumPluginVersion"
     implementation "org.apache.ant:ant:$antVersion"
+    implementation "org.apache.grails.gradle.common:grails-gradle-common:$projectVersion"
 }
 
 gradlePlugin {
diff --git a/grails-forge/buildSrc/src/main/groovy/org/apache/grails/forge/buildlogic/shadowjar/GrailsGroovyExtensionTransformer.groovy b/grails-forge/buildSrc/src/main/groovy/org/apache/grails/forge/buildlogic/shadowjar/GrailsGroovyExtensionTransformer.groovy
new file mode 100644
index 0000000000..3622fa483c
--- /dev/null
+++ b/grails-forge/buildSrc/src/main/groovy/org/apache/grails/forge/buildlogic/shadowjar/GrailsGroovyExtensionTransformer.groovy
@@ -0,0 +1,96 @@
+package org.apache.grails.forge.buildlogic.shadowjar
+
+import com.github.jengelman.gradle.plugins.shadow.transformers.Transformer
+import com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext
+import groovy.transform.CompileStatic
+import org.apache.grails.gradle.common.PropertyFileUtils
+import org.apache.tools.zip.ZipEntry
+import org.apache.tools.zip.ZipOutputStream
+import org.gradle.api.file.FileTreeElement
+
+/**
+ * Based on com.github.jengelman.gradle.plugins.shadow.transformers.GroovyExtensionModuleTransformer but with
+ * reproducible property file support
+ */
+@CompileStatic
+class GrailsGroovyExtensionTransformer implements Transformer {
+
+    private static final String GROOVY_EXTENSION_MODULE_DESCRIPTOR_PATH = "META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule"
+
+    private static final String MODULE_NAME_KEY = 'moduleName'
+    private static final String MODULE_VERSION_KEY = 'moduleVersion'
+    private static final String EXTENSION_CLASSES_KEY = 'extensionClasses'
+    private static final String STATIC_EXTENSION_CLASSES_KEY = 'staticExtensionClasses'
+
+    private static final String MERGED_MODULE_NAME = 'MergedByGrailsShadowJar'
+    private static final String MERGED_MODULE_VERSION = '1.0.0'
+
+    private final Properties module = new Properties()
+
+    @Override
+    boolean canTransformResource(FileTreeElement element) {
+        def path = element.relativePath.pathString
+        path == GROOVY_EXTENSION_MODULE_DESCRIPTOR_PATH
+    }
+
+    @Override
+    void transform(TransformerContext context) {
+        def props = new Properties()
+        props.load(context.is)
+        props.entrySet().each { Map.Entry<Object, Object> entry ->
+            String key = entry.key as String
+            String value = entry.value as String
+            switch (key) {
+                case MODULE_NAME_KEY:
+                    handle(key, value) {
+                        module.setProperty(key, MERGED_MODULE_NAME)
+                    }
+                    break
+                case MODULE_VERSION_KEY:
+                    handle(key, value) {
+                        module.setProperty(key, MERGED_MODULE_VERSION)
+                    }
+                    break
+                case [EXTENSION_CLASSES_KEY, STATIC_EXTENSION_CLASSES_KEY]:
+                    handle(key, value) { String existingValue ->
+                        def newValue = "${existingValue},${value}"
+                        module.setProperty(key, newValue)
+                    }
+                    break
+            }
+        }
+    }
+
+    private handle(String key, String value, Closure mergeValue) {
+        def existingValue = module.getProperty(key)
+        if (existingValue) {
+            mergeValue(existingValue)
+        } else {
+            module.setProperty(key, value)
+        }
+    }
+
+    @Override
+    boolean hasTransformedResource() {
+        return module.size() > 0
+    }
+
+    @Override
+    void modifyOutputStream(ZipOutputStream os, boolean preserveFileTimestamps) {
+        ZipEntry entry = new ZipEntry(GROOVY_EXTENSION_MODULE_DESCRIPTOR_PATH)
+        entry.time = TransformerContext.getEntryTimestamp(preserveFileTimestamps, entry.time)
+        os.putNextEntry(entry)
+
+        os.write()
+        try (InputStream inputStream = toInputStream(module)) {
+            inputStream.transferTo(os)
+        }
+        os.closeEntry()
+    }
+
+    private static InputStream toInputStream(Properties props) {
+        def baos = new ByteArrayOutputStream()
+        props.store(baos, null)
+        return PropertyFileUtils.makePropertiesOutputReproducible(baos)
+    }
+}
diff --git a/grails-forge/grails-cli/build.gradle b/grails-forge/grails-cli/build.gradle
index 3d6e707de6..a48763bb71 100644
--- a/grails-forge/grails-cli/build.gradle
+++ b/grails-forge/grails-cli/build.gradle
@@ -15,6 +15,7 @@
  *  limitations under the License.
  */
 import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+import org.apache.grails.forge.buildlogic.shadowjar.GrailsGroovyExtensionTransformer
 import org.apache.grails.forge.buildlogic.shadowjar.GrailsShadowLicenseTransform
 import org.apache.grails.forge.buildlogic.shadowjar.GrailsShadowNoticeTransform
 
@@ -100,8 +101,9 @@ shadowJarTask.configure { ShadowJar it ->
     it.mergeServiceFiles()
     it.mergeServiceFiles('META-INF/grails.factories')
 
-    // groovy extensions are shipped with grails-core
-    it.mergeGroovyExtensionModules()
+    // groovy extensions are shipped with grails-core, use our own transformer instead of mergeGroovyExtensionModules()
+    // for property file reproducibility
+    it.transform(GrailsGroovyExtensionTransformer)
 
     // combines all NOTICE files
     it.transform(GrailsShadowNoticeTransform) {
@@ -117,6 +119,8 @@ shadowJarTask.configure { ShadowJar it ->
     it.exclude(
             'DISCLAIMER', // because we include our own jars, we need to exclude any files that we include
             'META-INF/DEPENDENCIES', // until we publish our own SBOM, this won't be correct so exclude
+            'META-INF/grails-plugin.xml', // we do not start or compile a grails application so these files are not needed (grails-core, url mappings, etc plugins)
+            'META-INF/grails-plugin.xml.asc', // avoid signing artifacts
             'about.html' // restatement of the Eclipse Distribution License - Version 1.0 for jakarta
     )
 }
diff --git a/grails-common/build.gradle b/grails-gradle/common/build.gradle
similarity index 66%
copy from grails-common/build.gradle
copy to grails-gradle/common/build.gradle
index 3dadf85c54..a306925d4f 100644
--- a/grails-common/build.gradle
+++ b/grails-gradle/common/build.gradle
@@ -18,36 +18,37 @@
 plugins {
     id 'groovy'
     id 'java-library'
-    id 'project-report'
 }
 
 version = projectVersion
-group = 'org.apache.grails.common'
+group = 'org.apache.grails.gradle.common'
 
-dependencies {
-    implementation platform(project(':grails-bom'))
-
-    // WARNING: this file is not to depend on grails-core; it is meant to be a shared library that can be used
-    // independently of Grails.  Mainly to share data between Grails & GORM
+ext {
+    pomTitle = 'Grails Gradle Common'
+    pomDescription = 'Various common utilities that are shared between Grails Gradle code and the Grails Framework'
+    pomDevelopers = ['jdaugherty': 'James Daugherty']
+}
 
-    // the annotation metadata readers are used by both GORM & grails-core so it can be placed in a shared build
-    implementation 'org.springframework:spring-core'
+dependencies {
+    compileOnly platform(project(':grails-gradle-bom'))
 
-    api 'org.apache.groovy:groovy'
+    // compile with the Groovy version provided by Gradle
+    // to ensure build compatibility with Gradle, currently Groovy 3.0.x
+    // see: https://docs.gradle.org/current/userguide/compatibility.html#groovy
+    compileOnly 'org.codehaus.groovy:groovy'
 
-    testImplementation 'org.apache.groovy:groovy-test-junit5'
+    // Testing
+    testImplementation platform(project(':grails-gradle-bom'))
+    testImplementation 'org.slf4j:slf4j-simple'
+    testImplementation('org.spockframework:spock-core') { transitive = false }
+    testImplementation 'org.codehaus.groovy:groovy-test-junit5'
     testImplementation 'org.junit.jupiter:junit-jupiter-api'
     testImplementation 'org.junit.platform:junit-platform-runner'
 
     testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
 
-    // Logging
-    api 'org.slf4j:slf4j-api'
-    api 'org.slf4j:jcl-over-slf4j'
-
-    // Testing
-    testImplementation 'org.slf4j:slf4j-simple'
-    testImplementation('org.spockframework:spock-core') { transitive = false }
+    // for easier setting of environment variables in tests
+    testImplementation 'uk.org.webcompere:system-stubs-core:2.1.8'
 }
 
 apply {
diff --git a/grails-gradle/common/src/main/groovy/org/apache/grails/gradle/common/PropertyFileUtils.groovy b/grails-gradle/common/src/main/groovy/org/apache/grails/gradle/common/PropertyFileUtils.groovy
new file mode 100644
index 0000000000..1ca3b9b76f
--- /dev/null
+++ b/grails-gradle/common/src/main/groovy/org/apache/grails/gradle/common/PropertyFileUtils.groovy
@@ -0,0 +1,80 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.apache.grails.gradle.common
+
+import groovy.transform.CompileStatic
+import org.codehaus.groovy.runtime.IOGroovyMethods
+
+import java.nio.charset.StandardCharsets
+import java.time.LocalDate
+import java.time.ZoneOffset
+import java.util.regex.Pattern
+
+@CompileStatic
+final class PropertyFileUtils {
+    private final static Pattern TIME_REGEX = Pattern.compile('^#(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)(?:,|\\s).*$')
+
+    private PropertyFileUtils() {
+        // prevent  instantiation
+    }
+
+    static ByteArrayInputStream makePropertiesOutputReproducible(ByteArrayOutputStream propertyOutputStream) {
+        List<String> lines = propertyOutputStream.toString(StandardCharsets.ISO_8859_1.name()).readLines()
+
+        ByteArrayOutputStream toWrite = new ByteArrayOutputStream()
+        toWrite.withWriter(StandardCharsets.ISO_8859_1.name()) { Writer writer ->
+            BufferedWriter bufferedWriter = new BufferedWriter(writer)
+            makePropertyLinesReproducible(lines, bufferedWriter)
+            bufferedWriter.flush()
+        }
+
+        new ByteArrayInputStream(toWrite.toByteArray())
+    }
+
+    static void makePropertiesFileReproducible(File factoriesFile) {
+        List<String> lines = factoriesFile.readLines(StandardCharsets.ISO_8859_1.name())
+
+        factoriesFile.withWriter(StandardCharsets.ISO_8859_1.name()) { BufferedWriter writer ->
+            makePropertyLinesReproducible(lines, writer)
+        }
+    }
+
+    private static void makePropertyLinesReproducible(List<String> lines, BufferedWriter writer) {
+        String sourceDateEpoch = determineSourceDateEpoch()
+
+        boolean dateReplaced = false
+        lines.each { String line ->
+            if (!dateReplaced && TIME_REGEX.matcher(line).matches()) {
+                dateReplaced = true
+                IOGroovyMethods.writeLine(writer, "# SOURCE_DATE_EPOCH = ${sourceDateEpoch}" as String)
+                return
+            }
+
+            writer.writeLine(line)
+        }
+    }
+
+    private static String determineSourceDateEpoch() {
+        String sourceDateEpoch = System.getenv('SOURCE_DATE_EPOCH')
+        if (!sourceDateEpoch) {
+            sourceDateEpoch = LocalDate.now(ZoneOffset.UTC)
+                    .atStartOfDay(ZoneOffset.UTC)
+                    .toEpochSecond().toString()
+        }
+        sourceDateEpoch
+    }
+}
diff --git a/grails-gradle/common/src/test/groovy/org/apache/grails/gradle/common/PropertyFileUtilsSpec.groovy b/grails-gradle/common/src/test/groovy/org/apache/grails/gradle/common/PropertyFileUtilsSpec.groovy
new file mode 100644
index 0000000000..8020e763b5
--- /dev/null
+++ b/grails-gradle/common/src/test/groovy/org/apache/grails/gradle/common/PropertyFileUtilsSpec.groovy
@@ -0,0 +1,144 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.apache.grails.gradle.common
+
+import spock.lang.Specification
+import uk.org.webcompere.systemstubs.SystemStubs
+
+import java.nio.charset.StandardCharsets
+import java.time.LocalDate
+import java.time.ZoneOffset
+
+class PropertyFileUtilsSpec extends Specification {
+
+    def 'file - replaces first timestamp comment with default SOURCE_DATE_EPOCH'() {
+        given:
+        File file = File.createTempFile('props', '.properties')
+        file.write([
+                '# comment before',
+                '#Fri Jan 01 00:00:00 UTC 1970',
+                'key1=value1',
+                '#Sat Mar 03 15:00:00 UTC 2001'
+        ].join('\n'), StandardCharsets.ISO_8859_1.name())
+
+        when:
+        PropertyFileUtils.makePropertiesFileReproducible(file)
+
+        then:
+        List<String> lines = file.readLines(StandardCharsets.ISO_8859_1.name())
+        String expected = LocalDate.now(ZoneOffset.UTC)
+                .atStartOfDay(ZoneOffset.UTC)
+                .toEpochSecond()
+                .toString()
+        lines[1] == "# SOURCE_DATE_EPOCH = ${expected}"
+        // later timestamp is untouched
+        lines.contains('#Sat Mar 03 15:00:00 UTC 2001')
+
+        cleanup:
+        file?.delete()
+    }
+
+    def 'file - preserves content when no timestamp comment is present'() {
+        given:
+        File file = File.createTempFile('notimestamp', '.properties')
+        file.deleteOnExit()
+        file.write('fuz=buz\nweb=foo', StandardCharsets.ISO_8859_1.name())
+
+        when:
+        PropertyFileUtils.makePropertiesFileReproducible(file)
+
+        then:
+        file.readLines(StandardCharsets.ISO_8859_1.name()) == ['fuz=buz', 'web=foo']
+    }
+
+    def 'file - uses SOURCE_DATE_EPOCH env var when set'() {
+        given:
+        File file = File.createTempFile('envprops', '.properties')
+        file.deleteOnExit()
+        file.write('#Mon Apr 04 04:04:04 UTC 2004\nhello=world', StandardCharsets.ISO_8859_1.name())
+
+        when:
+        SystemStubs.withEnvironmentVariable('SOURCE_DATE_EPOCH', '42424242').execute {
+            PropertyFileUtils.makePropertiesFileReproducible(file)
+        }
+
+        then:
+        file.readLines(StandardCharsets.ISO_8859_1.name())[0] == '# SOURCE_DATE_EPOCH = 42424242'
+    }
+
+    def 'outputstream - replaces first timestamp comment with default SOURCE_DATE_EPOCH'() {
+        given:
+        ByteArrayOutputStream baos = new ByteArrayOutputStream()
+        baos.write([
+                '# comment before',
+                '#Fri Jan 01 00:00:00 UTC 1970',
+                'key1=value1',
+                '#Sat Mar 03 15:00:00 UTC 2001'
+        ].join('\n').getBytes(StandardCharsets.ISO_8859_1.name()))
+
+        when:
+        ByteArrayInputStream result = PropertyFileUtils.makePropertiesOutputReproducible(baos)
+        String output = new String(result.readAllBytes(), StandardCharsets.ISO_8859_1.name())
+        List<String> lines = output.readLines()
+
+        then:
+        String expected = LocalDate.now(ZoneOffset.UTC)
+                .atStartOfDay(ZoneOffset.UTC)
+                .toEpochSecond()
+                .toString()
+        lines[1] == "# SOURCE_DATE_EPOCH = ${expected}"
+        // later timestamp is untouched
+        lines.contains('#Sat Mar 03 15:00:00 UTC 2001')
+    }
+
+    def 'outputstream - uses SOURCE_DATE_EPOCH env var when set'() {
+        given:
+        ByteArrayOutputStream baos = new ByteArrayOutputStream()
+        baos.write([
+                '#Tue Apr 02 02:02:02 UTC 2002',
+                'testing=another'
+        ].join('\n').getBytes(StandardCharsets.ISO_8859_1.name()))
+
+        when:
+        ByteArrayInputStream result = null
+        SystemStubs.withEnvironmentVariable('SOURCE_DATE_EPOCH', '42424242').execute {
+            result = PropertyFileUtils.makePropertiesOutputReproducible(baos)
+        }
+        List<String> lines = new String(
+                result.readAllBytes(),
+                StandardCharsets.ISO_8859_1.name()
+        ).readLines()
+
+        then:
+        lines[0] == '# SOURCE_DATE_EPOCH = 42424242'
+        lines[1] == 'testing=another'
+    }
+
+    def 'outputstream - leaves content unchanged when no timestamp comment present'() {
+        given:
+        ByteArrayOutputStream baos = new ByteArrayOutputStream()
+        baos.write('alpha=one\nbeta=two\n'.getBytes(StandardCharsets.ISO_8859_1.name()))
+
+        when:
+        ByteArrayInputStream result = PropertyFileUtils.makePropertiesOutputReproducible(baos)
+        String output = new String(result.readAllBytes(), StandardCharsets.ISO_8859_1.name())
+
+        then:
+        output == 'alpha=one\nbeta=two\n'
+    }
+}
+
diff --git a/grails-gradle/gradle/publish-root-config.gradle b/grails-gradle/gradle/publish-root-config.gradle
index bd6642b6dc..16a23ec5c3 100644
--- a/grails-gradle/gradle/publish-root-config.gradle
+++ b/grails-gradle/gradle/publish-root-config.gradle
@@ -24,8 +24,9 @@ version = projectVersion
 group = 'this.will.be.overridden'
 
 def publishedProjects = [
-        'grails-gradle-bom',
         'grails-docs-core',
+        'grails-gradle-bom',
+        'grails-gradle-common',
         'grails-gradle-model',
         'grails-gradle-plugins',
         'grails-gradle-tasks',
diff --git a/grails-gradle/plugins/build.gradle b/grails-gradle/plugins/build.gradle
index 66a788ee39..db070c9c79 100644
--- a/grails-gradle/plugins/build.gradle
+++ b/grails-gradle/plugins/build.gradle
@@ -40,6 +40,7 @@ dependencies {
     // see: https://docs.gradle.org/current/userguide/compatibility.html#groovy
     compileOnly "org.codehaus.groovy:groovy"
 
+    implementation project(':grails-gradle-common')
     implementation project(':grails-gradle-tasks')
 
     // spock is leaking from the grails-gradle-bom through grails-gradle-model
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
index 039e95b390..10d292071e 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
@@ -26,6 +26,7 @@ import groovy.transform.CompileDynamic
 import groovy.transform.CompileStatic
 import io.spring.gradle.dependencymanagement.DependencyManagementPlugin
 import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension
+import org.apache.grails.gradle.common.PropertyFileUtils
 import org.apache.tools.ant.filters.EscapeUnicode
 import org.apache.tools.ant.filters.ReplaceTokens
 import org.gradle.api.Action
@@ -170,34 +171,6 @@ class GrailsGradlePlugin extends GroovyPlugin {
         }
     }
 
-    // TODO: this is copied from grails-common, but we should consider moving grails-common up to grails-gradle once
-    // the publish plugin is moved out of grails-core
-    static void makePropertiesFileReproducible(File factoriesFile) {
-        String sourceDateEpoch = System.getenv('SOURCE_DATE_EPOCH')
-        if (!sourceDateEpoch) {
-            sourceDateEpoch = LocalDate.now(ZoneOffset.UTC)
-                    .atStartOfDay(ZoneOffset.UTC)
-                    .toEpochSecond().toString()
-        }
-
-        Pattern timeRegex = Pattern.compile('^#(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)(?:,|\\s).*$')
-
-        List<String> lines = factoriesFile.readLines(StandardCharsets.ISO_8859_1.name())
-
-        boolean dateReplaced = false
-        factoriesFile.withWriter { BufferedWriter writer ->
-            lines.each { String line ->
-                if (!dateReplaced && timeRegex.matcher(line).matches()) {
-                    dateReplaced = true
-                    writer.writeLine("# SOURCE_DATE_EPOCH = ${sourceDateEpoch}" as String)
-                    return
-                }
-
-                writer.writeLine(line)
-            }
-        }
-    }
-
     private static Provider<String> getMainClassProvider(Project project) {
         Provider<FindMainClassTask> findMainClassTask = project.tasks.named('findMainClass', FindMainClassTask)
         project.provider {
@@ -403,7 +376,7 @@ class GrailsGradlePlugin extends GroovyPlugin {
                         entry key: me.key, value: me.value
                     }
                 }
-                makePropertiesFileReproducible(buildInfoFile)
+                PropertyFileUtils.makePropertiesFileReproducible(buildInfoFile)
             }
 
             TaskContainer tasks = project.tasks
diff --git a/grails-gradle/settings.gradle b/grails-gradle/settings.gradle
index 20833b8127..387fa65488 100644
--- a/grails-gradle/settings.gradle
+++ b/grails-gradle/settings.gradle
@@ -52,6 +52,9 @@ buildCache {
 
 rootProject.name = 'grails-gradle'
 
+include 'grails-gradle-common'
+project(':grails-gradle-common').projectDir = file('common')
+
 include 'grails-gradle-plugins'
 project(':grails-gradle-plugins').projectDir = file('plugins')
 
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy
index 47636564bd..45b5122335 100644
--- a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy
@@ -21,7 +21,7 @@ package org.grails.gsp.compiler
 import grails.config.ConfigMap
 import org.apache.commons.logging.LogFactory
 import org.apache.commons.logging.Log
-import org.apache.grails.common.properties.PropertyFileUtils
+import org.apache.grails.gradle.common.PropertyFileUtils
 import org.codehaus.groovy.control.CompilationUnit
 import org.codehaus.groovy.control.CompilerConfiguration
 import org.codehaus.groovy.control.Phases