You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@paimon.apache.org by lz...@apache.org on 2023/03/24 02:26:58 UTC
[incubator-paimon-shade] branch main updated: [ci] add license check tool (#6)
This is an automated email from the ASF dual-hosted git repository.
lzljs3620320 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-paimon-shade.git
The following commit(s) were added to refs/heads/main by this push:
new 7284104 [ci] add license check tool (#6)
7284104 is described below
commit 7284104759e0512c3e68999c482734c18eb470e9
Author: Kerwin <37...@users.noreply.github.com>
AuthorDate: Fri Mar 24 10:26:53 2023 +0800
[ci] add license check tool (#6)
---
.github/workflows/ci.yml | 8 +-
NOTICE | 2 +-
paimon-shade-guava-30/pom.xml | 4 -
.../src/main/resources/META-INF/NOTICE | 2 +-
.../src/main/resources/META-INF/NOTICE | 2 +-
pom.xml | 36 ++
tools/ci/paimon-ci-tools/pom.xml | 91 ++++
.../tools/ci/licensecheck/JarFileChecker.java | 329 +++++++++++++
.../tools/ci/licensecheck/LicenseChecker.java | 53 +++
.../tools/ci/licensecheck/NoticeFileChecker.java | 390 +++++++++++++++
.../tools/ci/suffixcheck/ScalaSuffixChecker.java | 262 ++++++++++
.../ci/utils/dependency/DependencyParser.java | 228 +++++++++
.../paimon/tools/ci/utils/deploy/DeployParser.java | 74 +++
.../tools/ci/utils/notice/NoticeContents.java | 42 ++
.../paimon/tools/ci/utils/notice/NoticeParser.java | 95 ++++
.../paimon/tools/ci/utils/shade/ShadeParser.java | 113 +++++
.../paimon/tools/ci/utils/shared/Dependency.java | 133 ++++++
.../tools/ci/utils/shared/DependencyTree.java | 123 +++++
.../paimon/tools/ci/utils/shared/ParserUtils.java | 73 +++
.../src/main/resources/log4j2.properties | 25 +
...modules-defining-excess-dependencies.modulelist | 20 +
.../tools/ci/licensecheck/JarFileCheckerTest.java | 525 +++++++++++++++++++++
.../ci/licensecheck/NoticeFileCheckerTest.java | 203 ++++++++
.../utils/dependency/DependencyParserCopyTest.java | 121 +++++
.../utils/dependency/DependencyParserTreeTest.java | 148 ++++++
.../tools/ci/utils/deploy/DeployParserTest.java | 57 +++
.../tools/ci/utils/notice/NoticeParserTest.java | 93 ++++
.../tools/ci/utils/shade/ShadeParserTest.java | 95 ++++
.../tools/ci/utils/shared/DependencyTreeTest.java | 96 ++++
29 files changed, 3434 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bff41ca..1eaa8d1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,8 +39,12 @@ jobs:
run: |
set -o pipefail
- ./mvnw clean deploy ${{ env.MVN_COMMON_OPTIONS }} \
+ ./mvnw clean deploy ${{ env.MVN_COMMON_OPTIONS }} -Dmaven.test.skip=true \
-DaltDeploymentRepository=validation_repository::default::file:${{ env.MVN_VALIDATION_DIR }} \
| tee ${{ env.MVN_BUILD_OUTPUT_FILE }}
- # TODO Add check licensing
+ - name: Check licensing
+ run: |
+ ./mvnw ${{ env.MVN_COMMON_OPTIONS }} exec:java@check-licensing -N \
+ -Dexec.args="${{ env.MVN_BUILD_OUTPUT_FILE }} $(pwd) ${{ env.MVN_VALIDATION_DIR }}" \
+ -Dlog4j.configurationFile=file://$(pwd)/tools/ci/log4j.properties
diff --git a/NOTICE b/NOTICE
index 68d4a4a..6f7d8e0 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,5 @@
Apache Paimon (incubating)
-Copyright 2014-2023 The Apache Software Foundation
+Copyright 2023-2023 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
diff --git a/paimon-shade-guava-30/pom.xml b/paimon-shade-guava-30/pom.xml
index 31fc3a6..9932d4e 100644
--- a/paimon-shade-guava-30/pom.xml
+++ b/paimon-shade-guava-30/pom.xml
@@ -35,10 +35,6 @@ under the License.
<packaging>jar</packaging>
- <properties>
- <guava.version>30.1.1-jre</guava.version>
- </properties>
-
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
diff --git a/paimon-shade-jackson-parent/paimon-shade-jackson-2/src/main/resources/META-INF/NOTICE b/paimon-shade-jackson-parent/paimon-shade-jackson-2/src/main/resources/META-INF/NOTICE
index 160bf65..6e60fb1 100644
--- a/paimon-shade-jackson-parent/paimon-shade-jackson-2/src/main/resources/META-INF/NOTICE
+++ b/paimon-shade-jackson-parent/paimon-shade-jackson-2/src/main/resources/META-INF/NOTICE
@@ -1,5 +1,5 @@
paimon-shade-jackson-2
-Copyright 2014-2021 The Apache Software Foundation
+Copyright 2023-2023 The Apache Software Foundation
This project includes software developed at
The Apache Software Foundation (http://www.apache.org/).
diff --git a/paimon-shade-jackson-parent/paimon-shade-jackson-module-jsonSchema-2/src/main/resources/META-INF/NOTICE b/paimon-shade-jackson-parent/paimon-shade-jackson-module-jsonSchema-2/src/main/resources/META-INF/NOTICE
index 63e28f6..0037f85 100644
--- a/paimon-shade-jackson-parent/paimon-shade-jackson-module-jsonSchema-2/src/main/resources/META-INF/NOTICE
+++ b/paimon-shade-jackson-parent/paimon-shade-jackson-module-jsonSchema-2/src/main/resources/META-INF/NOTICE
@@ -1,5 +1,5 @@
paimon-shade-jackson-module-jsonSchema-2
-Copyright 2014-2021 The Apache Software Foundation
+Copyright 2023-2023 The Apache Software Foundation
This project includes software developed at
The Apache Software Foundation (http://www.apache.org/).
diff --git a/pom.xml b/pom.xml
index 7fe8abf..5113691 100644
--- a/pom.xml
+++ b/pom.xml
@@ -64,10 +64,16 @@ under the License.
<module>paimon-shade-guava-30</module>
<module>paimon-shade-caffeine-2</module>
<module>paimon-shade-jackson-parent</module>
+ <module>tools/ci/paimon-ci-tools</module>
</modules>
<properties>
<shading.prefix>org.apache.paimon.shade</shading.prefix>
+
+ <guava.version>30.1.1-jre</guava.version>
+ <log4j.version>2.17.1</log4j.version>
+ <junit5.version>5.8.1</junit5.version>
+ <assertj.version>3.23.1</assertj.version>
</properties>
<profiles>
@@ -139,6 +145,7 @@ under the License.
<name>shade-sources</name>
</property>
</activation>
+
<build>
<pluginManagement>
<plugins>
@@ -297,6 +304,35 @@ under the License.
</excludes>
</configuration>
</plugin>
+
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <version>3.1.0</version>
+ <inherited>false</inherited>
+ <executions>
+ <execution>
+ <id>check-license</id>
+ <!-- manually called -->
+ <phase>none</phase>
+ <goals>
+ <goal>java</goal>
+ </goals>
+ </execution>
+ </executions>
+ <configuration>
+ <mainClass>org.apache.paimon.tools.ci.licensecheck.LicenseChecker</mainClass>
+ <includePluginDependencies>true</includePluginDependencies>
+ <includeProjectDependencies>false</includeProjectDependencies>
+ </configuration>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.paimon</groupId>
+ <artifactId>paimon-ci-tools</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ </dependencies>
+ </plugin>
</plugins>
</build>
diff --git a/tools/ci/paimon-ci-tools/pom.xml b/tools/ci/paimon-ci-tools/pom.xml
new file mode 100644
index 0000000..d761b0f
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/pom.xml
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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
+
+ http://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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.paimon</groupId>
+ <artifactId>paimon-shade</artifactId>
+ <version>1.0</version>
+ <relativePath>../../..</relativePath>
+ </parent>
+
+ <artifactId>paimon-ci-tools</artifactId>
+ <version>1.0</version>
+ <name>Paimon : Tools : CI : Java</name>
+
+ <properties>
+ <japicmp.skip>true</japicmp.skip>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ <version>${guava.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit5.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <version>${assertj.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-slf4j-impl</artifactId>
+ <version>${log4j.version}</version>
+ <scope>compile</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-api</artifactId>
+ <version>${log4j.version}</version>
+ <scope>compile</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-core</artifactId>
+ <version>${log4j.version}</version>
+ <scope>compile</scope>
+ </dependency>
+ </dependencies>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <configuration>
+ <source>8</source>
+ <target>8</target>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/JarFileChecker.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/JarFileChecker.java
new file mode 100644
index 0000000..c3b05cb
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/JarFileChecker.java
@@ -0,0 +1,329 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.licensecheck;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.MalformedInputException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** Checks the Jar files created by the build process. */
+public class JarFileChecker {
+ private static final Logger LOG = LoggerFactory.getLogger(JarFileChecker.class);
+
+ public static int checkPath(Path path) throws Exception {
+ List<Path> files = getBuildJars(path);
+ LOG.info("Checking directory {} with a total of {} jar files.", path, files.size());
+
+ int severeIssues = 0;
+ for (Path file : files) {
+ severeIssues += checkJar(file);
+ }
+
+ return severeIssues;
+ }
+
+ private static List<Path> getBuildJars(Path path) throws IOException {
+ return Files.walk(path)
+ .filter(file -> file.toString().endsWith(".jar"))
+ .collect(Collectors.toList());
+ }
+
+ @VisibleForTesting
+ static int checkJar(Path file) throws Exception {
+ final URI uri = file.toUri();
+
+ int numSevereIssues = 0;
+ try (final FileSystem fileSystem =
+ FileSystems.newFileSystem(
+ new URI("jar:file", uri.getHost(), uri.getPath(), uri.getFragment()),
+ Collections.emptyMap())) {
+ if (isTestJarAndEmpty(file, fileSystem.getPath("/"))) {
+ return 0;
+ }
+ if (!noticeFileExistsAndIsValid(fileSystem.getPath("META-INF", "NOTICE"), file)) {
+ numSevereIssues++;
+ }
+ if (!licenseFileExistsAndIsValid(fileSystem.getPath("META-INF", "LICENSE"), file)) {
+ numSevereIssues++;
+ }
+
+ numSevereIssues +=
+ getNumLicenseFilesOutsideMetaInfDirectory(file, fileSystem.getPath("/"));
+
+ numSevereIssues += getFilesWithIncompatibleLicenses(file, fileSystem.getPath("/"));
+ }
+ return numSevereIssues;
+ }
+
+ private static boolean isTestJarAndEmpty(Path jar, Path jarRoot) throws IOException {
+ if (jar.getFileName().toString().endsWith("-tests.jar")) {
+ try (Stream<Path> files = Files.walk(jarRoot)) {
+ long numClassFiles =
+ files.filter(path -> !path.equals(jarRoot))
+ .filter(path -> path.getFileName().toString().endsWith(".class"))
+ .count();
+ if (numClassFiles == 0) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean noticeFileExistsAndIsValid(Path noticeFile, Path jar)
+ throws IOException {
+ if (!Files.exists(noticeFile)) {
+ LOG.error("Missing META-INF/NOTICE in {}", jar);
+ return false;
+ }
+
+ final String noticeFileContents = readFile(noticeFile);
+ if (!noticeFileContents.contains("The Apache Software Foundation")) {
+ LOG.error("The notice file in {} does not contain the expected entries.", jar);
+ return false;
+ }
+
+ return true;
+ }
+
+ private static boolean licenseFileExistsAndIsValid(Path licenseFile, Path jar)
+ throws IOException {
+ if (!Files.exists(licenseFile)) {
+ LOG.error("Missing META-INF/LICENSE in {}", jar);
+ return false;
+ }
+
+ final String licenseFileContents = readFile(licenseFile);
+ if (!licenseFileContents.contains("Apache License")
+ || !licenseFileContents.contains("Version 2.0, January 2004")) {
+ LOG.error("The license file in {} does not contain the expected entries.", jar);
+ return false;
+ }
+
+ return true;
+ }
+
+ private static int getFilesWithIncompatibleLicenses(Path jar, Path jarRoot) throws IOException {
+ // patterns are based on https://www.apache.org/legal/resolved.html#category-x
+ return findNonBinaryFilesContainingText(
+ jar,
+ jarRoot,
+ asPatterns(
+ "Binary Code License",
+ "Intel Simplified Software License",
+ "JSR 275",
+ "Microsoft Limited Public License",
+ "Amazon Software License",
+ // Java SDK for Satori RTM license
+ "as necessary for your use of Satori services",
+ "REDIS SOURCE AVAILABLE LICENSE",
+ "Booz Allen Public License",
+ "Confluent Community License Agreement Version 1.0",
+ // “Commons Clause” License Condition v1.0
+ "the License does not grant to you, the right to Sell the Software.",
+ "Sun Community Source License Version 3.0",
+ "GNU General Public License",
+ "GNU Affero General Public License",
+ "GNU Lesser General Public License",
+ "Q Public License",
+ "Sleepycat License",
+ "Server Side Public License",
+ "Code Project Open License",
+ // BSD 4-Clause
+ " All advertising materials mentioning features or use of this software must display the following acknowledgement",
+ // Facebook Patent clause v1
+ "The license granted hereunder will terminate, automatically and without notice, for anyone that makes any claim",
+ // Facebook Patent clause v2
+ "The license granted hereunder will terminate, automatically and without notice, if you (or any of your subsidiaries, corporate affiliates or agents) initiate directly or indirectly, or take a direct financial interest in, any Patent Assertion: (i) against Facebook",
+ "Netscape Public License",
+ "SOLIPSISTIC ECLIPSE PUBLIC LICENSE",
+ // DON'T BE A DICK PUBLIC LICENSE
+ "Do whatever you like with the original work, just don't be a dick.",
+ // JSON License
+ "The Software shall be used for Good, not Evil.",
+ // can sometimes be found in "funny" licenses
+ "Don’t be evil"));
+ }
+
+ private static Collection<Pattern> asPatterns(String... texts) {
+ return Stream.of(texts)
+ .map(JarFileChecker::asPatternWithPotentialLineBreaks)
+ .collect(Collectors.toList());
+ }
+
+ private static Pattern asPatternWithPotentialLineBreaks(String text) {
+ // allows word sequences to be separated by whitespace, line-breaks and comments(//, #)
+ return Pattern.compile(text.toLowerCase(Locale.ROOT).replaceAll(" ", " ?\\\\R?[\\\\s/#]*"));
+ }
+
+ private static int findNonBinaryFilesContainingText(
+ Path jar, Path jarRoot, Collection<Pattern> forbidden) throws IOException {
+ try (Stream<Path> files = Files.walk(jarRoot)) {
+ return files.filter(path -> !path.equals(jarRoot))
+ .filter(path -> !Files.isDirectory(path))
+ .filter(JarFileChecker::isNoClassFile)
+ // frequent false-positives due to dual-licensing; generated by maven
+ .filter(path -> !getFileName(path).equals("dependencies"))
+ // false-positives due to dual-licensing; use startsWith to cover .txt/.md files
+ .filter(path -> !getFileName(path).startsWith("license"))
+ // false-positives due to optional components; startsWith covers .txt/.md files
+ .filter(path -> !getFileName(path).startsWith("notice"))
+ // dual-licensed under GPL 2 and CDDL 1.1
+ // contained in hadoop/presto S3 FS and paimon-dist
+ .filter(path -> !pathStartsWith(path, "/META-INF/versions/11/javax/xml/bind"))
+ .filter(path -> !isJavaxManifest(jar, path))
+ // dual-licensed under GPL 2 and EPL 2.0
+ // contained in sql-avro-confluent-registry
+ .filter(path -> !pathStartsWith(path, "/org/glassfish/jersey/internal"))
+ .map(
+ path -> {
+ try {
+ final String fileContents;
+ try {
+ fileContents = readFile(path).toLowerCase(Locale.ROOT);
+ } catch (MalformedInputException mie) {
+ // binary file
+ return 0;
+ }
+
+ int violations = 0;
+ for (Pattern text : forbidden) {
+ if (text.matcher(fileContents).find()) {
+ // do not count individual violations because it can be
+ // confusing when checking with aliases for the same
+ // license
+ violations = 1;
+ LOG.error(
+ "File '{}' in jar '{}' contains match with forbidden regex '{}'.",
+ path,
+ jar,
+ text);
+ }
+ }
+ return violations;
+ } catch (IOException e) {
+ throw new RuntimeException(
+ String.format(
+ "Could not read contents of file '%s' in jar '%s'.",
+ path, jar),
+ e);
+ }
+ })
+ .reduce(Integer::sum)
+ .orElse(0);
+ }
+ }
+
+ private static int getNumLicenseFilesOutsideMetaInfDirectory(Path jar, Path jarRoot)
+ throws IOException {
+ try (Stream<Path> files = Files.walk(jarRoot)) {
+ /*
+ * LICENSE or NOTICE files found outside of the META-INF directory are most likely shading mistakes (we are including the files from other dependencies, thus providing an invalid LICENSE file)
+ *
+ * <p>In such a case, we recommend updating the shading exclusions, and adding the license file to META-INF/licenses.
+ */
+ final List<String> filesWithIssues =
+ files.filter(path -> !path.equals(jarRoot))
+ .filter(
+ path ->
+ getFileName(path).contains("license")
+ || getFileName(path).contains("notice"))
+ .filter(
+ path ->
+ !Files.isDirectory(
+ path)) // ignore directories, e.g. "license/"
+ .filter(JarFileChecker::isNoClassFile) // some class files contain
+ // LICENSE in their name
+ .filter(
+ path ->
+ !getFileName(path)
+ .endsWith(".ftl")) // a false positive in
+ // python
+ .map(Path::toString)
+ .filter(
+ path ->
+ !path.contains(
+ "META-INF")) // license files in META-INF are
+ // expected
+ .filter(
+ path ->
+ !path.endsWith(
+ "web/3rdpartylicenses.txt")) // a false positive
+ // in
+ // web
+ .collect(Collectors.toList());
+ for (String fileWithIssue : filesWithIssues) {
+ LOG.error(
+ "Jar file {} contains a LICENSE file in an unexpected location: {}",
+ jar,
+ fileWithIssue);
+ }
+ return filesWithIssues.size();
+ }
+ }
+
+ private static String getFileName(Path path) {
+ return path.getFileName().toString().toLowerCase();
+ }
+
+ private static boolean pathStartsWith(Path file, String path) {
+ return file.startsWith(file.getFileSystem().getPath(path));
+ }
+
+ private static boolean equals(Path file, String path) {
+ return file.equals(file.getFileSystem().getPath(path));
+ }
+
+ private static boolean isNoClassFile(Path file) {
+ return !getFileName(file).endsWith(".class");
+ }
+
+ private static boolean isJavaxManifest(Path jar, Path potentialManifestFile) {
+ try {
+ return equals(potentialManifestFile, "/META-INF/versions/11/META-INF/MANIFEST.MF")
+ && readFile(potentialManifestFile).contains("Specification-Title: jaxb-api");
+ } catch (IOException e) {
+ throw new RuntimeException(
+ String.format(
+ "Error while reading file %s from jar %s.", potentialManifestFile, jar),
+ e);
+ }
+ }
+
+ private static String readFile(Path file) throws IOException {
+ return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/LicenseChecker.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/LicenseChecker.java
new file mode 100644
index 0000000..9142f10
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/LicenseChecker.java
@@ -0,0 +1,53 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.licensecheck;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.nio.file.Paths;
+
+/** Utility for checking all things related to License and Notice files. */
+public class LicenseChecker {
+ // ---------------------------------------- Launcher ---------------------------------------- //
+
+ private static final Logger LOG = LoggerFactory.getLogger(LicenseChecker.class);
+
+ public static void main(String[] args) throws Exception {
+ if (args.length < 2) {
+ System.out.println(
+ "Usage: LicenseChecker <pathMavenBuildOutput> <pathPaimonRoot> <pathPaimonDeployed>");
+ System.exit(1);
+ }
+ LOG.warn(
+ "THIS UTILITY IS ONLY CHECKING FOR COMMON LICENSING MISTAKES. A MANUAL CHECK OF THE NOTICE FILES, DEPLOYED ARTIFACTS, ETC. IS STILL NEEDED!");
+
+ int severeIssueCount = NoticeFileChecker.run(new File(args[0]), Paths.get(args[1]));
+
+ severeIssueCount += JarFileChecker.checkPath(Paths.get(args[2]));
+
+ if (severeIssueCount > 0) {
+ LOG.warn("Found a total of {} severe license issues", severeIssueCount);
+
+ System.exit(1);
+ }
+ LOG.info("License check completed without severe issues.");
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/NoticeFileChecker.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/NoticeFileChecker.java
new file mode 100644
index 0000000..0f71917
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/licensecheck/NoticeFileChecker.java
@@ -0,0 +1,390 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.licensecheck;
+
+import org.apache.paimon.tools.ci.utils.dependency.DependencyParser;
+import org.apache.paimon.tools.ci.utils.deploy.DeployParser;
+import org.apache.paimon.tools.ci.utils.notice.NoticeContents;
+import org.apache.paimon.tools.ci.utils.notice.NoticeParser;
+import org.apache.paimon.tools.ci.utils.shade.ShadeParser;
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** Utility class checking for proper NOTICE files based on the maven build output. */
+public class NoticeFileChecker {
+
+ private static final Logger LOG = LoggerFactory.getLogger(NoticeFileChecker.class);
+
+ private static final List<String> MODULES_DEFINING_EXCESS_DEPENDENCIES =
+ loadFromResources("modules-defining-excess-dependencies.modulelist");
+
+ // Examples:
+ // "- org.apache.htrace:htrace-core:3.1.0-incubating"
+ // or
+ // "This project bundles "net.jcip:jcip-annotations:1.0".
+ private static final Pattern NOTICE_DEPENDENCY_PATTERN =
+ Pattern.compile(
+ "- ([^ :]+):([^:]+):([^ ]+)($| )|.*bundles \"([^:]+):([^:]+):([^\"]+)\".*");
+
+ static int run(File buildResult, Path root) throws IOException {
+ // parse included dependencies from build output
+ final Map<String, Set<Dependency>> modulesWithBundledDependencies =
+ combineAndFilterPaimonDependencies(
+ ShadeParser.parseShadeOutput(buildResult.toPath()),
+ DependencyParser.parseDependencyCopyOutput(buildResult.toPath()));
+
+ final Set<String> deployedModules = DeployParser.parseDeployOutput(buildResult);
+
+ LOG.info(
+ "Extracted "
+ + deployedModules.size()
+ + " modules that were deployed and "
+ + modulesWithBundledDependencies.keySet().size()
+ + " modules which bundle dependencies with a total of "
+ + modulesWithBundledDependencies.values().size()
+ + " dependencies");
+
+ // find modules producing a shaded-jar
+ List<Path> noticeFiles = findNoticeFiles(root);
+ LOG.info("Found {} NOTICE files to check", noticeFiles.size());
+
+ final Map<String, Optional<NoticeContents>> moduleToNotice =
+ noticeFiles.stream()
+ .collect(
+ Collectors.toMap(
+ NoticeFileChecker::getModuleFromNoticeFile,
+ noticeFile -> {
+ try {
+ return NoticeParser.parseNoticeFile(noticeFile);
+ } catch (IOException e) {
+ // some machine issue
+ throw new RuntimeException(e);
+ }
+ }));
+
+ return run(modulesWithBundledDependencies, deployedModules, moduleToNotice);
+ }
+
+ @VisibleForTesting
+ static int run(
+ Map<String, Set<Dependency>> modulesWithBundledDependencies,
+ Set<String> deployedModules,
+ Map<String, Optional<NoticeContents>> noticeFiles)
+ throws IOException {
+ int severeIssueCount = 0;
+
+ final Set<String> modulesSkippingDeployment =
+ new HashSet<>(modulesWithBundledDependencies.keySet());
+ modulesSkippingDeployment.removeAll(deployedModules);
+
+ LOG.debug(
+ "The following {} modules are skipping deployment: {}",
+ modulesSkippingDeployment.size(),
+ modulesSkippingDeployment.stream()
+ .sorted()
+ .collect(Collectors.joining("\n\t", "\n\t", "")));
+
+ for (String moduleSkippingDeployment : modulesSkippingDeployment) {
+ // TODO: this doesn't work for modules requiring a NOTICE that are bundled indirectly
+ // TODO: via another non-deployed module
+ boolean bundledByDeployedModule =
+ modulesWithBundledDependencies.entrySet().stream()
+ .filter(
+ entry ->
+ entry.getValue().stream()
+ .map(Dependency::getArtifactId)
+ .anyMatch(
+ artifactId ->
+ artifactId.equals(
+ moduleSkippingDeployment)))
+ .anyMatch(entry -> !modulesSkippingDeployment.contains(entry.getKey()));
+
+ if (!bundledByDeployedModule) {
+ modulesWithBundledDependencies.remove(moduleSkippingDeployment);
+ } else {
+ LOG.debug(
+ "Including module {} in license checks, despite not being deployed, because it is bundled by another deployed module.",
+ moduleSkippingDeployment);
+ }
+ }
+
+ // check that all required NOTICE files exists
+ severeIssueCount +=
+ ensureRequiredNoticeFiles(modulesWithBundledDependencies, noticeFiles.keySet());
+
+ // check each NOTICE file
+ for (Map.Entry<String, Optional<NoticeContents>> noticeFile : noticeFiles.entrySet()) {
+ severeIssueCount +=
+ checkNoticeFileAndLogProblems(
+ modulesWithBundledDependencies,
+ noticeFile.getKey(),
+ noticeFile.getValue().orElse(null));
+ }
+
+ return severeIssueCount;
+ }
+
+ private static Map<String, Set<Dependency>> combineAndFilterPaimonDependencies(
+ Map<String, Set<Dependency>> modulesWithBundledDependencies,
+ Map<String, Set<Dependency>> modulesWithCopiedDependencies) {
+
+ final Map<String, Set<Dependency>> combinedAndFiltered = new LinkedHashMap<>();
+
+ Stream.concat(
+ modulesWithBundledDependencies.entrySet().stream(),
+ modulesWithCopiedDependencies.entrySet().stream())
+ .forEach(
+ (entry) -> {
+ final Set<Dependency> dependencies =
+ combinedAndFiltered.computeIfAbsent(
+ entry.getKey(), ignored -> new LinkedHashSet<>());
+
+ for (Dependency dependency : entry.getValue()) {
+ if (!dependency.getGroupId().contains("org.apache.paimon")) {
+ dependencies.add(dependency);
+ }
+ }
+ });
+
+ return combinedAndFiltered;
+ }
+
+ private static int ensureRequiredNoticeFiles(
+ Map<String, Set<Dependency>> modulesWithShadedDependencies,
+ Collection<String> modulesWithNoticeFile) {
+ int severeIssueCount = 0;
+ Set<String> shadingModules = new HashSet<>(modulesWithShadedDependencies.keySet());
+ shadingModules.removeAll(modulesWithNoticeFile);
+ for (String moduleWithoutNotice : shadingModules) {
+ if (modulesWithShadedDependencies.get(moduleWithoutNotice).stream()
+ .anyMatch(dependency -> !dependency.getGroupId().equals("org.apache.paimon"))) {
+ LOG.error(
+ "Module {} is missing a NOTICE file. It has shaded dependencies: {}",
+ moduleWithoutNotice,
+ modulesWithShadedDependencies.get(moduleWithoutNotice).stream()
+ .map(Dependency::toString)
+ .collect(Collectors.joining("\n\t", "\n\t", "")));
+ severeIssueCount++;
+ }
+ }
+ return severeIssueCount;
+ }
+
+ private static String getModuleFromNoticeFile(Path noticeFile) {
+ Path moduleDirectory =
+ noticeFile
+ .getParent() // META-INF
+ .getParent() // resources
+ .getParent() // main
+ .getParent() // src
+ .getParent(); // <-- module name
+ return moduleDirectory.getFileName().toString();
+ }
+
+ private static int checkNoticeFileAndLogProblems(
+ Map<String, Set<Dependency>> modulesWithShadedDependencies,
+ String moduleName,
+ @Nullable NoticeContents noticeContents)
+ throws IOException {
+
+ final Map<Severity, List<String>> problemsBySeverity =
+ checkNoticeFile(modulesWithShadedDependencies, moduleName, noticeContents);
+
+ final List<String> severeProblems =
+ problemsBySeverity.getOrDefault(Severity.CRITICAL, Collections.emptyList());
+
+ if (!problemsBySeverity.isEmpty()) {
+ final List<String> toleratedProblems =
+ problemsBySeverity.getOrDefault(Severity.TOLERATED, Collections.emptyList());
+ final List<String> expectedProblems =
+ problemsBySeverity.getOrDefault(Severity.SUPPRESSED, Collections.emptyList());
+
+ LOG.info(
+ "Problems were detected for a NOTICE file.\n" + "\t{}:\n" + "{}{}{}",
+ moduleName,
+ convertProblemsToIndentedString(
+ severeProblems,
+ "These issue are legally problematic and MUST be fixed:"),
+ convertProblemsToIndentedString(
+ toleratedProblems,
+ "These issues are mistakes that aren't legally problematic. They SHOULD be fixed at some point, but we don't have to:"),
+ convertProblemsToIndentedString(
+ expectedProblems, "These issues are assumed to be false-positives:"));
+ }
+
+ return severeProblems.size();
+ }
+
+ @VisibleForTesting
+ static Map<Severity, List<String>> checkNoticeFile(
+ Map<String, Set<Dependency>> modulesWithShadedDependencies,
+ String moduleName,
+ @Nullable NoticeContents noticeContents) {
+
+ final Map<Severity, List<String>> problemsBySeverity = new HashMap<>();
+
+ if (noticeContents == null) {
+ addProblem(problemsBySeverity, Severity.CRITICAL, "The NOTICE file was empty.");
+ } else {
+ // first line must be the module name.
+ if (!noticeContents.getNoticeModuleName().equals(moduleName)) {
+ addProblem(
+ problemsBySeverity,
+ Severity.TOLERATED,
+ String.format(
+ "First line does not start with module name. firstLine=%s",
+ noticeContents.getNoticeModuleName()));
+ }
+
+ // collect all declared dependencies from NOTICE file
+ Set<Dependency> declaredDependencies = new HashSet<>();
+ for (Dependency declaredDependency : noticeContents.getDeclaredDependencies()) {
+ if (!declaredDependencies.add(declaredDependency)) {
+ addProblem(
+ problemsBySeverity,
+ Severity.CRITICAL,
+ String.format("Dependency %s is declared twice.", declaredDependency));
+ }
+ }
+
+ // find all dependencies missing from NOTICE file
+ Collection<Dependency> expectedDependencies =
+ modulesWithShadedDependencies.getOrDefault(moduleName, Collections.emptySet())
+ .stream()
+ .filter(
+ dependency ->
+ !dependency.getGroupId().equals("org.apache.paimon"))
+ .collect(Collectors.toList());
+
+ for (Dependency expectedDependency : expectedDependencies) {
+ if (!declaredDependencies.contains(expectedDependency)) {
+ addProblem(
+ problemsBySeverity,
+ Severity.CRITICAL,
+ String.format("Dependency %s is not listed.", expectedDependency));
+ }
+ }
+
+ boolean moduleDefinesExcessDependencies =
+ MODULES_DEFINING_EXCESS_DEPENDENCIES.contains(moduleName);
+
+ // find all dependencies defined in NOTICE file, which were not expected
+ for (Dependency declaredDependency : declaredDependencies) {
+ if (!expectedDependencies.contains(declaredDependency)) {
+ final Severity severity =
+ moduleDefinesExcessDependencies
+ ? Severity.SUPPRESSED
+ : Severity.TOLERATED;
+ addProblem(
+ problemsBySeverity,
+ severity,
+ String.format(
+ "Dependency %s is not bundled, but listed.",
+ declaredDependency));
+ }
+ }
+ }
+
+ return problemsBySeverity;
+ }
+
+ private static void addProblem(
+ Map<Severity, List<String>> problemsBySeverity, Severity severity, String problem) {
+ problemsBySeverity.computeIfAbsent(severity, ignored -> new ArrayList<>()).add(problem);
+ }
+
+ @VisibleForTesting
+ enum Severity {
+ /** Issues that a legally problematic which must be fixed. */
+ CRITICAL,
+ /** Issues that affect the correctness but aren't legally problematic. */
+ TOLERATED,
+ /** Issues where we intentionally break the rules. */
+ SUPPRESSED
+ }
+
+ private static String convertProblemsToIndentedString(List<String> problems, String header) {
+ return problems.isEmpty()
+ ? ""
+ : problems.stream()
+ .map(s -> "\t\t\t" + s)
+ .collect(Collectors.joining("\n", "\t\t " + header + " \n", "\n"));
+ }
+
+ private static List<Path> findNoticeFiles(Path root) throws IOException {
+ return Files.walk(root)
+ .filter(
+ file -> {
+ int nameCount = file.getNameCount();
+ return file.getName(nameCount - 3).toString().equals("resources")
+ && file.getName(nameCount - 2).toString().equals("META-INF")
+ && file.getName(nameCount - 1).toString().equals("NOTICE");
+ })
+ .collect(Collectors.toList());
+ }
+
+ private static List<String> loadFromResources(String fileName) {
+ try {
+ try (BufferedReader bufferedReader =
+ new BufferedReader(
+ new InputStreamReader(
+ Objects.requireNonNull(
+ NoticeFileChecker.class.getResourceAsStream(
+ "/" + fileName))))) {
+
+ List<String> result =
+ bufferedReader
+ .lines()
+ .filter(line -> !line.startsWith("#") && !line.isEmpty())
+ .collect(Collectors.toList());
+ LOG.debug("Loaded {} items from resource {}", result.size(), fileName);
+ return result;
+ }
+ } catch (Throwable e) {
+ // wrap anything in a RuntimeException to be callable from the static initializer
+ throw new RuntimeException("Error while loading resource", e);
+ }
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/suffixcheck/ScalaSuffixChecker.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/suffixcheck/ScalaSuffixChecker.java
new file mode 100644
index 0000000..544fb8b
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/suffixcheck/ScalaSuffixChecker.java
@@ -0,0 +1,262 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.suffixcheck;
+
+import org.apache.paimon.tools.ci.utils.dependency.DependencyParser;
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+import org.apache.paimon.tools.ci.utils.shared.DependencyTree;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** Utility for checking the presence/absence of scala-suffixes. */
+public class ScalaSuffixChecker {
+ private static final Logger LOG = LoggerFactory.getLogger(ScalaSuffixChecker.class);
+
+ // [INFO] --- maven-dependency-plugin:3.1.1:tree (default-cli) @ paimon-annotations ---
+ private static final Pattern moduleNamePattern =
+ Pattern.compile(".* --- maven-dependency-plugin.* @ (.*) ---.*");
+
+ // [INFO] +- junit:junit:jar:4.13.2:test
+ // [INFO] | \- org.hamcrest:hamcrest-core:jar:1.3:test
+ // [INFO] \- org.apache.logging.log4j:log4j-1.2-api:jar:2.14.1:test
+ private static final Pattern blockPattern = Pattern.compile(".* [+|\\\\].*");
+
+ // [INFO] +- org.scala-lang:scala-reflect:jar:2.11.12:test
+ private static final Pattern scalaSuffixPattern = Pattern.compile("_2.1[0-9]");
+
+ private static final Set<String> EXCLUDED_MODULES =
+ new HashSet<>(
+ Arrays.asList());
+
+ public static void main(String[] args) throws IOException {
+ if (args.length < 2) {
+ System.out.println("Usage: ScalaSuffixChecker <pathMavenBuildOutput> <pathPaimonRoot>");
+ System.exit(1);
+ }
+
+ final Path mavenOutputPath = Paths.get(args[0]);
+ final Path paimonRootPath = Paths.get(args[1]);
+
+ final ParseResult parseResult = parseMavenOutput(mavenOutputPath);
+ if (parseResult.getCleanModules().isEmpty()) {
+ LOG.error("Parsing found 0 scala-free modules; the parsing is likely broken.");
+ System.exit(1);
+ }
+ if (parseResult.getInfectedModules().isEmpty()) {
+ LOG.error("Parsing found 0 scala-dependent modules; the parsing is likely broken.");
+ System.exit(1);
+ }
+
+ final Collection<String> violations = checkScalaSuffixes(parseResult, paimonRootPath);
+
+ if (!violations.isEmpty()) {
+ LOG.error(
+ "Violations found:{}",
+ violations.stream().collect(Collectors.joining("\n\t", "\n\t", "")));
+ System.exit(1);
+ }
+ }
+
+ private static ParseResult parseMavenOutput(final Path path) throws IOException {
+ final Set<String> cleanModules = new HashSet<>();
+ final Set<String> infectedModules = new HashSet<>();
+
+ final Map<String, DependencyTree> dependenciesByModule =
+ DependencyParser.parseDependencyTreeOutput(path);
+
+ for (String module : dependenciesByModule.keySet()) {
+ final String moduleName = stripScalaSuffix(module);
+ if (isExcluded(moduleName)) {
+ continue;
+ }
+ LOG.trace("Processing module '{}'.", moduleName);
+
+ final List<Dependency> dependencies =
+ dependenciesByModule.get(module).flatten().collect(Collectors.toList());
+
+ boolean infected = false;
+ for (Dependency dependency : dependencies) {
+ final boolean dependsOnScala = dependsOnScala(dependency);
+ final boolean isTestDependency = dependency.getScope().get().equals("test");
+ final boolean isExcluded = isExcluded(dependency.getArtifactId());
+ LOG.trace("\tdependency:{}", dependency);
+ LOG.trace("\t\tdepends-on-scala:{}", dependsOnScala);
+ LOG.trace("\t\tis-test-dependency:{}", isTestDependency);
+ LOG.trace("\t\tis-excluded:{}", isExcluded);
+ if (dependsOnScala && !isTestDependency && !isExcluded) {
+ LOG.trace("\t\tOutbreak detected at {}!", moduleName);
+ infected = true;
+ }
+ }
+
+ if (infected) {
+ infectedModules.add(moduleName);
+ } else {
+ cleanModules.add(moduleName);
+ }
+ }
+
+ return new ParseResult(cleanModules, infectedModules);
+ }
+
+ private static String stripScalaSuffix(final String moduleName) {
+ final int i = moduleName.indexOf("_2.");
+ return i > 0 ? moduleName.substring(0, i) : moduleName;
+ }
+
+ private static boolean dependsOnScala(final Dependency dependency) {
+ return dependency.getGroupId().contains("org.scala-lang")
+ || scalaSuffixPattern.matcher(dependency.getArtifactId()).find();
+ }
+
+ private static Collection<String> checkScalaSuffixes(
+ final ParseResult parseResult, Path paimonRootPath) throws IOException {
+ final Collection<String> violations = new ArrayList<>();
+
+ // exclude e2e modules and paimon-docs for convenience as they
+ // a) are not deployed during a release
+ // b) exist only for dev purposes
+ // c) no-one should depend on them
+ final Collection<String> excludedModules = new ArrayList<>();
+ excludedModules.add("paimon-docs");
+ excludedModules.addAll(getEndToEndTestModules(paimonRootPath));
+
+ for (String excludedModule : excludedModules) {
+ parseResult.getCleanModules().remove(excludedModule);
+ parseResult.getInfectedModules().remove(excludedModule);
+ }
+
+ violations.addAll(checkCleanModules(parseResult.getCleanModules(), paimonRootPath));
+ violations.addAll(checkInfectedModules(parseResult.getInfectedModules(), paimonRootPath));
+
+ return violations;
+ }
+
+ private static Collection<String> getEndToEndTestModules(Path paimonRootPath)
+ throws IOException {
+ try (Stream<Path> pathStream =
+ Files.walk(paimonRootPath.resolve("paimon-e2e-tests"), 5)) {
+ return pathStream
+ .filter(path -> path.getFileName().toString().equals("pom.xml"))
+ .map(path -> path.getParent().getFileName().toString())
+ .collect(Collectors.toList());
+ }
+ }
+
+ private static Collection<String> checkCleanModules(
+ Collection<String> modules, Path paimonRootPath) throws IOException {
+ return checkModules(
+ modules,
+ paimonRootPath,
+ "_${scala.binary.version}",
+ "Scala-free module '%s' is referenced with scala suffix in '%s'.");
+ }
+
+ private static Collection<String> checkInfectedModules(
+ Collection<String> modules, Path paimonRootPath) throws IOException {
+ return checkModules(
+ modules,
+ paimonRootPath,
+ "",
+ "Scala-dependent module '%s' is referenced without scala suffix in '%s'.");
+ }
+
+ private static Collection<String> checkModules(
+ Collection<String> modules,
+ Path paimonRootPath,
+ String moduleSuffix,
+ String violationTemplate)
+ throws IOException {
+
+ final ArrayList<String> sortedModules = new ArrayList<>(modules);
+ sortedModules.sort(String::compareTo);
+
+ final Collection<String> violations = new ArrayList<>();
+ for (String module : sortedModules) {
+ int numPreviousViolations = violations.size();
+ try (Stream<Path> pathStream = Files.walk(paimonRootPath, 3)) {
+ final List<Path> pomFiles =
+ pathStream
+ .filter(path -> path.getFileName().toString().equals("pom.xml"))
+ .collect(Collectors.toList());
+
+ for (Path pomFile : pomFiles) {
+ try (Stream<String> lines = Files.lines(pomFile, StandardCharsets.UTF_8)) {
+ final boolean existsCleanReference =
+ lines.anyMatch(
+ line ->
+ line.contains(
+ module + moduleSuffix + "</artifactId>"));
+
+ if (existsCleanReference) {
+ violations.add(
+ String.format(
+ violationTemplate,
+ module,
+ paimonRootPath.relativize(pomFile)));
+ }
+ }
+ }
+ }
+ if (numPreviousViolations == violations.size()) {
+ LOG.info("OK {}", module);
+ }
+ }
+ return violations;
+ }
+
+ private static boolean isExcluded(String line) {
+ return EXCLUDED_MODULES.stream().anyMatch(line::contains);
+ }
+
+ private static class ParseResult {
+
+ private final Set<String> cleanModules;
+ private final Set<String> infectedModules;
+
+ private ParseResult(Set<String> cleanModules, Set<String> infectedModules) {
+ this.cleanModules = cleanModules;
+ this.infectedModules = infectedModules;
+ }
+
+ public Set<String> getCleanModules() {
+ return cleanModules;
+ }
+
+ public Set<String> getInfectedModules() {
+ return infectedModules;
+ }
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParser.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParser.java
new file mode 100644
index 0000000..1ddcc55
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParser.java
@@ -0,0 +1,228 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.dependency;
+
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+import org.apache.paimon.tools.ci.utils.shared.DependencyTree;
+import org.apache.paimon.tools.ci.utils.shared.ParserUtils;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.Stack;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/** Parsing utils for the Maven dependency plugin. */
+public class DependencyParser {
+
+ private static final Pattern DEPENDENCY_COPY_NEXT_MODULE_PATTERN =
+ Pattern.compile(
+ ".*maven-dependency-plugin:[^:]+:copy .* @ (?<module>[^ _]+)(?:_[0-9.]+)? --.*");
+
+ private static final Pattern DEPENDENCY_TREE_NEXT_MODULE_PATTERN =
+ Pattern.compile(
+ ".*maven-dependency-plugin:[^:]+:tree .* @ (?<module>[^ _]+)(?:_[0-9.]+)? --.*");
+
+ /** See {@link DependencyParserTreeTest} for examples. */
+ private static final Pattern DEPENDENCY_TREE_ITEM_PATTERN =
+ Pattern.compile(
+ ".* +"
+ + "(?<groupId>.*?):"
+ + "(?<artifactId>.*?):"
+ + "(?<type>.*?):"
+ + "(?:(?<classifier>.*?):)?"
+ + "(?<version>.*?):"
+ + "(?<scope>[^ ]*)"
+ + "(?<optional> \\(optional\\))?");
+
+ /** See {@link DependencyParserCopyTest} for examples. */
+ private static final Pattern DEPENDENCY_COPY_ITEM_PATTERN =
+ Pattern.compile(
+ ".* Configured Artifact: +"
+ + "(?<groupId>.*?):"
+ + "(?<artifactId>.*?):"
+ + "(?:(?<classifier>.*?):)?"
+ + "(?<version>.*?):"
+ + "(?:\\?:)?" // unknown cause; e.g.: javax.xml.bind:jaxb-api:?:jar
+ + "(?<type>.*)");
+
+ /**
+ * Parses the output of a Maven build where {@code dependency:copy} was used, and returns a set
+ * of copied dependencies for each module.
+ *
+ * <p>The returned dependencies will NEVER contain the scope or optional flag.
+ */
+ public static Map<String, Set<Dependency>> parseDependencyCopyOutput(Path buildOutput)
+ throws IOException {
+ return processLines(buildOutput, DependencyParser::parseDependencyCopyOutput);
+ }
+
+ /**
+ * Parses the output of a Maven build where {@code dependency:tree} was used, and returns a set
+ * of dependencies for each module.
+ */
+ public static Map<String, DependencyTree> parseDependencyTreeOutput(Path buildOutput)
+ throws IOException {
+ return processLines(buildOutput, DependencyParser::parseDependencyTreeOutput);
+ }
+
+ private static <X> X processLines(Path buildOutput, Function<Stream<String>, X> processor)
+ throws IOException {
+ try (Stream<String> lines = Files.lines(buildOutput)) {
+ return processor.apply(lines.filter(line -> line.contains("[INFO]")));
+ }
+ }
+
+ @VisibleForTesting
+ static Map<String, Set<Dependency>> parseDependencyCopyOutput(Stream<String> lines) {
+ return ParserUtils.parsePluginOutput(
+ lines,
+ DEPENDENCY_COPY_NEXT_MODULE_PATTERN,
+ DependencyParser::parseCopyDependencyBlock);
+ }
+
+ @VisibleForTesting
+ static Map<String, DependencyTree> parseDependencyTreeOutput(Stream<String> lines) {
+ return ParserUtils.parsePluginOutput(
+ lines,
+ DEPENDENCY_TREE_NEXT_MODULE_PATTERN,
+ DependencyParser::parseTreeDependencyBlock);
+ }
+
+ private static Set<Dependency> parseCopyDependencyBlock(Iterator<String> block) {
+ final Set<Dependency> dependencies = new LinkedHashSet<>();
+
+ Optional<Dependency> parsedDependency = parseCopyDependency(block.next());
+ while (parsedDependency.isPresent()) {
+ dependencies.add(parsedDependency.get());
+
+ if (block.hasNext()) {
+ parsedDependency = parseCopyDependency(block.next());
+ } else {
+ parsedDependency = Optional.empty();
+ }
+ }
+
+ return dependencies;
+ }
+
+ private static DependencyTree parseTreeDependencyBlock(Iterator<String> block) {
+ // discard one line, which only contains the current module name
+ block.next();
+
+ if (!block.hasNext()) {
+ throw new IllegalStateException("Expected more output from the dependency-plugin.");
+ }
+
+ final DependencyTree dependencies = new DependencyTree();
+
+ final Stack<Dependency> parentStack = new Stack<>();
+ final Stack<Integer> treeDepthStack = new Stack<>();
+ String line = block.next();
+ Optional<Dependency> parsedDependency = parseTreeDependency(line);
+ while (parsedDependency.isPresent()) {
+ int treeDepth = getDepth(line);
+
+ while (!treeDepthStack.isEmpty() && treeDepth <= treeDepthStack.peek()) {
+ parentStack.pop();
+ treeDepthStack.pop();
+ }
+
+ final Dependency dependency = parsedDependency.get();
+
+ if (parentStack.isEmpty()) {
+ dependencies.addDirectDependency(dependency);
+ } else {
+ dependencies.addTransitiveDependencyTo(dependency, parentStack.peek());
+ }
+
+ if (treeDepthStack.isEmpty() || treeDepth > treeDepthStack.peek()) {
+ treeDepthStack.push(treeDepth);
+ parentStack.push(dependency);
+ }
+
+ if (block.hasNext()) {
+ line = block.next();
+ parsedDependency = parseTreeDependency(line);
+ } else {
+ parsedDependency = Optional.empty();
+ }
+ }
+
+ return dependencies;
+ }
+
+ static Optional<Dependency> parseCopyDependency(String line) {
+ Matcher dependencyMatcher = DEPENDENCY_COPY_ITEM_PATTERN.matcher(line);
+ if (!dependencyMatcher.find()) {
+ return Optional.empty();
+ }
+
+ return Optional.of(
+ Dependency.create(
+ dependencyMatcher.group("groupId"),
+ dependencyMatcher.group("artifactId"),
+ dependencyMatcher.group("version"),
+ dependencyMatcher.group("classifier")));
+ }
+
+ @VisibleForTesting
+ static Optional<Dependency> parseTreeDependency(String line) {
+ Matcher dependencyMatcher = DEPENDENCY_TREE_ITEM_PATTERN.matcher(line);
+ if (!dependencyMatcher.find()) {
+ return Optional.empty();
+ }
+
+ return Optional.of(
+ Dependency.create(
+ dependencyMatcher.group("groupId"),
+ dependencyMatcher.group("artifactId"),
+ dependencyMatcher.group("version"),
+ dependencyMatcher.group("classifier"),
+ dependencyMatcher.group("scope"),
+ dependencyMatcher.group("optional") != null));
+ }
+
+ /**
+ * The depths returned by this method do NOT return a continuous sequence.
+ *
+ * <pre>
+ * +- org.apache.paimon:...
+ * | +- org.apache.paimon:...
+ * | | \- org.apache.paimon:...
+ * ...
+ * </pre>
+ */
+ private static int getDepth(String line) {
+ final int level = line.indexOf('+');
+ if (level != -1) {
+ return level;
+ }
+ return line.indexOf('\\');
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/deploy/DeployParser.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/deploy/DeployParser.java
new file mode 100644
index 0000000..89a3e5e
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/deploy/DeployParser.java
@@ -0,0 +1,74 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.deploy;
+
+import org.apache.paimon.tools.ci.utils.shared.ParserUtils;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** Parsing utils for the Maven deploy plugin. */
+public class DeployParser {
+
+ // Examples:
+ //
+ // Deployment on CI with alternative repo
+ // [INFO] --- maven-deploy-plugin:2.8.2:deploy (default-deploy) @ paimon-parent ---
+ // [INFO] Using alternate deployment repository.../tmp/paimon-validation-deployment
+ //
+ // Skipped deployment:
+ // [INFO] --- maven-deploy-plugin:2.8.2:deploy (default-deploy) @ paimon-parent ---
+ // [INFO] Skipping artifact deployment
+ private static final Pattern DEPLOY_MODULE_PATTERN =
+ Pattern.compile(
+ ".maven-deploy-plugin:.*:deploy .* @ (?<module>[^ _]+)(?:_[0-9.]+)? --.*");
+
+ /**
+ * Parses the output of a Maven build where {@code deploy:deploy} was used, and returns a set of
+ * deployed modules.
+ */
+ public static Set<String> parseDeployOutput(File buildResult) throws IOException {
+ try (Stream<String> linesStream = Files.lines(buildResult.toPath())) {
+ return parseDeployOutput(linesStream);
+ }
+ }
+
+ @VisibleForTesting
+ static Set<String> parseDeployOutput(Stream<String> lines) {
+ return ParserUtils.parsePluginOutput(
+ lines, DEPLOY_MODULE_PATTERN, DeployParser::parseDeployBlock)
+ .entrySet().stream()
+ .filter(Map.Entry::getValue)
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toSet());
+ }
+
+ private static boolean parseDeployBlock(Iterator<String> block) {
+ return block.hasNext() && !block.next().contains("Skipping artifact deployment");
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/notice/NoticeContents.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/notice/NoticeContents.java
new file mode 100644
index 0000000..8dadaa3
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/notice/NoticeContents.java
@@ -0,0 +1,42 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.notice;
+
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+
+import java.util.Collection;
+
+/** Represents the parsed contents of a NOTICE file. */
+public class NoticeContents {
+ private final String noticeModuleName;
+ private final Collection<Dependency> declaredDependencies;
+
+ public NoticeContents(String noticeModuleName, Collection<Dependency> declaredDependencies) {
+ this.noticeModuleName = noticeModuleName;
+ this.declaredDependencies = declaredDependencies;
+ }
+
+ public String getNoticeModuleName() {
+ return noticeModuleName;
+ }
+
+ public Collection<Dependency> getDeclaredDependencies() {
+ return declaredDependencies;
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/notice/NoticeParser.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/notice/NoticeParser.java
new file mode 100644
index 0000000..293dad8
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/notice/NoticeParser.java
@@ -0,0 +1,95 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.notice;
+
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Parsing utils for NOTICE files. */
+public class NoticeParser {
+
+ // "- org.apache.htrace:htrace-core:3.1.0-incubating"
+ private static final Pattern NOTICE_DEPENDENCY_PATTERN =
+ Pattern.compile(
+ "- "
+ + "(?<groupId>[^ ]*?):"
+ + "(?<artifactId>[^ ]*?):"
+ + "(?:(?<classifier>[^ ]*?):)?"
+ + "(?<version>[^ ]*?)"
+ + "($| )");
+ // "This project bundles "net.jcip:jcip-annotations:1.0".
+ private static final Pattern NOTICE_BUNDLES_DEPENDENCY_PATTERN =
+ Pattern.compile(
+ ".*bundles \""
+ + "(?<groupId>[^ ]*?):"
+ + "(?<artifactId>[^ ]*?):"
+ + "(?:(?<classifier>[^ ]*?):)?"
+ + "(?<version>[^ ]*?)"
+ + "\".*");
+
+ public static Optional<NoticeContents> parseNoticeFile(Path noticeFile) throws IOException {
+ // 1st line contains module name
+ final List<String> noticeContents = Files.readAllLines(noticeFile);
+
+ return parseNoticeFile(noticeContents);
+ }
+
+ @VisibleForTesting
+ static Optional<NoticeContents> parseNoticeFile(List<String> noticeContents) {
+ if (noticeContents.isEmpty()) {
+ return Optional.empty();
+ }
+
+ final String noticeModuleName = noticeContents.get(0);
+
+ Collection<Dependency> declaredDependencies = new ArrayList<>();
+ for (String line : noticeContents) {
+ Optional<Dependency> dependency = tryParsing(NOTICE_DEPENDENCY_PATTERN, line);
+ if (!dependency.isPresent()) {
+ dependency = tryParsing(NOTICE_BUNDLES_DEPENDENCY_PATTERN, line);
+ }
+ dependency.ifPresent(declaredDependencies::add);
+ }
+
+ return Optional.of(new NoticeContents(noticeModuleName, declaredDependencies));
+ }
+
+ private static Optional<Dependency> tryParsing(Pattern pattern, String line) {
+ Matcher matcher = pattern.matcher(line);
+ if (matcher.find()) {
+ String groupId = matcher.group("groupId");
+ String artifactId = matcher.group("artifactId");
+ String version = matcher.group("version");
+ String classifier = matcher.group("classifier");
+ return Optional.of(Dependency.create(groupId, artifactId, version, classifier));
+ }
+ return Optional.empty();
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shade/ShadeParser.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shade/ShadeParser.java
new file mode 100644
index 0000000..3a44bf5
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shade/ShadeParser.java
@@ -0,0 +1,113 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.shade;
+
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+import org.apache.paimon.tools.ci.utils.shared.ParserUtils;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/** Utils for parsing the shade-plugin output. */
+public final class ShadeParser {
+
+ private static final Pattern SHADE_NEXT_MODULE_PATTERN =
+ Pattern.compile(
+ ".*:shade \\((?:shade-paimon|shade-dist|default)\\) @ (?<module>[^ _]+)(?:_[0-9.]+)? --.*");
+
+ private static final Pattern SHADE_INCLUDE_MODULE_PATTERN =
+ Pattern.compile(
+ ".* "
+ + "(?<groupId>.*?):"
+ + "(?<artifactId>.*?):"
+ + "(?<type>.*?):"
+ + "(?:(?<classifier>.*?):)?"
+ + "(?<version>.*?)"
+ + " in the shaded jar");
+
+ /**
+ * Parses the output of a Maven build where {@code shade:shade} was used, and returns a set of
+ * bundled dependencies for each module.
+ *
+ * <p>The returned dependencies will NEVER contain the scope or optional flag.
+ *
+ * <p>This method only considers the {@code shade-paimon} and {@code shade-dist} executions,
+ * because all artifacts we produce that are either published or referenced are created by these
+ * executions. In other words, all artifacts from other executions are only used internally by
+ * the module that created them.
+ */
+ public static Map<String, Set<Dependency>> parseShadeOutput(Path buildOutput)
+ throws IOException {
+ try (Stream<String> lines = Files.lines(buildOutput)) {
+ return parseShadeOutput(lines);
+ }
+ }
+
+ @VisibleForTesting
+ static Map<String, Set<Dependency>> parseShadeOutput(Stream<String> lines) {
+ return ParserUtils.parsePluginOutput(
+ lines.filter(line -> !line.contains(" Excluding ")),
+ SHADE_NEXT_MODULE_PATTERN,
+ ShadeParser::parseBlock);
+ }
+
+ private static Set<Dependency> parseBlock(Iterator<String> block) {
+ final Set<Dependency> dependencies = new LinkedHashSet<>();
+
+ Optional<Dependency> parsedDependency = parseDependency(block.next());
+ while (parsedDependency.isPresent()) {
+ dependencies.add(parsedDependency.get());
+
+ if (block.hasNext()) {
+ parsedDependency = parseDependency(block.next());
+ } else {
+ parsedDependency = Optional.empty();
+ }
+ }
+
+ return dependencies;
+ }
+
+ @VisibleForTesting
+ static Optional<Dependency> parseDependency(String line) {
+ Matcher dependencyMatcher = SHADE_INCLUDE_MODULE_PATTERN.matcher(line);
+ if (!dependencyMatcher.find()) {
+ return Optional.empty();
+ }
+
+ return Optional.of(
+ Dependency.create(
+ dependencyMatcher.group("groupId"),
+ dependencyMatcher.group("artifactId"),
+ dependencyMatcher.group("version"),
+ dependencyMatcher.group("classifier")));
+ }
+
+ private ShadeParser() {}
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/Dependency.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/Dependency.java
new file mode 100644
index 0000000..6dae361
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/Dependency.java
@@ -0,0 +1,133 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.shared;
+
+import javax.annotation.Nullable;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Represents a dependency.
+ *
+ * <p>For some properties we return an {@link Optional}, for those that, depending on the plugin
+ * goal, may not be determinable. For example, {@code dependency:copy} never prints the scope or
+ * optional flag.
+ */
+public final class Dependency {
+
+ private final String groupId;
+ private final String artifactId;
+ private final String version;
+ @Nullable private final String classifier;
+ @Nullable private final String scope;
+ @Nullable private final Boolean isOptional;
+
+ private Dependency(
+ String groupId,
+ String artifactId,
+ String version,
+ @Nullable String classifier,
+ @Nullable String scope,
+ @Nullable Boolean isOptional) {
+ this.groupId = Objects.requireNonNull(groupId);
+ this.artifactId = Objects.requireNonNull(artifactId);
+ this.version = Objects.requireNonNull(version);
+ this.classifier = classifier;
+ this.scope = scope;
+ this.isOptional = isOptional;
+ }
+
+ public static Dependency create(
+ String groupId,
+ String artifactId,
+ String version,
+ String classifier,
+ String scope,
+ boolean isOptional) {
+ return new Dependency(
+ groupId,
+ artifactId,
+ version,
+ classifier,
+ Objects.requireNonNull(scope),
+ isOptional);
+ }
+
+ public static Dependency create(
+ String groupId, String artifactId, String version, String classifier) {
+ return new Dependency(groupId, artifactId, version, classifier, null, null);
+ }
+
+ public String getGroupId() {
+ return groupId;
+ }
+
+ public String getArtifactId() {
+ return artifactId;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public Optional<String> getClassifier() {
+ return Optional.ofNullable(classifier);
+ }
+
+ public Optional<String> getScope() {
+ return Optional.ofNullable(scope);
+ }
+
+ public Optional<Boolean> isOptional() {
+ return Optional.ofNullable(isOptional);
+ }
+
+ @Override
+ public String toString() {
+ return groupId
+ + ":"
+ + artifactId
+ + ":"
+ + version
+ + (classifier != null ? ":" + classifier : "")
+ + (scope != null ? ":" + scope : "")
+ + (isOptional != null && isOptional ? " (optional)" : "");
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Dependency that = (Dependency) o;
+ return Objects.equals(groupId, that.groupId)
+ && Objects.equals(artifactId, that.artifactId)
+ && Objects.equals(version, that.version)
+ && Objects.equals(classifier, that.classifier)
+ && Objects.equals(scope, that.scope)
+ && Objects.equals(isOptional, that.isOptional);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(groupId, artifactId, version, classifier, scope, isOptional);
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/DependencyTree.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/DependencyTree.java
new file mode 100644
index 0000000..a6a5f4d
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/DependencyTree.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://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.paimon.tools.ci.utils.shared;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.graph.Traverser;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+/**
+ * Represents a dependency tree.
+ *
+ * <p>Every dependency can only occur exactly once.
+ */
+public class DependencyTree {
+
+ private final Map<String, Node> lookup = new LinkedHashMap<>();
+ private final List<Node> directDependencies = new ArrayList<>();
+
+ public void addDirectDependency(Dependency dependency) {
+ final String key = getKey(dependency);
+ if (lookup.containsKey(key)) {
+ return;
+ }
+ final Node node = new Node(dependency, null);
+
+ lookup.put(key, node);
+ directDependencies.add(node);
+ }
+
+ public void addTransitiveDependencyTo(Dependency transitiveDependency, Dependency parent) {
+ final String key = getKey(transitiveDependency);
+ if (lookup.containsKey(key)) {
+ return;
+ }
+ final Node node = lookup.get(getKey(parent)).addTransitiveDependency(transitiveDependency);
+
+ lookup.put(key, node);
+ }
+
+ private static final class Node {
+ private final Dependency dependency;
+ @Nullable private final Node parent;
+ private final List<Node> children = new ArrayList<>();
+
+ private Node(Dependency dependency, @Nullable Node parent) {
+ this.dependency = dependency;
+ this.parent = parent;
+ }
+
+ public Node addTransitiveDependency(Dependency dependency) {
+ final Node node = new Node(dependency, this);
+ this.children.add(node);
+ return node;
+ }
+
+ private boolean isRoot() {
+ return parent == null;
+ }
+ }
+
+ public List<Dependency> getPathTo(Dependency dependency) {
+ final LinkedList<Dependency> path = new LinkedList<>();
+
+ Node node = lookup.get(getKey(dependency));
+ path.addFirst(node.dependency);
+ while (!node.isRoot()) {
+ node = node.parent;
+ path.addFirst(node.dependency);
+ }
+
+ return path;
+ }
+
+ public Stream<Dependency> flatten() {
+ return StreamSupport.stream(
+ Traverser.<Node>forTree(node -> node.children)
+ .depthFirstPreOrder(directDependencies)
+ .spliterator(),
+ false)
+ .map(node -> node.dependency);
+ }
+
+ /**
+ * We don't use the {@link Dependency} as a key because we don't want lookups to be dependent on
+ * scope or the optional flag.
+ *
+ * @param dependency
+ * @return
+ */
+ @VisibleForTesting
+ static String getKey(Dependency dependency) {
+ return dependency.getGroupId()
+ + ":"
+ + dependency.getArtifactId()
+ + ":"
+ + dependency.getVersion()
+ + ":"
+ + dependency.getClassifier().orElse("(no-classifier)");
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/ParserUtils.java b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/ParserUtils.java
new file mode 100644
index 0000000..5d1e21b
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/java/org/apache/paimon/tools/ci/utils/shared/ParserUtils.java
@@ -0,0 +1,73 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.shared;
+
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/** Parsing utils. */
+public class ParserUtils {
+
+ /**
+ * Iterates over the given lines, identifying plugin execution blocks with the given pattern and
+ * parses the plugin output with the given parser.
+ *
+ * <p>This method assumes that the given pattern matches at most once for each module.
+ *
+ * <p>The given pattern must include a {@code module} group that captures the module that the
+ * plugin runs on (without the scala suffix!).
+ *
+ * @param lines maven output lines
+ * @param executionLinePattern pattern that matches plugin executions
+ * @param blockParser parser for the plugin block
+ * @return map containing the parser result for each module
+ * @param <D> block parser output
+ */
+ public static <D> Map<String, D> parsePluginOutput(
+ Stream<String> lines,
+ Pattern executionLinePattern,
+ Function<Iterator<String>, D> blockParser) {
+ final Map<String, D> result = new LinkedHashMap<>();
+
+ final Iterator<String> iterator = lines.iterator();
+
+ while (iterator.hasNext()) {
+ Matcher moduleMatcher = executionLinePattern.matcher(iterator.next());
+ while (!moduleMatcher.find()) {
+ if (iterator.hasNext()) {
+ moduleMatcher = executionLinePattern.matcher(iterator.next());
+ } else {
+ return result;
+ }
+ }
+ final String currentModule = moduleMatcher.group("module");
+
+ if (!iterator.hasNext()) {
+ throw new IllegalStateException("Expected more output from the plugin.");
+ }
+
+ result.put(currentModule, blockParser.apply(iterator));
+ }
+ return result;
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/main/resources/log4j2.properties b/tools/ci/paimon-ci-tools/src/main/resources/log4j2.properties
new file mode 100644
index 0000000..b0b222b
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/resources/log4j2.properties
@@ -0,0 +1,25 @@
+################################################################################
+# 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
+#
+# http://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.
+################################################################################
+
+rootLogger.level = DEBUG
+rootLogger.appenderRef.console.ref = ConsoleAppender
+
+appender.console.name = ConsoleAppender
+appender.console.type = CONSOLE
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = %d{HH:mm:ss,SSS} %-5p %-60c %x - %m%n
diff --git a/tools/ci/paimon-ci-tools/src/main/resources/modules-defining-excess-dependencies.modulelist b/tools/ci/paimon-ci-tools/src/main/resources/modules-defining-excess-dependencies.modulelist
new file mode 100644
index 0000000..c496e6d
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/main/resources/modules-defining-excess-dependencies.modulelist
@@ -0,0 +1,20 @@
+################################################################################
+# 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
+#
+# http://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 lists modules which define additional dependencies, not shown by the maven shade plugin output.
+# These are usually undeclared shaded (or otherwise included) dependencies from transitive dependencies.
diff --git a/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/licensecheck/JarFileCheckerTest.java b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/licensecheck/JarFileCheckerTest.java
new file mode 100644
index 0000000..15390d5
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/licensecheck/JarFileCheckerTest.java
@@ -0,0 +1,525 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.licensecheck;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.net.URI;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for the {@link JarFileChecker}.
+ *
+ * <p>For dev purposes, generate a deploy-directory with: mvn clean deploy
+ * -DaltDeploymentRepository=snapshot-repo::default::file:/tmp/paimon-deployment -DskipTests
+ * -Drat.skip and add a test checking that directory.
+ */
+class JarFileCheckerTest {
+
+ private static final List<String> VALID_NOTICE_PATH = Arrays.asList("META-INF", "NOTICE");
+ private static final List<String> VALID_LICENSE_PATH = Arrays.asList("META-INF", "LICENSE");
+
+ @Test
+ void testValidJarAccepted(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(
+ VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH))))
+ .isEqualTo(0);
+ }
+
+ @Test
+ void testRejectedOnMissingNoticeFile(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(
+ VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH))))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testRejectedOnInvalidNoticeFile(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(INVALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(
+ VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH))))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testRejectedOnNoticeFileInRoot(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ VALID_NOTICE_CONTENTS,
+ Arrays.asList("some_custom_notice")))))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testRejectedOnMissingLicenseFile(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH))))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testRejectedOnInvalidLicenseFile(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(
+ INVALID_LICENSE_CONTENTS, VALID_LICENSE_PATH))))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testRejectedOnLicenseFileInRoot(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ VALID_LICENSE_CONTENTS,
+ Arrays.asList("some_custom_license")))))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testRejectedOnLicenseFileInSomeDirectory(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ VALID_LICENSE_CONTENTS,
+ Arrays.asList(
+ "some",
+ "directory",
+ "some_custom_license")))))
+ .isEqualTo(1);
+ }
+
+ @Disabled(
+ "Currently not checked, but we may want to enforce this in the future to reduce ambiguity.")
+ void testRejectedOnAdditionalLicenseFileInMetaInf(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ VALID_LICENSE_CONTENTS,
+ Arrays.asList("META-INF", "LICENSE.txt")))))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testIgnoreLicenseDirectories(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.directoryEntry(
+ Arrays.asList("some", "license", "directory")))))
+ .isEqualTo(0);
+ }
+
+ @Test
+ void testIgnoreClassFiles(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ "content",
+ Arrays.asList("SomeLicenseClass.class")))))
+ .isEqualTo(0);
+ }
+
+ @Test
+ void testIgnoreFtlFiles(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ "content", Arrays.asList("SomeLicenseFile.ftl")))))
+ .isEqualTo(0);
+ }
+
+ @Test
+ void testIgnoreWebThirdPartyLicenses(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ "class contents",
+ Arrays.asList("web", "3rdpartylicenses.txt")))))
+ .isEqualTo(0);
+ }
+
+ @Test
+ void testForbiddenLGPLongTextDetected(@TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ "some GNU Lesser General public License text",
+ Collections.singletonList("some_file.txt")))))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testForbiddenLGPMultiLineLongTextWithCommentAndLeadingWhitespaceDetected(
+ @TempDir Path tempDir) throws Exception {
+ assertThat(
+ JarFileChecker.checkJar(
+ createJar(
+ tempDir,
+ Entry.fileEntry(VALID_NOTICE_CONTENTS, VALID_NOTICE_PATH),
+ Entry.fileEntry(VALID_LICENSE_CONTENTS, VALID_LICENSE_PATH),
+ Entry.fileEntry(
+ "some GNU Lesser General public \n\t\t//#License text",
+ Collections.singletonList("some_file.txt")))))
+ .isEqualTo(1);
+ }
+
+ private static class Entry {
+ final String contents;
+ final List<String> path;
+ final boolean isDirectory;
+
+ public static Entry directoryEntry(List<String> path) {
+ return new Entry("", path, true);
+ }
+
+ public static Entry fileEntry(String contents, List<String> path) {
+ return new Entry(contents, path, false);
+ }
+
+ private Entry(String contents, List<String> path, boolean isDirectory) {
+ this.contents = contents;
+ this.path = path;
+ this.isDirectory = isDirectory;
+ }
+ }
+
+ private static Path createJar(Path tempDir, Entry... entries) throws Exception {
+ final Path path = tempDir.resolve(UUID.randomUUID().toString() + ".jar");
+
+ final URI uriWithoutScheme = path.toUri();
+
+ final URI uri =
+ new URI(
+ "jar:file",
+ uriWithoutScheme.getHost(),
+ uriWithoutScheme.getPath(),
+ uriWithoutScheme.getFragment());
+
+ // causes FileSystems#newFileSystem to automatically create a valid zip file
+ // this is easier than manually creating a valid empty zip file manually
+ final Map<String, String> env = new HashMap<>();
+ env.put("create", "true");
+
+ try (FileSystem zip = FileSystems.newFileSystem(uri, env)) {
+ // shortcut to getting the single root
+ final Path root = zip.getPath("").toAbsolutePath();
+ for (Entry entry : entries) {
+ final Path zipPath =
+ root.resolve(
+ zip.getPath(
+ entry.path.get(0),
+ entry.path
+ .subList(1, entry.path.size())
+ .toArray(new String[] {})));
+ if (entry.isDirectory) {
+ Files.createDirectories(zipPath);
+ } else {
+ Files.createDirectories(zipPath.getParent());
+ Files.write(zipPath, entry.contents.getBytes());
+ }
+ }
+ }
+ return path;
+ }
+
+ private static final String INVALID_NOTICE_CONTENTS = "" + "invalid";
+
+ private static final String VALID_NOTICE_CONTENTS =
+ ""
+ + "Paimon : SomeModule\n"
+ + "Copyright 2014-2020 The Apache Software Foundation\n"
+ + "\n"
+ + "This product includes software developed at\n"
+ + "The Apache Software Foundation (http://www.apache.org/).";
+
+ private static final String INVALID_LICENSE_CONTENTS = "" + "invalid";
+
+ private static final String VALID_LICENSE_CONTENTS =
+ ""
+ + "\n"
+ + " Apache License\n"
+ + " Version 2.0, January 2004\n"
+ + " http://www.apache.org/licenses/\n"
+ + "\n"
+ + " TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n"
+ + "\n"
+ + " 1. Definitions.\n"
+ + "\n"
+ + " \"License\" shall mean the terms and conditions for use, reproduction,\n"
+ + " and distribution as defined by Sections 1 through 9 of this document.\n"
+ + "\n"
+ + " \"Licensor\" shall mean the copyright owner or entity authorized by\n"
+ + " the copyright owner that is granting the License.\n"
+ + "\n"
+ + " \"Legal Entity\" shall mean the union of the acting entity and all\n"
+ + " other entities that control, are controlled by, or are under common\n"
+ + " control with that entity. For the purposes of this definition,\n"
+ + " \"control\" means (i) the power, direct or indirect, to cause the\n"
+ + " direction or management of such entity, whether by contract or\n"
+ + " otherwise, or (ii) ownership of fifty percent (50%) or more of the\n"
+ + " outstanding shares, or (iii) beneficial ownership of such entity.\n"
+ + "\n"
+ + " \"You\" (or \"Your\") shall mean an individual or Legal Entity\n"
+ + " exercising permissions granted by this License.\n"
+ + "\n"
+ + " \"Source\" form shall mean the preferred form for making modifications,\n"
+ + " including but not limited to software source code, documentation\n"
+ + " source, and configuration files.\n"
+ + "\n"
+ + " \"Object\" form shall mean any form resulting from mechanical\n"
+ + " transformation or translation of a Source form, including but\n"
+ + " not limited to compiled object code, generated documentation,\n"
+ + " and conversions to other media types.\n"
+ + "\n"
+ + " \"Work\" shall mean the work of authorship, whether in Source or\n"
+ + " Object form, made available under the License, as indicated by a\n"
+ + " copyright notice that is included in or attached to the work\n"
+ + " (an example is provided in the Appendix below).\n"
+ + "\n"
+ + " \"Derivative Works\" shall mean any work, whether in Source or Object\n"
+ + " form, that is based on (or derived from) the Work and for which the\n"
+ + " editorial revisions, annotations, elaborations, or other modifications\n"
+ + " represent, as a whole, an original work of authorship. For the purposes\n"
+ + " of this License, Derivative Works shall not include works that remain\n"
+ + " separable from, or merely link (or bind by name) to the interfaces of,\n"
+ + " the Work and Derivative Works thereof.\n"
+ + "\n"
+ + " \"Contribution\" shall mean any work of authorship, including\n"
+ + " the original version of the Work and any modifications or additions\n"
+ + " to that Work or Derivative Works thereof, that is intentionally\n"
+ + " submitted to Licensor for inclusion in the Work by the copyright owner\n"
+ + " or by an individual or Legal Entity authorized to submit on behalf of\n"
+ + " the copyright owner. For the purposes of this definition, \"submitted\"\n"
+ + " means any form of electronic, verbal, or written communication sent\n"
+ + " to the Licensor or its representatives, including but not limited to\n"
+ + " communication on electronic mailing lists, source code control systems,\n"
+ + " and issue tracking systems that are managed by, or on behalf of, the\n"
+ + " Licensor for the purpose of discussing and improving the Work, but\n"
+ + " excluding communication that is conspicuously marked or otherwise\n"
+ + " designated in writing by the copyright owner as \"Not a Contribution.\"\n"
+ + "\n"
+ + " \"Contributor\" shall mean Licensor and any individual or Legal Entity\n"
+ + " on behalf of whom a Contribution has been received by Licensor and\n"
+ + " subsequently incorporated within the Work.\n"
+ + "\n"
+ + " 2. Grant of Copyright License. Subject to the terms and conditions of\n"
+ + " this License, each Contributor hereby grants to You a perpetual,\n"
+ + " worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n"
+ + " copyright license to reproduce, prepare Derivative Works of,\n"
+ + " publicly display, publicly perform, sublicense, and distribute the\n"
+ + " Work and such Derivative Works in Source or Object form.\n"
+ + "\n"
+ + " 3. Grant of Patent License. Subject to the terms and conditions of\n"
+ + " this License, each Contributor hereby grants to You a perpetual,\n"
+ + " worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n"
+ + " (except as stated in this section) patent license to make, have made,\n"
+ + " use, offer to sell, sell, import, and otherwise transfer the Work,\n"
+ + " where such license applies only to those patent claims licensable\n"
+ + " by such Contributor that are necessarily infringed by their\n"
+ + " Contribution(s) alone or by combination of their Contribution(s)\n"
+ + " with the Work to which such Contribution(s) was submitted. If You\n"
+ + " institute patent litigation against any entity (including a\n"
+ + " cross-claim or counterclaim in a lawsuit) alleging that the Work\n"
+ + " or a Contribution incorporated within the Work constitutes direct\n"
+ + " or contributory patent infringement, then any patent licenses\n"
+ + " granted to You under this License for that Work shall terminate\n"
+ + " as of the date such litigation is filed.\n"
+ + "\n"
+ + " 4. Redistribution. You may reproduce and distribute copies of the\n"
+ + " Work or Derivative Works thereof in any medium, with or without\n"
+ + " modifications, and in Source or Object form, provided that You\n"
+ + " meet the following conditions:\n"
+ + "\n"
+ + " (a) You must give any other recipients of the Work or\n"
+ + " Derivative Works a copy of this License; and\n"
+ + "\n"
+ + " (b) You must cause any modified files to carry prominent notices\n"
+ + " stating that You changed the files; and\n"
+ + "\n"
+ + " (c) You must retain, in the Source form of any Derivative Works\n"
+ + " that You distribute, all copyright, patent, trademark, and\n"
+ + " attribution notices from the Source form of the Work,\n"
+ + " excluding those notices that do not pertain to any part of\n"
+ + " the Derivative Works; and\n"
+ + "\n"
+ + " (d) If the Work includes a \"NOTICE\" text file as part of its\n"
+ + " distribution, then any Derivative Works that You distribute must\n"
+ + " include a readable copy of the attribution notices contained\n"
+ + " within such NOTICE file, excluding those notices that do not\n"
+ + " pertain to any part of the Derivative Works, in at least one\n"
+ + " of the following places: within a NOTICE text file distributed\n"
+ + " as part of the Derivative Works; within the Source form or\n"
+ + " documentation, if provided along with the Derivative Works; or,\n"
+ + " within a display generated by the Derivative Works, if and\n"
+ + " wherever such third-party notices normally appear. The contents\n"
+ + " of the NOTICE file are for informational purposes only and\n"
+ + " do not modify the License. You may add Your own attribution\n"
+ + " notices within Derivative Works that You distribute, alongside\n"
+ + " or as an addendum to the NOTICE text from the Work, provided\n"
+ + " that such additional attribution notices cannot be construed\n"
+ + " as modifying the License.\n"
+ + "\n"
+ + " You may add Your own copyright statement to Your modifications and\n"
+ + " may provide additional or different license terms and conditions\n"
+ + " for use, reproduction, or distribution of Your modifications, or\n"
+ + " for any such Derivative Works as a whole, provided Your use,\n"
+ + " reproduction, and distribution of the Work otherwise complies with\n"
+ + " the conditions stated in this License.\n"
+ + "\n"
+ + " 5. Submission of Contributions. Unless You explicitly state otherwise,\n"
+ + " any Contribution intentionally submitted for inclusion in the Work\n"
+ + " by You to the Licensor shall be under the terms and conditions of\n"
+ + " this License, without any additional terms or conditions.\n"
+ + " Notwithstanding the above, nothing herein shall supersede or modify\n"
+ + " the terms of any separate license agreement you may have executed\n"
+ + " with Licensor regarding such Contributions.\n"
+ + "\n"
+ + " 6. Trademarks. This License does not grant permission to use the trade\n"
+ + " names, trademarks, service marks, or product names of the Licensor,\n"
+ + " except as required for reasonable and customary use in describing the\n"
+ + " origin of the Work and reproducing the content of the NOTICE file.\n"
+ + "\n"
+ + " 7. Disclaimer of Warranty. Unless required by applicable law or\n"
+ + " agreed to in writing, Licensor provides the Work (and each\n"
+ + " Contributor provides its Contributions) on an \"AS IS\" BASIS,\n"
+ + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n"
+ + " implied, including, without limitation, any warranties or conditions\n"
+ + " of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n"
+ + " PARTICULAR PURPOSE. You are solely responsible for determining the\n"
+ + " appropriateness of using or redistributing the Work and assume any\n"
+ + " risks associated with Your exercise of permissions under this License.\n"
+ + "\n"
+ + " 8. Limitation of Liability. In no event and under no legal theory,\n"
+ + " whether in tort (including negligence), contract, or otherwise,\n"
+ + " unless required by applicable law (such as deliberate and grossly\n"
+ + " negligent acts) or agreed to in writing, shall any Contributor be\n"
+ + " liable to You for damages, including any direct, indirect, special,\n"
+ + " incidental, or consequential damages of any character arising as a\n"
+ + " result of this License or out of the use or inability to use the\n"
+ + " Work (including but not limited to damages for loss of goodwill,\n"
+ + " work stoppage, computer failure or malfunction, or any and all\n"
+ + " other commercial damages or losses), even if such Contributor\n"
+ + " has been advised of the possibility of such damages.\n"
+ + "\n"
+ + " 9. Accepting Warranty or Additional Liability. While redistributing\n"
+ + " the Work or Derivative Works thereof, You may choose to offer,\n"
+ + " and charge a fee for, acceptance of support, warranty, indemnity,\n"
+ + " or other liability obligations and/or rights consistent with this\n"
+ + " License. However, in accepting such obligations, You may act only\n"
+ + " on Your own behalf and on Your sole responsibility, not on behalf\n"
+ + " of any other Contributor, and only if You agree to indemnify,\n"
+ + " defend, and hold each Contributor harmless for any liability\n"
+ + " incurred by, or claims asserted against, such Contributor by reason\n"
+ + " of your accepting any such warranty or additional liability.\n"
+ + "\n"
+ + " END OF TERMS AND CONDITIONS\n"
+ + "\n"
+ + " APPENDIX: How to apply the Apache License to your work.\n"
+ + "\n"
+ + " To apply the Apache License to your work, attach the following\n"
+ + " boilerplate notice, with the fields enclosed by brackets \"[]\"\n"
+ + " replaced with your own identifying information. (Don't include\n"
+ + " the brackets!) The text should be enclosed in the appropriate\n"
+ + " comment syntax for the file format. We also recommend that a\n"
+ + " file or class name and description of purpose be included on the\n"
+ + " same \"printed page\" as the copyright notice for easier\n"
+ + " identification within third-party archives.\n"
+ + "\n"
+ + " Copyright [yyyy] [name of copyright owner]\n"
+ + "\n"
+ + " Licensed under the Apache License, Version 2.0 (the \"License\");\n"
+ + " you may not use this file except in compliance with the License.\n"
+ + " You may obtain a copy of the License at\n"
+ + "\n"
+ + " http://www.apache.org/licenses/LICENSE-2.0\n"
+ + "\n"
+ + " Unless required by applicable law or agreed to in writing, software\n"
+ + " distributed under the License is distributed on an \"AS IS\" BASIS,\n"
+ + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
+ + " See the License for the specific language governing permissions and\n"
+ + " limitations under the License.\n";
+}
diff --git a/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/licensecheck/NoticeFileCheckerTest.java b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/licensecheck/NoticeFileCheckerTest.java
new file mode 100644
index 0000000..8c86773
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/licensecheck/NoticeFileCheckerTest.java
@@ -0,0 +1,203 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.licensecheck;
+
+import org.apache.paimon.tools.ci.utils.notice.NoticeContents;
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.*;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NoticeFileCheckerTest {
+ @Test
+ void testRunHappyPath() throws IOException {
+ final String moduleName = "test";
+ final Dependency bundledDependency = Dependency.create("a", "b", "c", null);
+ final Map<String, Set<Dependency>> bundleDependencies = new HashMap<>();
+ bundleDependencies.put(moduleName, Collections.singleton(bundledDependency));
+ final Set<String> deployedModules = Collections.singleton(moduleName);
+ final Optional<NoticeContents> noticeContents =
+ Optional.of(
+ new NoticeContents(
+ moduleName, Collections.singletonList(bundledDependency)));
+
+ assertThat(
+ NoticeFileChecker.run(
+ bundleDependencies,
+ deployedModules,
+ Collections.singletonMap(moduleName, noticeContents)))
+ .isEqualTo(0);
+ }
+
+ @Test
+ void testRunRejectsMissingNotice() throws IOException {
+ final String moduleName = "test";
+ final Dependency bundledDependency = Dependency.create("a", "b", "c", null);
+ final Map<String, Set<Dependency>> bundleDependencies = new HashMap<>();
+ bundleDependencies.put(moduleName, Collections.singleton(bundledDependency));
+ final Set<String> deployedModules = Collections.singleton(moduleName);
+ final Optional<NoticeContents> missingNotice = Optional.empty();
+
+ assertThat(
+ NoticeFileChecker.run(
+ bundleDependencies,
+ deployedModules,
+ Collections.singletonMap(moduleName, missingNotice)))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testRunRejectsIncorrectNotice() throws IOException {
+ final String moduleName = "test";
+ final Dependency bundledDependency = Dependency.create("a", "b", "c", null);
+ final Map<String, Set<Dependency>> bundleDependencies = new HashMap<>();
+ bundleDependencies.put(moduleName, Collections.singleton(bundledDependency));
+ final Set<String> deployedModules = Collections.singleton(moduleName);
+ final Optional<NoticeContents> emptyNotice =
+ Optional.of(new NoticeContents(moduleName, Collections.emptyList()));
+
+ assertThat(
+ NoticeFileChecker.run(
+ bundleDependencies,
+ deployedModules,
+ Collections.singletonMap(moduleName, emptyNotice)))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testRunSkipsNonDeployedModules() throws IOException {
+ final String moduleName = "test";
+ final Dependency bundledDependency = Dependency.create("a", "b", "c", null);
+ final Map<String, Set<Dependency>> bundleDependencies = new HashMap<>();
+ bundleDependencies.put(moduleName, Collections.singleton(bundledDependency));
+ final Set<String> deployedModules = Collections.emptySet();
+ // this would usually be a problem, but since the module is not deployed it's OK!
+ final Optional<NoticeContents> emptyNotice =
+ Optional.of(new NoticeContents(moduleName, Collections.emptyList()));
+
+ assertThat(
+ NoticeFileChecker.run(
+ bundleDependencies,
+ deployedModules,
+ Collections.singletonMap(moduleName, emptyNotice)))
+ .isEqualTo(0);
+ }
+
+ @Test
+ void testRunIncludesBundledNonDeployedModules() throws IOException {
+ final Map<String, Set<Dependency>> bundledDependencies = new HashMap<>();
+ final Map<String, Optional<NoticeContents>> notices = new HashMap<>();
+
+ // a module that is not deployed but bundles another dependency with an empty NOTICE
+ final String nonDeployedModuleName = "nonDeployed";
+ final Dependency nonDeployedDependency =
+ Dependency.create("a", nonDeployedModuleName, "c", null);
+ final Dependency bundledDependency = Dependency.create("a", "b", "c", null);
+ bundledDependencies.put(nonDeployedModuleName, Collections.singleton(bundledDependency));
+ // this would usually not be a problem, but since the module is not bundled it's not OK!
+ final Optional<NoticeContents> emptyNotice =
+ Optional.of(new NoticeContents(nonDeployedModuleName, Collections.emptyList()));
+ notices.put(nonDeployedModuleName, emptyNotice);
+
+ // a module that is deploys and bundles the above
+ final String bundlingModule = "bundling";
+ bundledDependencies.put(bundlingModule, Collections.singleton(nonDeployedDependency));
+ final Optional<NoticeContents> correctNotice =
+ Optional.of(
+ new NoticeContents(
+ bundlingModule, Collections.singletonList(nonDeployedDependency)));
+ notices.put(bundlingModule, correctNotice);
+
+ final Set<String> deployedModules = Collections.singleton(bundlingModule);
+
+ assertThat(NoticeFileChecker.run(bundledDependencies, deployedModules, notices))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void testCheckNoticeFileHappyPath() {
+ final String moduleName = "test";
+ final Dependency bundledDependency = Dependency.create("a", "b", "c", null);
+ final Map<String, Set<Dependency>> bundleDependencies = new HashMap<>();
+ bundleDependencies.put(moduleName, Collections.singleton(bundledDependency));
+
+ assertThat(
+ NoticeFileChecker.checkNoticeFile(
+ bundleDependencies,
+ moduleName,
+ new NoticeContents(
+ moduleName, Collections.singletonList(bundledDependency))))
+ .isEmpty();
+ }
+
+ @Test
+ void testCheckNoticeFileRejectsEmptyFile() {
+ assertThat(NoticeFileChecker.checkNoticeFile(Collections.emptyMap(), "test", null))
+ .containsOnlyKeys(NoticeFileChecker.Severity.CRITICAL);
+ }
+
+ @Test
+ void testCheckNoticeFileToleratesModuleNameMismatch() {
+ final String moduleName = "test";
+
+ assertThat(
+ NoticeFileChecker.checkNoticeFile(
+ Collections.emptyMap(),
+ moduleName,
+ new NoticeContents(moduleName + "2", Collections.emptyList())))
+ .containsOnlyKeys(NoticeFileChecker.Severity.TOLERATED);
+ }
+
+ @Test
+ void testCheckNoticeFileRejectsDuplicateLine() {
+ final String moduleName = "test";
+ final Map<String, Set<Dependency>> bundleDependencies = new HashMap<>();
+ bundleDependencies.put(
+ moduleName, Collections.singleton(Dependency.create("a", "b", "c", null)));
+
+ assertThat(
+ NoticeFileChecker.checkNoticeFile(
+ bundleDependencies,
+ moduleName,
+ new NoticeContents(
+ moduleName,
+ Arrays.asList(
+ Dependency.create("a", "b", "c", null),
+ Dependency.create("a", "b", "c", null)))))
+ .containsOnlyKeys(NoticeFileChecker.Severity.CRITICAL);
+ }
+
+ @Test
+ void testCheckNoticeFileRejectsMissingDependency() {
+ final String moduleName = "test";
+ final Map<String, Set<Dependency>> bundleDependencies = new HashMap<>();
+ bundleDependencies.put(
+ moduleName, Collections.singleton(Dependency.create("a", "b", "c", null)));
+
+ assertThat(
+ NoticeFileChecker.checkNoticeFile(
+ bundleDependencies,
+ moduleName,
+ new NoticeContents(moduleName, Collections.emptyList())))
+ .containsOnlyKeys(NoticeFileChecker.Severity.CRITICAL);
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParserCopyTest.java b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParserCopyTest.java
new file mode 100644
index 0000000..97671d5
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParserCopyTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.dependency;
+
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests the parsing of {@code dependency:copy}. */
+class DependencyParserCopyTest {
+
+ private static Stream<String> getTestDependencyCopy() {
+ return Stream.of(
+ "[INFO] --- maven-dependency-plugin:3.2.0:copy (copy) @ m1 ---",
+ "[INFO] Configured Artifact: external:dependency1:2.1:jar",
+ "[INFO] Configured Artifact: external:dependency4:classifier:2.4:jar",
+ "[INFO] Copying dependency1-2.1.jar to /some/path/dependency1-2.1.jar",
+ "[INFO] Copying dependency4-2.4.jar to /some/path/dependency4-2.4.jar",
+ "[INFO]",
+ "[INFO] --- maven-dependency-plugin:3.2.0:copy (copy) @ m2 ---",
+ "[INFO] Configured Artifact: internal:m1:1.1:jar",
+ "[INFO] Copying internal-1.1.jar to /some/path/m1-1.1.jar");
+ }
+
+ @Test
+ void testCopyParsing() {
+ final Map<String, Set<Dependency>> dependenciesByModule =
+ DependencyParser.parseDependencyCopyOutput(getTestDependencyCopy());
+
+ assertThat(dependenciesByModule).containsOnlyKeys("m1", "m2");
+ assertThat(dependenciesByModule.get("m1"))
+ .containsExactlyInAnyOrder(
+ Dependency.create("external", "dependency1", "2.1", null),
+ Dependency.create("external", "dependency4", "2.4", "classifier"));
+ assertThat(dependenciesByModule.get("m2"))
+ .containsExactlyInAnyOrder(Dependency.create("internal", "m1", "1.1", null));
+ }
+
+ @Test
+ void testCopyLineParsingGroupId() {
+ assertThat(
+ DependencyParser.parseCopyDependency(
+ "[INFO] Configured Artifact: external:dependency1:1.0:jar"))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.getGroupId()).isEqualTo("external"));
+ }
+
+ @Test
+ void testCopyLineParsingArtifactId() {
+ assertThat(
+ DependencyParser.parseCopyDependency(
+ "[INFO] Configured Artifact: external:dependency1:1.0:jar"))
+ .hasValueSatisfying(
+ dependency ->
+ assertThat(dependency.getArtifactId()).isEqualTo("dependency1"));
+ }
+
+ @Test
+ void testCopyLineParsingVersion() {
+ assertThat(
+ DependencyParser.parseCopyDependency(
+ "[INFO] Configured Artifact: external:dependency1:1.0:jar"))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.getVersion()).isEqualTo("1.0"));
+ }
+
+ @Test
+ void testCopyLineParsingScope() {
+ assertThat(
+ DependencyParser.parseCopyDependency(
+ "[INFO] Configured Artifact: external:dependency1:1.0:jar"))
+ .hasValueSatisfying(dependency -> assertThat(dependency.getScope()).isEmpty());
+ }
+
+ @Test
+ void testCopyLineParsingOptional() {
+ assertThat(
+ DependencyParser.parseCopyDependency(
+ "[INFO] Configured Artifact: external:dependency1:1.0:jar"))
+ .hasValueSatisfying(dependency -> assertThat(dependency.isOptional()).isEmpty());
+ }
+
+ @Test
+ void testCopyLineParsingWithNonJarType() {
+ assertThat(
+ DependencyParser.parseCopyDependency(
+ "[INFO] Configured Artifact: external:dependency1:1.0:pom"))
+ .hasValue(Dependency.create("external", "dependency1", "1.0", null));
+ }
+
+ @Test
+ void testCopyLineParsingClassifier() {
+ assertThat(
+ DependencyParser.parseCopyDependency(
+ "[INFO] Configured Artifact: external:dependency1:some_classifier:1.0:jar"))
+ .hasValueSatisfying(
+ dependency ->
+ assertThat(dependency.getClassifier()).hasValue("some_classifier"));
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParserTreeTest.java b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParserTreeTest.java
new file mode 100644
index 0000000..3aa4cb5
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/dependency/DependencyParserTreeTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.dependency;
+
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+import org.apache.paimon.tools.ci.utils.shared.DependencyTree;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests the parsing of {@code dependency:tree}. */
+class DependencyParserTreeTest {
+
+ private static Stream<String> getTestDependencyTree() {
+ return Stream.of(
+ "[INFO] --- maven-dependency-plugin:3.2.0:tree (default-cli) @ m1 ---",
+ "[INFO] internal:m1:jar:1.1",
+ "[INFO] +- external:dependency1:jar:2.1:compile",
+ "[INFO] | +- external:dependency2:jar:2.2:compile (optional)",
+ "[INFO] | | \\- external:dependency3:jar:2.3:provided",
+ "[INFO] | +- external:dependency4:jar:classifier:2.4:compile",
+ "[INFO]",
+ "[INFO] --- maven-dependency-plugin:3.2.0:tree (default-cli) @ m2 ---",
+ "[INFO] internal:m2:jar:1.2",
+ "[INFO] +- internal:m1:jar:1.1:compile",
+ "[INFO] | +- external:dependency4:jar:2.4:compile");
+ }
+
+ @Test
+ void testTreeParsing() {
+ final Map<String, DependencyTree> dependenciesByModule =
+ DependencyParser.parseDependencyTreeOutput(getTestDependencyTree());
+
+ assertThat(dependenciesByModule).containsOnlyKeys("m1", "m2");
+ assertThat(dependenciesByModule.get("m1").flatten())
+ .containsExactlyInAnyOrder(
+ Dependency.create("external", "dependency1", "2.1", null, "compile", false),
+ Dependency.create("external", "dependency2", "2.2", null, "compile", true),
+ Dependency.create(
+ "external", "dependency3", "2.3", null, "provided", false),
+ Dependency.create(
+ "external", "dependency4", "2.4", "classifier", "compile", false));
+ assertThat(dependenciesByModule.get("m2").flatten())
+ .containsExactlyInAnyOrder(
+ Dependency.create("internal", "m1", "1.1", null, "compile", false),
+ Dependency.create(
+ "external", "dependency4", "2.4", null, "compile", false));
+ }
+
+ @Test
+ void testTreeLineParsingGroupId() {
+ assertThat(
+ DependencyParser.parseTreeDependency(
+ "[INFO] +- external:dependency1:jar:1.0:compile"))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.getGroupId()).isEqualTo("external"));
+ }
+
+ @Test
+ void testTreeLineParsingArtifactId() {
+ assertThat(
+ DependencyParser.parseTreeDependency(
+ "[INFO] +- external:dependency1:jar:1.0:compile"))
+ .hasValueSatisfying(
+ dependency ->
+ assertThat(dependency.getArtifactId()).isEqualTo("dependency1"));
+ }
+
+ @Test
+ void testTreeLineParsingVersion() {
+ assertThat(
+ DependencyParser.parseTreeDependency(
+ "[INFO] +- external:dependency1:jar:1.0:compile"))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.getVersion()).isEqualTo("1.0"));
+ }
+
+ @Test
+ void testTreeLineParsingScope() {
+ assertThat(
+ DependencyParser.parseTreeDependency(
+ "[INFO] +- external:dependency1:jar:1.0:provided"))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.getScope()).hasValue("provided"));
+ }
+
+ @Test
+ void testTreeLineParsingWithNonJarType() {
+ assertThat(
+ DependencyParser.parseTreeDependency(
+ "[INFO] +- external:dependency1:pom:1.0:compile"))
+ .hasValue(
+ Dependency.create(
+ "external", "dependency1", "1.0", null, "compile", false));
+ }
+
+ @Test
+ void testTreeLineParsingWithClassifier() {
+ assertThat(
+ DependencyParser.parseTreeDependency(
+ "[INFO] +- external:dependency1:jar:some_classifier:1.0:compile"))
+ .hasValue(
+ Dependency.create(
+ "external",
+ "dependency1",
+ "1.0",
+ "some_classifier",
+ "compile",
+ false));
+ }
+
+ @Test
+ void testTreeLineParsingWithoutOptional() {
+ assertThat(
+ DependencyParser.parseTreeDependency(
+ "[INFO] +- external:dependency1:jar:1.0:compile"))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.isOptional()).hasValue(false));
+ }
+
+ @Test
+ void testTreeLineParsingWithOptional() {
+ assertThat(
+ DependencyParser.parseTreeDependency(
+ "[INFO] +- external:dependency1:jar:1.0:compile (optional)"))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.isOptional()).hasValue(true));
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/deploy/DeployParserTest.java b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/deploy/DeployParserTest.java
new file mode 100644
index 0000000..64c8d3b
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/deploy/DeployParserTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.deploy;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DeployParserTest {
+ @Test
+ void testParseDeployOutputDetectsDeployment() {
+ assertThat(
+ DeployParser.parseDeployOutput(
+ Stream.of(
+ "[INFO] --- maven-deploy-plugin:2.8.2:deploy (default-deploy) @ paimon-parent ---",
+ "[INFO] ")))
+ .containsExactly("paimon-parent");
+ }
+
+ @Test
+ void testParseDeployOutputDetectsDeploymentWithAltRepository() {
+ assertThat(
+ DeployParser.parseDeployOutput(
+ Stream.of(
+ "[INFO] --- maven-deploy-plugin:2.8.2:deploy (default-deploy) @ paimon-parent ---",
+ "[INFO] Using alternate deployment repository.../tmp/paimon-validation-deployment")))
+ .containsExactly("paimon-parent");
+ }
+
+ @Test
+ void testParseDeployOutputDetectsSkippedDeployments() {
+ assertThat(
+ DeployParser.parseDeployOutput(
+ Stream.of(
+ "[INFO] --- maven-deploy-plugin:2.8.2:deploy (default-deploy) @ paimon-parent ---",
+ "[INFO] Skipping artifact deployment")))
+ .isEmpty();
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/notice/NoticeParserTest.java b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/notice/NoticeParserTest.java
new file mode 100644
index 0000000..fefe861
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/notice/NoticeParserTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.notice;
+
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NoticeParserTest {
+ @Test
+ void testParseNoticeFileCommonPath() {
+ final String module = "some-module";
+ final Dependency dependency1 =
+ Dependency.create("groupId1", "artifactId1", "version1", null);
+ final Dependency dependency2 =
+ Dependency.create("groupId2", "artifactId2", "version2", "classifier2");
+ final Dependency dependency3 =
+ Dependency.create("org.codehaus.woodstox", "stax2-api", "4.2.1", null);
+ final List<String> noticeContents =
+ Arrays.asList(
+ module,
+ "",
+ "Some text about the applicable license",
+ "- groupId1:artifactId1:version1",
+ "- groupId2:artifactId2:classifier2:version2",
+ "- org.codehaus.woodstox:stax2-api:4.2.1 (https://github.com/FasterXML/stax2-api/tree/stax2-api-4.2.1)",
+ "",
+ "some epilogue");
+
+ assertThat(NoticeParser.parseNoticeFile(noticeContents))
+ .hasValueSatisfying(
+ contents -> {
+ assertThat(contents.getNoticeModuleName()).isEqualTo(module);
+ assertThat(contents.getDeclaredDependencies())
+ .containsExactlyInAnyOrder(
+ dependency1, dependency2, dependency3);
+ });
+ }
+
+ @Test
+ void testParseNoticeFileBundlesPath() {
+ final String module = "some-module";
+ final Dependency dependency =
+ Dependency.create("groupId", "artifactId", "version", "classifier");
+ final List<String> noticeContents =
+ Arrays.asList(
+ module, "", "Something bundles \"groupId:artifactId:classifier:version\"");
+
+ assertThat(NoticeParser.parseNoticeFile(noticeContents))
+ .hasValueSatisfying(
+ contents -> {
+ assertThat(contents.getNoticeModuleName()).isEqualTo(module);
+ assertThat(contents.getDeclaredDependencies())
+ .containsExactlyInAnyOrder(dependency);
+ });
+ }
+
+ @Test
+ void testParseNoticeFileMalformedDependencyIgnored() {
+ final String module = "some-module";
+ final Dependency dependency = Dependency.create("groupId", "artifactId", "version", null);
+ final List<String> noticeContents = Arrays.asList(module, "- " + dependency, "- a:b");
+
+ assertThat(NoticeParser.parseNoticeFile(noticeContents))
+ .hasValueSatisfying(
+ contents -> {
+ assertThat(contents.getNoticeModuleName()).isEqualTo(module);
+ assertThat(contents.getDeclaredDependencies())
+ .containsExactlyInAnyOrder(dependency);
+ });
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/shade/ShadeParserTest.java b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/shade/ShadeParserTest.java
new file mode 100644
index 0000000..a403c0e
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/shade/ShadeParserTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.shade;
+
+import org.apache.paimon.tools.ci.utils.shared.Dependency;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ShadeParserTest {
+
+ private static Stream<String> getTestDependencyCopy() {
+ return Stream.of(
+ "[INFO] --- maven-shade-plugin:3.2.0:shade (shade-paimon) @ m1 ---",
+ "[INFO] Including external:dependency1:jar:2.1 in the shaded jar.",
+ "[INFO] Excluding external:dependency3:jar:2.3 from the shaded jar.",
+ "[INFO] Including external:dependency4:jar:classifier:2.4 in the shaded jar.",
+ "[INFO] Replacing original artifact with shaded artifact.",
+ "[INFO] Replacing /some/path/m1.jar with /some/path/m1-shaded.jar",
+ "[INFO]",
+ "[INFO] --- maven-shade-plugin:3.2.0:shade (shade-paimon) @ m2 ---",
+ "[INFO] Including internal:m1:jar:1.1 in the shaded jar.",
+ "[INFO] Replacing /some/path/m2.jar with /some/path/m2-shaded.jar");
+ }
+
+ @Test
+ void testParsing() {
+ final Map<String, Set<Dependency>> dependenciesByModule =
+ ShadeParser.parseShadeOutput(getTestDependencyCopy());
+
+ assertThat(dependenciesByModule).containsOnlyKeys("m1", "m2");
+ assertThat(dependenciesByModule.get("m1"))
+ .containsExactlyInAnyOrder(
+ Dependency.create("external", "dependency1", "2.1", null),
+ Dependency.create("external", "dependency4", "2.4", "classifier"));
+ assertThat(dependenciesByModule.get("m2"))
+ .containsExactlyInAnyOrder(Dependency.create("internal", "m1", "1.1", null));
+ }
+
+ @Test
+ void testLineParsingGroupId() {
+ assertThat(
+ ShadeParser.parseDependency(
+ "Including external:dependency1:jar:1.0 in the shaded jar."))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.getGroupId()).isEqualTo("external"));
+ }
+
+ @Test
+ void testLineParsingArtifactId() {
+ assertThat(
+ ShadeParser.parseDependency(
+ "Including external:dependency1:jar:1.0 in the shaded jar."))
+ .hasValueSatisfying(
+ dependency ->
+ assertThat(dependency.getArtifactId()).isEqualTo("dependency1"));
+ }
+
+ @Test
+ void testLineParsingVersion() {
+ assertThat(
+ ShadeParser.parseDependency(
+ "Including external:dependency1:jar:1.0 in the shaded jar."))
+ .hasValueSatisfying(
+ dependency -> assertThat(dependency.getVersion()).isEqualTo("1.0"));
+ }
+
+ @Test
+ void testLineParsingWithNonJarType() {
+ assertThat(
+ ShadeParser.parseDependency(
+ "Including external:dependency1:pom:1.0 in the shaded jar."))
+ .isPresent();
+ }
+}
diff --git a/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/shared/DependencyTreeTest.java b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/shared/DependencyTreeTest.java
new file mode 100644
index 0000000..aa923f6
--- /dev/null
+++ b/tools/ci/paimon-ci-tools/src/test/java/org/apache/paimon/tools/ci/utils/shared/DependencyTreeTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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
+ *
+ * http://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.paimon.tools.ci.utils.shared;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DependencyTreeTest {
+ private static final Dependency DEPENDENCY =
+ Dependency.create("groupId", "artifactId", "version", null);
+
+ @Test
+ void testDependencyKeyIncludesGroupId() {
+ testDependencyKeyInclusion(
+ Dependency.create(
+ "xxx",
+ DEPENDENCY.getArtifactId(),
+ DEPENDENCY.getVersion(),
+ DEPENDENCY.getClassifier().orElse(null)));
+ }
+
+ @Test
+ void testDependencyKeyIncludesArtifactId() {
+ testDependencyKeyInclusion(
+ Dependency.create(
+ DEPENDENCY.getGroupId(),
+ "xxx",
+ DEPENDENCY.getVersion(),
+ DEPENDENCY.getClassifier().orElse(null)));
+ }
+
+ @Test
+ void testDependencyKeyIncludesVersion() {
+ testDependencyKeyInclusion(
+ Dependency.create(
+ DEPENDENCY.getGroupId(),
+ DEPENDENCY.getArtifactId(),
+ "xxx",
+ DEPENDENCY.getClassifier().orElse(null)));
+ }
+
+ @Test
+ void testDependencyKeyIncludesClassifier() {
+ testDependencyKeyInclusion(
+ Dependency.create(
+ DEPENDENCY.getGroupId(),
+ DEPENDENCY.getArtifactId(),
+ DEPENDENCY.getVersion(),
+ "xxx"));
+ }
+
+ private static void testDependencyKeyInclusion(Dependency modifiedDependency) {
+ final DependencyTree dependencyTree = new DependencyTree();
+ dependencyTree.addDirectDependency(DEPENDENCY);
+ dependencyTree.addDirectDependency(modifiedDependency);
+
+ assertThat(dependencyTree.flatten()).containsExactly(DEPENDENCY, modifiedDependency);
+ }
+
+ @Test
+ void testDependencyKeyIgnoresScopeAndOptionalFlag() {
+ final Dependency dependencyWithScopeAndOptionalFlag =
+ Dependency.create(
+ DEPENDENCY.getGroupId(),
+ DEPENDENCY.getArtifactId(),
+ DEPENDENCY.getVersion(),
+ DEPENDENCY.getClassifier().orElse(null),
+ "compile",
+ true);
+
+ final DependencyTree dependencyTree = new DependencyTree();
+ dependencyTree.addDirectDependency(DEPENDENCY);
+ dependencyTree.addDirectDependency(dependencyWithScopeAndOptionalFlag);
+
+ assertThat(dependencyTree.flatten()).containsExactly(DEPENDENCY);
+ assertThat(dependencyTree.getPathTo(dependencyWithScopeAndOptionalFlag))
+ .containsExactly(DEPENDENCY);
+ }
+}