From 9250bf34cf92002df6f3b1fc521f488deca3dd98 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 11 Aug 2026 15:43:35 +0200 Subject: [PATCH 01/22] TS-47380 Apply KotlinModuleMetadataTransformer explicitly Shadow deprecated `enableKotlinModuleRemapping` in 9.5.0 and removes it in Shadow 10 (GradleUp/shadow#2073). Applying the transformer explicitly keeps the contents of the `.kotlin_module` files relocated. The shaded agent jar is byte-identical to before. Also drop the four explicit relocate(...) calls. They are leftovers from the kotlin-shadow-relocator plugin removed in 3fd564fd1, where they took care of the Kotlin metadata that the transformer now handles. In regular builds they only duplicated what enableAutoRelocation already does, save for two unwanted string rewrites: the "retrofitBuilderAction" parameter name in HttpUtils and a string in retrofit's own @Metadata, both caused by "retrofit" matching beyond package boundaries. In -Pdebug=true builds they relocated kotlin, okhttp3, okio and retrofit even though that build exists precisely to keep the package names in the jar matching the ones IntelliJ knows from the source. Co-Authored-By: Claude Opus 5 (1M context) --- .../kotlin/com.teamscale.shadow-convention.gradle.kts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts index 955ff9f24..7d132a3b5 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts @@ -1,4 +1,5 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import com.github.jengelman.gradle.plugins.shadow.transformers.KotlinModuleMetadataTransformer plugins { java @@ -10,11 +11,10 @@ tasks.named("shadowJar") { enableAutoRelocation = providers.gradleProperty("debug").map { it != "true" }.orElse(true) archiveClassifier = null as String? mergeServiceFiles() - // Relocates the .kotlin_metadata files to ensure reflection in Kotlin does not break - relocate("kotlin", "shadow.kotlin") - relocate("okhttp3", "shadow.okhttp3") - relocate("okio", "shadow.okio") - relocate("retrofit", "shadow.retrofit") + // Rewrites the package parts inside the .kotlin_module files so they match the relocated + // classes. Shadow still does this implicitly via the deprecated enableKotlinModuleRemapping + // flag, but that flag is removed in Shadow 10, so we apply the transformer explicitly. + transform(KotlinModuleMetadataTransformer::class.java) val archiveFile = this.archiveFile doLast("revertKotlinPackageChanges") { revertKotlinPackageChanges(archiveFile) } } From 37b74480f6c07aa006c0706f1441665b81a2e37c Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 11 Aug 2026 15:45:06 +0200 Subject: [PATCH 02/22] TS-47380 Derive the shadow package prefix in logback configs at build time The logback configurations hardcoded the shadow relocation prefix, so they were only valid for the production build. A -Pdebug=true build disables auto relocation, but the bundled configs still asked for shadow.ch.qos.logback.*; logback then failed to create the appenders and LoggingUtils swallowed the resulting JoranException, so the agent silently logged nowhere. The configurations are now checked in without the prefix, which makes them usable from the IDE, from unit tests and in debug builds. ShadowLoggingPackages adds the prefix while packaging them into the shaded jar, into the shadow distribution and into the maven plugin jar, whose config is handed to the shaded agent. VerifyShadowedLoggingConfigs scans the produced archives and asserts the expected form for both build modes, so a newly added configuration that is not covered by the patterns fails the build. Also in this commit: - The entries for org.apache.spark and org.eclipse.jetty are gone. Neither can exist in the shipped agent since Jetty was removed in 37.0.0 and spark-core is only a test dependency. - sample-debugging-app no longer keeps its own copy of logback.console.xml and uses the now unprefixed dist template instead. - The shadow convention applies KotlinModuleMetadataTransformer explicitly and no longer declares the relocations that auto relocation already covers. Co-Authored-By: Claude Opus 5 (1M context) --- agent/build.gradle.kts | 15 ++++ agent/src/dist/logging/logback.console.xml | 5 +- agent/src/dist/logging/logback.debug.xml | 11 +-- .../src/dist/logging/logback.rolling-file.xml | 9 +-- .../agent/logback-default-debugging.xml | 11 +-- .../jacoco/agent/logback-default.xml | 9 +-- buildSrc/src/main/kotlin/ShadowedPackages.kt | 56 ++++++++++++++ .../kotlin/VerifyShadowedLoggingConfigs.kt | 75 +++++++++++++++++++ ...com.teamscale.shadow-convention.gradle.kts | 5 +- sample-debugging-app/jacocoagent.properties | 2 +- sample-debugging-app/logback.console.xml | 15 ---- teamscale-maven-plugin/build.gradle.kts | 15 ++++ .../com/teamscale/maven/tia/logback-agent.xml | 5 +- 13 files changed, 182 insertions(+), 51 deletions(-) create mode 100644 buildSrc/src/main/kotlin/ShadowedPackages.kt create mode 100644 buildSrc/src/main/kotlin/VerifyShadowedLoggingConfigs.kt delete mode 100644 sample-debugging-app/logback.console.xml diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts index e8e6c82d7..3ec1cbd2d 100644 --- a/agent/build.gradle.kts +++ b/agent/build.gradle.kts @@ -99,6 +99,21 @@ distributions { } } +// The logging templates in src/dist are checked in without the shadow prefix so they also work when +// relocation is disabled. Add the prefix while packaging them, since the distribution ships the shaded agent. +listOf(tasks.shadowDistZip, tasks.shadowDistTar, tasks.installShadowDist).forEach { task -> + task { shadowLoggingPackages(usesShadowedPackages) } +} + +val verifyShadowedLoggingConfigs by tasks.registering(VerifyShadowedLoggingConfigs::class) { + archives.from(tasks.shadowJar, tasks.shadowDistZip) + relocated = usesShadowedPackages +} + +tasks.check { + dependsOn(verifyShadowedLoggingConfigs) +} + tasks.shadowDistZip { archiveFileName = "teamscale-jacoco-agent.zip" } diff --git a/agent/src/dist/logging/logback.console.xml b/agent/src/dist/logging/logback.console.xml index c03fb4d28..efe483c0d 100644 --- a/agent/src/dist/logging/logback.console.xml +++ b/agent/src/dist/logging/logback.console.xml @@ -1,6 +1,6 @@ - + %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n @@ -9,7 +9,4 @@ - - - diff --git a/agent/src/dist/logging/logback.debug.xml b/agent/src/dist/logging/logback.debug.xml index 67af8e37e..23608d685 100644 --- a/agent/src/dist/logging/logback.debug.xml +++ b/agent/src/dist/logging/logback.debug.xml @@ -2,24 +2,24 @@ - + %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n - + ${defaultLogDir}/teamscale-jacoco-agent.log - + ${defaultLogDir}/teamscale-jacoco-agent-%i.log.zip 1 10 - + 1MB @@ -32,7 +32,4 @@ - - - diff --git a/agent/src/dist/logging/logback.rolling-file.xml b/agent/src/dist/logging/logback.rolling-file.xml index 2675158ad..f5814f731 100644 --- a/agent/src/dist/logging/logback.rolling-file.xml +++ b/agent/src/dist/logging/logback.rolling-file.xml @@ -2,18 +2,18 @@ - + ${defaultLogDir}/teamscale-jacoco-agent.log - + ${defaultLogDir}/teamscale-jacoco-agent-%i.log.zip 1 10 - + 1MB @@ -25,7 +25,4 @@ - - - diff --git a/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default-debugging.xml b/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default-debugging.xml index 99ce5765c..29062cc7e 100644 --- a/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default-debugging.xml +++ b/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default-debugging.xml @@ -2,24 +2,24 @@ - + %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n - + ${defaultLogDir}/teamscale-jacoco-agent.log - + ${defaultLogDir}/teamscale-jacoco-agent-%i.log.zip 1 10 - + 1MB @@ -32,7 +32,4 @@ - - - diff --git a/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default.xml b/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default.xml index 0f5dce35b..af1bea31f 100644 --- a/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default.xml +++ b/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default.xml @@ -2,17 +2,17 @@ - + ${defaultLogDir}/teamscale-jacoco-agent.log - + ${defaultLogDir}/teamscale-jacoco-agent-%i.log.zip 1 10 - + 1MB @@ -24,7 +24,4 @@ - - - diff --git a/buildSrc/src/main/kotlin/ShadowedPackages.kt b/buildSrc/src/main/kotlin/ShadowedPackages.kt new file mode 100644 index 000000000..00c3af0ab --- /dev/null +++ b/buildSrc/src/main/kotlin/ShadowedPackages.kt @@ -0,0 +1,56 @@ +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.file.CopySpec +import org.gradle.api.file.FileCopyDetails +import org.gradle.api.provider.Provider +import java.io.Serializable + +/** The prefix that the shadow plugin's auto relocation puts all relocated dependency packages under. */ +const val SHADOW_PACKAGE_PREFIX = "shadow" + +/** Ant patterns under which our own logback configuration files are packaged. */ +private val LOGBACK_CONFIG_PATTERNS = listOf("com/teamscale/**/logback*.xml", "logging/logback*.xml") + +/** Packages that our logback configuration files reference by fully qualified name and that get relocated. */ +private val RELOCATED_LOGGING_PACKAGES = listOf("ch.qos.logback") + +/** + * Whether this build relocates dependencies under [SHADOW_PACKAGE_PREFIX]. Auto relocation is disabled via + * `-Pdebug=true` to make the agent easier to debug locally. + */ +val Project.usesShadowedPackages: Provider + get() = providers.gradleProperty("debug").map { it != "true" }.orElse(true) + +/** + * Prefixes references to relocated packages in the logback configuration files packaged by this task, cf. + * [ShadowLoggingPackages]. + * + * Whether we relocate is decided by a gradle property, which Gradle does not track when it is read at + * configuration time, so it is registered as an explicit task input. Otherwise switching between debug and + * production builds would leave the previously packaged configuration files in place. + */ +fun T.shadowLoggingPackages(relocated: Provider) where T : Task, T : CopySpec { + inputs.property("shadowedLoggingPackages", relocated) + filesMatching(LOGBACK_CONFIG_PATTERNS, ShadowLoggingPackages(relocated.get())) +} + +/** + * Prefixes references to [RELOCATED_LOGGING_PACKAGES] in logback configuration files with + * [SHADOW_PACKAGE_PREFIX] so they match the relocated classes in the shaded agent jar. + * + * This lets us keep the configuration files in the source tree free of the prefix, so they can be used as-is + * from the IDE, from unit tests and in `-Pdebug=true` builds, where no relocation happens. + */ +private class ShadowLoggingPackages(private val enabled: Boolean) : Action, Serializable { + override fun execute(details: FileCopyDetails) { + if (!enabled) return + // Anchoring on the opening quote restricts the replacement to XML attribute values, which covers + // both `class="..."` and ``. + details.filter { line -> + RELOCATED_LOGGING_PACKAGES.fold(line) { result, packageName -> + result.replace("\"$packageName.", "\"$SHADOW_PACKAGE_PREFIX.$packageName.") + } + } + } +} diff --git a/buildSrc/src/main/kotlin/VerifyShadowedLoggingConfigs.kt b/buildSrc/src/main/kotlin/VerifyShadowedLoggingConfigs.kt new file mode 100644 index 000000000..425a0182a --- /dev/null +++ b/buildSrc/src/main/kotlin/VerifyShadowedLoggingConfigs.kt @@ -0,0 +1,75 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.util.zip.ZipFile + +/** + * Asserts that the logback configuration files packaged into the given archives reference the relocated + * logback classes, i.e. that [ShadowLoggingPackages] was applied to all of them. + * + * The archives are scanned for logback configurations instead of checking a fixed list of files, so this also + * fails if a newly added configuration file is not covered by [LOGBACK_CONFIG_PATTERNS]. + */ +abstract class VerifyShadowedLoggingConfigs : DefaultTask() { + + /** The archives to check. Nested archives are not inspected. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val archives: ConfigurableFileCollection + + /** Whether the build relocates dependencies, cf. [usesShadowedPackages]. */ + @get:Input + abstract val relocated: Property + + @TaskAction + fun verify() { + val isRelocated = relocated.get() + archives.forEach { archive -> + ZipFile(archive).use { zip -> + val configs = zip.entries().asSequence() + .filter { CONFIG_NAME.matches(it.name.substringAfterLast('/')) } + .associate { it.name to zip.getInputStream(it).reader().readText() } + if (configs.isEmpty()) { + throw GradleException("Did not find any logback configuration in ${archive.name}") + } + configs.forEach { (path, content) -> verify(archive, path, content, isRelocated) } + } + } + } + + private fun verify(archive: File, path: String, content: String, relocated: Boolean) { + val relocatedReference = "\"$SHADOW_PACKAGE_PREFIX.$LOGBACK_PACKAGE." + val plainReference = "\"$LOGBACK_PACKAGE." + if (relocated) { + if (!content.contains(relocatedReference)) { + throw GradleException( + "$path in ${archive.name} does not reference any relocated logback class." + + " Is it covered by one of the LOGBACK_CONFIG_PATTERNS?" + ) + } + if (content.contains(plainReference)) { + throw GradleException( + "$path in ${archive.name} still references non-relocated logback classes," + + " which do not exist in the shaded jar." + ) + } + } else if (content.contains(relocatedReference)) { + throw GradleException( + "$path in ${archive.name} references relocated logback classes," + + " but this build does not relocate anything." + ) + } + } + + private companion object { + val CONFIG_NAME = Regex("logback.*\\.xml") + const val LOGBACK_PACKAGE = "ch.qos.logback" + } +} diff --git a/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts index 7d132a3b5..82bd104e8 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts @@ -8,9 +8,12 @@ plugins { } tasks.named("shadowJar") { - enableAutoRelocation = providers.gradleProperty("debug").map { it != "true" }.orElse(true) + enableAutoRelocation = usesShadowedPackages archiveClassifier = null as String? mergeServiceFiles() + // Our logback configurations are checked in without the shadow prefix so they also work when + // relocation is disabled. Add the prefix while packaging them into the shaded jar. + shadowLoggingPackages(usesShadowedPackages) // Rewrites the package parts inside the .kotlin_module files so they match the relocated // classes. Shadow still does this implicitly via the deprecated enableKotlinModuleRemapping // flag, but that flag is removed in Shadow 10, so we apply the transformer explicitly. diff --git a/sample-debugging-app/jacocoagent.properties b/sample-debugging-app/jacocoagent.properties index 1ab66e2bf..3a463c2d7 100644 --- a/sample-debugging-app/jacocoagent.properties +++ b/sample-debugging-app/jacocoagent.properties @@ -1,5 +1,5 @@ includes=*com.example.* -logging-config=./logback.console.xml +logging-config=../agent/src/dist/logging/logback.console.xml # teamscale-commit=master:HEAD # teamscale-server-url=http://localhost:8080/ # teamscale-project= diff --git a/sample-debugging-app/logback.console.xml b/sample-debugging-app/logback.console.xml deleted file mode 100644 index 1fb559c9d..000000000 --- a/sample-debugging-app/logback.console.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n - - - - - - - - - - \ No newline at end of file diff --git a/teamscale-maven-plugin/build.gradle.kts b/teamscale-maven-plugin/build.gradle.kts index 7c84f996e..272afe4f8 100644 --- a/teamscale-maven-plugin/build.gradle.kts +++ b/teamscale-maven-plugin/build.gradle.kts @@ -14,6 +14,21 @@ mavenPlugin { helpMojoPackage = "com.teamscale.maven.help" } +// This module is not shaded itself, but the bundled logback configuration is handed to the shaded +// agent, so it has to reference the relocated logback classes. +tasks.processResources { + shadowLoggingPackages(usesShadowedPackages) +} + +val verifyShadowedLoggingConfigs by tasks.registering(VerifyShadowedLoggingConfigs::class) { + archives.from(tasks.jar) + relocated = usesShadowedPackages +} + +tasks.check { + dependsOn(verifyShadowedLoggingConfigs) +} + dependencies { runtimeOnly(project(":agent")) implementation(project(":report-generator")) diff --git a/teamscale-maven-plugin/src/main/resources/com/teamscale/maven/tia/logback-agent.xml b/teamscale-maven-plugin/src/main/resources/com/teamscale/maven/tia/logback-agent.xml index 3915f2c46..d78683c6e 100644 --- a/teamscale-maven-plugin/src/main/resources/com/teamscale/maven/tia/logback-agent.xml +++ b/teamscale-maven-plugin/src/main/resources/com/teamscale/maven/tia/logback-agent.xml @@ -1,6 +1,6 @@ - + ${TEAMSCALE_AGENT_LOG_FILE} %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n @@ -10,7 +10,4 @@ - - - From c7448a8b61fb24b3e79af26f9e5a71f5ec1e09c3 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 07:28:48 +0200 Subject: [PATCH 03/22] TS-47380 Allow attaching a debugger to JVMs spawned by system tests Gradle's --debug-jvm only suspends the test JVM. System tests that spawn their own JVM via ProcessUtils (e.g. teamscale-profiler-configuration-test, sut-uses-logback-test) run the agent in that spawned process, which could not be debugged at all. Running the build with -PdebugSut[=] now makes every `java` process started by a system test wait for a debugger on the given port (5005 by default). Other commands, such as Maven or chcp.com, are left untouched. Co-Authored-By: Claude Opus 5 (1M context) --- ...eamscale.system-test-convention.gradle.kts | 11 ++++++++ .../teamscale/test/commons/ProcessUtils.kt | 26 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts index 895fac377..76a9264ca 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts @@ -5,10 +5,21 @@ plugins { val provider = SystemTestPorts.registerWith(project) +/** + * Port on which JVMs spawned by the system tests wait for a debugger, requested via `-PdebugSut[=]`. + * Absent unless the property is set, in which case no JVM waits for anything. + */ +val debugSutPort = providers.gradleProperty("debugSut") + .map { if (it.isEmpty() || it == "true") "5005" else it } + tasks.test { dependsOn(":agent:shadowJar") usesService(provider) + // The spawned JVM suspends until a debugger attaches, so the test must not be run in parallel with others + // and must not inherit a timeout. Both are the caller's responsibility (see docs/DEBUGGING.md). + debugSutPort.orNull?.let { environment("SYSTEM_TEST_DEBUG_PORT", it) } + val teamscalePort = provider.get().pickFreePort() val agentPort = provider.get().pickFreePort() extensions.create("ports", provider).apply { diff --git a/common-system-test/src/main/kotlin/com/teamscale/test/commons/ProcessUtils.kt b/common-system-test/src/main/kotlin/com/teamscale/test/commons/ProcessUtils.kt index 74b59825f..649be4514 100644 --- a/common-system-test/src/main/kotlin/com/teamscale/test/commons/ProcessUtils.kt +++ b/common-system-test/src/main/kotlin/com/teamscale/test/commons/ProcessUtils.kt @@ -117,13 +117,37 @@ object ProcessUtils { * * @return ProcessBuilder configured with commands and working directory */ - fun build(): ProcessBuilder = ProcessBuilder(commands).apply { + fun build(): ProcessBuilder = ProcessBuilder(commands.withDebuggerArgumentIfRequested()).apply { workingDirectory?.let { directory(it) } environmentVariables?.let { environment().putAll(it) } removeEnvironmentVariables.forEach { environment().remove(it) } } } + /** + * Environment variable through which the build tells us that JVMs spawned by system tests should wait for a + * debugger to attach. Set by the system test convention plugin when Gradle is run with `-PdebugSut`. + */ + private const val DEBUG_PORT_ENVIRONMENT_VARIABLE = "SYSTEM_TEST_DEBUG_PORT" + + /** + * Makes a `java` command line suspend at startup until a debugger connects, if the build requested it via + * [DEBUG_PORT_ENVIRONMENT_VARIABLE]. Other commands (Maven, `chcp.com`, ...) are left untouched, so only the + * JVM that the system test actually profiles waits for the debugger. + */ + private fun List.withDebuggerArgumentIfRequested(): List { + val port = System.getenv(DEBUG_PORT_ENVIRONMENT_VARIABLE) ?: return this + val executable = firstOrNull()?.takeIf { it.isJavaExecutable() } ?: return this + return listOf( + executable, + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:$port" + ) + drop(1) + } + + /** Whether this command is a `java` executable, with or without a path and the Windows `.exe` extension. */ + private fun String.isJavaExecutable() = + substringAfterLast('/').substringAfterLast('\\').removeSuffix(".exe") == "java" + /** * Immutable result of process execution. */ From c34661ab14ed41e90726c44cd2be624f384bbb7f Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 07:28:56 +0200 Subject: [PATCH 04/22] TS-47380 Keep Teamscale credentials for the sample app out of version control To profile sample-debugging-app against a real Teamscale instance, the committed jacocoagent.properties had to be edited with a real access token, which is easy to commit by accident. The run task now prefers jacocoagent.local.properties if it exists, and that file is git-ignored. jacocoagent.properties stays in the repository as the template to copy. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 ++ sample-debugging-app/build.gradle.kts | 10 +++++++++- sample-debugging-app/jacocoagent.properties | 15 +++++++++++---- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index a5e638df1..5bb15a034 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,7 @@ report-generator/test-coverage-*.xml **/maven-wrapper.jar **/logTest/** **/jacoco.exec +# local profiler configuration containing Teamscale credentials, see docs/DEBUGGING.md +jacocoagent.local.properties **/target/** .claude/settings.local.json diff --git a/sample-debugging-app/build.gradle.kts b/sample-debugging-app/build.gradle.kts index 95fbd3fb8..e2453fbbe 100644 --- a/sample-debugging-app/build.gradle.kts +++ b/sample-debugging-app/build.gradle.kts @@ -20,10 +20,18 @@ tasks.jar { } } +/** + * Uses `jacocoagent.local.properties` if it exists, so credentials for a real Teamscale instance can be kept out of + * version control (the file is git-ignored), and the committed `jacocoagent.properties` otherwise. + */ +val agentConfigFile = + listOf("jacocoagent.local.properties", "jacocoagent.properties") + .first { layout.projectDirectory.file(it).asFile.exists() } + tasks.named("run") { teamscaleAgent( mapOf( - "config-file" to "jacocoagent.properties" + "config-file" to agentConfigFile ) ) dependsOn(":agent:shadowJar") diff --git a/sample-debugging-app/jacocoagent.properties b/sample-debugging-app/jacocoagent.properties index 3a463c2d7..937f02d44 100644 --- a/sample-debugging-app/jacocoagent.properties +++ b/sample-debugging-app/jacocoagent.properties @@ -1,8 +1,15 @@ +# Configuration for profiling the sample-debugging-app, see docs/DEBUGGING.md. +# +# This file is committed, so do NOT put credentials in it. To talk to a real Teamscale instance, copy it to +# jacocoagent.local.properties (git-ignored) and fill in the values there; the run task prefers that file if present. + includes=*com.example.* logging-config=../agent/src/dist/logging/logback.console.xml -# teamscale-commit=master:HEAD -# teamscale-server-url=http://localhost:8080/ -# teamscale-project= + +# Uncomment and complete the following to upload coverage to a local Teamscale instance. +# teamscale-server-url=http://127.0.0.1:9999/teamscale/ +# teamscale-project= # teamscale-user=admin -# teamscale-access-token= +# teamscale-access-token= Access Keys> # teamscale-partition=Agent Debugging +# teamscale-commit=master:HEAD From 46ffe87c4af62eac91098cef410d74d176cc6813 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 07:29:14 +0200 Subject: [PATCH 05/22] TS-47380 Give http-server-shutdown its own system under test Every other system test packages its system under test alongside itself in src/main/.../systemundertest/. http-server-shutdown instead ran the top-level sample-app module, and teamscale-profiler-configuration-test declared a dependency on it that it never used, since it runs its own jar. http-server-shutdown now runs its own SystemUnderTest, which it already contained but never executed, and the unused dependency is gone. Nothing in the build references sample-app any more. Co-Authored-By: Claude Opus 5 (1M context) --- system-tests/http-server-shutdown/build.gradle.kts | 14 +++++++++++--- .../main/kotlin/systemundertest/SystemUnderTest.kt | 12 ++++++++++-- .../teamscale/tia/HttpServerShutdownSystemTest.kt | 4 ++-- .../build.gradle.kts | 1 - 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/system-tests/http-server-shutdown/build.gradle.kts b/system-tests/http-server-shutdown/build.gradle.kts index a1b3e040a..9d9e77412 100644 --- a/system-tests/http-server-shutdown/build.gradle.kts +++ b/system-tests/http-server-shutdown/build.gradle.kts @@ -4,9 +4,17 @@ plugins { com.teamscale.coverage } +tasks.jar { + manifest { + attributes["Main-Class"] = "systemundertest.SystemUnderTest" + } + // create a fat jar so the Kotlin standard library is available when the jar is run via `java -jar` + from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + tasks.test { environment("AGENT_JAR", agentJar) - val sampleJar = project(":sample-app").tasks["jar"].outputs.files.singleFile - environment("SAMPLE_JAR", sampleJar) - dependsOn(":sample-app:assemble") + environment("SYSTEM_UNDER_TEST_JAR", tasks.jar.get().outputs.files.singleFile) + dependsOn(tasks.jar) } diff --git a/system-tests/http-server-shutdown/src/main/kotlin/systemundertest/SystemUnderTest.kt b/system-tests/http-server-shutdown/src/main/kotlin/systemundertest/SystemUnderTest.kt index b7d25de8f..9d9db40b9 100644 --- a/system-tests/http-server-shutdown/src/main/kotlin/systemundertest/SystemUnderTest.kt +++ b/system-tests/http-server-shutdown/src/main/kotlin/systemundertest/SystemUnderTest.kt @@ -1,6 +1,14 @@ package systemundertest -/** Fake system under test to generate some coverage. */ -class SystemUnderTest { +/** + * Fake system under test to generate some coverage. Exits on its own so the test can verify that the agent's HTTP + * server does not keep the JVM alive with non-daemon threads. + */ +object SystemUnderTest { + @JvmStatic + fun main(args: Array) { + println("Production code: ${foo()}") + } + fun foo() = 2 } diff --git a/system-tests/http-server-shutdown/src/test/kotlin/com/teamscale/tia/HttpServerShutdownSystemTest.kt b/system-tests/http-server-shutdown/src/test/kotlin/com/teamscale/tia/HttpServerShutdownSystemTest.kt index 1652f8782..a39dd5440 100644 --- a/system-tests/http-server-shutdown/src/test/kotlin/com/teamscale/tia/HttpServerShutdownSystemTest.kt +++ b/system-tests/http-server-shutdown/src/test/kotlin/com/teamscale/tia/HttpServerShutdownSystemTest.kt @@ -17,11 +17,11 @@ class HttpServerShutdownSystemTest { @Throws(Exception::class) fun testShutdown() { val agentJar = System.getenv("AGENT_JAR") - val sampleJar = System.getenv("SAMPLE_JAR") + val systemUnderTestJar = System.getenv("SYSTEM_UNDER_TEST_JAR") val result = ProcessUtils.execute( "java", "-javaagent:$agentJar=http-server-port=${SystemTestUtils.AGENT_PORT}", - "-jar", sampleJar + "-jar", systemUnderTestJar ) println(result.stderr) println(result.stdout) diff --git a/system-tests/teamscale-profiler-configuration-test/build.gradle.kts b/system-tests/teamscale-profiler-configuration-test/build.gradle.kts index cd00f588e..2c6129210 100644 --- a/system-tests/teamscale-profiler-configuration-test/build.gradle.kts +++ b/system-tests/teamscale-profiler-configuration-test/build.gradle.kts @@ -16,7 +16,6 @@ tasks.test { environment("AGENT_JAR", agentJar) environment("SYSTEM_UNDER_TEST_JAR", tasks.jar.get().outputs.files.singleFile) - dependsOn(":sample-app:assemble") val teamscalePropertiesPath = agentJar.toPath().parent.parent.resolve("teamscale.properties") doFirst { From f122be4f68848a22fa49d8a3391c414cc813e3ba Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 07:29:23 +0200 Subject: [PATCH 06/22] TS-47380 Cover TS-23151 with a system test instead of a manual script The agent logs into a temporary directory that users cannot realistically find, and never fails the profiled application. A misconfiguration therefore shows up as an application that runs fine and silently collects no coverage, unless the agent reports the problem on the console. That behaviour was only checked by hand via sample-app/run-log-test.sh. It is now a system test covering both cases the script exercised: options that fail to parse, and a log directory that cannot be written to. Co-Authored-By: Claude Opus 5 (1M context) --- .../build.gradle.kts | 14 ++++ .../java/systemundertest/SystemUnderTest.java | 10 +++ .../InvalidOptionsLoggingSystemTest.kt | 71 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 system-tests/invalid-options-logging-test/build.gradle.kts create mode 100644 system-tests/invalid-options-logging-test/src/main/java/systemundertest/SystemUnderTest.java create mode 100644 system-tests/invalid-options-logging-test/src/test/kotlin/com/teamscale/logging/InvalidOptionsLoggingSystemTest.kt diff --git a/system-tests/invalid-options-logging-test/build.gradle.kts b/system-tests/invalid-options-logging-test/build.gradle.kts new file mode 100644 index 000000000..143ca2f63 --- /dev/null +++ b/system-tests/invalid-options-logging-test/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + com.teamscale.`kotlin-convention` + com.teamscale.`system-test-convention` +} + +tasks.jar { + manifest.attributes["Main-Class"] = "systemundertest.SystemUnderTest" +} + +tasks.test { + environment("AGENT_JAR", agentJar) + environment("SYSTEM_UNDER_TEST_JAR", tasks.jar.get().outputs.files.singleFile) + dependsOn(tasks.jar) +} diff --git a/system-tests/invalid-options-logging-test/src/main/java/systemundertest/SystemUnderTest.java b/system-tests/invalid-options-logging-test/src/main/java/systemundertest/SystemUnderTest.java new file mode 100644 index 000000000..0c99958ab --- /dev/null +++ b/system-tests/invalid-options-logging-test/src/main/java/systemundertest/SystemUnderTest.java @@ -0,0 +1,10 @@ +package systemundertest; + +/** Fake system under test to generate some coverage. */ +public class SystemUnderTest { + + public static void main(String[] args) { + System.out.println("Production code"); + } + +} diff --git a/system-tests/invalid-options-logging-test/src/test/kotlin/com/teamscale/logging/InvalidOptionsLoggingSystemTest.kt b/system-tests/invalid-options-logging-test/src/test/kotlin/com/teamscale/logging/InvalidOptionsLoggingSystemTest.kt new file mode 100644 index 000000000..304c200e2 --- /dev/null +++ b/system-tests/invalid-options-logging-test/src/test/kotlin/com/teamscale/logging/InvalidOptionsLoggingSystemTest.kt @@ -0,0 +1,71 @@ +package com.teamscale.logging + +import com.teamscale.test.commons.ProcessUtils +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +/** + * Regression tests for TS-23151: a misconfigured profiler must report the problem on the console. + * + * By default the agent logs into a temporary directory that users have no realistic way of finding, and it never + * fails the profiled application. So if these messages do not reach the console, the only symptom of a broken + * configuration is an application that runs perfectly and silently collects no coverage. + * + * These tests replace the former manual `sample-app/run-log-test.sh` script. + */ +class InvalidOptionsLoggingSystemTest { + + /** + * Options that fail to parse are reported on stderr. This covers errors raised while the options are still being + * parsed, i.e. before the configured logging is up, which is what makes them easy to lose. + */ + @Test + fun optionParseErrorIsPrintedToTheConsole() { + val result = ProcessUtils.execute( + "java", "-javaagent:$AGENT_JAR=config-id=foo", "-jar", SYSTEM_UNDER_TEST_JAR + ) + + assertThat(result.stderr) + .`as`("the parse error must reach the console and not only the log file") + .contains("Failed to parse agent options") + .contains("teamscale-server-url") + assertThat(result.exitCode) + .`as`("a configuration error must never stop the profiled application from starting") + .isEqualTo(0) + assertThat(result.stdout).contains("Production code") + } + + /** + * If the log directory cannot be written to, the agent says so on the console instead of losing the message it + * was about to write into exactly that directory. + */ + @Test + @DisabledOnOs(OS.WINDOWS, disabledReason = "file permissions behave differently on Windows") + fun unwritableLogDirectoryIsReportedOnTheConsole(@TempDir tempDirectory: Path) { + val logDirectory = Files.createDirectory(tempDirectory.resolve("read-only")) + logDirectory.toFile().setWritable(false) + assumeTrue(!Files.isWritable(logDirectory), "requires a non-writable directory, so cannot run as root") + + val result = ProcessUtils.execute( + "java", "-javaagent:$AGENT_JAR=debug=$logDirectory", "-jar", SYSTEM_UNDER_TEST_JAR + ) + + assertThat(result.stdout) + .`as`("the agent must report that it cannot write its logs") + .contains("Could not create debug log directory") + .contains("Falling back to console-only logging") + assertThat(result.exitCode).isEqualTo(0) + assertThat(result.stdout).contains("Production code") + } + + companion object { + private val AGENT_JAR: String = System.getenv("AGENT_JAR") + private val SYSTEM_UNDER_TEST_JAR: String = System.getenv("SYSTEM_UNDER_TEST_JAR") + } +} From 42557027b0767c192780d81eb48d686e5b12ec78 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 07:29:30 +0200 Subject: [PATCH 07/22] TS-47380 Remove the sample-app module Nothing in the build depends on it any more: http-server-shutdown runs its own system under test, and run-log-test.sh has been replaced by the invalid-options-logging-test system test. The remaining run-with-profiler.sh was a manual smoke test of the packaged distribution against a hardcoded localhost Teamscale, which no longer justifies a module of its own. Co-Authored-By: Claude Opus 5 (1M context) --- renovate.json | 1 - sample-app/.gitignore | 1 - sample-app/build.gradle.kts | 44 ------------------------------ sample-app/config.properties | 5 ---- sample-app/log-test.properties | 4 --- sample-app/run-log-test.sh | 18 ------------ sample-app/run-with-profiler.sh | 11 -------- sample-app/src/main/java/Main.java | 11 -------- settings.gradle.kts | 1 - 9 files changed, 96 deletions(-) delete mode 100644 sample-app/.gitignore delete mode 100644 sample-app/build.gradle.kts delete mode 100644 sample-app/config.properties delete mode 100644 sample-app/log-test.properties delete mode 100755 sample-app/run-log-test.sh delete mode 100755 sample-app/run-with-profiler.sh delete mode 100644 sample-app/src/main/java/Main.java diff --git a/renovate.json b/renovate.json index b6483c8ff..c3dd0efb0 100644 --- a/renovate.json +++ b/renovate.json @@ -9,7 +9,6 @@ "rangeStrategy": "bump", "separateMajorMinor": false, "ignorePaths": [ - "sample-app/**", "report-generator/build.gradle.kts" ], "packageRules": [ diff --git a/sample-app/.gitignore b/sample-app/.gitignore deleted file mode 100644 index a9a5aecf4..000000000 --- a/sample-app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -tmp diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts deleted file mode 100644 index c28971305..000000000 --- a/sample-app/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -// Needed to make git properties work with Java 8, -// see https://github.com/n0mer/gradle-git-properties/issues/195#issuecomment-982326268 -buildscript { - dependencies { - classpath("org.eclipse.jgit:org.eclipse.jgit") { - version { - strictly("5.13.0.202109080827-r") - } - } - } -} - -plugins { - application - com.teamscale.`java-convention` - com.teamscale.coverage - alias(libs.plugins.gitProperties) -} - -version = "unspecified" - -application { - applicationName = "sample-app" - mainClass = "Main" -} - -tasks.jar { - manifest { - attributes["Main-Class"] = "Main" - } - // make it a fat jar - from(configurations.runtimeClasspath.get().files.map { if (it.isDirectory) it else zipTree(it) }) -} - -gitProperties { - dotGitDirectory = rootProject.layout.projectDirectory.dir(".git") -} - -dependencies { - // this logback version is the oldest one available that I could get to work and possibly incompatible - // with the one used in the agent. This way, we can test if the shadowing works correctly - implementation("ch.qos.logback:logback-core:1.0.0") - implementation("ch.qos.logback:logback-classic:1.0.0") -} diff --git a/sample-app/config.properties b/sample-app/config.properties deleted file mode 100644 index f1df248bf..000000000 --- a/sample-app/config.properties +++ /dev/null @@ -1,5 +0,0 @@ -teamscale-server-url=http://localhost:8080 -teamscale-project=t -teamscale-user=admin -teamscale-partition=Manual Tests -teamscale-access-token=1234 diff --git a/sample-app/log-test.properties b/sample-app/log-test.properties deleted file mode 100644 index 7430d4e09..000000000 --- a/sample-app/log-test.properties +++ /dev/null @@ -1,4 +0,0 @@ -logging-config=./tmp/teamscale-jacoco-agent/logging/logback.console.xml - -# Invalid config option causing TS-23151. Comment out to see the good case -foo=bar \ No newline at end of file diff --git a/sample-app/run-log-test.sh b/sample-app/run-log-test.sh deleted file mode 100755 index bcfc235e5..000000000 --- a/sample-app/run-log-test.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -profiler_dist="../agent/build/distributions/teamscale-jacoco-agent.zip" - -# Please comment out in case you don't need to build either of these -../gradlew :agent:assemble -../gradlew :sample-app:assemble - -rm -rf tmp -unzip -d tmp "$profiler_dist" -unzip -d tmp build/distributions/sample-app.zip - -# Make default logs dir readonly -mkdir tmp/teamscale-jacoco-agent/logs -chmod u-w tmp/teamscale-jacoco-agent/logs - -JAVA_TOOL_OPTIONS="-javaagent:tmp/teamscale-jacoco-agent/lib/teamscale-jacoco-agent.jar=config-file=./log-test.properties" tmp/sample-app/bin/sample-app - -chmod u+w tmp/teamscale-jacoco-agent/logs diff --git a/sample-app/run-with-profiler.sh b/sample-app/run-with-profiler.sh deleted file mode 100755 index 49d0661df..000000000 --- a/sample-app/run-with-profiler.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -profiler_dist="../agent/build/distributions/teamscale-jacoco-agent.zip" - -# Please comment out in case you don't need to build either of these -../gradlew :agent:assemble -../gradlew :sample-app:assemble - -rm -rf tmp -unzip -d tmp "$profiler_dist" -unzip -d tmp build/distributions/sample-app.zip -JAVA_TOOL_OPTIONS="-javaagent:tmp/teamscale-jacoco-agent/lib/teamscale-jacoco-agent.jar=config-file=./config.properties" tmp/sample-app/bin/sample-app diff --git a/sample-app/src/main/java/Main.java b/sample-app/src/main/java/Main.java deleted file mode 100644 index c10249f58..000000000 --- a/sample-app/src/main/java/Main.java +++ /dev/null @@ -1,11 +0,0 @@ -import org.slf4j.LoggerFactory; - -/** Main class. */ -public class Main { - - /** Main method. */ - public static void main(String[] args) { - LoggerFactory.getLogger("testlogger").error("testing logging with incompatible logback version"); - } - -} diff --git a/settings.gradle.kts b/settings.gradle.kts index bd7876ef7..2bab5deb3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,7 +20,6 @@ include(":agent") include(":report-generator") include(":teamscale-gradle-plugin") include(":teamscale-client") -include(":sample-app") include(":impacted-test-engine") include(":tia-client") include(":tia-runlisteners") From 8d01fc806ed88ac3c3037703382863b7b087b9fe Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 07:29:39 +0200 Subject: [PATCH 08/22] TS-47380 Document how to debug the profiler The README explained how to attach a debugger to the agent, but not how to get the profiler talking to a real Teamscale instance, how its configuration is assembled from five different sources, where it writes its logs, or why a misconfigured profiler starts up without complaining. Adds docs/DEBUGGING.md covering those topics, and reduces the README's debugging sections to a pointer so each topic is documented in one place. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 43 +------ docs/DEBUGGING.md | 306 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 37 deletions(-) create mode 100644 docs/DEBUGGING.md diff --git a/README.md b/README.md index 1242bb723..0365b4069 100644 --- a/README.md +++ b/README.md @@ -107,43 +107,12 @@ git config --local core.hooksPath .githooks ### Debug locally -For IntelliJ, there is a run config `SampleApp` which profiles the included `sample-debugging-app` and can be used to -debug the agent. This run config omits relocating packages in the shadow jar because with relocating, the package -names in the jar would not match with the ones IntelliJ knows from the code and so debugging would not work. -The agent is configured in the config file `sample-debugging-app/jacocoagent.properties`. By default, no upload -is configured but the file includes all required options to upload to Teamscale, they are just commented out. -Feel free to adapt it to your needs. -If you get an `IllegalStateException: Cannot process instrumented class com/example/Main`, make sure that you use -the built-in IntelliJ functionality for building and running instead of -gradle (IntelliJ Settings -> Build, Execution, Deployment -> Build Tools -> Gradle -> Build and run using: IntelliJ IDEA). - -### Debugging the Gradle plugin - -* increase the plugin version (=`appVersion`) in [build.gradle.kts](build.gradle.kts) -* `./gradlew publishToMavenLocal` will deploy your checked out version to your local m2 cache -* then you can import this version into any other gradle project by - * adding the following to the `settings.gradle.kts` -```kotlin -pluginManagement { - repositories { - mavenLocal() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositories { - mavenLocal() - mavenCentral() - } -} -``` - * declaring a plugin dependency on the incremented version of the teamscale plugin -* to debug a build that uses the plugin, run `./gradlew` with `--no-daemon -Dorg.gradle.debug=true`. - The build will pause and wait for you to attach a debugger, via IntelliJ's `Run > Attach to Process`. -* to debug the impacted test engine during a build, run `./gradlew` with `--no-daemon --debug-jvm` and wait for the test phase to start. - The build will pause and wait for you to attach a debugger, via IntelliJ's `Run > Attach to Process`. -* These two debug flags can also be combined. The build will then pause twice. +Run the `SampleApp` run configuration in IntelliJ, or `./gradlew :sample-debugging-app:run -Pdebug=true` on the command +line, to profile the included `sample-debugging-app` with breakpoints in the agent working. + +**[docs/DEBUGGING.md](docs/DEBUGGING.md) is the full guide**: setting up end-to-end communication with a real Teamscale +instance, how the agent's configuration is resolved from its five sources, where the profiler writes its logs, why a +misconfigured profiler fails silently, and how to attach a debugger to system tests. ### Contributing diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md new file mode 100644 index 000000000..bbad8300e --- /dev/null +++ b/docs/DEBUGGING.md @@ -0,0 +1,306 @@ +# Debugging the Teamscale Java Profiler + +This document is for developers working **on** the profiler. For debugging a profiler *deployment*, see the +[official documentation](https://docs.teamscale.com/reference/coverage-profilers/teamscale-java-profiler/). + +- [Debugging the agent in the IDE](#debugging-the-agent-in-the-ide) +- [Where the profiler writes its logs](#where-the-profiler-writes-its-logs) +- [End-to-end against a real Teamscale](#end-to-end-against-a-real-teamscale) +- [How the configuration is resolved](#how-the-configuration-is-resolved) +- [Configuring the profiler from Teamscale (`config-id`)](#configuring-the-profiler-from-teamscale-config-id) +- [When nothing happens at all](#when-nothing-happens-at-all) +- [Debugging system tests](#debugging-system-tests) +- [Debugging the Gradle and Maven plugins](#debugging-the-gradle-and-maven-plugins) + +## Debugging the agent in the IDE + +Use the `SampleApp` run configuration. It runs `:sample-debugging-app:run` with `-Pdebug=true` and attaches the +freshly built agent to a tiny application (`com.example.Main`). Breakpoints anywhere in the agent sources work. + +From the command line the equivalent is: + +```bash +./gradlew :sample-debugging-app:run -Pdebug=true +``` + +Two things to be aware of: + +**`-Pdebug=true` turns off relocation.** The shadow plugin normally relocates all dependencies under a `shadow.` +package prefix. Relocated class names do not match what the IDE knows from the source tree, so breakpoints in +dependencies would not bind and stack traces would be unreadable. `-Pdebug=true` disables relocation, and +`ShadowedPackages.kt` additionally keeps the packaged logback configuration files free of the prefix so that the same +configuration files work relocated and unrelocated. + +The flip side: **this is not the artifact that ships.** Bugs that are caused by relocation itself — a class name +built at runtime, a resource path, Kotlin module metadata — will not reproduce under `-Pdebug=true`. If a problem +disappears when you enable debugging, suspect the shading, and reproduce against a normal `./gradlew :agent:shadowJar` +build. + +**IntelliJ must build the project, not Gradle.** If you get +`IllegalStateException: Cannot process instrumented class com/example/Main`, switch +_Settings → Build, Execution, Deployment → Build Tools → Gradle → Build and run using_ to **IntelliJ IDEA**. + +### Which application to profile + +`sample-debugging-app` is a playground: nothing in the build depends on it, so you can change it freely to reproduce +whatever you are chasing without breaking anything. + +Do not add system tests against it. Every system test brings its own system under test in +`src/main/.../systemundertest/`, packaged as a runnable jar by its own `build.gradle.kts`. Keeping the two apart is +what lets the playground stay disposable. + +### Useful breakpoints + +| Where | Fires when | +|---|---| +| `PreMain.premain` | Once, at JVM startup. Good entry point for anything option- or startup-related. | +| `AgentOptionsParser.parse` | Once, while options are being merged. See [configuration resolution](#how-the-configuration-is-resolved). | +| `Agent.dumpReport` | On every dump: interval, `POST /dump`, and JVM shutdown. | +| `LenientCoverageTransformer.transform` | **For every single loaded class.** Always make this breakpoint conditional, e.g. `classname.startsWith("com/example")`, or the JVM will not make progress. | + +## Where the profiler writes its logs + +By default the agent logs into a **new temporary directory per process**: + +``` +/teamscale-java-profiler--/logs/teamscale-jacoco-agent.log +``` + +The path is announced with a `Logging to ...` line — but that line is written *into that very log file*, so there is +no way to find the log unless you already know where it is. Options, in increasing order of convenience: + +- `debug=true` — DEBUG level to both the file **and** the console. The console appender is what makes the log + discoverable. +- `debug=` — same, but the file lands in `/logs` instead of a temporary directory. +- `logging-config=` — full control via a logback configuration. `agent/src/dist/logging/` contains ready-made + configurations (`logback.console.xml`, `logback.debug.xml`, `logback.rolling-file.xml`). + +`sample-debugging-app/jacocoagent.properties` uses `logging-config=../agent/src/dist/logging/logback.console.xml`, which +is why the sample app prints the profiler's log to the console instead of hiding it in a temp directory. + +The default file appender rolls at 1 MB and keeps 10 compressed files. On a chatty application the startup lines — +usually the most interesting ones — are the first to be rolled away, so capture them early. + +## End-to-end against a real Teamscale + +The profiler talks to Teamscale for four different things — registration, configuration retrieval, coverage upload, and +log forwarding — and each can fail on its own. This is how to get all of them running locally. + +### 1. A Teamscale instance + +A local instance is assumed to be reachable at `http://127.0.0.1:9999/teamscale/`. + +### 2. An access key + +The REST API does **not** accept your password; it needs an access key. Log in, then go to the avatar in the top-right +corner → **Access Keys** (`/user/access-key`) → **Generate New Access Key**, and copy the key. + +### 3. A project + +Coverage is always uploaded into a project, so one has to exist. Create a project that analyses your checkout of this +repository, so that the sample application's source file (`sample-debugging-app/src/main/java/com/example/Main.java`) +is known to Teamscale and the uploaded coverage has something to attach itself to. Note the project ID — that is what +goes into `teamscale-project`, not the display name. + +### 4. Configure the sample application + +Do not put credentials into the committed `jacocoagent.properties`. Copy it instead: + +```bash +cp sample-debugging-app/jacocoagent.properties sample-debugging-app/jacocoagent.local.properties +``` + +`jacocoagent.local.properties` is git-ignored, and the `run` task prefers it over `jacocoagent.properties` when it +exists. Fill in: + +```properties +includes=*com.example.* +logging-config=../agent/src/dist/logging/logback.console.xml + +teamscale-server-url=http://127.0.0.1:9999/teamscale/ +teamscale-project= +teamscale-user=admin +teamscale-access-token= +teamscale-partition=Agent Debugging +teamscale-commit=master:HEAD +``` + +`teamscale-commit` accepts `:`, and `HEAD` is a valid timestamp. Alternatively use +`teamscale-revision=`; the two are mutually exclusive. If you provide neither, the agent tries to auto-detect +the commit from `git.properties` files inside the profiled code — which the sample application does not have. + +### 5. Run it + +```bash +./gradlew :sample-debugging-app:run -Pdebug=true +``` + +The sample application prints one line and exits immediately. That is enough: `dump-on-exit` defaults to `true`, so the +coverage dump and upload happen during JVM shutdown. You do **not** have to wait for the dump interval, which defaults +to 480 minutes. + +Expect this sequence in the console: + +``` +WARN Using multiple java agents could interfere with coverage recording: ... +WARN For best results consider registering the Teamscale Java Profiler first. +INFO Logging to /var/folders/.../teamscale-java-profiler--/logs +INFO Teamscale Java profiler version +INFO Starting JaCoCo's agent +INFO Excluding 23 package prefixes from instrumentation: kotlin.*:shadow.*:... +INFO Starting Teamscale Java Profiler for process @ with options: config-file=... +INFO Upload method: Uploading to Teamscale as user for to at commit +INFO Logs are being forwarded to Teamscale at +INFO Dumping every 480 minutes. +Hello Java Profiler! +INFO Teamscale Java Profiler is shutting down... +INFO Teamscale Java Profiler successfully shut down. +``` + +The two warnings at the top are expected here and not a problem: Gradle attaches its own Java agent to the `run` task, +and it comes first on the command line. + +`Upload method:` is the line to check first — it tells you which uploader was actually configured. Without any +`teamscale-*` options it reads `configured output directory on the local disk`, which means nothing will be uploaded +anywhere. + +Then in Teamscale, look for the coverage under the partition you configured. If the upload succeeded but you see no +coverage, the upload most likely landed on a commit Teamscale does not know about — check `teamscale-commit`. + +### What can go wrong here + +- **`The generated coverage report is empty`** — the `includes`/`excludes` patterns did not match anything that ran. + Widen `includes` first, then narrow it down. +- **No `class-dir` set** — that is fine and is the normal case. The agent then tells JaCoCo to dump the instrumented + classes into `/jacoco-class-dump` and analyses those (`JacocoAgentOptionsBuilder`). You only need + `class-dir` when the classes JaCoCo sees differ from the ones you want reported. +- **Nothing at all in the log** — see [When nothing happens at all](#when-nothing-happens-at-all). + +## How the configuration is resolved + +Options come from five places. They are applied in this order, and **later sources overwrite earlier ones** +(`AgentOptionsParser.parse`): + +| # | Source | Contributes | +|---|---|---| +| 1 | `teamscale.properties` next to the agent | `url`, `username`, `accesskey` only | +| 2 | `TEAMSCALE_ACCESS_TOKEN` env var | the access token only | +| 3 | The `-javaagent:...=` string | any option, including `config-file=` | +| 4 | `TEAMSCALE_JAVA_PROFILER_CONFIG_ID` env var, then the options fetched from Teamscale | any option | +| 5 | `TEAMSCALE_JAVA_PROFILER_CONFIG_FILE` env var | any option | + +Consequences worth remembering: + +- A `config-id` needs `teamscale-server-url`, `teamscale-user` and `teamscale-access-token` to be known **before** + step 4, i.e. from `teamscale.properties` or the agent options. Otherwise you get an explicit + `Config-id '...' specified but the following required option(s) are missing: ...`. +- A config file given via the environment (step 5) overrides what Teamscale sent (step 4). The agent logs a warning + when both are set. +- `teamscale.properties` is looked up at `/../teamscale.properties` — that is the *parent* of the + directory holding the jar, because in the distribution the jar lives in `lib/`. It is **not** a config file; it only + ever carries credentials. `PreMain` logs a DEBUG message about this because the two are frequently confused. + +## Configuring the profiler from Teamscale (`config-id`) + +Instead of passing options, the agent can fetch them from Teamscale: + +```bash +java -javaagent:teamscale-jacoco-agent.jar=config-id=my-config -jar app.jar +``` + +The full exchange, all under `api/v2024.7.0/`: + +| Step | Request | Implemented in | +|---|---|---| +| Register and fetch configuration | `POST /profilers?configuration-id=` | `ConfigurationViaTeamscale.retrieve` | +| Heartbeat, once a minute | `PUT /profilers/` | `ConfigurationViaTeamscale.sendHeartbeat` | +| Forward log entries | `POST /profilers//logs` | `LogToTeamscaleAppender` | +| Unregister on shutdown | `DELETE /profilers/` | `ConfigurationViaTeamscale.unregisterProfiler` | + +The server answers the registration with a profiler ID and a `configurationOptions` string, which is a newline-separated +list of the same `key=value` options you would otherwise pass on the command line. + +`TeamscaleProfilerConfigurationSystemTest` exercises this whole round trip against `TeamscaleMockServer` and is the +fastest way to see the sequence without a server. + +## When nothing happens at all + +The profiler deliberately never crashes the application it profiles. That makes several failure modes quiet: + +| Situation | Behaviour | +|---|---| +| No options and no `TEAMSCALE_JAVA_PROFILER_CONFIG_ID`/`_CONFIG_FILE` | `premain` returns immediately, before logging is even initialised. Nothing is logged anywhere. This is intentional: it lets the profiler be registered globally via `JAVA_TOOL_OPTIONS` without profiling every JVM on the machine. | +| Invalid options (`AgentOptionParseException`) | Error is logged, the profiler unregisters itself from Teamscale, and the application starts normally without coverage. | +| Teamscale unreachable while fetching a `config-id` (`AgentOptionReceiveException`) | Two-minute timeout, then the application starts normally without coverage. | +| Anything throwing after options were parsed | `PreMain.logStartupFailure` logs it and the application continues. | +| Coverage collected but report empty | `EmptyReportException`, logged as a warning on every dump. | + +So "the application ran fine and there is no coverage" is the expected symptom of almost every misconfiguration. When +in doubt, start with `debug=true` so you at least get console output. + +## Debugging system tests + +System tests exercise the **packaged** agent jar, so they catch shading problems that `-Pdebug=true` hides. They come +in two shapes, and they are debugged differently. + +**The agent is attached to the Gradle test JVM** (most tests — those calling `teamscaleAgent(...)` in their +`build.gradle.kts`). Use Gradle's built-in flag: + +```bash +./gradlew :system-tests:default-excludes-test:test --debug-jvm +``` + +The build pauses on port 5005 until you attach via IntelliJ's _Run → Attach to Process_ or a Remote JVM Debug +configuration. + +**The test spawns its own JVM** (e.g. `teamscale-profiler-configuration-test`, `sut-uses-logback-test`, which call +`ProcessUtils.execute("java", ...)`). `--debug-jvm` only suspends the test JVM, not the spawned one. Use `-PdebugSut` +instead: + +```bash +./gradlew :system-tests:teamscale-profiler-configuration-test:test -PdebugSut +``` + +Every `java` process the test spawns then starts with +`-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005` and waits for a debugger. Pass a port explicitly +with `-PdebugSut=5006`. Because the spawned JVM suspends until you attach, run a **single** system test at a time. + +Notes: + +- Combine with `-Pdebug=true` to also get unrelocated class names — but remember that this changes the artifact under + test, which is the whole point of a system test. +- Only directly spawned `java` processes are affected. JVMs forked by Maven in the Maven-based system tests are not. +- Tests that opt in with `teamscaleAgent(mapOf("debug" to logFilePath))` write the agent's log to their project's + `logTest/` directory. That directory is wiped at the start of every test run, so copy anything you want to keep. + +## Debugging the Gradle and Maven plugins + +To try your working copy out in another project, increase the plugin version (`appVersion` in +[build.gradle.kts](../build.gradle.kts)) and run `./gradlew publishToMavenLocal` to deploy it to your local m2 cache. +The consuming project can then pick it up by adding the following to its `settings.gradle.kts`: + +```kotlin +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + mavenCentral() + } +} +``` + +and declaring a plugin dependency on the incremented version. + +To attach a debugger: + +- **A build that uses the plugin**: `./gradlew --no-daemon -Dorg.gradle.debug=true`. The build pauses and waits for you + to attach via IntelliJ's _Run → Attach to Process_. +- **The impacted test engine during a build**: `./gradlew --no-daemon --debug-jvm`, then attach once the test phase + starts. +- Both flags can be combined; the build then pauses twice. From dbb155ba0b66f3852e80675e4d41da2c4fd0c5ae Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 09:41:07 +0200 Subject: [PATCH 09/22] TS-47380 Declare the agent jar dependency in teamscaleAgent() The two JavaExec tasks that attach the profiler each declared the dependency on :agent:shadowJar themselves. Declaring it in teamscaleAgent() instead keeps it next to the -javaagent argument that needs the jar. --- buildSrc/src/main/kotlin/AgentJarExtension.kt | 1 + sample-debugging-app/build.gradle.kts | 1 - system-tests/debug-logging-test/build.gradle.kts | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/AgentJarExtension.kt b/buildSrc/src/main/kotlin/AgentJarExtension.kt index 08138cf52..6be69f0c1 100644 --- a/buildSrc/src/main/kotlin/AgentJarExtension.kt +++ b/buildSrc/src/main/kotlin/AgentJarExtension.kt @@ -12,6 +12,7 @@ val Test.logFilePath /** Adds a convenient way to attach the Teamscale JaCoCo agent to the JVM with the given options in a readable map format. */ fun JavaExec.teamscaleAgent(options: Map) { + dependsOn(":agent:shadowJar") jvmArgs( "-javaagent:$agentJar=${options.entries.joinToString(separator = ",") { "${it.key}=${it.value}" }}" ) diff --git a/sample-debugging-app/build.gradle.kts b/sample-debugging-app/build.gradle.kts index e2453fbbe..02c34949b 100644 --- a/sample-debugging-app/build.gradle.kts +++ b/sample-debugging-app/build.gradle.kts @@ -34,5 +34,4 @@ tasks.named("run") { "config-file" to agentConfigFile ) ) - dependsOn(":agent:shadowJar") } diff --git a/system-tests/debug-logging-test/build.gradle.kts b/system-tests/debug-logging-test/build.gradle.kts index 37bb35cb5..725f8b65d 100644 --- a/system-tests/debug-logging-test/build.gradle.kts +++ b/system-tests/debug-logging-test/build.gradle.kts @@ -4,7 +4,6 @@ plugins { } tasks.register("runWithoutGradleWorker") { - dependsOn(":agent:shadowJar") mainClass = "jul.test.SystemUnderTest" classpath = sourceSets["main"].runtimeClasspath systemProperty("java.util.logging.manager", "jul.test.CustomLogManager") From 1239483aef2b8f9c71a0468b233505da2adef0f8 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 09:41:21 +0200 Subject: [PATCH 10/22] TS-47380 Make the build configuration cache and project isolation ready The configuration cache could not be stored at all. Two script lambdas captured the build script itself, the installer's --patch-module argument provider captured a SourceSetOutput that the cache cannot restore into that field type, and the jlink opt-outs pointed at a bug that badass-jlink fixed in 4.0.0. Project isolation additionally failed on our own cross-project access. group and version now come from a beforeProject hook in the settings file instead of allprojects {}, the publishToMavenLocal aggregator depends on explicit task paths instead of inspecting evaluated subprojects, and both the shaded agent jar and the installer's jlink image are shared through configurations rather than by reaching into another project's tasks. The latter also removes evaluationDependsOn(":installer"). The packaged distribution is unchanged. The configuration cache is stored and reused now, but stays disabled by default: jdkDownload puts a Groovy closure into the jlink targetPlatforms input, which the cache replaces with a non-serializable BrokenObject, so every build that runs jlink still has to fall back. The opt-outs are kept with that as their reason. Explicit daemon heap is needed because the Kotlin daemon runs out of memory compiling the larger modules, and configuring all projects in parallel needs more headroom in the Gradle daemon itself. The one remaining project isolation problem comes from io.github.gradle-nexus.publish-plugin, which cross-configures subprojects and has no newer release to upgrade to. Co-Authored-By: Claude Opus 5 (1M context) --- agent/build.gradle.kts | 24 +++++++++-- build.gradle.kts | 41 ++++++++----------- buildSrc/src/main/kotlin/SharedArtifacts.kt | 11 +++++ buildSrc/src/main/kotlin/VersionUtils.kt | 3 -- .../kotlin/com.teamscale.agent-jar.gradle.kts | 21 +++++++--- gradle.properties | 6 +++ installer/build.gradle.kts | 22 ++++++++-- settings.gradle.kts | 18 ++++++++ .../cucumber-maven-tia/build.gradle.kts | 2 +- system-tests/gradle-cucumber/build.gradle.kts | 2 +- .../junit-run-listener-test/build.gradle.kts | 2 +- .../build.gradle.kts | 3 +- system-tests/tia-maven/build.gradle.kts | 2 +- 13 files changed, 113 insertions(+), 44 deletions(-) create mode 100644 buildSrc/src/main/kotlin/SharedArtifacts.kt delete mode 100644 buildSrc/src/main/kotlin/VersionUtils.kt diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts index 3ec1cbd2d..21f92b382 100644 --- a/agent/build.gradle.kts +++ b/agent/build.gradle.kts @@ -15,7 +15,15 @@ plugins { alias(libs.plugins.oci) } -evaluationDependsOn(":installer") +// The jlink runtime image of the installer, which the distribution below ships alongside the agent. +val installerImageDependency = configurations.dependencyScope("installerImage") +val installerImage = configurations.resolvable("installerImagePath") { + extendsFrom(installerImageDependency.get()) +} + +dependencies { + installerImageDependency(project(":installer", JLINK_IMAGE_CONFIGURATION)) +} publishAs { artifactId = "teamscale-jacoco-agent" @@ -23,7 +31,7 @@ publishAs { description = "JVM profiler that simplifies various aspects around recording and uploading test coverage" } -val appVersion = rootProject.extra["appVersion"].toString() +val appVersion = extra["appVersion"].toString() val jacocoVersion = libs.versions.jacoco.get() val outputVersion = "$appVersion-jacoco-$jacocoVersion" @@ -82,17 +90,25 @@ tasks.startShadowScripts { applicationName = "convert" } +// Shares the shaded agent jar with the projects that attach the profiler to a JVM, cf. the +// com.teamscale.agent-jar convention plugin. +configurations.consumable(AGENT_JAR_CONFIGURATION) +artifacts.add(AGENT_JAR_CONFIGURATION, tasks.shadowJar) + distributions { named("shadow") { distributionBaseName = "teamscale-jacoco-agent" contents { - from(project(":installer").tasks["jlink"]) { + from(installerImage.get()) { into("installer") } + // Captured in a local so the copy action does not reference the build script itself, + // which cannot be stored in the configuration cache. + val distributionVersion = outputVersion filesMatching("**/VERSION.txt") { filter { - it.replace("%APP_VERSION_TOKEN_REPLACED_DURING_BUILD%", outputVersion) + it.replace("%APP_VERSION_TOKEN_REPLACED_DURING_BUILD%", distributionVersion) } } } diff --git a/build.gradle.kts b/build.gradle.kts index c363f9c79..d67eb405c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,31 +2,26 @@ plugins { alias(libs.plugins.nexusPublish) } -group = "com.teamscale" - -val appVersion = "37.0.2" -extra.set("appVersion", appVersion) - -val snapshotVersion = appVersion + if (VersionUtils.isTaggedRelease()) "" else "-SNAPSHOT" - -allprojects { - version = snapshotVersion -} +// group and version are set for every project from settings.gradle.kts + +/** + * The projects that publish Maven artifacts, i.e. those applying the `com.teamscale.publish` convention + * plugin. Listed explicitly because looking them up in the other projects would break project isolation. + */ +val publishedProjects = listOf( + ":agent", + ":impacted-test-engine", + ":report-generator", + ":teamscale-client", + ":teamscale-gradle-plugin", + ":teamscale-maven-plugin", + ":tia-client", + ":tia-runlisteners", +) // Installs all Maven artifacts to your local Maven repository -val publishToMavenLocal = tasks.register("publishToMavenLocal") - -subprojects { - // must be run after evaluation because the publishToMavenLocal tasks are generated by our plugin during - // project evaluation - afterEvaluate { - val publishTask = tasks.findByPath("publishToMavenLocal") - if (publishTask != null) { - publishToMavenLocal.configure { - dependsOn(publishTask) - } - } - } +tasks.register("publishToMavenLocal") { + dependsOn(publishedProjects.map { "$it:publishToMavenLocal" }) } nexusPublishing { diff --git a/buildSrc/src/main/kotlin/SharedArtifacts.kt b/buildSrc/src/main/kotlin/SharedArtifacts.kt new file mode 100644 index 000000000..610a80d7d --- /dev/null +++ b/buildSrc/src/main/kotlin/SharedArtifacts.kt @@ -0,0 +1,11 @@ +// Names of the configurations through which projects share build artifacts with each other. +// +// Producers expose an artifact under these names, consumers depend on the producing project targeting the +// same name. Sharing artifacts this way instead of reaching into another project's tasks is what keeps the +// build compatible with project isolation. + +/** The shaded agent jar, produced by :agent and consumed via the com.teamscale.agent-jar convention plugin. */ +const val AGENT_JAR_CONFIGURATION = "agentJarElements" + +/** The jlink runtime image of the installer, produced by :installer and shipped in the :agent distribution. */ +const val JLINK_IMAGE_CONFIGURATION = "jlinkImageElements" diff --git a/buildSrc/src/main/kotlin/VersionUtils.kt b/buildSrc/src/main/kotlin/VersionUtils.kt deleted file mode 100644 index 9eb632855..000000000 --- a/buildSrc/src/main/kotlin/VersionUtils.kt +++ /dev/null @@ -1,3 +0,0 @@ -object VersionUtils { - fun isTaggedRelease() = System.getenv()["GITHUB_REF"]?.contains("/tags/") ?: false -} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/com.teamscale.agent-jar.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.agent-jar.gradle.kts index 9c389a9db..0d39d4fa0 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.agent-jar.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.agent-jar.gradle.kts @@ -4,23 +4,33 @@ plugins { id("com.teamscale.java-convention") } +/** The shaded agent jar, shared by the :agent project via [AGENT_JAR_CONFIGURATION]. */ +val agentJarDependency = configurations.dependencyScope("teamscaleAgent") +val agentJarSource = configurations.resolvable("teamscaleAgentJar") { + extendsFrom(agentJarDependency.get()) +} + +dependencies { + agentJarDependency(project(":agent", AGENT_JAR_CONFIGURATION)) +} + /** * Creates a copy of the agent jar file in the temporary directory of this task * to isolate it from other tasks running in parallel. */ fun Task.createAgentCopy() { - val shadowJarOutputs = project.project(":agent").tasks.named("shadowJar").map { it.outputs.files.singleFile } - dependsOn(shadowJarOutputs) - doFirst("copyAgent", CopyAgent(shadowJarOutputs, agentJar)) + val agentJarFiles = agentJarSource.get() + dependsOn(agentJarFiles) + doFirst("copyAgent", CopyAgent(agentJarFiles.elements.map { it.single().asFile }, agentJar)) } class CopyAgent( - val shadowJarOutputs: Provider, + val source: Provider, val agentJar: File ) : Action, Serializable { override fun execute(t: Task) { agentJar.parentFile.mkdir() - shadowJarOutputs.get().copyTo(agentJar, overwrite = true) + source.get().copyTo(agentJar, overwrite = true) } } @@ -31,4 +41,3 @@ tasks.withType { tasks.test { createAgentCopy() } - diff --git a/gradle.properties b/gradle.properties index ff61dcad1..b2e18be29 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,8 @@ +# The defaults are not enough for this build: the Kotlin daemon runs out of memory while compiling the +# larger modules, and configuring all projects in parallel (project isolation) needs more headroom in the +# Gradle daemon itself. +org.gradle.jvmargs=-Xmx3g +kotlin.daemon.jvmargs=-Xmx2g + #org.gradle.configuration-cache=true #org.gradle.configuration-cache.parallel=true diff --git a/installer/build.gradle.kts b/installer/build.gradle.kts index 2a141ee08..2e78d447e 100644 --- a/installer/build.gradle.kts +++ b/installer/build.gradle.kts @@ -1,5 +1,6 @@ import org.beryx.jlink.BaseTask import org.beryx.jlink.CreateMergedModuleTask +import org.beryx.jlink.JlinkTask import org.beryx.jlink.util.JdkUtil import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -23,7 +24,9 @@ tasks.jar { // Workaround for https://youtrack.jetbrains.com/issue/KT-55389 tasks.compileJava { - val mainSourceSetOutput = sourceSets.main.get().output + // Typed as FileCollection because the configuration cache cannot restore the captured value + // into a field declared as the more specific SourceSetOutput. + val mainSourceSetOutput: FileCollection = sourceSets.main.get().output options.compilerArgumentProviders.add(CommandLineArgumentProvider { listOf( "--patch-module", @@ -36,18 +39,31 @@ tasks.withType { options.release = 21 } +// The jlink tasks expose `targetPlatforms` as an @Input. `jdkDownload` below stores the JDK home as a +// lazily evaluated Groovy closure in there. The configuration cache replaces that closure's owner with a +// non-serializable BrokenObject, so fingerprinting the input fails once the task graph is restored: +// java.io.NotSerializableException: ...ClosureCodec$BrokenObject +// Removing these opt-outs therefore requires provisioning the target JDKs ourselves and passing plain +// paths to `setJdkHome`. Until then, any build that runs jlink falls back to no configuration cache. tasks.withType { - notCompatibleWithConfigurationCache("https://github.com/beryx/badass-jlink-plugin/issues/304") + notCompatibleWithConfigurationCache("jdkDownload stores a Groovy closure in the targetPlatforms input") } tasks.withType { - notCompatibleWithConfigurationCache("https://github.com/beryx/badass-jlink-plugin/issues/304") + notCompatibleWithConfigurationCache("jdkDownload stores a Groovy closure in the targetPlatforms input") } tasks.withType { compilerOptions.jvmTarget = JvmTarget.JVM_21 } +// Shares the jlink runtime image with the :agent project, which ships it in its distribution. +configurations.consumable(JLINK_IMAGE_CONFIGURATION) +artifacts.add(JLINK_IMAGE_CONFIGURATION, tasks.named("jlink").map { it.imageDir }) { + type = "directory" + builtBy(tasks.named("jlink")) +} + application { applicationName = "installer" mainClass = "com.teamscale.profiler.installer.RootCommand" diff --git a/settings.gradle.kts b/settings.gradle.kts index 2bab5deb3..d048c54b0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,24 @@ dependencyResolutionManagement { } } +// Coordinates are configured from the settings file rather than via allprojects {} in the root build +// file, because cross-configuring projects from the root is incompatible with project isolation. +// Everything the action below reads must be a local, since values it captures are isolated from the +// settings script and script object references cannot be serialized. +run { + /** The version of the profiler. Released builds use it as is, all others get a snapshot suffix. */ + val appVersion = "37.0.2" + val isTaggedRelease = providers.environmentVariable("GITHUB_REF").map { it.contains("/tags/") } + val projectVersion = appVersion + if (isTaggedRelease.getOrElse(false)) "" else "-SNAPSHOT" + + gradle.lifecycle.beforeProject { + group = "com.teamscale" + version = projectVersion + // The plain version without the snapshot suffix, e.g. for naming the distribution. + extra.set("appVersion", appVersion) + } +} + include(":agent") include(":report-generator") include(":teamscale-gradle-plugin") diff --git a/system-tests/cucumber-maven-tia/build.gradle.kts b/system-tests/cucumber-maven-tia/build.gradle.kts index 708306ce3..36400e900 100644 --- a/system-tests/cucumber-maven-tia/build.gradle.kts +++ b/system-tests/cucumber-maven-tia/build.gradle.kts @@ -6,5 +6,5 @@ plugins { tasks.test { // install dependencies needed by the Maven test project - dependsOn(rootProject.tasks["publishToMavenLocal"]) + dependsOn(":publishToMavenLocal") } diff --git a/system-tests/gradle-cucumber/build.gradle.kts b/system-tests/gradle-cucumber/build.gradle.kts index 3b8c4c0a4..4edcc7677 100644 --- a/system-tests/gradle-cucumber/build.gradle.kts +++ b/system-tests/gradle-cucumber/build.gradle.kts @@ -6,5 +6,5 @@ plugins { tasks.test { // install dependencies needed by the Gradle test project - dependsOn(rootProject.tasks["publishToMavenLocal"]) + dependsOn(":publishToMavenLocal") } diff --git a/system-tests/junit-run-listener-test/build.gradle.kts b/system-tests/junit-run-listener-test/build.gradle.kts index 0711312c5..148a92b2b 100644 --- a/system-tests/junit-run-listener-test/build.gradle.kts +++ b/system-tests/junit-run-listener-test/build.gradle.kts @@ -6,5 +6,5 @@ plugins { tasks.test { // install dependencies needed by the Maven test projects - dependsOn(rootProject.tasks["publishToMavenLocal"]) + dependsOn(":publishToMavenLocal") } diff --git a/system-tests/teamscale-properties-test/build.gradle.kts b/system-tests/teamscale-properties-test/build.gradle.kts index 1857b9fed..2294b7fff 100644 --- a/system-tests/teamscale-properties-test/build.gradle.kts +++ b/system-tests/teamscale-properties-test/build.gradle.kts @@ -1,3 +1,4 @@ +import kotlin.io.path.deleteIfExists import kotlin.io.path.writeText plugins { @@ -18,7 +19,7 @@ tasks.test { ) } doLast { - delete(teamscalePropertiesPath) + teamscalePropertiesPath.deleteIfExists() } teamscaleAgent( diff --git a/system-tests/tia-maven/build.gradle.kts b/system-tests/tia-maven/build.gradle.kts index 9ca8c0b58..95042d297 100644 --- a/system-tests/tia-maven/build.gradle.kts +++ b/system-tests/tia-maven/build.gradle.kts @@ -6,6 +6,6 @@ plugins { tasks.test { // install dependencies needed by the Maven test project - dependsOn(rootProject.tasks["publishToMavenLocal"]) + dependsOn(":publishToMavenLocal") } From a46f332f28250445fab48e5106114522a5b485f3 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 09:49:02 +0200 Subject: [PATCH 11/22] TS-47380 Drop the redundant dependsOn on the agent jar task The com.teamscale.agent-jar convention plugin already wires up the dependency: createAgentCopy() resolves the teamscaleAgentJar configuration, which points at :agent's shaded jar, and declares it as a task dependency. Depending on :agent:shadowJar by path on top of that adds nothing and hides where the dependency actually comes from. The same redundancy existed twice, in teamscaleAgent() and in the test task of the system test convention. Verified that :agent:shadowJar is still in the task graph of both a JavaExec and a test task without it. Co-Authored-By: Claude Opus 5 (1M context) --- buildSrc/src/main/kotlin/AgentJarExtension.kt | 1 - .../main/kotlin/com.teamscale.system-test-convention.gradle.kts | 1 - 2 files changed, 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/AgentJarExtension.kt b/buildSrc/src/main/kotlin/AgentJarExtension.kt index 6be69f0c1..08138cf52 100644 --- a/buildSrc/src/main/kotlin/AgentJarExtension.kt +++ b/buildSrc/src/main/kotlin/AgentJarExtension.kt @@ -12,7 +12,6 @@ val Test.logFilePath /** Adds a convenient way to attach the Teamscale JaCoCo agent to the JVM with the given options in a readable map format. */ fun JavaExec.teamscaleAgent(options: Map) { - dependsOn(":agent:shadowJar") jvmArgs( "-javaagent:$agentJar=${options.entries.joinToString(separator = ",") { "${it.key}=${it.value}" }}" ) diff --git a/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts index 76a9264ca..2a544d5f0 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts @@ -13,7 +13,6 @@ val debugSutPort = providers.gradleProperty("debugSut") .map { if (it.isEmpty() || it == "true") "5005" else it } tasks.test { - dependsOn(":agent:shadowJar") usesService(provider) // The spawned JVM suspends until a debugger attaches, so the test must not be run in parallel with others From 98d678e13b80ddbe941fd41bd3fe358c6c757313 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 10:08:15 +0200 Subject: [PATCH 12/22] TS-47380 Make the jlink tasks compatible with the configuration cache The jlink tasks expose their target platforms as an @Input. Configuring a platform with `jdkDownload` stores the JDK home in there as a lazily evaluated Groovy closure, and the configuration cache replaces that closure's owner with a non-serializable BrokenObject. Fingerprinting the input then fails once the task graph is restored from the cache: java.io.NotSerializableException: ...ClosureCodec$BrokenObject Since the whole build degrades to running without the configuration cache as soon as an incompatible task is in the graph, this affected `build`, `assemble`, `dist` and `publish` alike, not just the installer. We now provision the JDKs ourselves and hand jlink a plain path, which keeps the input serializable. The archives are declared as dependencies of an Adoptium Ivy repository, so Gradle caches them across builds instead of re-downloading them into the build directory, and unpacking them is an ordinary Sync task. The archive names and the release the repository points at both derive from the JDK version in gradle.properties, so an upgrade only has to touch one place. The opt-out for CreateMergedModuleTask was redundant either way, as it extends BaseTask like every other task that reads a target platform. Verified that this does not change what we ship: of the 227 files in the two runtime images, 226 are bit-identical to the ones the previous setup produced. The remaining one, lib/modules, differs between two runs of unchanged code as well, so jlink does not write it deterministically. Co-Authored-By: Claude Opus 5 (1M context) --- gradle.properties | 4 ++ installer/build.gradle.kts | 87 ++++++++++++++++++++++++-------------- settings.gradle.kts | 21 +++++++++ 3 files changed, 80 insertions(+), 32 deletions(-) diff --git a/gradle.properties b/gradle.properties index b2e18be29..6b150da50 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,5 +4,9 @@ org.gradle.jvmargs=-Xmx3g kotlin.daemon.jvmargs=-Xmx2g +# The Adoptium JDK that the installer's runtime images are linked against. Both the repository in +# settings.gradle.kts and the archive names in installer/build.gradle.kts are derived from it. +runtimeJdkVersion=21.0.6+7 + #org.gradle.configuration-cache=true #org.gradle.configuration-cache.parallel=true diff --git a/installer/build.gradle.kts b/installer/build.gradle.kts index 2e78d447e..4fae4c947 100644 --- a/installer/build.gradle.kts +++ b/installer/build.gradle.kts @@ -1,7 +1,5 @@ import org.beryx.jlink.BaseTask -import org.beryx.jlink.CreateMergedModuleTask import org.beryx.jlink.JlinkTask -import org.beryx.jlink.util.JdkUtil import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -39,20 +37,6 @@ tasks.withType { options.release = 21 } -// The jlink tasks expose `targetPlatforms` as an @Input. `jdkDownload` below stores the JDK home as a -// lazily evaluated Groovy closure in there. The configuration cache replaces that closure's owner with a -// non-serializable BrokenObject, so fingerprinting the input fails once the task graph is restored: -// java.io.NotSerializableException: ...ClosureCodec$BrokenObject -// Removing these opt-outs therefore requires provisioning the target JDKs ourselves and passing plain -// paths to `setJdkHome`. Until then, any build that runs jlink falls back to no configuration cache. -tasks.withType { - notCompatibleWithConfigurationCache("jdkDownload stores a Groovy closure in the targetPlatforms input") -} - -tasks.withType { - notCompatibleWithConfigurationCache("jdkDownload stores a Groovy closure in the targetPlatforms input") -} - tasks.withType { compilerOptions.jvmTarget = JvmTarget.JVM_21 } @@ -75,8 +59,59 @@ application { ) } -val ADOPTIUM_BINARY_REPOSITORY = "https://api.adoptium.net/v3/binary" -val RUNTIME_JDK_VERSION = "21.0.6+7" +val runtimeJdkVersion = providers.gradleProperty("runtimeJdkVersion").get() + +/** + * Provisions the JDK that the runtime image for the given operating system is linked against and returns the + * path to its JDK home. + * + * The jlink plugin can download the JDK itself via `jdkDownload`, but it stores that download as a lazily + * evaluated Groovy closure in its `targetPlatforms` input. The configuration cache replaces the closure's + * owner with a non-serializable BrokenObject, which makes fingerprinting that input fail once the task graph + * is restored. Handing jlink a plain path keeps the input serializable, and declaring the archive as a + * dependency lets Gradle cache it across builds instead of re-downloading it into the build directory. + * + * The archives are resolved from the Adoptium repository declared in settings.gradle.kts, which is what the + * `net.adoptium.cdn` group below refers to. + */ +fun provisionRuntimeJdk(operatingSystem: String, archiveExtension: String): String { + val archiveName = "OpenJDK${runtimeJdkVersion.substringBefore(".")}U-jdk_x64_" + + "${operatingSystem}_hotspot_${runtimeJdkVersion.replace("+", "_")}" + + val jdk = configurations.dependencyScope("${operatingSystem}RuntimeJdk") + val jdkArchive = configurations.resolvable("${operatingSystem}RuntimeJdkArchive") { + extendsFrom(jdk.get()) + } + dependencies.add( + jdk.name, + mapOf("group" to "net.adoptium.cdn", "name" to archiveName, "ext" to archiveExtension) + ) + + val jdkHome = layout.buildDirectory.dir("runtime-jdks/$operatingSystem") + val unpackJdk = tasks.register("unpack${operatingSystem.replaceFirstChar(Char::titlecase)}RuntimeJdk") { + description = "Unpacks the JDK that the $operatingSystem runtime image is linked against." + // The archive tree is built during configuration, so that the copy action does not have to reach back + // into the build script at execution time. + val archiveFile = jdkArchive.map { it.singleFile } + from(if (archiveExtension == "zip") zipTree(archiveFile) else tarTree(archiveFile)) { + // Everything sits below a single jdk- folder, which we strip to get a predictable path. + eachFile { + relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) + } + includeEmptyDirs = false + } + into(jdkHome) + } + + // The JDK home is a plain string, so Gradle cannot infer this dependency by itself. CreateMergedModuleTask + // and JlinkTask both extend BaseTask, so this covers every task that reads a target platform. + tasks.withType { + dependsOn(unpackJdk) + } + + return jdkHome.get().asFile.absolutePath +} + jlink { forceMerge("kotlin") options = listOf( @@ -89,22 +124,10 @@ jlink { } targetPlatform("linux-x86_64") { - setJdkHome( - jdkDownload( - "$ADOPTIUM_BINARY_REPOSITORY/version/jdk-${RUNTIME_JDK_VERSION}/linux/x64/jdk/hotspot/normal/eclipse", - closureOf { - archiveExtension = "tar.gz" - }) - ) + setJdkHome(provisionRuntimeJdk("linux", "tar.gz")) } targetPlatform("windows-x86_64") { - setJdkHome( - jdkDownload( - "$ADOPTIUM_BINARY_REPOSITORY/version/jdk-${RUNTIME_JDK_VERSION}/windows/x64/jdk/hotspot/normal/eclipse", - closureOf { - archiveExtension = "zip" - }) - ) + setJdkHome(provisionRuntimeJdk("windows", "zip")) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index d048c54b0..5e172bee0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,27 @@ plugins { dependencyResolutionManagement { repositories { mavenCentral() + + // The JDKs that the installer's runtime images are linked against. Declaring them as dependencies + // instead of letting the jlink plugin download them keeps the download out of the configuration + // phase and makes it cacheable, cf. installer/build.gradle.kts. + ivy { + val jdkVersion = providers.gradleProperty("runtimeJdkVersion").get() + val repository = "temurin${jdkVersion.substringBefore(".")}-binaries" + url = uri( + "https://github.com/adoptium/$repository/releases/download/jdk-${jdkVersion.replace("+", "%2B")}/" + ) + patternLayout { + artifact("[artifact].[ext]") + } + // The release only contains the archives themselves, there is no module metadata to fetch. + metadataSources { + artifact() + } + content { + includeGroup("net.adoptium.cdn") + } + } } oci { registries { From ee265c19ddb3d1bbabbd2aca83e997d49b89489e Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 10:22:30 +0200 Subject: [PATCH 13/22] TS-47380 Enable the configuration cache by default Every task in the build is compatible with it now, so developers no longer have to remember --configuration-cache. Also turns on parallel storing and loading of the cache entry. Project isolation stays off, and gradle.properties records why: the nexus publish plugin cross-configures every project via allprojects {}, which fails any build that enables it, down to ./gradlew help. Co-Authored-By: Claude Opus 5 (1M context) --- gradle.properties | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 6b150da50..9e0535ab3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,10 @@ kotlin.daemon.jvmargs=-Xmx2g # settings.gradle.kts and the archive names in installer/build.gradle.kts are derived from it. runtimeJdkVersion=21.0.6+7 -#org.gradle.configuration-cache=true -#org.gradle.configuration-cache.parallel=true +# The whole build works with the configuration cache, so it is enabled by default. +# +# Project isolation is not, even though our own build logic no longer violates it: the +# io.github.gradle-nexus.publish-plugin cross-configures every project via allprojects {}, which fails +# any build run with -Dorg.gradle.unsafe.isolated-projects=true, down to ./gradlew help. +org.gradle.configuration-cache=true +org.gradle.configuration-cache.parallel=true From 101c41e6e065eea5df256a1ef5dabd34ce7651d5 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Wed, 12 Aug 2026 13:03:36 +0200 Subject: [PATCH 14/22] TS-47380 Publish to Maven Central through nmcp io.github.gradle-nexus.publish-plugin applies plugins to every project via allprojects {}, which project isolation forbids. It is also the last release of a plugin built for OSSRH, which Sonatype has replaced with the Central Portal; we were already routing through the portal's OSSRH compatibility API. com.gradleup.nmcp publishes to the portal directly. Each publishing project contributes its publications to an outgoing variant, and the root project aggregates those into a single deployment by declaring ordinary project dependencies, so nothing cross-configures anything. The Gradle plugin keeps going to the Gradle Plugin Portal only, as before, so it stays out of the aggregation on both sides. Credentials still come from the sonatypeUsername and sonatypePassword properties, which the nexus plugin read by convention and we now pass explicitly, so the CI secrets stay as they are. They have to be a Central Portal user token rather than a portal login. Verified that the deployment is equivalent to what we published before: the same seven artifacts, each with a jar, sources, javadoc, POM, module file and checksums, and release rather than snapshot file names when GITHUB_REF points at a tag. The signing tasks stay wired in front of the staging step, though signatures themselves could only be checked where the keys are available, i.e. in CI. Project isolation still cannot be enabled: with the publish plugin gone, org.beryx.jlink is now the first thing to fail it. gradle.properties records the details. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/actions.yml | 2 +- build.gradle.kts | 30 ++++++++++++++----- buildSrc/build.gradle.kts | 1 + .../kotlin/com.teamscale.publish.gradle.kts | 6 ++++ gradle.properties | 15 +++++++--- gradle/libs.versions.toml | 2 +- 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 9fd712835..dd7a68bda 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -55,7 +55,7 @@ jobs: - name: Publish to Maven Central if: startsWith(github.ref, 'refs/tags/v') run: | - ./gradlew publishMavenPublicationToSonatypeRepository closeAndReleaseSonatypeStagingRepository \ + ./gradlew publishAggregationToCentralPortal \ -Psigning.secretKeyRingFile=${{ github.workspace }}/.gnupg/secring.gpg \ -PgpgDirectory=${{ github.workspace }}/.gnupg \ -Psigning.password=${{ secrets.MAVEN_CENTRAL_GPG_SIGNATURE }} \ diff --git a/build.gradle.kts b/build.gradle.kts index d67eb405c..ec94a1fd5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,7 @@ plugins { - alias(libs.plugins.nexusPublish) + // Ships in the same artifact as com.gradleup.nmcp, which buildSrc puts on the classpath for the + // com.teamscale.publish convention plugin, so this must not repeat the version. + id("com.gradleup.nmcp.aggregation") } // group and version are set for every project from settings.gradle.kts @@ -24,13 +26,25 @@ tasks.register("publishToMavenLocal") { dependsOn(publishedProjects.map { "$it:publishToMavenLocal" }) } -nexusPublishing { - repositories { - // see https://central.sonatype.org/publish/publish-portal-ossrh-staging-api/#configuration - sonatype { - nexusUrl = uri("https://ossrh-staging-api.central.sonatype.com/service/local/") - snapshotRepositoryUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - } +// Collects the publications of all projects below into a single deployment and uploads it to Maven Central +// via the Central Portal. Publishing this way, rather than by cross-configuring the projects from here, is +// what keeps the release path compatible with project isolation. +nmcpAggregation { + centralPortal { + // The user token generated at https://central.sonatype.com/account, not the portal login itself. + username = providers.gradleProperty("sonatypeUsername") + password = providers.gradleProperty("sonatypePassword") + // Release the deployment as soon as the portal has validated it. Use USER_MANAGED to stop after + // validation and release by hand from the portal instead. + publishingType = "AUTOMATIC" + } +} + +dependencies { + // The Gradle plugin is released through the Gradle Plugin Portal, so it is not part of the deployment. + // The com.teamscale.publish convention plugin leaves it out on the producing side for the same reason. + (publishedProjects - ":teamscale-gradle-plugin").forEach { + nmcpAggregation(project(it)) } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 3a91ed04d..f4991b04c 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(plugin(libs.plugins.shadow)) implementation(plugin(libs.plugins.kotlinJvm)) implementation(plugin(libs.plugins.testRetry)) + implementation(plugin(libs.plugins.nmcp)) implementation(libs.asm.core) implementation(libs.asm.commons) diff --git a/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts index 4e462ec95..92979c604 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts @@ -27,6 +27,12 @@ publishing { } } +// Contributes this project's publications to the aggregated deployment that the root project uploads to +// Maven Central. The Gradle plugin is released through the Gradle Plugin Portal instead, so it stays out. +if (project.name != "teamscale-gradle-plugin") { + pluginManager.apply("com.gradleup.nmcp") +} + signing { setRequired({ // Do not require signing for deployment to maven local diff --git a/gradle.properties b/gradle.properties index 9e0535ab3..4f106c63d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,9 +9,16 @@ kotlin.daemon.jvmargs=-Xmx2g runtimeJdkVersion=21.0.6+7 # The whole build works with the configuration cache, so it is enabled by default. -# -# Project isolation is not, even though our own build logic no longer violates it: the -# io.github.gradle-nexus.publish-plugin cross-configures every project via allprojects {}, which fails -# any build run with -Dorg.gradle.unsafe.isolated-projects=true, down to ./gradlew help. org.gradle.configuration-cache=true org.gradle.configuration-cache.parallel=true + +# Project isolation is not enabled yet. Our own build logic satisfies it, but org.beryx.jlink walks the +# configurations of every project that :installer depends on, which it is not allowed to do: +# Plugin 'org.beryx.jlink': Project ':installer' cannot access 'Project.configurations' +# functionality on another project ':agent' +# at org.beryx.jlink.util.Util.getAllDependentProjectsExt(Util.groovy:351) +# That fails every build, down to ./gradlew help, so enabling it has to wait until :installer either builds +# its image with a different plugin or stops depending on other projects. +# The property is intentionally the stable name; org.gradle.unsafe.isolated-projects is deprecated as of +# Gradle 9.7. +#org.gradle.isolated-projects=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0f172992..8c4d625f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -80,7 +80,7 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version = "26.1. coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } [plugins] -nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version = "2.0.0" } +nmcp = { id = "com.gradleup.nmcp", version = "1.6.1" } pluginPublish = { id = "com.gradle.plugin-publish", version = "2.1.1" } gitProperties = { id = "com.gorylenko.gradle-git-properties", version = "4.0.1" } mavenPluginDevelopment = { id = "org.gradlex.maven-plugin-development", version = "1.0.3" } From 4e688ff0c05ce57d7aa801f03efdf53e9296658a Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Thu, 13 Aug 2026 09:22:22 +0200 Subject: [PATCH 15/22] TS-47380 Rename the sample-debugging-app module to sample-app Co-Authored-By: Claude Opus 5 (1M context) --- .idea/runConfigurations/SampleApp.xml | 2 +- README.md | 3 +-- docs/DEBUGGING.md | 14 +++++++------- .../build.gradle.kts | 0 .../jacocoagent.properties | 2 +- .../src/main/java/com/example/Main.java | 0 settings.gradle.kts | 2 +- 7 files changed, 11 insertions(+), 12 deletions(-) rename {sample-debugging-app => sample-app}/build.gradle.kts (100%) rename {sample-debugging-app => sample-app}/jacocoagent.properties (89%) rename {sample-debugging-app => sample-app}/src/main/java/com/example/Main.java (100%) diff --git a/.idea/runConfigurations/SampleApp.xml b/.idea/runConfigurations/SampleApp.xml index e691eb91f..00ce08322 100644 --- a/.idea/runConfigurations/SampleApp.xml +++ b/.idea/runConfigurations/SampleApp.xml @@ -10,7 +10,7 @@