From 3f010a7370c8efa40473ddc5ff0c60f057fda7af Mon Sep 17 00:00:00 2001 From: Jerome Prinet Date: Tue, 6 Oct 2026 12:56:44 +0200 Subject: [PATCH] Detect Build Scans published from agents when DurableTaskStep.USE_WATCHING is enabled --- ...DetectionTaskListenerDecoratorFactory.java | 50 +++++++++++++++--- .../hudson/plugins/gradle/util/RunUtil.java | 15 +++--- .../BuildScanDetectionIntegrationTest.groovy | 27 ++++++++++ ...eTaskStepUseWatchingIntegrationTest.groovy | 51 +++++++++++++++++++ 4 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 plugin/src/test/groovy/hudson/plugins/gradle/injection/BuildScanDetectionWithDurableTaskStepUseWatchingIntegrationTest.groovy diff --git a/plugin/src/main/java/hudson/plugins/gradle/injection/BuildScanDetectionTaskListenerDecoratorFactory.java b/plugin/src/main/java/hudson/plugins/gradle/injection/BuildScanDetectionTaskListenerDecoratorFactory.java index e2284008..49bc9df1 100644 --- a/plugin/src/main/java/hudson/plugins/gradle/injection/BuildScanDetectionTaskListenerDecoratorFactory.java +++ b/plugin/src/main/java/hudson/plugins/gradle/injection/BuildScanDetectionTaskListenerDecoratorFactory.java @@ -3,7 +3,11 @@ import hudson.Extension; import hudson.model.Queue; import hudson.model.Run; +import hudson.plugins.gradle.BuildScanPublishedListener; +import hudson.plugins.gradle.DefaultBuildScanPublishedListener; import hudson.plugins.gradle.enriched.EnrichedSummaryConfig; +import hudson.plugins.gradle.enriched.ScanDetailService; +import hudson.remoting.Channel; import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; import org.jenkinsci.plugins.workflow.job.WorkflowRun; import org.jenkinsci.plugins.workflow.log.TaskListenerDecorator; @@ -13,6 +17,7 @@ import java.io.IOException; import java.io.OutputStream; import java.io.Serializable; +import java.nio.charset.Charset; import java.util.logging.Level; import java.util.logging.Logger; @@ -39,23 +44,52 @@ public TaskListenerDecorator of(@Nonnull FlowExecutionOwner owner) { return null; } - @SuppressWarnings("rawtypes") public static class BuildScanDetectionTaskListenerDecorator extends TaskListenerDecorator implements Serializable { - private static final long serialVersionUID = 1L; + private static final long serialVersionUID = 2L; - private final transient Run run; + // Survives serialization to an agent: with DurableTaskStep.USE_WATCHING the agent decorates its own output + private final BuildScanPublishedListener listener; + private final String charset; - public BuildScanDetectionTaskListenerDecorator(Run run) { - this.run = run; + public BuildScanDetectionTaskListenerDecorator(Run run) { + this.listener = new RunBuildScanPublishedListener(run.getExternalizableId()); + this.charset = run.getCharset().name(); } @Nonnull @Override public OutputStream decorate(@Nonnull OutputStream logger) { - if (run != null) { - return new BuildScanDetectionLogProcessor(logger, run); + return new BuildScanDetectionLogProcessor(logger, Charset.forName(charset), listener); + } + } + + /** + * Attaches detected Build Scans to the run. When sent to an agent, it is replaced by a remoting proxy, + * so Build Scans detected on the agent are still recorded on the controller. + */ + static final class RunBuildScanPublishedListener implements BuildScanPublishedListener, Serializable { + private static final long serialVersionUID = 1L; + + private final String runId; + + RunBuildScanPublishedListener(String runId) { + this.runId = runId; + } + + @Override + public void onBuildScanPublished(String scanUrl) { + Run run = Run.fromExternalizableId(runId); + if (run == null) { + LOGGER.log(Level.FINE, "Run {0} not found, ignoring Build Scan {1}", new Object[]{runId, scanUrl}); + return; } - return logger; + new DefaultBuildScanPublishedListener(run, new ScanDetailService(EnrichedSummaryConfig.get())) + .onBuildScanPublished(scanUrl); + } + + private Object writeReplace() { + Channel channel = Channel.current(); + return channel != null ? channel.export(BuildScanPublishedListener.class, this) : this; } } } diff --git a/plugin/src/main/java/hudson/plugins/gradle/util/RunUtil.java b/plugin/src/main/java/hudson/plugins/gradle/util/RunUtil.java index df536bdb..d21f619d 100644 --- a/plugin/src/main/java/hudson/plugins/gradle/util/RunUtil.java +++ b/plugin/src/main/java/hudson/plugins/gradle/util/RunUtil.java @@ -20,14 +20,17 @@ public static A getOrCreateAction( Class actionClass, Supplier actionFactory ) { - A action = actionable.getAction(actionClass); + // Parallel pipeline branches may report their first Build Scan concurrently + synchronized (actionable) { + A action = actionable.getAction(actionClass); - if (action == null) { - action = actionFactory.get(); - actionable.addAction(action); - } + if (action == null) { + action = actionFactory.get(); + actionable.addAction(action); + } - return action; + return action; + } } public static boolean isFreestyleBuildWithGradle(@Nullable Run build) { diff --git a/plugin/src/test/groovy/hudson/plugins/gradle/injection/BuildScanDetectionIntegrationTest.groovy b/plugin/src/test/groovy/hudson/plugins/gradle/injection/BuildScanDetectionIntegrationTest.groovy index 24b02817..691087d5 100644 --- a/plugin/src/test/groovy/hudson/plugins/gradle/injection/BuildScanDetectionIntegrationTest.groovy +++ b/plugin/src/test/groovy/hudson/plugins/gradle/injection/BuildScanDetectionIntegrationTest.groovy @@ -59,6 +59,33 @@ echo "https://scans.gradle.com/s/nested1"''' } + @Requires(value = { os.linux || os.macOs }, reason = "Uses shell commands") + def 'build scans from steps running on an agent are detected'() { + given: + withEnrichedSummaryConfig { + globalBuildScanDetection = true + } + createSlave('agent') + def pipelineJob = j.createProject(WorkflowJob) + pipelineJob.setDefinition(new CpsFlowDefinition(""" +node('agent') { + sh '''echo "Publishing build scan..." +echo "https://scans.gradle.com/s/agent1"''' + sh '''echo "Publishing build scan..." +echo "https://scans.gradle.com/s/agent2"''' +} +""", false)) + + when: + def build = j.buildAndAssertSuccess(pipelineJob) + + then: + println JenkinsRule.getLog(build) + def action = build.getAction(BuildScanAction) + action != null + action.scanUrls == ['https://scans.gradle.com/s/agent1', 'https://scans.gradle.com/s/agent2'] + } + @Requires(value = { os.linux || os.macOs }, reason = "Uses shell commands") def 'withGradle does not double-parse when global detection is enabled'() { given: diff --git a/plugin/src/test/groovy/hudson/plugins/gradle/injection/BuildScanDetectionWithDurableTaskStepUseWatchingIntegrationTest.groovy b/plugin/src/test/groovy/hudson/plugins/gradle/injection/BuildScanDetectionWithDurableTaskStepUseWatchingIntegrationTest.groovy new file mode 100644 index 00000000..48859125 --- /dev/null +++ b/plugin/src/test/groovy/hudson/plugins/gradle/injection/BuildScanDetectionWithDurableTaskStepUseWatchingIntegrationTest.groovy @@ -0,0 +1,51 @@ +package hudson.plugins.gradle.injection + +import hudson.plugins.gradle.BaseGradleIntegrationTest +import hudson.plugins.gradle.BuildScanAction +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition +import org.jenkinsci.plugins.workflow.job.WorkflowJob +import org.jenkinsci.plugins.workflow.steps.durable_task.DurableTaskStep +import org.jvnet.hudson.test.JenkinsRule +import spock.lang.Requires + +class BuildScanDetectionWithDurableTaskStepUseWatchingIntegrationTest extends BaseGradleIntegrationTest { + + private boolean useWatching + + def setup() { + // Read once in a static initializer, so a system property set by a rule may come too late + useWatching = DurableTaskStep.USE_WATCHING + DurableTaskStep.USE_WATCHING = true + } + + def cleanup() { + DurableTaskStep.USE_WATCHING = useWatching + } + + @Requires(value = { os.linux || os.macOs }, reason = "Uses shell commands") + def 'build scans from steps running on an agent are detected when DurableTaskStep.USE_WATCHING=true'() { + given: + withEnrichedSummaryConfig { + globalBuildScanDetection = true + } + createSlave('agent') + def pipelineJob = j.createProject(WorkflowJob) + pipelineJob.setDefinition(new CpsFlowDefinition(""" +node('agent') { + sh '''echo "Publishing build scan..." +echo "https://scans.gradle.com/s/agent1"''' + sh '''echo "Publishing build scan..." +echo "https://scans.gradle.com/s/agent2"''' +} +""", false)) + + when: + def build = j.buildAndAssertSuccess(pipelineJob) + + then: + println JenkinsRule.getLog(build) + def action = build.getAction(BuildScanAction) + action != null + action.scanUrls == ['https://scans.gradle.com/s/agent1', 'https://scans.gradle.com/s/agent2'] + } +}