Skip to content

Commit f74417d

Browse files
runningcodeclaude
andauthored
feat(snapshots): Add snapshot upload task (#1091)
* feat(snapshots): Add snapshot upload task Add a new `sentryUploadSnapshots` task that uploads snapshots to Sentry via `sentry-cli build snapshots`. The task is registered when the user configures a snapshots path via the new `sentry.snapshots` extension block. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshots): Wire telemetry provider for snapshot uploads Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(snapshots): Remove afterEvaluate and isPresent check Register the task unconditionally and let the lazy DirectoryProperty handle resolution at execution time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(snapshots): Add required --app-id argument sentry-cli build snapshots requires --app-id. Add it as a required property on the SnapshotsExtension. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: Add changelog entry for snapshot uploads Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(snapshots): Simplify task registration API Move all wiring logic into the task's register() method so the plugin call site only passes project, extension, and org/project overrides. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: Apply spotless formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(snapshots): Wire telemetry and sentry properties properly Add proper telemetry setup via configureTelemetryNoVariant, wire sentryProperties file, and pass buildEvents and cliExecutable to the register method. Remove unused GradleInternal import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshots): Make variant nullable in getPropertiesFilePath The snapshot upload task has no variant context, so variant needs to be optional when resolving the sentry.properties file path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(snapshots): Remove telemetry from snapshot uploads Telemetry wiring is not needed for the snapshot upload task. Remove the unused buildEvents parameter and the configureTelemetryNoVariant helper function. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshots): Skip buildType paths when variant is null When variant is null (e.g. snapshot uploads), buildTypeName resolves to an empty string, producing nonsensical paths with double separators like src//sentry.properties. Guard buildTypeName-based paths the same way flavorName-based paths are already guarded. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(snapshots): Add tests for null variant property lookup Verify that getPropertiesFilePath with a null variant finds files in the project root and does not search buildType-specific paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 711904d commit f74417d

8 files changed

Lines changed: 300 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- (Experimental) Add snapshot upload support via `sentryUploadSnapshots` task ([#1091](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1091))
8+
59
### Dependencies
610

711
- Bump CLI from v3.2.0 to v3.3.0 ([#1084](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1084), [#1090](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1090), [#1093](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1093))

plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryPlugin.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.android.build.api.variant.ApplicationAndroidComponentsExtension
44
import io.sentry.BuildConfig
55
import io.sentry.android.gradle.autoinstall.installDependencies
66
import io.sentry.android.gradle.extensions.SentryPluginExtension
7+
import io.sentry.android.gradle.tasks.SentryUploadSnapshotsTask
78
import io.sentry.android.gradle.util.AgpVersions
89
import java.io.File
910
import javax.inject.Inject
@@ -65,6 +66,14 @@ constructor(private val buildEvents: BuildEventListenerRegistryInternal) : Plugi
6566
sentryProjectParameter,
6667
)
6768

69+
SentryUploadSnapshotsTask.register(
70+
project,
71+
extension,
72+
cliExecutable,
73+
sentryOrgParameter,
74+
sentryProjectParameter,
75+
)
76+
6877
project.installDependencies(extension, true)
6978
}
7079
}

plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryPropertiesFileProvider.kt

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,41 +18,53 @@ internal object SentryPropertiesFileProvider {
1818
* @return A [String] for the path if sentry.properties is found or null otherwise
1919
*/
2020
@JvmStatic
21-
fun getPropertiesFilePath(project: Project, variant: SentryVariant): String? {
22-
val flavorName = variant.flavorName.orEmpty()
23-
val buildTypeName = variant.buildTypeName.orEmpty()
21+
fun getPropertiesFilePath(project: Project, variant: SentryVariant? = null): String? {
22+
val flavorName = variant?.flavorName.orEmpty()
23+
val buildTypeName = variant?.buildTypeName.orEmpty()
2424

2525
val projDir = project.projectDir
2626
val rootDir = project.rootDir
2727

2828
val sep = File.separator
2929

3030
// Local Project dirs
31-
val possibleFiles = mutableListOf("${projDir}${sep}src${sep}${buildTypeName}${sep}$FILENAME")
31+
val possibleFiles = mutableListOf<String>()
32+
if (buildTypeName.isNotBlank()) {
33+
possibleFiles.add("${projDir}${sep}src${sep}${buildTypeName}${sep}$FILENAME")
34+
}
3235
if (flavorName.isNotBlank()) {
33-
possibleFiles.add("${projDir}${sep}src${sep}${buildTypeName}${sep}$flavorName${sep}$FILENAME")
34-
possibleFiles.add(
35-
"${projDir}${sep}src${sep}${flavorName}${sep}${buildTypeName}${sep}$FILENAME"
36-
)
36+
if (buildTypeName.isNotBlank()) {
37+
possibleFiles.add(
38+
"${projDir}${sep}src${sep}${buildTypeName}${sep}$flavorName${sep}$FILENAME"
39+
)
40+
possibleFiles.add(
41+
"${projDir}${sep}src${sep}${flavorName}${sep}${buildTypeName}${sep}$FILENAME"
42+
)
43+
}
3744
possibleFiles.add("${projDir}${sep}src${sep}${flavorName}${sep}$FILENAME")
3845
}
3946
possibleFiles.add("${projDir}${sep}$FILENAME")
4047

4148
// Other flavors dirs
4249
possibleFiles.addAll(
43-
variant.productFlavors.map { "${projDir}${sep}src${sep}${it}${sep}$FILENAME" }
50+
variant?.productFlavors?.map { "${projDir}${sep}src${sep}${it}${sep}$FILENAME" }
51+
?: emptyList()
4452
)
4553

4654
// Root project dirs
47-
possibleFiles.add("${rootDir}${sep}src${sep}${buildTypeName}${sep}$FILENAME")
55+
if (buildTypeName.isNotBlank()) {
56+
possibleFiles.add("${rootDir}${sep}src${sep}${buildTypeName}${sep}$FILENAME")
57+
}
4858
if (flavorName.isNotBlank()) {
4959
possibleFiles.add("${rootDir}${sep}src${sep}${flavorName}${sep}$FILENAME")
50-
possibleFiles.add(
51-
"${rootDir}${sep}src${sep}${buildTypeName}${sep}${flavorName}${sep}$FILENAME"
52-
)
53-
possibleFiles.add(
54-
"${rootDir}${sep}src${sep}${flavorName}${sep}${buildTypeName}${sep}$FILENAME"
55-
)
60+
if (buildTypeName.isNotBlank()) {
61+
possibleFiles.add(
62+
"${rootDir}${sep}src${sep}${buildTypeName}${sep}${flavorName}${sep}$FILENAME"
63+
)
64+
possibleFiles.add(
65+
"${rootDir}${sep}src${sep}${flavorName}${sep}${buildTypeName}${sep}$FILENAME"
66+
)
67+
}
5668
}
5769
possibleFiles.add("${rootDir}${sep}$FILENAME")
5870

plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ abstract class SentryPluginExtension @Inject constructor(project: Project) {
127127
vcsInfoAction.execute(vcsInfo)
128128
}
129129

130+
val snapshots: SnapshotsExtension = objects.newInstance(SnapshotsExtension::class.java)
131+
132+
/** Configure the snapshots upload. */
133+
@Experimental
134+
fun snapshots(snapshotsAction: Action<SnapshotsExtension>) {
135+
snapshotsAction.execute(snapshots)
136+
}
137+
130138
/**
131139
* Disables or enables the reporting of dependencies metadata for Sentry. If enabled the plugin
132140
* will collect external dependencies and will take care of uploading them to Sentry as part of
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package io.sentry.android.gradle.extensions
2+
3+
import javax.inject.Inject
4+
import org.gradle.api.file.DirectoryProperty
5+
import org.gradle.api.model.ObjectFactory
6+
import org.gradle.api.provider.Property
7+
import org.jetbrains.annotations.ApiStatus.Experimental
8+
9+
@Experimental
10+
open class SnapshotsExtension @Inject constructor(objects: ObjectFactory) {
11+
12+
/** The application identifier used to associate snapshots with an app. */
13+
val appId: Property<String> = objects.property(String::class.java)
14+
15+
/** The path to the folder containing snapshots to upload. */
16+
val path: DirectoryProperty = objects.directoryProperty()
17+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package io.sentry.android.gradle.tasks
2+
3+
import io.sentry.android.gradle.SentryPropertiesFileProvider.getPropertiesFilePath
4+
import io.sentry.android.gradle.autoinstall.SENTRY_GROUP
5+
import io.sentry.android.gradle.extensions.SentryPluginExtension
6+
import io.sentry.android.gradle.util.asSentryCliExec
7+
import org.gradle.api.Project
8+
import org.gradle.api.file.DirectoryProperty
9+
import org.gradle.api.provider.Property
10+
import org.gradle.api.provider.Provider
11+
import org.gradle.api.tasks.Input
12+
import org.gradle.api.tasks.InputDirectory
13+
import org.gradle.api.tasks.PathSensitive
14+
import org.gradle.api.tasks.PathSensitivity
15+
import org.gradle.api.tasks.TaskProvider
16+
import org.gradle.work.DisableCachingByDefault
17+
18+
@DisableCachingByDefault(because = "Uploads should not be cached")
19+
abstract class SentryUploadSnapshotsTask : SentryCliExecTask() {
20+
21+
init {
22+
group = SENTRY_GROUP
23+
description = "Uploads snapshots to Sentry"
24+
}
25+
26+
@get:Input abstract val appId: Property<String>
27+
28+
@get:InputDirectory
29+
@get:PathSensitive(PathSensitivity.RELATIVE)
30+
abstract val snapshotsPath: DirectoryProperty
31+
32+
override fun getArguments(args: MutableList<String>) {
33+
args.add("build")
34+
args.add("snapshots")
35+
args.add("--app-id")
36+
args.add(appId.get())
37+
args.add(snapshotsPath.get().asFile.absolutePath)
38+
}
39+
40+
companion object {
41+
fun register(
42+
project: Project,
43+
extension: SentryPluginExtension,
44+
cliExecutable: Provider<String>,
45+
sentryOrgOverride: String?,
46+
sentryProjectOverride: String?,
47+
): TaskProvider<SentryUploadSnapshotsTask> {
48+
return project.tasks.register(
49+
"sentryUploadSnapshots",
50+
SentryUploadSnapshotsTask::class.java,
51+
) { task ->
52+
task.workingDir(project.rootDir)
53+
task.debug.set(extension.debug)
54+
task.cliExecutable.set(cliExecutable)
55+
task.sentryProperties.set(
56+
getPropertiesFilePath(project)?.let { file -> project.file(file) }
57+
)
58+
task.sentryOrganization.set(
59+
sentryOrgOverride?.let { project.provider { it } } ?: extension.org
60+
)
61+
task.sentryProject.set(
62+
sentryProjectOverride?.let { project.provider { it } } ?: extension.projectName
63+
)
64+
task.sentryAuthToken.set(extension.authToken)
65+
task.sentryUrl.set(extension.url)
66+
task.appId.set(extension.snapshots.appId)
67+
task.snapshotsPath.set(extension.snapshots.path)
68+
task.asSentryCliExec()
69+
}
70+
}
71+
}
72+
}

plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPropertiesFileProviderTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,28 @@ class SentryPropertiesFileProviderTest(private val agpVersion: SemVer) {
177177
assertEquals("42", File(getPropertiesFilePath(project, variant)!!).readText())
178178
}
179179

180+
@Test
181+
fun `getPropertiesFilePath with null variant finds file inside project folder`() {
182+
val (project, _) = createTestAndroidProject(forceEvaluate = !AgpVersions.isAGP74(agpVersion))
183+
createTestFile(project.projectDir, "sentry.properties")
184+
185+
assertEquals("42", File(getPropertiesFilePath(project)!!).readText())
186+
}
187+
188+
@Test
189+
fun `getPropertiesFilePath with null variant skips buildType paths`() {
190+
val rootProject = ProjectBuilder.builder().build()
191+
val (project, _) =
192+
createTestAndroidProject(
193+
parent = rootProject,
194+
forceEvaluate = !AgpVersions.isAGP74(agpVersion),
195+
)
196+
// Only place the file under src/debug/ — with null variant this should not be found
197+
createTestFile(project.projectDir, "src${sep}debug${sep}sentry.properties")
198+
199+
assertEquals(null, getPropertiesFilePath(project))
200+
}
201+
180202
@Test
181203
fun `getPropertiesFilePath finds file inside root buildType flavor folder`() {
182204
val rootProject = ProjectBuilder.builder().build()
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package io.sentry.android.gradle.tasks
2+
3+
import java.io.File
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertFalse
6+
import kotlin.test.assertNull
7+
import kotlin.test.assertTrue
8+
import org.gradle.api.Project
9+
import org.gradle.testfixtures.ProjectBuilder
10+
import org.junit.Test
11+
12+
class SentryUploadSnapshotsTaskTest {
13+
14+
@Test
15+
fun `cli-executable is set correctly`() {
16+
val task = createTestTask {
17+
it.cliExecutable.set("sentry-cli")
18+
it.appId.set("com.example")
19+
it.snapshotsPath.set(File("/path/to/snapshots"))
20+
}
21+
22+
val args = task.computeCommandLineArgs()
23+
24+
assertTrue("sentry-cli" in args)
25+
assertTrue("build" in args)
26+
assertTrue("snapshots" in args)
27+
assertTrue("--app-id" in args)
28+
assertTrue("com.example" in args)
29+
assertTrue("/path/to/snapshots" in args)
30+
assertFalse("--log-level=debug" in args)
31+
}
32+
33+
@Test
34+
fun `--log-level=debug is set correctly`() {
35+
val task = createTestTask {
36+
it.cliExecutable.set("sentry-cli")
37+
it.appId.set("com.example")
38+
it.snapshotsPath.set(File("/path/to/snapshots"))
39+
it.debug.set(true)
40+
}
41+
42+
val args = task.computeCommandLineArgs()
43+
44+
assertTrue("--log-level=debug" in args)
45+
}
46+
47+
@Test
48+
fun `with sentryProperties file SENTRY_PROPERTIES is set correctly`() {
49+
val project = createProject()
50+
val propertiesFile = project.file("dummy/folder/sentry.properties")
51+
val task = createTestTask(project) { it.sentryProperties.set(propertiesFile) }
52+
53+
task.setSentryPropertiesEnv()
54+
55+
assertEquals(propertiesFile.absolutePath, task.environment["SENTRY_PROPERTIES"].toString())
56+
}
57+
58+
@Test
59+
fun `without sentryProperties file SENTRY_PROPERTIES is not set`() {
60+
val task = createTestTask()
61+
62+
task.setSentryPropertiesEnv()
63+
64+
assertNull(task.environment["SENTRY_PROPERTIES"])
65+
}
66+
67+
@Test
68+
fun `with sentryOrganization adds --org`() {
69+
val task = createTestTask {
70+
it.cliExecutable.set("sentry-cli")
71+
it.sentryOrganization.set("dummy-org")
72+
it.appId.set("com.example")
73+
it.snapshotsPath.set(File("/path/to/snapshots"))
74+
}
75+
76+
val args = task.computeCommandLineArgs()
77+
78+
assertTrue("--org" in args)
79+
assertTrue("dummy-org" in args)
80+
}
81+
82+
@Test
83+
fun `with sentryProject adds --project`() {
84+
val task = createTestTask {
85+
it.cliExecutable.set("sentry-cli")
86+
it.sentryProject.set("dummy-proj")
87+
it.appId.set("com.example")
88+
it.snapshotsPath.set(File("/path/to/snapshots"))
89+
}
90+
91+
val args = task.computeCommandLineArgs()
92+
93+
assertTrue("--project" in args)
94+
assertTrue("dummy-proj" in args)
95+
}
96+
97+
@Test
98+
fun `with sentryUrl adds --url`() {
99+
val task = createTestTask {
100+
it.cliExecutable.set("sentry-cli")
101+
it.sentryUrl.set("https://some-host.sentry.io")
102+
it.appId.set("com.example")
103+
it.snapshotsPath.set(File("/path/to/snapshots"))
104+
}
105+
106+
val args = task.computeCommandLineArgs()
107+
108+
assertTrue("--url" in args)
109+
assertTrue("https://some-host.sentry.io" in args)
110+
}
111+
112+
@Test
113+
fun `the --url parameter is placed as the first argument`() {
114+
val task = createTestTask {
115+
it.cliExecutable.set("sentry-cli")
116+
it.sentryUrl.set("https://some-host.sentry.io")
117+
it.appId.set("com.example")
118+
it.snapshotsPath.set(File("/path/to/snapshots"))
119+
}
120+
121+
val args = task.computeCommandLineArgs()
122+
123+
assertEquals(1, args.indexOf("--url"))
124+
}
125+
126+
private fun createProject(): Project {
127+
with(ProjectBuilder.builder().build()) {
128+
plugins.apply("io.sentry.android.gradle")
129+
return this
130+
}
131+
}
132+
133+
private fun createTestTask(
134+
project: Project = createProject(),
135+
block: (SentryUploadSnapshotsTask) -> Unit = {},
136+
): SentryUploadSnapshotsTask =
137+
project.tasks
138+
.register("testUploadSnapshots", SentryUploadSnapshotsTask::class.java) { block(it) }
139+
.get()
140+
}

0 commit comments

Comments
 (0)