Skip to content
Open
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions examples/sentry-maven-plugin-example/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<!-- <sentry.cli.debug>true</sentry.cli.debug>-->
<project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>
</properties>
<build>
<!-- add this to your pom.xml vvv -->
Expand Down Expand Up @@ -46,6 +47,8 @@
<skip>false</skip>
<skipSourceBundle>false</skipSourceBundle>
<skipAutoInstall>false</skipAutoInstall>
<!-- reproducible Builds require maven-jar-plugin >= 3.2.0 -->
<reproducibleBundleId>false</reproducibleBundleId>
<skipTelemetry>false</skipTelemetry>
<skipValidateSdkDependencyVersions>false</skipValidateSdkDependencyVersions>
<additionalSourceDirsForSourceContext>
Expand All @@ -66,6 +69,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
Expand All @@ -87,6 +91,18 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
Expand Down
120 changes: 104 additions & 16 deletions src/main/java/io/sentry/UploadSourceBundleMojo.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -85,20 +92,25 @@ 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 @Nullable String deterministicBundleId =
reproducibleBundleId ? generateDeterministicBundleId(collectedSourcesTargetDir) : null;
final @NotNull String bundleId =
deterministicBundleId != null ? deterministicBundleId : 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) {
Comment on lines 109 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When reproducibleBundleId is true, the build silently falls back to a random UUID if generateDeterministicBundleId() fails, instead of failing the build.
Severity: MEDIUM

Suggested Fix

Modify generateDeterministicBundleId to throw an exception upon failure instead of returning null. This will cause the build to fail as expected when reproducibility cannot be guaranteed. Alternatively, introduce a new configuration option, similar to ignoreSourceBundleUploadFailure, to allow users to explicitly control the failure behavior.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/main/java/io/sentry/UploadSourceBundleMojo.java#L109-L113

Potential issue: When the `reproducibleBundleId` option is enabled, any exception during
the deterministic bundle ID generation (e.g., an `IOException` from file system issues)
is caught, and the `generateDeterministicBundleId` method returns `null`. This causes
the logic to fall back to generating a `UUID.randomUUID()`, silently producing a
non-reproducible build. This behavior violates the user's explicit configuration for
reproducibility, as they would expect the build to fail in such a scenario rather than
silently degrade.

Did we get this right? 👍 / 👎 to inform future reviews.

final @Nullable ISpan span = SentryTelemetryService.getInstance().startTask("collectSources");
logger.debug("Collecting files from source directories");

Expand Down Expand Up @@ -168,6 +180,85 @@ 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, or null on failure
*/
private @Nullable 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<Path> stream = Files.walk(collectedSourcesDir.toPath())) {
final @NotNull List<Path> 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('\\', '/');
updateDigestWithLengthPrefix(digest, relativePath.getBytes(StandardCharsets.UTF_8));

// Include the file content in the hash
final byte[] fileBytes = Files.readAllBytes(file);
updateDigestWithLengthPrefix(digest, fileBytes);
}
}
}

final byte[] hashBytes = digest.digest();
return bytesToUuid(hashBytes);
} catch (NoSuchAlgorithmException e) {
logger.warn("MD5 algorithm not available, falling back to random UUID", e);
} 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");
} catch (Throwable t) {
logger.warn("Failed to generate deterministic bundle ID, falling back to random UUID", t);
SentryTelemetryService.getInstance().captureError(t, "generateDeterministicBundleId");
} finally {
SentryTelemetryService.getInstance().endTask(span);
}
return null;
}

private void updateDigestWithLengthPrefix(
final @NotNull MessageDigest digest, final byte[] data) {
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(data.length).array());
digest.update(data);
}

/**
* 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,
Expand Down Expand Up @@ -280,11 +371,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.write("\n");
fileWriter.write("io.sentry.build-tool=maven");
fileWriter.write("\n");
fileWriter.write("io.sentry.bundle-ids=" + bundleId);
fileWriter.write("\n");

final @NotNull Resource resource = new Resource();
resource.setDirectory(sentryBuildDir.getPath());
Expand All @@ -300,13 +397,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;
}
}
2 changes: 2 additions & 0 deletions src/main/java/io/sentry/config/PluginConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused boolean constant added to PluginConfig

Low Severity

DEFAULT_REPRODUCIBLE_BUNDLE_ID (the boolean constant) is defined but never referenced anywhere in the codebase. Only DEFAULT_REPRODUCIBLE_BUNDLE_ID_STRING is used (in UploadSourceBundleMojo's @Parameter annotation). Unlike every other DEFAULT_* boolean constant in PluginConfig, there's no corresponding instance variable or usage for reproducibleBundleId in this class, making it dead code.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 814cc7b. Configure here.

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ fun basePom(
skipPlugin: Boolean = false,
skipSourceBundle: Boolean = false,
ignoreSourceBundleUploadFailure: Boolean = false,
reproducibleBundleId: Boolean = false,
sentryCliPath: String? = null,
extraSourceRoots: List<String> = listOf(),
extraSourceContextDirs: List<String> = emptyList(),
Expand Down Expand Up @@ -91,6 +92,7 @@ fun basePom(
<skip>$skipPlugin</skip>
<skipSourceBundle>$skipSourceBundle</skipSourceBundle>
<ignoreSourceBundleUploadFailure>$ignoreSourceBundleUploadFailure</ignoreSourceBundleUploadFailure>
<reproducibleBundleId>$reproducibleBundleId</reproducibleBundleId>
<skipTelemetry>true</skipTelemetry>
<org>sentry-sdks</org>
<project>sentry-maven</project>
Expand Down
Loading
Loading