Skip to content

Commit 7d06b75

Browse files
authored
Merge pull request #14945 from apache/shadowJarLicensing
#14886 - add licensing to distributed shadowJar
2 parents ea8d7eb + 31f5f40 commit 7d06b75

File tree

14 files changed

+504
-91
lines changed

14 files changed

+504
-91
lines changed

.github/workflows/gradle.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,6 @@ jobs:
125125
./gradlew build
126126
--continue --stacktrace
127127
--rerun-tasks
128-
- name: "✅ Verify Forge CLI"
129-
run: |
130-
cd grails-forge
131-
cp grails-forge-cli/build/distributions/apache-grails-forge-cli-*.zip forge-cli.zip
132-
unzip forge-cli -d tmp
133-
mv tmp/apache-grails-forge-cli-* tmp/forge-cli
134-
./tmp/forge-cli/bin/grails-forge-cli --version
135128
- name: "✅ Verify combined CLI"
136129
run: |
137130
cd grails-forge

NOTICE

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ The Apache Software Foundation (http://www.apache.org/).
88
Additional Licenses
99
------------------
1010

11+
This product uses the Jakarta Annotations™ API which is a trademark of the Eclipse Foundation. It is licensed under
12+
the Eclipse Public License v. 2.0 which is available at https://www.eclipse.org/legal/epl-2.0.
13+
1114
Project Reactor
1215
This product includes software from Project Reactor, licensed under Apache License, Version 2.0.
1316
2011-2014 Pivotal Software, Inc.

gradle/dependency-licenses.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
apply plugin: 'com.github.hierynomus.license-report'
2121

2222
List<String> licenseExclusions = rootProject.subprojects.collect {
23-
"org.grails:${it.name}:${rootProject.projectVersion}" as String
23+
"org.apache.grails:${it.findProperty('pomArtifactId') ?: it.name}:${rootProject.projectVersion}" as String
2424
}
2525

2626
downloadLicenses {

grails-core/NOTICE

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Apache Grails
2+
Copyright 2005-2025 The Apache Software Foundation
3+
4+
This product includes software developed at
5+
The Apache Software Foundation (http://www.apache.org/).
6+
7+
8+
Additional Licenses
9+
------------------
10+
11+
This product uses the Jakarta Annotations™ API which is a trademark of the Eclipse Foundation. It is licensed under
12+
the Eclipse Public License v. 2.0 which is available at https://www.eclipse.org/legal/epl-2.0.

grails-forge/build.gradle

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ file('../gradle.properties').withInputStream {
2828
}
2929

3030
ext {
31+
isReproducibleBuild = System.getenv("SOURCE_DATE_EPOCH") != null
3132
buildInstant = java.util.Optional.ofNullable(System.getenv('SOURCE_DATE_EPOCH'))
3233
.map(Long::parseLong)
3334
.map(Instant::ofEpochSecond)
@@ -41,7 +42,7 @@ ext {
4142

4243
allprojects {
4344
props.forEach { k, v ->
44-
if(!project.hasProperty(k as String)) {
45+
if (!project.hasProperty(k as String)) {
4546
project.ext.set(k as String, v)
4647
}
4748
}
@@ -72,6 +73,18 @@ allprojects {
7273
}
7374
}
7475

76+
subprojects {
77+
configurations.configureEach {
78+
resolutionStrategy {
79+
def cacheHours = isCiBuild || isReproducibleBuild ? 0 : 24
80+
cacheDynamicVersionsFor(cacheHours, 'hours')
81+
cacheChangingModulesFor(cacheHours, 'hours')
82+
}
83+
}
84+
85+
apply from: rootProject.layout.projectDirectory.file('gradle/dependency-licenses.gradle')
86+
}
87+
7588
apply {
7689
// we must apply the publish configuration first or the docs config will not work
7790
from layout.projectDirectory.file('gradle/publish-root-config.gradle')

grails-forge/buildSrc/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ configurations.configureEach {
6464
}
6565

6666
dependencies {
67+
implementation "gradle.plugin.com.hierynomus.gradle.plugins:license-gradle-plugin:${rootProperties.gradleLicensePluginVersion}", {
68+
// Due to https://github.com/hierynomus/license-gradle-plugin/issues/161, spring must be excluded
69+
exclude group: 'org.springframework', module: 'spring-core'
70+
}
6771
implementation "io.micronaut.build.internal:micronaut-gradle-plugins:$micronautGradlePlugins"
6872
implementation "com.fizzed:rocker-compiler:$rockerVersion"
6973
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
@@ -80,6 +84,7 @@ dependencies {
8084
}
8185
implementation "org.antlr:antlr4-runtime:$antlr4Version"
8286
implementation "org.gradle.crypto.checksum:org.gradle.crypto.checksum.gradle.plugin:$gradleChecksumPluginVersion"
87+
implementation "org.apache.ant:ant:$antVersion"
8388
}
8489

8590
gradlePlugin {
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.grails.forge.buildlogic.shadowjar
18+
19+
import com.github.jengelman.gradle.plugins.shadow.transformers.Transformer
20+
import com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext
21+
import groovy.transform.CompileDynamic
22+
import groovy.transform.CompileStatic
23+
import org.apache.tools.zip.ZipEntry
24+
import org.apache.tools.zip.ZipOutputStream
25+
import org.gradle.api.file.FileTreeElement
26+
import org.gradle.api.tasks.Input
27+
28+
import java.util.regex.Pattern
29+
30+
/**
31+
* This transformer assists in combining all known licenses into a single META-INF/LICENSE file. Please note that the
32+
* shadow plugin will initially copy all dependencies that are in the local project and then it will copy all other external
33+
* dependencies. Transformers only apply to the copied jar file dependencies, and not to the local project dependencies.
34+
*/
35+
@CompileStatic
36+
class GrailsShadowLicenseTransform implements Transformer {
37+
38+
private static final List<Pattern> LICENSE_PATTERNS = [
39+
~'(?i)META-INF/[^/]*LICENSE[^/]*',
40+
~'(?i)META-INF/LICENSES/.*',
41+
~'(?i)[^/]*LICENSE[^/]*',
42+
~'(?i)LICENSES/.*'
43+
]
44+
45+
private static final String LICENSE_PATH = 'META-INF/LICENSE'
46+
47+
private LinkedHashMap<String, LicenseHolder> licenses = [:]
48+
49+
@Input
50+
String licenseAppendixEnding = 'LIMITATIONS UNDER THE LICENSE.'
51+
52+
@Input
53+
String licenseTermsEnding = 'END OF TERMS AND CONDITIONS'
54+
55+
@Input
56+
String licenseTermsStart = 'APACHE LICENSE VERSION 2.0'
57+
58+
@Input
59+
String licenseText // to be loaded by file
60+
61+
@Input
62+
Boolean separators = false
63+
64+
/**
65+
* Whether this transformer can process the given resource. If set to true, it's expected the transformer will
66+
* write the resource.
67+
*/
68+
@Override
69+
boolean canTransformResource(FileTreeElement element) {
70+
def path = element.relativePath.pathString
71+
LICENSE_PATTERNS.any { pattern -> pattern.matcher(path).matches() }
72+
}
73+
74+
/**
75+
* Parses any file that matches the license patterns and extracts the license text, deduplicating & combining where
76+
* possible.
77+
*
78+
* @param context contains the input stream of the resource to transform
79+
*/
80+
@Override
81+
// Multiple assignments without list expressions on the right hand side are unsupported in static type checking mode
82+
@CompileDynamic
83+
void transform(TransformerContext context) {
84+
if (!licenses) {
85+
// Add our license as previously seen so we can dedupe - this transformer only applies to the copy of other jars
86+
def (grailsLicense, grailsIndexMappings) = normalize(licenseText)
87+
licenses[grailsLicense] = new LicenseHolder(license: licenseText, indexMappings: grailsIndexMappings)
88+
}
89+
90+
context.is.withReader {
91+
BufferedReader reader = new BufferedReader(it)
92+
93+
def license = stripJavaBlockComment(reader.text)
94+
def (String normalized, List<Integer> indexMappings) = normalize(license)
95+
96+
// resect Apache License
97+
String resected = resectLicense(license, normalized, indexMappings)
98+
if (!resected.trim()) {
99+
return // only contained duplicated license terms with the ASF license
100+
}
101+
102+
def (String resectedNormalized, List<Integer> resectedIndexMappings) = normalize(resected)
103+
def previouslySeen = getVariations(resectedNormalized).any { licenses.containsKey(it) }
104+
if (!previouslySeen) {
105+
licenses[resectedNormalized] = new LicenseHolder(license: resected, indexMappings: resectedIndexMappings)
106+
}
107+
}
108+
}
109+
110+
/**
111+
* Some libraries ship with a license.header file that contains a Java block comment. This method strips the
112+
* Java block comment syntax and returns the text without the comment.
113+
*/
114+
private static String stripJavaBlockComment(String text) {
115+
if (!text.startsWith('/*')) {
116+
return text
117+
}
118+
119+
return text
120+
.replaceAll('^/\\*+|\\*+/\\s*$', '') // opening & closing comment
121+
.readLines()
122+
.collect {
123+
it.replaceFirst(/^(\s*\*)?/, '').trim()
124+
} // leading whitespace & *
125+
.join('\n')
126+
}
127+
128+
/**
129+
* Normalizes the license text by collapsing whitespace and uppercasing all characters. It also returns a mapping
130+
* of the normalized text to the original license text, where each character in the normalized text maps to its
131+
* original index in the license text. This allows us to index into the normalized text from sections of the license text
132+
* for deduplication purposes.
133+
*
134+
* @param license the original license text
135+
* @return a tuple containing the normalized license text and a list of index mappings
136+
*/
137+
private static Tuple2<String, List<Integer>> normalize(String license) {
138+
def sb = new StringBuilder()
139+
List<Integer> indexMappings = [] // each char in sb maps to original index
140+
141+
boolean previousWhitespace = false
142+
for (int i = 0; i < license.length(); i++) {
143+
char c = license.charAt(i)
144+
if (c.isWhitespace()) {
145+
if (!previousWhitespace) {
146+
sb.append(' ')
147+
indexMappings << i
148+
previousWhitespace = true
149+
}
150+
} else {
151+
sb.append(Character.toUpperCase(c))
152+
indexMappings << i
153+
previousWhitespace = false
154+
}
155+
}
156+
157+
String normalized = sb.toString().trim()
158+
int startTrim = sb.indexOf(normalized)
159+
int endTrim = startTrim + normalized.length()
160+
new Tuple2<String, List<Integer>>(normalized, indexMappings[startTrim..<endTrim])
161+
}
162+
163+
/**
164+
* For a given license, this method will extract the duplicate license text and return the remaining text
165+
* @param license the original license text
166+
* @param normalized a normalized version of the license text, with all whitespace collapsed and all characters uppercased
167+
* @param indexMappings a mapping of the normalized text to the original license text, where each character in the normalized text maps to its original index in the license text
168+
* @return either null if no additional license text was found or the additional license text
169+
*/
170+
private String resectLicense(String license, String normalized, List<Integer> indexMappings) {
171+
if (!normalized.startsWith(licenseTermsStart.toUpperCase())) {
172+
return license // not ASF license, return as is
173+
}
174+
175+
// try to search on the appendix first
176+
String endOfLicenseMarker = normalize(licenseAppendixEnding).v1
177+
int end1Index = normalized.indexOf(endOfLicenseMarker)
178+
if (end1Index >= 0) {
179+
// license included the appendix
180+
def originalEnding = indexMappings[end1Index + endOfLicenseMarker.size() - 1] + 1
181+
if (originalEnding > license.length()) {
182+
// only the license is present
183+
return null
184+
}
185+
186+
return license.substring(originalEnding)
187+
}
188+
189+
// try to search on the terms ending
190+
String endMarker = normalize(licenseTermsEnding).v1
191+
int end2Index = normalized.indexOf(endMarker)
192+
if (end2Index >= 0) {
193+
// bare license
194+
def originalEnding = indexMappings[end2Index + endMarker.size() - 1] + 1
195+
if (originalEnding > license.length()) {
196+
// only the license is present
197+
return null
198+
}
199+
200+
return license.substring(originalEnding)
201+
}
202+
203+
license
204+
}
205+
206+
/**
207+
* Some licenses mix http & https links, handles simple variations of the license to ensure the license can be
208+
* deduplicated.
209+
*
210+
* @param license the license text
211+
* @return a list of variations of the license text
212+
*/
213+
private static List<String> getVariations(String license) {
214+
[license.trim()].collectMany {
215+
[it, it.replace('http://', 'https://'), it.replace('https://', 'http://')]
216+
}
217+
}
218+
219+
/**
220+
* Whether this transformer will modify the output stream.
221+
*/
222+
@Override
223+
boolean hasTransformedResource() {
224+
// Must always be true since we want to write the LICENSE file and all license files originate from jar files
225+
// after our project restructure
226+
true
227+
}
228+
229+
/**
230+
* Writes the combined license file to the output stream. The file will be written to
231+
* META-INF/LICENSE and will contain all licenses found in the project
232+
*
233+
* @param os the jar file output stream
234+
* @param preserveFileTimestamps whether to preserve file timestamps in the output jar
235+
*/
236+
@Override
237+
void modifyOutputStream(ZipOutputStream os, boolean preserveFileTimestamps) {
238+
ZipEntry zipEntry = new ZipEntry(LICENSE_PATH)
239+
zipEntry.time = TransformerContext.getEntryTimestamp(preserveFileTimestamps, zipEntry.time)
240+
os.putNextEntry(zipEntry)
241+
242+
os.withPrintWriter { writer ->
243+
licenses.entrySet().withIndex().each { license ->
244+
if (license.v1.value == null) {
245+
return // skip the license that will be copied by shadow from our existing jars
246+
}
247+
248+
writer.println(license.v1.value.license)
249+
if (separators && license.v2 < licenses.size() - 1) {
250+
writer.println("-------------------------${license.v2}---------------------------")
251+
}
252+
}
253+
254+
writer.flush()
255+
}
256+
257+
licenses = [:]
258+
}
259+
260+
private static class LicenseHolder {
261+
262+
String license
263+
List<Integer> indexMappings
264+
}
265+
}

0 commit comments

Comments
 (0)