diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbfda10..eef16b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,9 @@
### Features
- add option to ignore source bundle upload failures ([#209](https://github.com/getsentry/sentry-maven-plugin/pull/209))
+- Deterministic Bundle Id generation ([#220](https://github.com/getsentry/sentry-maven-plugin/pull/220))
+ - to enable set reproducibleBundleId to true and add an outputTimestamp to properties
+ - reproducible JAR builds require maven-jar-plugin >= 3.2.0
### Dependencies
diff --git a/examples/sentry-maven-plugin-example/pom.xml b/examples/sentry-maven-plugin-example/pom.xml
index 68d8497..00ab836 100644
--- a/examples/sentry-maven-plugin-example/pom.xml
+++ b/examples/sentry-maven-plugin-example/pom.xml
@@ -11,6 +11,7 @@
1.8
1.8
+ 2026-01-01T00:00:00Z
@@ -46,6 +47,8 @@
false
false
false
+
+ false
false
false
@@ -66,6 +69,7 @@
org.apache.maven.plugins
maven-assembly-plugin
+ 3.2.0
@@ -87,6 +91,18 @@
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.2.0
+
+
+
+ true
+
+
+
+
org.codehaus.mojo
build-helper-maven-plugin
diff --git a/src/main/java/io/sentry/UploadSourceBundleMojo.java b/src/main/java/io/sentry/UploadSourceBundleMojo.java
index 2c0d96c..d2c0ce1 100644
--- a/src/main/java/io/sentry/UploadSourceBundleMojo.java
+++ b/src/main/java/io/sentry/UploadSourceBundleMojo.java
@@ -5,11 +5,15 @@
import io.sentry.cli.SentryCliRunner;
import io.sentry.telemetry.SentryTelemetryService;
import java.io.*;
-import java.nio.charset.Charset;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.util.*;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.Resource;
@@ -73,6 +77,9 @@ public class UploadSourceBundleMojo extends AbstractMojo {
@Parameter(defaultValue = DEFAULT_IGNORE_SOURCE_BUNDLE_UPLOAD_FAILURE_STRING)
private boolean ignoreSourceBundleUploadFailure;
+ @Parameter(defaultValue = DEFAULT_REPRODUCIBLE_BUNDLE_ID_STRING)
+ private boolean reproducibleBundleId;
+
@SuppressWarnings("NullAway")
@Component
private @NotNull BuildPluginManager pluginManager;
@@ -85,20 +92,27 @@ public void execute() throws MojoExecutionException {
return;
}
- final @NotNull String bundleId = UUID.randomUUID().toString();
final @NotNull File collectedSourcesTargetDir = new File(sentryBuildDir(), "collected-sources");
final @NotNull File sourceBundleTargetDir = new File(sentryBuildDir(), "source-bundle");
final @NotNull SentryCliRunner cliRunner =
new SentryCliRunner(
debugSentryCli, sentryCliExecutablePath, mavenProject, mavenSession, pluginManager);
+ collectSources(collectedSourcesTargetDir);
+
+ final @NotNull String bundleId;
+ if (reproducibleBundleId) {
+ bundleId = generateDeterministicBundleId(collectedSourcesTargetDir);
+ } else {
+ bundleId = UUID.randomUUID().toString();
+ }
+
createDebugMetaPropertiesFile(bundleId);
- collectSources(bundleId, collectedSourcesTargetDir);
bundleSources(cliRunner, bundleId, collectedSourcesTargetDir, sourceBundleTargetDir);
uploadSourceBundle(cliRunner, sourceBundleTargetDir);
}
- private void collectSources(@NotNull String bundleId, @NotNull File outputDir) {
+ private void collectSources(@NotNull File outputDir) {
final @Nullable ISpan span = SentryTelemetryService.getInstance().startTask("collectSources");
logger.debug("Collecting files from source directories");
@@ -168,6 +182,81 @@ private void collectSources(@NotNull String bundleId, @NotNull File outputDir) {
return new File(outputDirectory, "sentry");
}
+ /**
+ * Generates a deterministic bundle ID based on the MD5 hash of all collected source files. This
+ * ensures reproducible builds produce the same bundle ID when the source files are identical.
+ *
+ * @param collectedSourcesDir the directory containing the collected source files
+ * @return a UUID v4 string derived from the hash of the source files
+ */
+ private @NotNull String generateDeterministicBundleId(final @NotNull File collectedSourcesDir) {
+ final @Nullable ISpan span =
+ SentryTelemetryService.getInstance().startTask("generateDeterministicBundleId");
+ try {
+ final @NotNull MessageDigest digest = MessageDigest.getInstance("MD5");
+
+ if (collectedSourcesDir.exists() && collectedSourcesDir.isDirectory()) {
+ try (final @NotNull Stream stream = Files.walk(collectedSourcesDir.toPath())) {
+ final @NotNull List sortedFiles =
+ stream
+ .filter(Files::isRegularFile)
+ .sorted(
+ Comparator.comparing(
+ p ->
+ collectedSourcesDir
+ .toPath()
+ .relativize(p)
+ .toString()
+ .replace('\\', '/')))
+ .collect(Collectors.toList());
+
+ for (final @NotNull Path file : sortedFiles) {
+ final @NotNull String relativePath =
+ collectedSourcesDir.toPath().relativize(file).toString().replace('\\', '/');
+ digest.update(relativePath.getBytes(StandardCharsets.UTF_8));
+
+ // Include the file content in the hash
+ final byte[] fileBytes = Files.readAllBytes(file);
+ digest.update(fileBytes);
+ }
+ }
+ }
+
+ final byte[] hashBytes = digest.digest();
+ return bytesToUuid(hashBytes);
+ } catch (NoSuchAlgorithmException e) {
+ logger.warn("MD5 algorithm not available, falling back to random UUID", e);
+ return UUID.randomUUID().toString();
+ } catch (IOException e) {
+ logger.warn(
+ "Failed to read source files for bundle ID generation, falling back to random UUID", e);
+ SentryTelemetryService.getInstance().captureError(e, "generateDeterministicBundleId");
+ return UUID.randomUUID().toString();
+ } catch (Throwable t) {
+ logger.warn("Failed to generate deterministic bundle ID, falling back to random UUID", t);
+ SentryTelemetryService.getInstance().captureError(t, "generateDeterministicBundleId");
+ return UUID.randomUUID().toString();
+ } finally {
+ SentryTelemetryService.getInstance().endTask(span);
+ }
+ }
+
+ /**
+ * Converts 16 bytes into a UUID v4 string format (RFC 4122).
+ *
+ * @param hashBytes the hash bytes (exactly 16 bytes expected from MD5)
+ * @return a UUID string in the format xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
+ */
+ private @NotNull String bytesToUuid(final byte[] hashBytes) {
+ // Set version 4 (bits 12-15 of time_hi_and_version to 0100)
+ hashBytes[6] = (byte) ((hashBytes[6] & 0x0F) | 0x40);
+ // Set variant to RFC 4122 (bits 6-7 of clock_seq_hi_and_reserved to 10)
+ hashBytes[8] = (byte) ((hashBytes[8] & 0x3F) | 0x80);
+
+ final @NotNull ByteBuffer buffer = ByteBuffer.wrap(hashBytes);
+ return new UUID(buffer.getLong(), buffer.getLong()).toString();
+ }
+
private void bundleSources(
final @NotNull SentryCliRunner cliRunner,
final @NotNull String bundleId,
@@ -280,11 +369,17 @@ private void createDebugMetaPropertiesFile(final @NotNull String bundleId)
}
final @NotNull File debugMetaFile = new File(sentryBuildDir, "sentry-debug-meta.properties");
- final @NotNull Properties properties = createDebugMetaProperties(bundleId);
try (final @NotNull BufferedWriter fileWriter =
- Files.newBufferedWriter(debugMetaFile.toPath(), Charset.defaultCharset())) {
- properties.store(fileWriter, "Generated by sentry-maven-plugin");
+ Files.newBufferedWriter(debugMetaFile.toPath(), StandardCharsets.UTF_8)) {
+ // Write properties without timestamp comment for reproducible builds
+ // Properties are written in sorted order for consistency
+ fileWriter.write("# Generated by sentry-maven-plugin");
+ fileWriter.newLine();
+ fileWriter.write("io.sentry.build-tool=maven");
+ fileWriter.newLine();
+ fileWriter.write("io.sentry.bundle-ids=" + bundleId);
+ fileWriter.newLine();
final @NotNull Resource resource = new Resource();
resource.setDirectory(sentryBuildDir.getPath());
@@ -300,13 +395,4 @@ private void createDebugMetaPropertiesFile(final @NotNull String bundleId)
SentryTelemetryService.getInstance().endTask(span);
}
}
-
- private @NotNull Properties createDebugMetaProperties(final @NotNull String bundleId) {
- final @NotNull Properties properties = new Properties();
-
- properties.setProperty("io.sentry.bundle-ids", bundleId);
- properties.setProperty("io.sentry.build-tool", "maven");
-
- return properties;
- }
}
diff --git a/src/main/java/io/sentry/config/PluginConfig.java b/src/main/java/io/sentry/config/PluginConfig.java
index 80619ef..4dc12c6 100644
--- a/src/main/java/io/sentry/config/PluginConfig.java
+++ b/src/main/java/io/sentry/config/PluginConfig.java
@@ -14,6 +14,8 @@ public class PluginConfig {
public static final @NotNull String DEFAULT_SKIP_SOURCE_BUNDLE_STRING = "false";
public static final boolean DEFAULT_IGNORE_SOURCE_BUNDLE_UPLOAD_FAILURE = false;
public static final @NotNull String DEFAULT_IGNORE_SOURCE_BUNDLE_UPLOAD_FAILURE_STRING = "false";
+ public static final boolean DEFAULT_REPRODUCIBLE_BUNDLE_ID = false;
+ public static final @NotNull String DEFAULT_REPRODUCIBLE_BUNDLE_ID_STRING = "false";
public static final boolean DEFAULT_SKIP_TELEMETRY = false;
public static final @NotNull String DEFAULT_SKIP_TELEMETRY_STRING = "false";
public static final boolean DEFAULT_DEBUG_SENTRY_CLI = false;
diff --git a/src/test/java/io/sentry/integration/uploadSourceBundle/PomUtils.kt b/src/test/java/io/sentry/integration/uploadSourceBundle/PomUtils.kt
index b7ddf55..4255d97 100644
--- a/src/test/java/io/sentry/integration/uploadSourceBundle/PomUtils.kt
+++ b/src/test/java/io/sentry/integration/uploadSourceBundle/PomUtils.kt
@@ -4,6 +4,7 @@ fun basePom(
skipPlugin: Boolean = false,
skipSourceBundle: Boolean = false,
ignoreSourceBundleUploadFailure: Boolean = false,
+ reproducibleBundleId: Boolean = false,
sentryCliPath: String? = null,
extraSourceRoots: List = listOf(),
extraSourceContextDirs: List = emptyList(),
@@ -91,6 +92,7 @@ fun basePom(
$skipPlugin
$skipSourceBundle
$ignoreSourceBundleUploadFailure
+ $reproducibleBundleId
true
sentry-sdks
sentry-maven
diff --git a/src/test/java/io/sentry/integration/uploadSourceBundle/UploadSourceBundleTestIT.kt b/src/test/java/io/sentry/integration/uploadSourceBundle/UploadSourceBundleTestIT.kt
index 21a1a3f..e9133a7 100644
--- a/src/test/java/io/sentry/integration/uploadSourceBundle/UploadSourceBundleTestIT.kt
+++ b/src/test/java/io/sentry/integration/uploadSourceBundle/UploadSourceBundleTestIT.kt
@@ -29,6 +29,7 @@ class UploadSourceBundleTestIT {
skipPlugin: Boolean = false,
skipSourceBundle: Boolean = false,
ignoreSourceBundleUploadFailure: Boolean = false,
+ reproducibleBundleId: Boolean = false,
sentryCliPath: String? = null,
extraSourceRoots: List = listOf(),
extraSourceContextDirs: List = emptyList(),
@@ -39,6 +40,7 @@ class UploadSourceBundleTestIT {
skipPlugin,
skipSourceBundle,
ignoreSourceBundleUploadFailure,
+ reproducibleBundleId,
sentryCliPath,
extraSourceRoots,
extraSourceContextDirs,
@@ -247,4 +249,176 @@ class UploadSourceBundleTestIT {
myProps.load(FileInputStream("$baseDir/target/sentry/properties/sentry-debug-meta.properties"))
return myProps.getProperty("io.sentry.bundle-ids")
}
+
+ private fun getPropertiesFileContent(baseDir: String): String =
+ File("$baseDir/target/sentry/properties/sentry-debug-meta.properties").readText()
+
+ @Test
+ fun `bundle ID changes when source content changes`() {
+ val projectDir = File(file, "content-change-test")
+ projectDir.mkdirs()
+
+ val sourceDir = File(projectDir, "src/main/java")
+ sourceDir.mkdirs()
+
+ installMavenWrapper(projectDir, "3.8.6")
+ getPOM(projectDir, reproducibleBundleId = true)
+
+ // First build with initial content
+ File(sourceDir, "Main.java").writeText("public class Main { int version = 1; }")
+
+ val verifier1 = Verifier(projectDir.absolutePath)
+ verifier1.isAutoclean = false
+ verifier1.executeGoal("install")
+ verifier1.verifyErrorFreeLog()
+
+ val bundleId1 = getBundleIdFromProperties(projectDir.absolutePath)
+ verifier1.resetStreams()
+
+ // Clean and rebuild with different content
+ File(projectDir, "target").deleteRecursively()
+ File(sourceDir, "Main.java").writeText("public class Main { int version = 2; }")
+
+ val verifier2 = Verifier(projectDir.absolutePath)
+ verifier2.isAutoclean = false
+ verifier2.executeGoal("install")
+ verifier2.verifyErrorFreeLog()
+
+ val bundleId2 = getBundleIdFromProperties(projectDir.absolutePath)
+ verifier2.resetStreams()
+
+ // Verify bundle ID changed
+ assertTrue(
+ bundleId1 != bundleId2,
+ "Bundle ID should change when source content changes",
+ )
+ }
+
+ @Test
+ fun `properties file does not contain timestamp`() {
+ val baseDir = setupProject()
+ val path = getPOM(baseDir)
+ val verifier = Verifier(path)
+ verifier.isAutoclean = false
+ verifier.executeGoal("install")
+ verifier.verifyErrorFreeLog()
+
+ val propertiesContent = getPropertiesFileContent(baseDir.absolutePath)
+
+ // Properties.store() adds a timestamp like "#Wed Jan 08 10:30:00 CET 2025"
+ // Verify this pattern is not present
+ val timestampPattern = Regex("#\\w{3} \\w{3} \\d{2} \\d{2}:\\d{2}:\\d{2}")
+ assertTrue(
+ !timestampPattern.containsMatchIn(propertiesContent),
+ "Properties file should not contain a timestamp comment. Content: $propertiesContent",
+ )
+
+ verifier.resetStreams()
+ }
+
+ @Test
+ fun `build is reproducible with reproducibleBundleId enabled`() {
+ val projectDir = File(file, "reproducible-artifact-compare-test")
+ projectDir.mkdirs()
+
+ val sourceDir = File(projectDir, "src/main/java/com/example")
+ sourceDir.mkdirs()
+
+ // Create source files
+ File(sourceDir, "Main.java").writeText(
+ """
+ package com.example;
+ public class Main {
+ public static void main(String[] args) {
+ System.out.println("Reproducible build test");
+ }
+ }
+ """.trimIndent(),
+ )
+
+ installMavenWrapper(projectDir, "3.9.11")
+
+ // Create POM with outputTimestamp for reproducible builds
+ val pomContent =
+ basePom(reproducibleBundleId = true)
+ .replace(
+ "",
+ """
+ 2025-01-01T00:00:00Z""",
+ )
+ Files.write(Path("${projectDir.absolutePath}/pom.xml"), pomContent.toByteArray(), StandardOpenOption.CREATE)
+
+ // First build: clean install
+ val verifier1 = Verifier(projectDir.absolutePath)
+ verifier1.isAutoclean = false
+ verifier1.executeGoal("clean")
+ verifier1.executeGoal("install")
+ verifier1.verifyErrorFreeLog()
+ verifier1.resetStreams()
+
+ // Second build: clean verify artifact:compare
+ val verifier2 = Verifier(projectDir.absolutePath)
+ verifier2.isAutoclean = false
+ verifier2.executeGoal("clean")
+ verifier2.executeGoal("verify")
+ verifier2.addCliOption("-Dartifact.buildCompare.saveAll=true")
+ verifier2.executeGoal("artifact:compare")
+ verifier2.verifyErrorFreeLog()
+
+ verifier2.resetStreams()
+ }
+
+ @Test
+ fun `build is not reproducible with reproducibleBundleId disabled`() {
+ val projectDir = File(file, "non-reproducible-artifact-compare-test")
+ projectDir.mkdirs()
+
+ val sourceDir = File(projectDir, "src/main/java/com/example")
+ sourceDir.mkdirs()
+
+ File(sourceDir, "Main.java").writeText(
+ """
+ package com.example;
+ public class Main {
+ public static void main(String[] args) {
+ System.out.println("Non-reproducible bundle ID test");
+ }
+ }
+ """.trimIndent(),
+ )
+
+ installMavenWrapper(projectDir, "3.9.11")
+
+ // Create POM with reproducibleBundleId=false but still with outputTimestamp
+ // This means the JAR will be reproducible, but the bundle ID will change
+ val pomContent =
+ basePom(reproducibleBundleId = false)
+ .replace(
+ "",
+ """
+ 2025-01-01T00:00:00Z""",
+ )
+ Files.write(Path("${projectDir.absolutePath}/pom.xml"), pomContent.toByteArray(), StandardOpenOption.CREATE)
+
+ // First build
+ val verifier1 = Verifier(projectDir.absolutePath)
+ verifier1.isAutoclean = false
+ verifier1.executeGoal("clean")
+ verifier1.executeGoal("install")
+ verifier1.verifyErrorFreeLog()
+ verifier1.resetStreams()
+
+ // Second build - should fail artifact:compare because bundle ID changes
+ val verifier2 = Verifier(projectDir.absolutePath)
+ verifier2.isAutoclean = false
+ verifier2.executeGoal("clean")
+
+ assertFailsWith {
+ verifier2.executeGoals(listOf("verify", "artifact:compare"))
+ }
+
+ verifier2.verifyTextInLog("[ERROR] [Reproducible Builds] rebuild comparison result: 1 files match, 1 differ")
+
+ verifier2.resetStreams()
+ }
}