From dd7a0bb0b7a89017780a94758b0955aeae5657d6 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 18 Sep 2026 12:30:02 +0200 Subject: [PATCH 1/6] fix(android): Generate modules.json into build folder instead of source tree The Android Gradle plugin collected `modules.json` into the version-controlled `src/main/assets` directory on release builds via an `Exec` task with no declared inputs/outputs, then removed it afterward with a cleanup task. That broke up-to-date checks and build caching for asset merging, and a failed build could leave the file behind in the source tree. Replace it with a typed `CollectModulesTask` that writes into `build/generated/sentry/modules/`, registered as a generated assets source via the AGP Variant API (with a classic source-set fallback), so AGP merges and orders it into `mergeAssets` with correct up-to-date/caching behavior. Lint tasks depend on it explicitly (Gradle 9 rejects the previously implicit dependency), and the source-map cleanup now runs after it. Mirrors the `sentry.options.json` fix (#6751); closes the same anti-pattern for `modules.json` from #6750. Co-Authored-By: Claude Opus 4.8 --- packages/core/sentry.gradle.kts | 242 +++++++++++++++++++++++--------- 1 file changed, 174 insertions(+), 68 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 89903f6ade..0ba3149606 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -4,9 +4,9 @@ import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property -import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive @@ -178,6 +178,76 @@ abstract class GenerateSentryOptionsTask : DefaultTask() { } } +/** + * Collects the JavaScript modules referenced by a release bundle's source map into a `build` folder + * directory registered as a generated assets source, so `modules.json` is never written into the + * version-controlled `src/main/assets` tree. Declared inputs/outputs make it participate in up-to-date + * checks and the build cache; the node process runs through injected [org.gradle.process.ExecOperations] + * so the action is Configuration Cache compatible. + */ +abstract class CollectModulesTask : DefaultTask() { + // The bundle source map. File collection so a missing file is an empty input, not a failure. + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sourcemapFiles: ConfigurableFileCollection + + @get:Input + abstract val collectModulesScript: Property + + @get:Input + abstract val modulesPaths: Property + + // Config-time gate (script present and `skipCollectModules` not set). `@Input` (not `onlyIf`) so + // toggling it re-runs the task, which clears the output when disabled — a skipped task would leave a + // stale file to be packaged. + @get:Input + abstract val collectEnabled: Property + + // Working dir for the node process so a relative `modulesPaths` (e.g. "node_modules") resolves; the + // absolute path is intentionally not a content input. + @get:Internal + abstract val workingDirectory: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:Inject + abstract val execOps: org.gradle.process.ExecOperations + + @TaskAction + fun collect() { + val outDir = outputDir.get().asFile + outDir.mkdirs() + val dest = File(outDir, "modules.json") + // Idempotent: clear any prior output so a disabled opt-out or a missing source map leaves an + // empty dir rather than packaging a stale file. + if (dest.exists()) { + dest.delete() + } + + if (!collectEnabled.get()) { + logger.info("modules.json collection disabled; generated assets directory left empty") + return + } + + val sourcemap = sourcemapFiles.files.firstOrNull { it.exists() } + if (sourcemap == null) { + logger.warn("Source map not found; modules.json generated assets directory left empty") + return + } + + val args = + listOf("node", collectModulesScript.get(), sourcemap.absolutePath, dest.absolutePath, modulesPaths.get()) + logger.info("Sentry-CollectModules arguments: $args") + execOps.exec { + workingDir(workingDirectory.get().asFile) + val osCompatibility = if (Os.isFamily(Os.FAMILY_WINDOWS)) listOf("cmd", "/c") else emptyList() + commandLine(osCompatibility + args) + } + logger.lifecycle("Generated modules.json into $outDir") + } +} + extra["shouldCopySentryOptionsFile"] = object : groovy.lang.Closure(this) { fun doCall(): Boolean = System.getenv("SENTRY_COPY_OPTIONS_FILE") != "false" @@ -285,6 +355,63 @@ fun wireSentryOptionsAssets(variant: Any) { } } +// Wires a variant's generated modules dir into its assets via `addGeneratedSourceDirectory` (AGP 7.3+), +// reflectively since a script plugin can't depend on AGP types. Falls back to the source set otherwise. +fun wireSentryModulesAssets( + variant: Any, + modulesTask: TaskProvider, + generatedDir: org.gradle.api.provider.Provider, + variantCapitalized: String, +) { + try { + val sources = variant.javaClass.getMethod("getSources").invoke(variant) + val assets = sources.javaClass.getMethod("getAssets").invoke(sources) + val addMethod = + assets?.javaClass?.methods?.firstOrNull { it.name == "addGeneratedSourceDirectory" } + if (assets == null || addMethod == null) { + applySentryModulesSourceSetFallback(modulesTask, generatedDir, variantCapitalized) + return + } + val wiredWith: (CollectModulesTask) -> DirectoryProperty = { it.outputDir } + addMethod.invoke(assets, modulesTask, wiredWith) + } catch (e: Exception) { + project.logger.info("[sentry] variant assets wiring failed for modules: ${e.message}. Falling back to sourceSets.") + applySentryModulesSourceSetFallback(modulesTask, generatedDir, variantCapitalized) + } +} + +fun applySentryModulesSourceSetFallback( + modulesTask: TaskProvider, + generatedDir: org.gradle.api.provider.Provider, + variantCapitalized: String, +) { + try { + val android = extensions.getByName("android") + val sourceSets = android.javaClass.getMethod("getSourceSets").invoke(android) + val getByName = + sourceSets.javaClass.methods.first { it.name == "getByName" && it.parameterCount == 1 } + val mainSourceSet = getByName.invoke(sourceSets, "main") + val assets = mainSourceSet.javaClass.getMethod("getAssets").invoke(mainSourceSet) + val srcDir = + assets.javaClass.methods.first { + it.name == "srcDir" && it.parameterCount == 1 && it.parameterTypes[0] == Any::class.java + } + srcDir.invoke(assets, generatedDir.get().asFile) + // Scope to this variant's merge task only: modules.json is release-only, so a debug merge must + // never depend on (and thus trigger) the release modules/bundle tasks. + tasks + .matching { it.name == "merge${variantCapitalized}Assets" } + .configureEach { dependsOn(modulesTask) } + project.logger.info("[sentry] Wired modules.json into assets via sourceSets fallback") + } catch (e: Exception) { + project.logger.warn( + "[sentry] Failed to wire modules.json into assets: ${e.message}. " + + "modules.json may not be packaged. Please report this issue at " + + "https://github.com/getsentry/sentry-react-native/issues", + ) + } +} + plugins.withId("com.android.application") { try { val androidComponents = extensions.getByName("androidComponents") @@ -701,24 +828,57 @@ fun processVariant(v: Any) { } val reactRoot = reactRootResolved - val modulesOutput = "$reactRoot/android/app/src/main/assets/modules.json" - val currentVariants = extractCurrentVariants(bundleTask, v) ?: return var previousCliTask: TaskProvider? = null - var applicationVariant: String? = null val nameCleanup = "${bundleTask.name}_SentryUploadCleanUp" - val nameModulesCleanup = "${bundleTask.name}_SentryCollectModulesCleanUp" - var lastModulesTask: TaskProvider? = null + + // Collect the bundle's JS modules into a build-folder dir registered as a generated assets source, + // so `modules.json` is never written into src/main/assets. One task per (release) variant; AGP wires + // it into `merge${variantCapitalized}Assets` with correct ordering and up-to-date/caching behavior. + val sentryPackageForModules = resolveSentryReactNativeSDKPath(reactRoot) + val collectModulesScriptPath = + config["collectModulesScript"] + ?.toString() + ?.let { file(it).absolutePath } + ?: "$sentryPackageForModules/dist/js/tools/collectModules.js" + + @Suppress("UNCHECKED_CAST") + val modulesPathsValue = + (config["modulesPaths"] as? List) + ?.joinToString(",") + ?: "$reactRoot/node_modules" + val skipCollectModules = config["skipCollectModules"] == true + val modulesGeneratedDir = layout.buildDirectory.dir("generated/sentry/modules/$vName") + + val modulesTask = + tasks.register("${bundleTask.name}_SentryCollectModules", CollectModulesTask::class.java) { + description = "collect javascript modules from bundle source map" + group = "sentry.io" + sourcemapFiles.from(sourcemapOutput) + collectModulesScript.set(collectModulesScriptPath) + modulesPaths.set(modulesPathsValue) + collectEnabled.set(!skipCollectModules && File(collectModulesScriptPath).exists()) + workingDirectory.set(reactRoot) + outputDir.set(modulesGeneratedDir) + dependsOn(sentryBundleTaskName) + } + + wireSentryModulesAssets(v, modulesTask, modulesGeneratedDir, variantCapitalized) + + // Lint model/analysis tasks read merged assets (now including the generated modules dir) without a + // declared dependency; Gradle 9 fails on that. Declare it for this variant's lint tasks so + // modules.json is produced first. Scoped to the variant so a debug lint won't trigger release modules. + tasks + .matching { it.name.contains("lint", ignoreCase = true) && it.name.contains(variantCapitalized) } + .configureEach { dependsOn(modulesTask) } currentVariants.forEach { (_, currentVariant) -> val variant = currentVariant.variantName val releaseName = currentVariant.releaseName val versionCode = currentVariant.versionCode - applicationVariant = currentVariant.applicationVariant val nameCliTask = "${bundleTask.name}_SentryUpload_${releaseName}_$versionCode" - val nameModulesTask = "${bundleTask.name}_SentryCollectModules_${releaseName}_$versionCode" if (tasks.names.contains(nameCliTask)) return@forEach @@ -866,58 +1026,14 @@ fun processVariant(v: Any) { enabled = true } - val modulesTask = - tasks.register(nameModulesTask, Exec::class.java) { - description = "collect javascript modules from bundle source map" - group = "sentry.io" - - workingDir(reactRoot) - - val sentryPackage = resolveSentryReactNativeSDKPath(reactRoot) - - val collectModulesScript = - config["collectModulesScript"] - ?.toString() - ?.let { file(it).absolutePath } - ?: "$sentryPackage/dist/js/tools/collectModules.js" - - @Suppress("UNCHECKED_CAST") - val modulesPaths = - (config["modulesPaths"] as? List) - ?.joinToString(",") - ?: "$reactRoot/node_modules" - val args = listOf("node", collectModulesScript, sourcemapOutput.toString(), modulesOutput, modulesPaths) - - if (File(collectModulesScript).exists()) { - project.logger.info("Sentry-CollectModules arguments: $args") - commandLine(args) - - val skip = config["skipCollectModules"] == true - enabled = !skip - } else { - project.logger.info("collectModulesScript not found: $collectModulesScript") - enabled = false - } - } - lastModulesTask = modulesTask - if (previousCliTask != null) { previousCliTask!!.configure { finalizedBy(cliTask) } } else { bundleTask.finalizedBy(cliTask) } previousCliTask = cliTask - cliTask.configure { finalizedBy(modulesTask) } } - val modulesCleanUpTask = - tasks.register(nameModulesCleanup, Delete::class.java) { - description = "clean up collected modules generated file" - group = "sentry.io" - - delete(modulesOutput) - } - val cliCleanUpTask = tasks.register(nameCleanup, Delete::class.java) { description = "clean up extra sourcemap" @@ -927,23 +1043,13 @@ fun processVariant(v: Any) { delete("${layout.buildDirectory.get().asFile}/intermediates/assets/release/index.android.bundle.map") } - cliCleanUpTask.configure { onlyIf { result.shouldCleanUp } } + // The modules task reads the source map; ensure the upload cleanup (which deletes it) can only run + // after modules has run. They were previously serialized through the `finalizedBy` chain. + cliCleanUpTask.configure { + onlyIf { result.shouldCleanUp } + mustRunAfter(modulesTask) + } previousCliTask?.configure { finalizedBy(cliCleanUpTask) } - - tasks - .matching { task -> - val appVariant = applicationVariant ?: return@matching false - ( - "package$appVariant".equals(task.name, ignoreCase = true) || - "package${appVariant}Bundle".equals(task.name, ignoreCase = true) - ) && - task.enabled - }.configureEach { - if (lastModulesTask != null) { - dependsOn(lastModulesTask!!) - } - finalizedBy(modulesCleanUpTask) - } } project.afterEvaluate { From 6abb40c4a27afe14cca17fb18fd76738c77a8cf9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 18 Sep 2026 12:30:53 +0200 Subject: [PATCH 2/6] docs(changelog): Reference PR #6753 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92eb98e1d2..955e8f4893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Declare optional peer dependencies so imports resolve under strict and Plug'n'Play package managers ([#6729](https://github.com/getsentry/sentry-react-native/pull/6729)) - Honor `shutdownTimeout` on iOS ([#6749](https://github.com/getsentry/sentry-react-native/pull/6749)) - Android Gradle plugin no longer writes generated `sentry.options.json` into your source tree during builds ([#6751](https://github.com/getsentry/sentry-react-native/pull/6751)) +- Android Gradle plugin no longer writes generated `modules.json` into your source tree during release builds ([#6753](https://github.com/getsentry/sentry-react-native/pull/6753)) ### Internal From 8208e1513316ed040104c86dd274c6d3a156b5c9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 18 Sep 2026 12:56:20 +0200 Subject: [PATCH 3/6] fix(android): Scope modules.json fallback to variant source set and warn on stale copy Addresses review feedback on the modules.json generated-assets change: - Source-set fallback (old AGP < 7.3) now registers each variant's generated modules dir into that variant's own source set instead of the shared "main". Adding per-variant dirs to "main" leaked modules.json into debug and caused a duplicate-asset merge conflict between multiple non-debug variants. Because each variant is now isolated, no dedup guard is required (unlike the shared-dir options fallback). The primary AGP 7.3+ variant-API path was already scoped and is unchanged. - Warn on a stale modules.json left in src/main/assets by older plugin versions (mirrors the sentry.options.json warning), since a leftover copy would clash with the generated one during asset merge on upgrade. Co-Authored-By: Claude Opus 4.8 --- packages/core/sentry.gradle.kts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 0ba3149606..5228c220e0 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -305,6 +305,18 @@ if (legacyOptionsFile.exists()) { ) } +// Older plugin versions generated modules.json directly into src/main/assets and cleaned it up +// afterwards; a crashed build (or a committed copy) could leave it behind. It is now generated into +// the build folder, so a leftover copy would clash with the generated one during asset merge. Warn +// (never delete — it may be intentional) so the user can remove the stale copy. +val legacyModulesFile = File(project.projectDir, "src/main/assets/modules.json") +if (legacyModulesFile.exists()) { + project.logger.warn( + "[sentry] Found a stale modules.json in src/main/assets; it is now generated into the build " + + "folder and the old copy may conflict. Please remove: ${legacyModulesFile.absolutePath}", + ) +} + // Guards the classic source-set fallback so it registers at most once, only when the variant API is absent. val sentryOptionsSourceSetFallbackApplied = AtomicBoolean(false) @@ -361,6 +373,7 @@ fun wireSentryModulesAssets( variant: Any, modulesTask: TaskProvider, generatedDir: org.gradle.api.provider.Provider, + variantName: String, variantCapitalized: String, ) { try { @@ -369,20 +382,21 @@ fun wireSentryModulesAssets( val addMethod = assets?.javaClass?.methods?.firstOrNull { it.name == "addGeneratedSourceDirectory" } if (assets == null || addMethod == null) { - applySentryModulesSourceSetFallback(modulesTask, generatedDir, variantCapitalized) + applySentryModulesSourceSetFallback(modulesTask, generatedDir, variantName, variantCapitalized) return } val wiredWith: (CollectModulesTask) -> DirectoryProperty = { it.outputDir } addMethod.invoke(assets, modulesTask, wiredWith) } catch (e: Exception) { project.logger.info("[sentry] variant assets wiring failed for modules: ${e.message}. Falling back to sourceSets.") - applySentryModulesSourceSetFallback(modulesTask, generatedDir, variantCapitalized) + applySentryModulesSourceSetFallback(modulesTask, generatedDir, variantName, variantCapitalized) } } fun applySentryModulesSourceSetFallback( modulesTask: TaskProvider, generatedDir: org.gradle.api.provider.Provider, + variantName: String, variantCapitalized: String, ) { try { @@ -390,8 +404,13 @@ fun applySentryModulesSourceSetFallback( val sourceSets = android.javaClass.getMethod("getSourceSets").invoke(android) val getByName = sourceSets.javaClass.methods.first { it.name == "getByName" && it.parameterCount == 1 } - val mainSourceSet = getByName.invoke(sourceSets, "main") - val assets = mainSourceSet.javaClass.getMethod("getAssets").invoke(mainSourceSet) + // Register into the variant-specific source set (e.g. "release", "stagingRelease"), NOT the + // shared "main": modules.json is per-variant and release-only, so adding each variant's dir to + // "main" would leak the file into debug and clash between multiple non-debug variants (duplicate + // asset merge). Scoping to the variant source set keeps each variant's modules.json isolated, so + // no dedup guard is needed (unlike the shared-dir options fallback). + val variantSourceSet = getByName.invoke(sourceSets, variantName) + val assets = variantSourceSet.javaClass.getMethod("getAssets").invoke(variantSourceSet) val srcDir = assets.javaClass.methods.first { it.name == "srcDir" && it.parameterCount == 1 && it.parameterTypes[0] == Any::class.java @@ -402,7 +421,7 @@ fun applySentryModulesSourceSetFallback( tasks .matching { it.name == "merge${variantCapitalized}Assets" } .configureEach { dependsOn(modulesTask) } - project.logger.info("[sentry] Wired modules.json into assets via sourceSets fallback") + project.logger.info("[sentry] Wired modules.json into '$variantName' assets via sourceSets fallback") } catch (e: Exception) { project.logger.warn( "[sentry] Failed to wire modules.json into assets: ${e.message}. " + @@ -864,7 +883,7 @@ fun processVariant(v: Any) { dependsOn(sentryBundleTaskName) } - wireSentryModulesAssets(v, modulesTask, modulesGeneratedDir, variantCapitalized) + wireSentryModulesAssets(v, modulesTask, modulesGeneratedDir, vName, variantCapitalized) // Lint model/analysis tasks read merged assets (now including the generated modules dir) without a // declared dependency; Gradle 9 fails on that. Declare it for this variant's lint tasks so From b9eb181a39cf97b8973f89830277c75f9512b0e1 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 21 Sep 2026 14:55:57 +0200 Subject: [PATCH 4/6] fix(android): Scope modules lint dependency to the exact variant and fingerprint the collect-modules script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two review findings on the modules.json generation change: - The lint-task dependency used a loose `it.name.contains(variantCapitalized)` substring match, so a longer build type whose name ends in a shorter one (e.g. `qaRelease` vs `release`) would have its lint tasks pull in the wrong variant's modules task. Match the variant as a full task-name segment via the known AGP lint verbs / `generateLint…` prefix instead, mirroring the exact match already used for the `mergeAssets` fallback wiring. - `collectModulesScript` was an `@Input` on the path string only, which does not fingerprint the script's content — editing it in place (e.g. an SDK upgrade at the same path) would not re-run the task. Make it `@Internal` and add `collectModulesScriptFiles` (`@InputFiles`, RELATIVE path sensitivity) to fingerprint content, tolerating a missing script as an empty input. Co-Authored-By: Claude Opus 4.8 --- packages/core/sentry.gradle.kts | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 5228c220e0..e81d86c4a7 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -191,9 +191,19 @@ abstract class CollectModulesTask : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sourcemapFiles: ConfigurableFileCollection - @get:Input + // Absolute path to the collect-modules node script, used to build the command line. `@Internal` + // because the path alone is not a meaningful content input — the script's *content* is fingerprinted + // via [collectModulesScriptFiles] so editing it in place (e.g. an SDK upgrade at the same path) + // re-runs the task instead of shipping a stale modules.json. + @get:Internal abstract val collectModulesScript: Property + // Content fingerprint of the collect-modules script. File collection (like [sourcemapFiles]) so a + // missing script is an empty input rather than a task-validation failure. + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val collectModulesScriptFiles: ConfigurableFileCollection + @get:Input abstract val modulesPaths: Property @@ -876,6 +886,7 @@ fun processVariant(v: Any) { group = "sentry.io" sourcemapFiles.from(sourcemapOutput) collectModulesScript.set(collectModulesScriptPath) + collectModulesScriptFiles.from(collectModulesScriptPath) modulesPaths.set(modulesPathsValue) collectEnabled.set(!skipCollectModules && File(collectModulesScriptPath).exists()) workingDirectory.set(reactRoot) @@ -887,10 +898,21 @@ fun processVariant(v: Any) { // Lint model/analysis tasks read merged assets (now including the generated modules dir) without a // declared dependency; Gradle 9 fails on that. Declare it for this variant's lint tasks so - // modules.json is produced first. Scoped to the variant so a debug lint won't trigger release modules. + // modules.json is produced first. Scope precisely to THIS variant: a bare `contains(variantCapitalized)` + // would also match a longer variant whose name ends in this one (e.g. a `qaRelease` build type's + // `lintQaRelease` contains — and ends with — "Release"), wrongly pulling the `release` modules task + // into another variant's lint. AGP lint task names are either `` (lintRelease, + // lintReportRelease, lintAnalyzeRelease, lintVitalRelease, …) or `Lint…` + // (generateReleaseLintReportModel, copyReleaseLintReportModel), so match the variant as a full segment. + val lintVerbs = + setOf("lint", "lintReport", "lintAnalyze", "lintVital", "lintVitalReport", "lintVitalAnalyze", "lintFix") + val generatorLintPrefix = Regex("^(?:generate|copy)${Regex.escape(variantCapitalized)}Lint") tasks - .matching { it.name.contains("lint", ignoreCase = true) && it.name.contains(variantCapitalized) } - .configureEach { dependsOn(modulesTask) } + .matching { task -> + val name = task.name + (name.endsWith(variantCapitalized) && name.removeSuffix(variantCapitalized) in lintVerbs) || + generatorLintPrefix.containsMatchIn(name) + }.configureEach { dependsOn(modulesTask) } currentVariants.forEach { (_, currentVariant) -> val variant = currentVariant.variantName From a2fcc048c5c5d25b65688bd7062f186694c891bc Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 21 Sep 2026 15:49:53 +0200 Subject: [PATCH 5/6] refactor(android): Make the modules lint-dependency matcher verb-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match a variant's whole AGP lint family by the `Lint` camelCase word boundary (name starts with the lowercase `lint` verb, or embeds a capital-L `Lint` segment) scoped to that variant, instead of an allowlist of lint verbs plus a generator-prefix regex. This covers `updateLintBaseline*`, `*UnitTest` lint models, and any future AGP lint task without relying on those tasks transitively depending on an allowlisted `lintAnalyze*` task. The word boundary — rather than a case-insensitive `lint` substring — excludes unrelated `ktlint*` tasks (e.g. `ktlintReleaseCheck`), which must not be made to depend on the modules task and pull in the JS bundler. Cross-variant matches (e.g. `release` inside `qaRelease`/`releaseStaging`) are excluded precisely using the set of processed variant names, which is complete by the time lint tasks are realized. Co-Authored-By: Claude Opus 4.8 --- packages/core/sentry.gradle.kts | 35 ++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index e81d86c4a7..4aab1f95f5 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -803,6 +803,13 @@ plugins.withId("com.android.application") { } } +// Capitalized names of every non-debug variant the plugin has seen, shared across [processVariant] +// calls (all of which run during configuration, before any lint task is realized). Used to scope each +// variant's lint→modules dependency without enumerating AGP's lint verbs: a lint task belongs to a +// *different* variant when its name contains a longer processed variant name that has the current one +// as a substring (e.g. `release` vs `qaRelease` / `releaseStaging`), so it can be excluded precisely. +val sentryProcessedVariantCaps: MutableSet = java.util.Collections.synchronizedSet(mutableSetOf()) + fun processVariant(v: Any) { val vName = v.javaClass.getMethod("getName").invoke(v) as String if (vName.contains("debug", ignoreCase = true)) return @@ -814,6 +821,7 @@ fun processVariant(v: Any) { val sentryAutoUploadGeneralEnabled = shouldSentryAutoUploadGeneral() val variantCapitalized = Character.toUpperCase(vName[0]).toString() + vName.substring(1) + sentryProcessedVariantCaps.add(variantCapitalized) val sentryBundleTaskName = listOf( "createBundle${variantCapitalized}JsAndAssets", @@ -898,20 +906,25 @@ fun processVariant(v: Any) { // Lint model/analysis tasks read merged assets (now including the generated modules dir) without a // declared dependency; Gradle 9 fails on that. Declare it for this variant's lint tasks so - // modules.json is produced first. Scope precisely to THIS variant: a bare `contains(variantCapitalized)` - // would also match a longer variant whose name ends in this one (e.g. a `qaRelease` build type's - // `lintQaRelease` contains — and ends with — "Release"), wrongly pulling the `release` modules task - // into another variant's lint. AGP lint task names are either `` (lintRelease, - // lintReportRelease, lintAnalyzeRelease, lintVitalRelease, …) or `Lint…` - // (generateReleaseLintReportModel, copyReleaseLintReportModel), so match the variant as a full segment. - val lintVerbs = - setOf("lint", "lintReport", "lintAnalyze", "lintVital", "lintVitalReport", "lintVitalAnalyze", "lintFix") - val generatorLintPrefix = Regex("^(?:generate|copy)${Regex.escape(variantCapitalized)}Lint") + // modules.json is produced first. Match the whole AGP lint family verb-agnostically: every AGP lint + // task either starts with the lowercase `lint` verb (lint/lintAnalyze/lintReport/lintVital*/lintFix, + // incl. `lintAnalyzeUnitTest`) or embeds a capitalized `Lint` segment (`updateLintBaseline*`, + // `generate*Lint*Model`). Requiring the `Lint` word boundary (start-of-name or capital L) — NOT a + // case-insensitive `lint` substring — excludes unrelated `ktlint*` tasks (e.g. `ktlintReleaseCheck`) + // whose lowercase `lint` is mid-name; those must not pull in the JS bundler. Scope precisely to THIS + // variant: a task belongs to a *different* variant when its name also contains a longer processed + // variant name that has this one as a substring (e.g. `release` vs `qaRelease` / `releaseStaging`), so + // exclude those — their own variant pass wires them to their own modules task. This scoping needs the + // full variant set, which is complete by the time lint tasks are realized (all onVariants callbacks run + // during configuration). tasks .matching { task -> val name = task.name - (name.endsWith(variantCapitalized) && name.removeSuffix(variantCapitalized) in lintVerbs) || - generatorLintPrefix.containsMatchIn(name) + (name.startsWith("lint") || name.contains("Lint")) && + name.contains(variantCapitalized) && + sentryProcessedVariantCaps.none { other -> + other != variantCapitalized && other.contains(variantCapitalized) && name.contains(other) + } }.configureEach { dependsOn(modulesTask) } currentVariants.forEach { (_, currentVariant) -> From f6b5d2d9f0de26877f2f74ffb5ef8c510f7d0c61 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 21 Sep 2026 16:28:57 +0200 Subject: [PATCH 6/6] fix(android): Order and re-key modules collection to avoid source-map races The CollectModulesTask was decoupled from the source-map upload chain so it runs via mergeAssets, which introduced two races with the upload flow: - The upload rewrites the source map in place (copy-debugid) in its doFirst, while the modules task reads that same file. With nothing ordering them, a parallel / configuration-cache build could read a half-written map and fail JSON parsing. Order the upload strictly after the modules task. - The "clean up extra sourcemap" task deletes the source map after uploading. Fingerprinting that (deleted) map as the task input invalidated the modules task on every rebuild and could package an empty modules.json when an up-to-date bundle task didn't regenerate the map. Fingerprint the stable bundle instead (it changes iff the JS/module list changes) and read the map at execution as an @Internal input. Co-Authored-By: Claude Opus 4.8 --- packages/core/sentry.gradle.kts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 4aab1f95f5..f6e9dfea56 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -186,9 +186,22 @@ abstract class GenerateSentryOptionsTask : DefaultTask() { * so the action is Configuration Cache compatible. */ abstract class CollectModulesTask : DefaultTask() { - // The bundle source map. File collection so a missing file is an empty input, not a failure. + // Up-to-date fingerprint: the release JS bundle. modules.json is derived from the bundle's source + // map, but that map is a transient artifact the upload flow deletes after uploading (the + // "clean up extra sourcemap" task). Fingerprinting the map would invalidate this task on every + // rebuild and, worse, could package an empty modules.json when the deleted map isn't regenerated + // (the forced map is not a declared output of the bundle task). The bundle is a stable, declared + // artifact that changes iff the JS — and hence the module list — changes, so it is the correct + // content key. File collection so a missing file is an empty input, not a failure. @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val bundleFiles: ConfigurableFileCollection + + // The bundle source map, read at execution to extract the module list. `@Internal` (not a + // fingerprinted input) for the reasons on [bundleFiles]; the upload's copy-debugid rewrite is + // ordered after this task so the read is never concurrent with a write. File collection so a + // missing file is an empty input, not a failure. + @get:Internal abstract val sourcemapFiles: ConfigurableFileCollection // Absolute path to the collect-modules node script, used to build the command line. `@Internal` @@ -892,6 +905,7 @@ fun processVariant(v: Any) { tasks.register("${bundleTask.name}_SentryCollectModules", CollectModulesTask::class.java) { description = "collect javascript modules from bundle source map" group = "sentry.io" + bundleFiles.from(bundleOutput) sourcemapFiles.from(sourcemapOutput) collectModulesScript.set(collectModulesScriptPath) collectModulesScriptFiles.from(collectModulesScriptPath) @@ -939,6 +953,11 @@ fun processVariant(v: Any) { val cliTask = tasks.register(nameCliTask) { onlyIf { sentryAutoUploadGeneralEnabled } + // The upload rewrites the source map in place (copy-debugid, in doFirst below); the + // modules task reads that same map. Order the rewrite strictly after the read so a + // parallel / configuration-cache build can never read a half-written map (the cleanup + // deletion is ordered the same way, further down). + mustRunAfter(modulesTask) description = "upload debug symbols to sentry" group = "sentry.io"