diff --git a/build.gradle.kts b/build.gradle.kts index 2084c7f1fad..08f3d6ed27c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { id("com.github.spotbugs") version "5.0.14" id("de.thetaphi.forbiddenapis") version "3.8" - id("org.shipkit.shipkit-auto-version") version "2.1.2" + id("tracer-version") id("io.github.gradle-nexus.publish-plugin") version "2.0.0" id("com.gradleup.shadow") version "8.3.6" apply false @@ -56,11 +56,8 @@ apply(from = rootDir.resolve("gradle/spotless.gradle")) val compileTask = tasks.register("compile") -val repoVersion = version - allprojects { group = "com.datadoghq" - version = repoVersion if (isCI.isPresent) { layout.buildDirectory = providers.provider { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index c62a5b24541..890b521d2ef 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -2,6 +2,7 @@ plugins { groovy `java-gradle-plugin` `kotlin-dsl` + `jvm-test-suite` id("com.diffplug.spotless") version "6.13.0" } @@ -25,6 +26,10 @@ gradlePlugin { id = "call-site-instrumentation" implementationClass = "datadog.gradle.plugin.CallSiteInstrumentationPlugin" } + create("tracer-version-plugin") { + id = "tracer-version" + implementationClass = "datadog.gradle.plugin.version.TracerVersionPlugin" + } } } @@ -42,19 +47,46 @@ dependencies { implementation("org.eclipse.aether", "aether-transport-http", "1.1.0") implementation("org.apache.maven", "maven-aether-provider", "3.3.9") + implementation("com.github.zafarkhaja:java-semver:0.10.2") + implementation("com.google.guava", "guava", "20.0") implementation("org.ow2.asm", "asm", "9.8") implementation("org.ow2.asm", "asm-tree", "9.8") - - testImplementation(libs.spock.core) - testImplementation(libs.groovy) } tasks.compileKotlin { dependsOn(":call-site-instrumentation-plugin:build") } -tasks.test { - useJUnitPlatform() - enabled = project.hasProperty("runBuildSrcTests") +testing { + @Suppress("UnstableApiUsage") + suites { + val test by getting(JvmTestSuite::class) { + dependencies { + implementation(libs.spock.core) + implementation(libs.groovy) + } + targets.configureEach { + testTask.configure { + enabled = project.hasProperty("runBuildSrcTests") + } + } + } + + val integTest by registering(JvmTestSuite::class) { + dependencies { + implementation(gradleTestKit()) + implementation("org.assertj:assertj-core:3.25.3") + } + // Makes the gradle plugin publish its declared plugins to this source set + gradlePlugin.testSourceSet(sources) + } + + withType(JvmTestSuite::class).configureEach { + useJUnitJupiter(libs.versions.junit5) + targets.configureEach { + testTask + } + } + } } diff --git a/buildSrc/src/integTest/kotlin/datadog/gradle/plugin/version/TracerVersionIntegrationTest.kt b/buildSrc/src/integTest/kotlin/datadog/gradle/plugin/version/TracerVersionIntegrationTest.kt new file mode 100644 index 00000000000..af27e60ee45 --- /dev/null +++ b/buildSrc/src/integTest/kotlin/datadog/gradle/plugin/version/TracerVersionIntegrationTest.kt @@ -0,0 +1,319 @@ +package datadog.gradle.plugin.version + +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.CleanupMode +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.IOException + + +class TracerVersionIntegrationTest { + + @Test + fun `should use default version when not under a git clone`(@TempDir projectDir: File) { + assertTracerVersion(projectDir, "0.1.0-SNAPSHOT") + } + + @Test + fun `should use default version when no git tags`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "0.1.0-SNAPSHOT", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + } + ) + } + + @Test + fun `should ignore dirtiness when no git tags`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "0.1.0-SNAPSHOT", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + + File(projectDir, "settings.gradle.kts").appendText(""" + + // uncommitted change this file, + """.trimIndent()) + } + ) + } + + @Test + fun `should use default version when unmatching git tags`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "0.1.0-SNAPSHOT", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "something1.40.1", "-m", "Not our tag") + } + ) + } + + @Test + fun `should use exact version when on tag`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "1.52.0", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "v1.52.0", "-m", "") + } + ) + } + + @Test + fun `should increment minor and mark dirtiness`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "1.53.0-SNAPSHOT-DIRTY", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "v1.52.0", "-m", "") + + File(projectDir, "settings.gradle.kts").appendText(""" + + // uncommitted change this file, + """.trimIndent()) + } + ) + } + + @Test + fun `should ignore dirtiness if CI env`(@TempDir projectDir: File) { // CI patch some tracked files + assertTracerVersion( + projectDir, + "1.52.0", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "v1.52.0", "-m", "") + + // dirty file ignored + File(projectDir, "settings.gradle.kts").appendText(""" + + // uncommitted change this file, + """.trimIndent()) + }, + additionalEnv = mapOf("CI" to "true") + ) + } + + @Test + fun `should increment minor with added commits after version tag`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "1.53.0-SNAPSHOT", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "v1.52.0", "-m", "") + + File(projectDir, "settings.gradle.kts").appendText( + """ + + // Committed change this file, + """.trimIndent() + ) + exec(projectDir, "git", "commit", "-am", "Another commit") + } + ) + } + + @Test + fun `should increment minor with snapshot and dirtiness with added commits after version tag and dirty`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "1.53.0-SNAPSHOT-DIRTY", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "v1.52.0", "-m", "") + + val settingsFile = File(projectDir, "settings.gradle.kts") + settingsFile.appendText(""" + + // uncommitted change + """.trimIndent()) + + exec(projectDir, "git", "commit", "-am", "Another commit") + + settingsFile.appendText(""" + // An uncommitted modification + """.trimIndent()) + } + ) + } + + @Test + fun `should increment patch on release branch and no patch release tag`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "1.52.1-SNAPSHOT", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "v1.52.0", "-m", "") + + val settingsFile = File(projectDir, "settings.gradle.kts") + settingsFile.appendText(""" + + // Committed change + """.trimIndent()) + + exec(projectDir, "git", "commit", "-am", "Another commit") + exec(projectDir, "git", "switch", "-c", "release/v1.52.x") + } + ) + } + + @Test + fun `should increment patch on release branch and with previous patch release tag`(@TempDir projectDir: File) { + assertTracerVersion( + projectDir, + "1.52.2-SNAPSHOT", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "v1.52.0", "-m", "") + exec(projectDir, "git", "switch", "-c", "release/v1.52.x") + + val settingsFile = File(projectDir, "settings.gradle.kts") + settingsFile.appendText(""" + + // Committed change + """.trimIndent()) + exec(projectDir, "git", "commit", "-am", "Another commit") + exec(projectDir, "git", "tag", "v1.52.1", "-m", "") + + settingsFile.appendText(""" + + // Another committed change + """.trimIndent()) + exec(projectDir, "git", "commit", "-am", "Another commit") + } + ) + } + + @Test + fun `should compute version on worktrees`(@TempDir projectDir: File, @TempDir workTreeDir: File) { + assertTracerVersion( + projectDir, + "1.53.0-SNAPSHOT", + beforeGradle = { + exec(projectDir, "git", "init", "--initial-branch", "main") + exec(projectDir, "git", "config", "user.email", "test@datadoghq.com") + exec(projectDir, "git", "config", "user.name", "Test") + exec(projectDir, "git", "add", "-A") + exec(projectDir, "git", "commit", "-m", "A commit") + exec(projectDir, "git", "tag", "v1.52.0", "-m", "") + + exec(projectDir, "git", "commit", "-m", "Initial commit", "--allow-empty") + exec(projectDir, "git", "worktree", "add", workTreeDir.absolutePath) + // happening on the worktree + File(workTreeDir, "settings.gradle.kts").appendText( + """ + + // Committed change this file, + """.trimIndent() + ) + exec(workTreeDir, "git", "commit", "-am", "Another commit") + }, + workingDirectory = workTreeDir + ) + } + + private fun assertTracerVersion( + projectDir: File, + expectedVersion: String, + beforeGradle: () -> Unit = {}, + workingDirectory: File = projectDir, + additionalEnv: Map = mapOf("CI" to "false"), + ) { + File(projectDir, "settings.gradle.kts").writeText( + """ + rootProject.name = "test-project" + """.trimIndent() + ) + File(projectDir, "build.gradle.kts").writeText( + """ + plugins { + id("tracer-version") + } + + tasks.register("printVersion") { + logger.quiet(project.version.toString()) + } + + group = "datadog.tracer.version.test" + """.trimIndent() + ) + + beforeGradle() + + val buildResult = GradleRunner.create() + .forwardOutput() + // .withGradleVersion(gradleVersion) // Use current gradle version + .withPluginClasspath() + .withArguments("printVersion", "--quiet") + .withEnvironment(System.getenv() + additionalEnv) + .withProjectDir(workingDirectory) + // .withDebug(true) + .build() + + assertThat(buildResult.output).isEqualToIgnoringNewLines(expectedVersion) + } + + private fun exec(workingDirectory: File, vararg args: String) { + val exitCode = ProcessBuilder() + .command(*args) + .directory(workingDirectory) + .inheritIO() + .start() + .waitFor() + + if (exitCode != 0) { + throw IOException(String.format("Process failed: %s Exit code %d", args.joinToString(" "), exitCode)) + } + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/version/GitCommandValueSource.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/version/GitCommandValueSource.kt new file mode 100644 index 00000000000..dd60de6f162 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/version/GitCommandValueSource.kt @@ -0,0 +1,61 @@ +package datadog.gradle.plugin.version + +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.ValueSource +import org.gradle.api.provider.ValueSourceParameters +import org.gradle.process.ExecOperations +import java.io.ByteArrayOutputStream +import java.nio.charset.Charset +import javax.inject.Inject + +abstract class GitCommandValueSource @Inject constructor( + private val execOperations: ExecOperations +) : ValueSource { + override fun obtain(): String { + val workDir = parameters.workingDirectory.get() + val commands = parameters.gitCommand.get() + + val outputStream = ByteArrayOutputStream() + val result = try { + execOperations.exec { + commandLine(commands) + workingDir(workDir) + standardOutput = outputStream + errorOutput = outputStream + isIgnoreExitValue = true + } + } catch (e: Exception) { + throw GradleException("Failed to run: ${commands.joinToString(" ")}", e) + } + + val output = outputStream.toString(Charset.defaultCharset().name()).trim() + when { + result.exitValue == 128 && + (output.startsWith("fatal: not a git repository") + || output.startsWith("fatal: No names found, cannot describe anything.")) + -> { + // Behaves as if not a git repo + return "" + } + result.exitValue != 0 -> { + throw GradleException( + """ + Failed to run: ${commands.joinToString(" ")} + (exit code: ${result.exitValue}) + Output: + $output + """.trimIndent() + ) + } + } + + return output + } + + interface Parameters : ValueSourceParameters { + val workingDirectory: DirectoryProperty + val gitCommand: ListProperty + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/version/TracerVersionPlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/version/TracerVersionPlugin.kt new file mode 100644 index 00000000000..ef6a5ab3015 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/version/TracerVersionPlugin.kt @@ -0,0 +1,145 @@ +package datadog.gradle.plugin.version + +import com.github.zafarkhaja.semver.Version +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.logging.Logging +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ProviderFactory +import org.gradle.kotlin.dsl.property +import java.io.File +import javax.inject.Inject + +class TracerVersionPlugin @Inject constructor( + private val providerFactory: ProviderFactory, +) : Plugin { + private val logger = Logging.getLogger(TracerVersionPlugin::class.java) + + override fun apply(targetProject: Project) { + if (targetProject.rootProject != targetProject) { + throw IllegalStateException("Only root project can apply plugin") + } + targetProject.extensions.create("tracerVersion", TracerVersionExtension::class.java) + val extension = targetProject.extensions.getByType(TracerVersionExtension::class.java) + + extension.detectDirty.set( + providerFactory.environmentVariable("CI").map { it != "true" }.orElse(true) + ) + + val versionProvider = versionProvider(targetProject, extension) + targetProject.allprojects { + version = versionProvider + } + } + + private fun versionProvider( + targetProject: Project, + extension: TracerVersionExtension + ): String { + val repoWorkingDirectory = targetProject.rootDir + + val buildVersion: String = if (!repoWorkingDirectory.resolve(".git").exists()) { + // Not a git repository + extension.defaultVersion.get() + } else { + providerFactory.zip( + gitDescribeProvider(extension, repoWorkingDirectory), + gitCurrentBranchProvider(repoWorkingDirectory) + ) { describeString, currentBranch -> + toTracerVersion(describeString, extension) { + when { + currentBranch.startsWith("release/v") -> { + logger.info("Incrementing patch because release branch : $currentBranch") + nextPatchVersion() + } + else -> { + logger.info("Incrementing minor") + nextMinorVersion() + } + } + } + }.get() + } + + logger.lifecycle("Tracer build version: {}", buildVersion) + return buildVersion + } + + private fun gitCurrentBranchProvider( + repoWorkingDirectory: File + ) = providerFactory.of(GitCommandValueSource::class.java) { + parameters { + gitCommand.addAll( + "git", + "rev-parse", + "--abbrev-ref", + "HEAD", + ) + workingDirectory.set(repoWorkingDirectory) + } + } + + private fun gitDescribeProvider( + extension: TracerVersionExtension, + repoWorkingDirectory: File + ) = providerFactory.of(GitCommandValueSource::class.java) { + parameters { + val tagPrefix = extension.tagVersionPrefix.get() + gitCommand.addAll( + "git", + "describe", + "--abbrev=8", + "--tags", + "--first-parent", + "--match=$tagPrefix[0-9].[0-9]*.[0-9]*", + ) + + if (extension.detectDirty.get()) { + gitCommand.add("--dirty") + } + + workingDirectory.set(repoWorkingDirectory) + } + } + + private fun toTracerVersion(describeString: String, extension: TracerVersionExtension, nextVersion: Version.() -> Version): String { + logger.info("Git describe output: {}", describeString) + + val tagPrefix = extension.tagVersionPrefix.get() + val tagRegex = Regex("$tagPrefix(\\d+\\.\\d+\\.\\d+)(.*)") + val matchResult = tagRegex.find(describeString) + ?: return extension.defaultVersion.get() + + val (lastTagVersion, describeTrailer) = matchResult.destructured + val hasLaterCommits = describeTrailer.isNotBlank() + val version = Version.parse(lastTagVersion).let { + if (hasLaterCommits) { + it.nextVersion() + } else { + it + } + } + + return buildString { + append(version.toString()) + + if (hasLaterCommits) { + append(if (extension.useSnapshot.get()) "-SNAPSHOT" else describeTrailer) + } + + if (describeTrailer.endsWith("-dirty")) { + append(if (extension.useSnapshot.get()) "-DIRTY" else "-dirty") + } + } + } + + open class TracerVersionExtension @Inject constructor(objectFactory: ObjectFactory) { + val defaultVersion = objectFactory.property(String::class) + .convention("0.1.0-SNAPSHOT") + val tagVersionPrefix = objectFactory.property(String::class) + .convention("v") + val useSnapshot = objectFactory.property(Boolean::class) + .convention(true) + val detectDirty = objectFactory.property(Boolean::class) + } +}