diff --git a/.github/workflows/test-plugin.yml b/.github/workflows/test-plugin.yml index 4ae7afe..95f762f 100644 --- a/.github/workflows/test-plugin.yml +++ b/.github/workflows/test-plugin.yml @@ -31,7 +31,7 @@ jobs: java-version: '17' - name: Run 'normal' tests - run: ./gradlew test --tests 'SentryProguardGradlePluginTest' + run: ./gradlew test --tests 'SentryProguardGradlePluginTest' --tests '*.tasks.*' - name: Publish Test Report uses: mikepenz/action-junit-report@v6 diff --git a/README.md b/README.md index 14425d3..0605b16 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,15 @@ sentryProguard { project.set("SENTRY_PROJECT") authToken.set("SENTRY_AUTH_TOKEN") noUpload.set(false) + cliConfig { + version.set("2.0.0") + command.set("${SentryCliConfig.PlaceHolder.CLI_FILE_PATH} some-command --org ${SentryCliConfig.PlaceHolder.ORG}") + } } ``` +**noUpload**: + The `sentryProguard.noUpload` function is useful for development purposes. Normally, you don't want to upload the mapping file to Sentry while creating a minified version on developer machines. Instead, you just want to upload the mapping file on your CI. In case you do a "real release". @@ -43,6 +49,14 @@ By default, you don't set the [Gradle property](https://docs.gradle.org/8.0.2/us In this case the plugin won't upload the mapping files. On your CI, however, you set the property and therefore the mapping file will be uploaded. +**cliConfig**: + +This option is **not required** to be overridden or to be changed. +By default, the plugin will download the version of the Sentry CLI bundled with the plugin and use it to upload the mapping file. +However, if you want to use a custom version of the Sentry CLI and this would require to change the command to upload the mapping file, you can do so by overriding the `cliConfig` configuration. +The `command` property can contain various placeholders like `${SentryCliConfig.PlaceHolder.CLI_FILE_PATH}` or `${SentryCliConfig.PlaceHolder.ORG}`. +Those placeholders will be replaced by the plugin with the actual values before executing the command. + ## How it works under the hood If you run "any" task on a [`minifiedEnabled`](https://developer.android.com/reference/tools/gradle-api/8.0/com/android/build/api/variant/CanMinifyCode) [build type](https://developer.android.com/studio/build/build-variants#build-types), the Plugin will: diff --git a/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/SentryProguardExtension.kt b/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/SentryProguardExtension.kt index c4c2031..ec8646b 100644 --- a/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/SentryProguardExtension.kt +++ b/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/SentryProguardExtension.kt @@ -1,7 +1,15 @@ package com.ioki.sentry.proguard.gradle.plugin +import com.ioki.sentry.proguard.gradle.plugin.SentryCliConfig.PlaceHolder.AUTH_TOKEN +import com.ioki.sentry.proguard.gradle.plugin.SentryCliConfig.PlaceHolder.CLI_FILE_PATH +import com.ioki.sentry.proguard.gradle.plugin.SentryCliConfig.PlaceHolder.MAPPING_FILE_PATH +import com.ioki.sentry.proguard.gradle.plugin.SentryCliConfig.PlaceHolder.ORG +import com.ioki.sentry.proguard.gradle.plugin.SentryCliConfig.PlaceHolder.PROJECT +import com.ioki.sentry.proguard.gradle.plugin.SentryCliConfig.PlaceHolder.UUID +import org.gradle.api.Action import org.gradle.api.plugins.ExtensionContainer import org.gradle.api.provider.Property +import org.gradle.api.tasks.Nested internal fun ExtensionContainer.createSentryProguardExtension(): SentryProguardExtension = create("sentryProguard", SentryProguardExtension::class.java) @@ -14,4 +22,48 @@ interface SentryProguardExtension { val authToken: Property val noUpload: Property + + @get:Nested + val cliConfig: SentryCliConfig + + fun cliConfig(action: Action) { + action.execute(cliConfig) + } +} + +interface SentryCliConfig { + companion object PlaceHolder { + const val CLI_FILE_PATH = "{cliFilePath}" + const val UUID = "{uuid}" + const val MAPPING_FILE_PATH = "{mappingFilePath}" + const val ORG = "{org}" + const val PROJECT = "{project}" + const val AUTH_TOKEN = "{authToken}" + + internal const val DEFAULT_COMMAND = + "$CLI_FILE_PATH upload-proguard --uuid $UUID $MAPPING_FILE_PATH --org $ORG --project $PROJECT --auth-token $AUTH_TOKEN" + } + + val version: Property + + val command: Property +} + +typealias Command = String + +internal fun Command.build( + cliFilePath: String, + uuid: String, + mappingFilePath: String, + org: String, + project: String, + authToken: String +): List { + return this.replace(CLI_FILE_PATH, cliFilePath) + .replace(UUID, uuid) + .replace(MAPPING_FILE_PATH, mappingFilePath) + .replace(ORG, org) + .replace(PROJECT, project) + .replace(AUTH_TOKEN, authToken) + .split("\\s+".toRegex()) } \ No newline at end of file diff --git a/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/SentryProguardGradlePlugin.kt b/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/SentryProguardGradlePlugin.kt index 96eca7a..b8f7d76 100644 --- a/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/SentryProguardGradlePlugin.kt +++ b/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/SentryProguardGradlePlugin.kt @@ -8,7 +8,7 @@ import com.ioki.sentry.proguard.gradle.plugin.tasks.registerDownloadSentryCliTas import com.ioki.sentry.proguard.gradle.plugin.tasks.registerUploadUuidToSentryTask import org.gradle.api.Plugin import org.gradle.api.Project -import java.util.* +import java.util.UUID private const val SENTRY_CLI_FILE_PATH = "sentry/cli" @@ -16,6 +16,10 @@ class SentryProguardGradlePlugin : Plugin { override fun apply(project: Project) { val extension = project.extensions.getByType(AndroidComponentsExtension::class.java) val sentryProguardExtension = project.extensions.createSentryProguardExtension() + val bundledCliVersion = object {}.javaClass.getResource("/SENTRY_CLI_VERSION").readText() + sentryProguardExtension.cliConfig.version.convention(bundledCliVersion) + sentryProguardExtension.cliConfig.command.convention(SentryCliConfig.DEFAULT_COMMAND) + project.replaceSentryProguardUuidInAndroidManifest(extension, sentryProguardExtension) } } @@ -25,7 +29,8 @@ private fun Project.replaceSentryProguardUuidInAndroidManifest( sentryProguardExtension: SentryProguardExtension, ) { val downloadSentryCliTask = tasks.registerDownloadSentryCliTask( - layout.buildDirectory.file(SENTRY_CLI_FILE_PATH), + cliFilePath = layout.buildDirectory.file(SENTRY_CLI_FILE_PATH), + cliVersion = sentryProguardExtension.cliConfig.version, ) extension.onVariants { variant -> diff --git a/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/DownloadSentryCliTask.kt b/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/DownloadSentryCliTask.kt index a677a30..a948ea9 100644 --- a/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/DownloadSentryCliTask.kt +++ b/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/DownloadSentryCliTask.kt @@ -6,26 +6,33 @@ import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider -import org.gradle.api.tasks.* +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider import org.gradle.process.ExecOperations import java.net.URL import java.nio.file.Files -import java.util.* import javax.inject.Inject import kotlin.io.path.deleteIfExists internal fun TaskContainer.registerDownloadSentryCliTask( - cliFilePath: Provider + cliFilePath: Provider, + cliVersion: Provider ): TaskProvider = register("downloadSentryCli", DownloadSentryCliTask::class.java) { - val sentryCliVersion = object {}.javaClass.getResource("/SENTRY_CLI_VERSION").readText() - it.downloadUrl.set(findSentryCliDownloadUrl(sentryCliVersion)) it.cliFilePath.set(cliFilePath) + it.cliVersion.set(cliVersion) + it.osName.set(System.getProperty("os.name").lowercase()) } internal abstract class DownloadSentryCliTask : DefaultTask() { @get:Input - abstract val downloadUrl: Property + abstract val cliVersion: Property + + @get:Input + abstract val osName: Property @get:OutputFile abstract val cliFilePath: RegularFileProperty @@ -35,7 +42,8 @@ internal abstract class DownloadSentryCliTask : DefaultTask() { @TaskAction fun downloadSentryCli() { - URL(downloadUrl.get()).openStream().use { + val cliDownloadUrl = findSentryCliDownloadUrl(cliVersion.get(), osName.get()) + URL(cliDownloadUrl).openStream().use { val cliFile = cliFilePath.asFile.get().toPath() cliFile.deleteIfExists() Files.copy(it, cliFile) @@ -44,24 +52,23 @@ internal abstract class DownloadSentryCliTask : DefaultTask() { it.commandLine("chmod", "u+x", cliFilePath.asFile.get().absolutePath) } } -} -private fun findSentryCliDownloadUrl(version: String): String { - val releaseDownloadsUrl = "https://github.com/getsentry/sentry-cli/releases/download/$version" - val osName = System.getProperty("os.name").lowercase(Locale.ROOT) - return when { - osName.contains("mac") -> - "$releaseDownloadsUrl/sentry-cli-Darwin-universal" + private fun findSentryCliDownloadUrl(version: String, osName: String): String { + val releaseDownloadsUrl = "https://github.com/getsentry/sentry-cli/releases/download/$version" + return when { + osName.contains("mac") -> + "$releaseDownloadsUrl/sentry-cli-Darwin-universal" - osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> - "$releaseDownloadsUrl/sentry-cli-Linux-x86_64" + osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> + "$releaseDownloadsUrl/sentry-cli-Linux-x86_64" - osName.contains("windows") && System.getProperty("os.arch") in listOf("x86", "ia32") -> - "$releaseDownloadsUrl/sentry-cli-Windows-i686.exe" + osName.contains("windows") && System.getProperty("os.arch") in listOf("x86", "ia32") -> + "$releaseDownloadsUrl/sentry-cli-Windows-i686.exe" - osName.contains("windows") -> - "$releaseDownloadsUrl/sentry-cli-Windows-x86_64.exe" + osName.contains("windows") -> + "$releaseDownloadsUrl/sentry-cli-Windows-x86_64.exe" - else -> throw GradleException("We do not support $osName") + else -> throw GradleException("We do not support $osName") + } } } diff --git a/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/UploadUuidToSentryTask.kt b/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/UploadUuidToSentryTask.kt index 93e2ec1..5f5bf60 100644 --- a/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/UploadUuidToSentryTask.kt +++ b/src/main/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/UploadUuidToSentryTask.kt @@ -1,6 +1,7 @@ package com.ioki.sentry.proguard.gradle.plugin.tasks import com.ioki.sentry.proguard.gradle.plugin.SentryProguardExtension +import com.ioki.sentry.proguard.gradle.plugin.build import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty @@ -35,6 +36,7 @@ internal fun TaskContainer.registerUploadUuidToSentryTask( it.cliFilePath.set(downloadSentryCliTask.flatMap { it.cliFilePath }) it.uuid.set(uuid) it.variantName.set(variantName) + it.cliCommand.set(sentryProguardExtension.cliConfig.command) } configureEach { task -> @@ -63,6 +65,9 @@ internal abstract class UploadUuidToSentryTask : DefaultTask() { @get:Input abstract val sentryAuthToken: Property + @get:Input + abstract val cliCommand: Property + @get:InputFile abstract val cliFilePath: RegularFileProperty @@ -75,19 +80,13 @@ internal abstract class UploadUuidToSentryTask : DefaultTask() { @TaskAction fun uploadUuidToSentry() { - val cliFilePath = cliFilePath.get().asFile.absolutePath - val command = listOf( - cliFilePath, - "upload-proguard", - "--uuid", - uuid.get(), - mappingFilePath.get().asFile.path, - "--org", - sentryOrg.get(), - "--project", - sentryProject.get(), - "--auth-token", - sentryAuthToken.get() + val command = cliCommand.get().build( + cliFilePath = cliFilePath.get().asFile.absolutePath, + uuid = uuid.get(), + mappingFilePath = mappingFilePath.get().asFile.absolutePath, + org = sentryOrg.get(), + project = sentryProject.get(), + authToken = sentryAuthToken.get() ) logger.log(LogLevel.INFO, "Execute the following command:\n$command") execOperations.exec { diff --git a/src/test/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/DownloadSentryCliTaskTest.kt b/src/test/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/DownloadSentryCliTaskTest.kt new file mode 100644 index 0000000..119a563 --- /dev/null +++ b/src/test/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/DownloadSentryCliTaskTest.kt @@ -0,0 +1,106 @@ +package com.ioki.sentry.proguard.gradle.plugin.tasks + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import strikt.api.expectThat +import strikt.assertions.contains +import strikt.assertions.isEqualTo +import strikt.assertions.isGreaterThan +import strikt.assertions.isTrue +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.copyToRecursively +import kotlin.io.path.exists +import kotlin.io.path.fileSize +import kotlin.io.path.readText +import kotlin.io.path.writeText + +class DownloadSentryCliTaskTest { + + @TempDir + lateinit var testTmpPath: Path + + @BeforeEach + @OptIn(ExperimentalPathApi::class) + fun moveTestProjectToTestTmpDir() { + val testProjectPath = Paths.get(System.getProperty("user.dir"), "androidTestProject") + testProjectPath.copyToRecursively( + testTmpPath, + overwrite = true, + followLinks = false + ) + } + + @Test + fun `downloadSentryCli task downloads sentry cli binary`() { + val result = GradleRunner.create() + .withProjectDir(testTmpPath.toFile()) + .withPluginClasspath() + .withArguments("downloadSentryCli") + .build() + + expectThat(result.task(":downloadSentryCli")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + + val cliPath = testTmpPath.resolve("build/sentry/cli") + expectThat(cliPath.exists()).isTrue() + expectThat(cliPath.fileSize()).isGreaterThan(0) + expectThat(cliPath.toFile().canExecute()).isTrue() + } + + @Test + fun `downloaded sentry cli is executable and returns version`() { + GradleRunner.create() + .withProjectDir(testTmpPath.toFile()) + .withPluginClasspath() + .withArguments("downloadSentryCli") + .build() + + val cliPath = testTmpPath.resolve("build/sentry/cli") + val process = ProcessBuilder(cliPath.toAbsolutePath().toString(), "--version") + .directory(testTmpPath.toFile()) + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().use { it.readText() } + val exitCode = process.waitFor() + + expectThat(exitCode).isEqualTo(0) + val bundledVersion = object {}.javaClass.getResource("/SENTRY_CLI_VERSION").readText() + expectThat(output.lowercase()).contains("sentry-cli $bundledVersion") + } + + @Test + fun `custom sentry cli version is downloaded executed and returns version`() { + val buildFile = testTmpPath.resolve("build.gradle.kts") + val newBuildFile = buildFile.readText().replace( + oldValue = """organization.set("sentryOrg")""", + newValue = """organization.set("sentryOrg") + cliConfig { + version.set("2.0.0") + } + """.trimIndent() + ) + buildFile.writeText(newBuildFile) + GradleRunner.create() + .withProjectDir(testTmpPath.toFile()) + .withPluginClasspath() + .withArguments("downloadSentryCli") + .build() + + val cliPath = testTmpPath.resolve("build/sentry/cli") + val process = ProcessBuilder(cliPath.toAbsolutePath().toString(), "--version") + .directory(testTmpPath.toFile()) + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().use { it.readText() } + val exitCode = process.waitFor() + + expectThat(exitCode).isEqualTo(0) + expectThat(output.lowercase()).contains("sentry-cli 2.0.0") + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/UploadUuidToSentryTaskTest.kt b/src/test/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/UploadUuidToSentryTaskTest.kt new file mode 100644 index 0000000..93c1a8d --- /dev/null +++ b/src/test/kotlin/com/ioki/sentry/proguard/gradle/plugin/tasks/UploadUuidToSentryTaskTest.kt @@ -0,0 +1,104 @@ +package com.ioki.sentry.proguard.gradle.plugin.tasks + +import com.ioki.sentry.proguard.gradle.plugin.Command +import com.ioki.sentry.proguard.gradle.plugin.SentryCliConfig +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import strikt.api.expectThat +import strikt.assertions.contains +import strikt.assertions.isEqualTo +import strikt.assertions.isTrue +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.copyToRecursively +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +class UploadUuidToSentryTaskTest { + + @TempDir + lateinit var testTmpPath: Path + + @BeforeEach + @OptIn(ExperimentalPathApi::class) + fun moveTestProjectToTestTmpDir() { + val testProjectPath = Paths.get(System.getProperty("user.dir"), "androidTestProject") + testProjectPath.copyToRecursively( + testTmpPath, + overwrite = true, + followLinks = false + ) + } + + @Test + fun `upload task executes default command with expected arguments`() { + prepareFakeCli() + prepareMappingFileFor("aRelease") + + val result = GradleRunner.create() + .withProjectDir(testTmpPath.toFile()) + .withPluginClasspath() + .withArguments("uploadSentryProguardUuidForARelease", "-x", "downloadSentryCli") + .build() + + expectThat(result.task(":uploadSentryProguardUuidForARelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + expectThat(result.output).contains("upload-proguard --uuid") + expectThat(result.output).contains("build/outputs/mapping/aRelease/mapping.txt") + expectThat(result.output).contains("--org sentryOrg --project sentryProject --auth-token sentryAuthToken") + } + + @Test + fun `upload task command can be overridden`() { + prepareFakeCli() + prepareMappingFileFor("aRelease") + overrideUploadCommand("${SentryCliConfig.PlaceHolder.CLI_FILE_PATH} override-command --uuid ${SentryCliConfig.PlaceHolder.UUID} ${SentryCliConfig.PlaceHolder.ORG} ${SentryCliConfig.PlaceHolder.AUTH_TOKEN} ${SentryCliConfig.PlaceHolder.PROJECT}") + + val result = GradleRunner.create() + .withProjectDir(testTmpPath.toFile()) + .withPluginClasspath() + .withArguments("uploadSentryProguardUuidForARelease", "-x", "downloadSentryCli") + .build() + + expectThat(result.task(":uploadSentryProguardUuidForARelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + expectThat(result.output).contains("override-command --uuid") + expectThat(result.output).contains("sentryOrg sentryAuthToken sentryProject") + } + + private fun prepareFakeCli() { + val cliPath = testTmpPath.resolve("build/sentry/cli") + cliPath.parent.createDirectories() + cliPath.writeText( + """ + #!/bin/sh + echo "$@" + """.trimIndent() + ) + expectThat(cliPath.toFile().setExecutable(true)).isTrue() + expectThat(cliPath.exists()).isTrue() + } + + private fun prepareMappingFileFor(variantName: String) { + val mappingFilePath = testTmpPath.resolve("build/outputs/mapping/$variantName/mapping.txt") + mappingFilePath.parent.createDirectories() + mappingFilePath.writeText("# test mapping") + } + + private fun overrideUploadCommand(command: String) { + val buildFile = testTmpPath.resolve("build.gradle.kts") + val newBuildFile = buildFile.readText().replace( + oldValue = """organization.set("sentryOrg")""", + newValue = """organization.set("sentryOrg") + cliConfig { + command.set("$command") + } + """.trimIndent() + ) + buildFile.writeText(newBuildFile) + } +} \ No newline at end of file