From 5e2020f8077e842ecc2b0ab2663fa95b50e8cfe4 Mon Sep 17 00:00:00 2001 From: Platform Engineering Bot Date: Tue, 19 May 2026 00:22:19 +0000 Subject: [PATCH] =?UTF-8?q?Update=20patches:=20d36839b=20=E2=86=92=20ae289?= =?UTF-8?q?6c5=20[ppc64le,s390x]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- images/Dockerfile | 4 +- patches/last_processed_commit.txt | 2 +- patches/runner-main-sdk8-ppc64le.patch | 6 +- patches/runner-main-sdk8-s390x.patch | 6 +- src/Runner.Common/Constants.cs | 1 + src/Runner.Worker/Dap/DapDebugger.cs | 79 +++++- src/Runner.Worker/Dap/DapReplExecutor.cs | 161 +++++++++--- src/Runner.Worker/Dap/DebuggerConfig.cs | 19 +- src/Runner.Worker/ExecutionContext.cs | 3 +- .../Pipelines/AgentJobRequestMessage.cs | 15 ++ .../Sdk/RSWebApi/AgentJobRequestMessageL0.cs | 34 ++- src/Test/L0/Worker/DapDebuggerL0.cs | 229 +++++++++++++++++- src/Test/L0/Worker/DapReplExecutorL0.cs | 105 +++++++- src/dev.sh | 2 +- src/global.json | 2 +- 15 files changed, 601 insertions(+), 67 deletions(-) diff --git a/images/Dockerfile b/images/Dockerfile index e5d2f6f4f7c..df5e145d08c 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -5,8 +5,8 @@ ARG TARGETOS ARG TARGETARCH ARG RUNNER_VERSION ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0 -ARG DOCKER_VERSION=29.4.0 -ARG BUILDX_VERSION=0.33.0 +ARG DOCKER_VERSION=29.5.0 +ARG BUILDX_VERSION=0.34.0 RUN apt update -y && apt install curl unzip -y diff --git a/patches/last_processed_commit.txt b/patches/last_processed_commit.txt index 98c5f1805db..586ed0f63bf 100644 --- a/patches/last_processed_commit.txt +++ b/patches/last_processed_commit.txt @@ -1 +1 @@ -d36839b001e3294e2b6e2663268c63b50d295df5 +ae2896c551a708bdc78bee403eb696e3a26ac95d diff --git a/patches/runner-main-sdk8-ppc64le.patch b/patches/runner-main-sdk8-ppc64le.patch index cc6764718d9..9cfa1b271b5 100644 --- a/patches/runner-main-sdk8-ppc64le.patch +++ b/patches/runner-main-sdk8-ppc64le.patch @@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755 if ! [ -x "$(command -v ldconfig)" ]; then diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs -index 7a1f1cbb..dafc3ccf 100644 +index b07d60d3..55ed142b 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -59,7 +59,9 @@ namespace GitHub.Runner.Common @@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644 NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051 diff --git a/src/dev.sh b/src/dev.sh -index fc732597..0765551f 100755 +index fafdbffb..baed6dd2 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then @@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644 -# From upstream commit: d36839b001e3294e2b6e2663268c63b50d295df5 +# From upstream commit: ae2896c551a708bdc78bee403eb696e3a26ac95d diff --git a/patches/runner-main-sdk8-s390x.patch b/patches/runner-main-sdk8-s390x.patch index cc6764718d9..9cfa1b271b5 100644 --- a/patches/runner-main-sdk8-s390x.patch +++ b/patches/runner-main-sdk8-s390x.patch @@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755 if ! [ -x "$(command -v ldconfig)" ]; then diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs -index 7a1f1cbb..dafc3ccf 100644 +index b07d60d3..55ed142b 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -59,7 +59,9 @@ namespace GitHub.Runner.Common @@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644 NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051 diff --git a/src/dev.sh b/src/dev.sh -index fc732597..0765551f 100755 +index fafdbffb..baed6dd2 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then @@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644 -# From upstream commit: d36839b001e3294e2b6e2663268c63b50d295df5 +# From upstream commit: ae2896c551a708bdc78bee403eb696e3a26ac95d diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 7a1f1cbb0a1..b07d60d39fc 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -179,6 +179,7 @@ public static class Features public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers"; public static readonly string BatchActionResolution = "actions_batch_action_resolution"; public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload"; + public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message"; } // Node version migration related constants diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 3b3ec7cbfaf..d5dba2fe25d 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -63,6 +63,7 @@ public sealed class DapDebugger : RunnerService, IDapDebugger private volatile DapSessionState _state = DapSessionState.NotStarted; private CancellationTokenRegistration? _cancellationRegistration; private bool _isFirstStep = true; + private bool _welcomeMessageSent; // Dev Tunnel relay host for remote debugging private TunnelRelayTunnelHost _tunnelRelayHost; @@ -490,6 +491,11 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can }); Trace.Info("Sent initialized event"); } + + if (request.Command == "configurationDone") + { + SendWelcomeMessage(); + } } catch (Exception ex) { @@ -508,6 +514,7 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can internal void HandleClientConnected() { _isClientConnected = true; + _welcomeMessageSent = false; Trace.Info("Client connected to debug session"); // If we're paused, re-send the stopped event so the new client @@ -818,6 +825,34 @@ private void SendOutput(string category, string text) }); } + internal void SendWelcomeMessage() + { + if (_welcomeMessageSent) + { + return; + } + _welcomeMessageSent = true; + + var debuggerConfig = _jobContext?.Global?.Debugger; + if (debuggerConfig?.OverrideWelcomeMessage == true) + { + if (!string.IsNullOrEmpty(debuggerConfig.WelcomeMessage)) + { + SendOutput("console", debuggerConfig.WelcomeMessage); + Trace.Info("Sent custom welcome message"); + } + else + { + Trace.Info("Welcome message suppressed by override"); + } + } + else + { + SendOutput("console", DapReplParser.GetGeneralHelp()); + Trace.Info("Sent default welcome message"); + } + } + internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) { bool pauseOnNextStep; @@ -860,6 +895,9 @@ internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) // Send stopped event to debugger (only if client is connected) SendStoppedEvent(reason, description); + // Emit a banner so the user knows where REPL commands will execute + SendExecutionContextBanner(); + // Wait for debugger command await WaitForCommandAsync(cancellationToken); } @@ -1195,7 +1233,12 @@ private async Task DispatchReplCommandAsync( case RunCommand run: var context = GetExecutionContextForFrame(frameId); - return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken); + bool isActionStep; + lock (_stateLock) + { + isActionStep = _currentStep is IActionRunner; + } + return await _replExecutor.ExecuteRunCommandAsync(run, context, isActionStep, cancellationToken); default: return new EvaluateResponseBody @@ -1407,6 +1450,40 @@ private void SendStoppedEvent(string reason, string description) }); } + /// + /// Emits a console output banner telling the user whether REPL + /// commands will execute on the host or inside the job container. + /// + private void SendExecutionContextBanner() + { + if (!_isClientConnected) + { + return; + } + + bool isActionStep = _currentStep is IActionRunner; + var container = _jobContext?.Global?.Container; + + string target; + if (isActionStep && container != null && + (!string.IsNullOrEmpty(container.ContainerId) || + FeatureManager.IsContainerHooksEnabled(_jobContext?.Global?.Variables))) + { + var image = container.ContainerImage ?? "container"; + var shortId = !string.IsNullOrEmpty(container.ContainerId) && container.ContainerId.Length >= 12 + ? container.ContainerId.Substring(0, 12) + : container.ContainerId ?? ""; + var idSuffix = !string.IsNullOrEmpty(shortId) ? $" ({shortId})" : ""; + target = $"job container: {image}{idSuffix}"; + } + else + { + target = "runner host"; + } + + SendOutput("console", $"\nCommands will run on {target}\n"); + } + private string MaskUserVisibleText(string value) { if (string.IsNullOrEmpty(value)) diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index 751f92c514c..434907c2cc7 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -9,6 +9,7 @@ using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Container; using GitHub.Runner.Worker.Handlers; namespace GitHub.Runner.Worker.Dap @@ -43,6 +44,7 @@ public DapReplExecutor(IHostContext hostContext, Action sendOutp public async Task ExecuteRunCommandAsync( RunCommand command, IExecutionContext context, + bool isActionStep, CancellationToken cancellationToken) { if (context == null) @@ -52,7 +54,7 @@ public async Task ExecuteRunCommandAsync( try { - return await ExecuteScriptAsync(command, context, cancellationToken); + return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken); } catch (Exception ex) { @@ -65,9 +67,17 @@ public async Task ExecuteRunCommandAsync( private async Task ExecuteScriptAsync( RunCommand command, IExecutionContext context, + bool isActionStep, CancellationToken cancellationToken) { - // 1. Resolve shell — same logic as ScriptHandler + // 1. Resolve step host — container or host, same as ActionRunner. + // Only action steps (user-defined run:/uses:) execute inside the + // container. Infrastructure steps (Set up job, Initialize + // containers, Complete job, etc.) always run on the host. + var stepHost = CreateStepHost(context, isActionStep); + var isContainerStepHost = stepHost is IContainerStepHost; + + // 2. Resolve shell — same logic as ScriptHandler string shellCommand; string argFormat; @@ -87,9 +97,9 @@ private async Task ExecuteScriptAsync( argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand); } - _trace.Info("Resolved REPL shell"); + _trace.Info($"Resolved REPL shell (container={isContainerStepHost})"); - // 2. Expand ${{ }} expressions in the script body, just like + // 3. Expand ${{ }} expressions in the script body, just like // ActionRunner evaluates step inputs before ScriptHandler sees them var contents = ExpandExpressions(command.Script, context); contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents); @@ -111,25 +121,47 @@ private async Task ExecuteScriptAsync( try { - // 3. Format arguments with script path - var resolvedPath = scriptFilePath.Replace("\"", "\\\""); + // 4. Resolve script path — translate for container if needed + var resolvedPath = stepHost.ResolvePathForStepHost(context, scriptFilePath).Replace("\"", "\\\""); if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}")) { return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'"); } var arguments = string.Format(argFormat, resolvedPath); - // 4. Resolve shell command path + // 5. Resolve shell command path — for containers, use the shell + // name directly (it will be resolved inside the container); + // for host execution, resolve the full path on the host. string prependPath = string.Join( Path.PathSeparator.ToString(), Enumerable.Reverse(context.Global.PrependPath)); - var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath) - ?? shellCommand; + var fileName = isContainerStepHost + ? shellCommand + : WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand; - // 5. Build environment — merge from execution context like a real step + // 6. Build environment — merge from execution context like a real step var environment = BuildEnvironment(context, command.Env); - // 6. Resolve working directory + // 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment + if (context.Global.PrependPath.Count > 0) + { + if (stepHost is IContainerStepHost containerHost) + { + containerHost.PrependPath = prependPath; + } + else + { + string taskEnvPATH; + environment.TryGetValue(Constants.PathVariable, out taskEnvPATH); + string originalPath = context.Global.Variables?.Get(Constants.PathVariable) ?? // Prefer a job variable. + taskEnvPATH ?? // Then a task-environment variable. + System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? // Then an environment variable. + string.Empty; + environment[Constants.PathVariable] = PathUtil.PrependPath(prependPath, originalPath); + } + } + + // 8. Resolve working directory — translate for container var workingDirectory = command.WorkingDirectory; if (string.IsNullOrEmpty(workingDirectory)) { @@ -141,48 +173,60 @@ private async Task ExecuteScriptAsync( : null; workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work); } + workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory); _trace.Info("Executing REPL command"); // Stream execution info to debugger SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n"); - // 7. Execute via IProcessInvoker (same as DefaultStepHost) - int exitCode; - using (var processInvoker = _hostContext.CreateService()) + // NOTE: When container hooks are enabled, ContainerStepHost routes + // execution through IContainerHookManager which does not raise + // OutputDataReceived/ErrorDataReceived events. Output will not be + // streamed to the debug console in that mode. + if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables)) + { + const string hookWarning = "Container hooks are enabled. REPL output will not be streamed to the debug console for this command."; + _trace.Warning(hookWarning); + SendOutput("stderr", hookWarning + "\n"); + } + + // 9. Execute via IStepHost — handles docker exec for containers, + // direct process execution for host, and container hooks + stepHost.OutputDataReceived += (sender, args) => { - processInvoker.OutputDataReceived += (sender, args) => + if (!string.IsNullOrEmpty(args.Data)) { - if (!string.IsNullOrEmpty(args.Data)) - { - var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); - SendOutput("stdout", masked + "\n"); - } - }; + var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); + SendOutput("stdout", masked + "\n"); + } + }; - processInvoker.ErrorDataReceived += (sender, args) => + stepHost.ErrorDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) { - if (!string.IsNullOrEmpty(args.Data)) - { - var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); - SendOutput("stderr", masked + "\n"); - } - }; - - exitCode = await processInvoker.ExecuteAsync( - workingDirectory: workingDirectory, - fileName: commandPath, - arguments: arguments, - environment: environment, - requireExitCodeZero: false, - outputEncoding: null, - killProcessOnCancel: true, - cancellationToken: cancellationToken); - } + var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); + SendOutput("stderr", masked + "\n"); + } + }; + + int exitCode = await stepHost.ExecuteAsync( + context: context, + workingDirectory: workingDirectory, + fileName: fileName, + arguments: arguments, + environment: environment, + requireExitCodeZero: false, + outputEncoding: null, + killProcessOnCancel: true, + inheritConsoleHandler: false, + standardInInput: null, + cancellationToken: cancellationToken); _trace.Info($"REPL command exited with code {exitCode}"); - // 8. Return only the exit code summary (output was already streamed) + // 10. Return only the exit code summary (output was already streamed) return new EvaluateResponseBody { Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.", @@ -198,6 +242,43 @@ private async Task ExecuteScriptAsync( } } + /// + /// Creates the appropriate for the current + /// execution context, mirroring how decides + /// between host and container execution. + /// + /// Only action steps (user-defined run:/uses: steps) run inside the + /// job container. Infrastructure steps like "Set up job", "Initialize + /// containers", "Stop containers", and "Complete job" always execute + /// on the host regardless of whether a container is configured. + /// + internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep) + { + if (!isActionStep) + { + _trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)"); + return _hostContext.CreateService(); + } + + var container = context?.Global?.Container; + if (container != null) + { + // Container hooks don't always set ContainerId, but the container + // step host handles that internally + var hooksEnabled = FeatureManager.IsContainerHooksEnabled(context.Global?.Variables); + if (hooksEnabled || !string.IsNullOrEmpty(container.ContainerId)) + { + _trace.Info("Creating ContainerStepHost for REPL execution"); + var containerStepHost = _hostContext.CreateService(); + containerStepHost.Container = container; + return containerStepHost; + } + } + + _trace.Info("Creating DefaultStepHost for REPL execution"); + return _hostContext.CreateService(); + } + /// /// Expands ${{ }} expressions in the input string using the /// runner's template evaluator — the same evaluation path that processes diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs index df139a15c18..13106cb563d 100644 --- a/src/Runner.Worker/Dap/DebuggerConfig.cs +++ b/src/Runner.Worker/Dap/DebuggerConfig.cs @@ -1,4 +1,4 @@ -using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker.Dap { @@ -8,10 +8,12 @@ namespace GitHub.Runner.Worker.Dap /// public sealed class DebuggerConfig { - public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) + public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null) { Enabled = enabled; Tunnel = tunnel; + OverrideWelcomeMessage = overrideWelcomeMessage; + WelcomeMessage = welcomeMessage; } /// Whether the debugger is enabled for this job. @@ -23,6 +25,19 @@ public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) /// public DebuggerTunnelInfo Tunnel { get; } + /// + /// When true, the runner overrides the default welcome message with + /// . A null or empty + /// suppresses the message entirely. When false, the default help text is shown. + /// + public bool OverrideWelcomeMessage { get; } + + /// + /// Optional welcome message content for the debugger console. Only used when + /// is true. + /// + public string WelcomeMessage { get; } + /// Whether the tunnel configuration is complete and valid. public bool HasValidTunnel => Tunnel != null && !string.IsNullOrEmpty(Tunnel.TunnelId) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index f072335b440..d071790f37d 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -970,7 +970,8 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation Global.WriteDebug = Global.Variables.Step_Debug ?? false; // Debugger enabled flag (from acquire response). - Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel); + var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false; + Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage); // Hook up JobServerQueueThrottling event, we will log warning on server tarpit. _jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived; diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index 96cf07a71c2..782878f79e4 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -267,6 +267,21 @@ public DebuggerTunnelInfo DebuggerTunnel set; } + /// + /// Optional welcome message shown in the debugger console when a client connects. + /// Only used when the actions_runner_override_debugger_welcome_message + /// feature flag is set to true in the job variables. With the flag set, + /// a non-empty value is shown as-is and a null or empty value suppresses the + /// default welcome message. When the flag is not set, the runner shows its + /// built-in help text and this field is ignored. + /// + [DataMember(EmitDefaultValue = false)] + public string DebuggerWelcomeMessage + { + get; + set; + } + /// /// Gets the workflow-level action dependencies (lockfile entries) /// diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs index 1a451d28f12..667c6810e84 100644 --- a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Runtime.Serialization.Json; using System.Text; @@ -17,13 +17,13 @@ public void VerifyEnableDebuggerDeserialization_WithTrue() // Arrange var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}"); - + // Act using var stream = new MemoryStream(); stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger)); stream.Position = 0; var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - + // Assert Assert.NotNull(recoveredMessage); Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true"); @@ -37,13 +37,13 @@ public void VerifyEnableDebuggerDeserialization_DefaultToFalse() // Arrange var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}"); - + // Act using var stream = new MemoryStream(); stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger)); stream.Position = 0; var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - + // Assert Assert.NotNull(recoveredMessage); Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent"); @@ -57,13 +57,13 @@ public void VerifyEnableDebuggerDeserialization_WithFalse() // Arrange var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}"); - + // Act using var stream = new MemoryStream(); stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger)); stream.Position = 0; var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; - + // Assert Assert.NotNull(recoveredMessage); Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false"); @@ -161,6 +161,26 @@ public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty() Assert.Empty(recoveredMessage.ActionsDependencies); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyDebuggerWelcomeMessageRoundTrips() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string json = DoubleQuotify("{'DebuggerWelcomeMessage': 'Welcome to debugging!'}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.Equal("Welcome to debugging!", recoveredMessage.DebuggerWelcomeMessage); + } + private static string DoubleQuotify(string text) { return text.Replace('\'', '"'); diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index f1a29306ff1..92efbaa00c9 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Net; using System.Net.Sockets; @@ -236,7 +236,7 @@ private static async Task ReadWebSocketDataUntilAsync(WebSocket client, } } - private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null) + private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null, bool overrideWelcomeMessage = false, string welcomeMessage = null) { var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo { @@ -245,7 +245,7 @@ private static Mock CreateJobContextWithTunnel(CancellationTo HostToken = "test-token", Port = port }; - var debuggerConfig = new DebuggerConfig(true, tunnel); + var debuggerConfig = new DebuggerConfig(true, tunnel, overrideWelcomeMessage, welcomeMessage); var jobContext = new Mock(); jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig }); @@ -742,6 +742,8 @@ public async Task OnJobCompletedSendsTerminatedAndExitedEvents() // Read the configurationDone response await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + // Read the welcome message output event + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); await waitTask; // Complete the job — OnJobCompletedAsync pauses when stepping, @@ -849,6 +851,8 @@ public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait() Command = "configurationDone" }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + // Read the welcome message output event await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); await waitTask; @@ -867,5 +871,224 @@ public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait() Assert.Equal(completedTask, finished); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSendsDefaultHelpWhenOverrideDisabled() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // First message: configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Second message: welcome output event with default help text + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("\"category\":\"console\"", welcomeMsg); + Assert.Contains("Actions Debug Console", welcomeMsg); + Assert.Contains("help", welcomeMsg); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageShowsCustomMessageWhenOverrideEnabled() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, + welcomeMessage: "Welcome to debugging!"); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // First: configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Second: custom welcome message + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("Welcome to debugging!", welcomeMsg); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithEmptyMessage() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, + welcomeMessage: ""); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // Read configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Send threads request — if welcome message was suppressed, this + // should be the next response (no output event in between) + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithNullMessage() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, + welcomeMessage: null); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // Read configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Send threads request — if welcome message was suppressed, this + // should be the next response (no output event in between) + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSentOnlyOnce() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + // First configurationDone + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Welcome message should appear + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("Actions Debug Console", welcomeMsg); + + // Second configurationDone — should NOT produce another welcome message + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "configurationDone" + }); + + var secondResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", secondResponse); + + // Next message should be threads response, not another welcome output + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } } } diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs index 687d2093a02..e70c615fc94 100644 --- a/src/Test/L0/Worker/DapReplExecutorL0.cs +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -5,9 +5,12 @@ using System.Threading.Tasks; using GitHub.DistributedTask.Expressions2; using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Tests; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; using GitHub.Runner.Worker.Dap; +using GitHub.Runner.Worker.Handlers; using Moq; using Xunit; @@ -40,7 +43,8 @@ private TestHostContext CreateTestContext([CallerMemberName] string testName = " private Mock CreateMockContext( DictionaryContextData exprValues = null, - IDictionary> jobDefaults = null) + IDictionary> jobDefaults = null, + ContainerInfo container = null) { var mock = new Mock(); mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData()); @@ -51,6 +55,7 @@ private Mock CreateMockContext( PrependPath = new List(), JobDefaults = jobDefaults ?? new Dictionary>(StringComparer.OrdinalIgnoreCase), + Container = container, }; mock.Setup(x => x.Global).Returns(global); @@ -65,7 +70,7 @@ public async Task ExecuteRunCommand_NullContext_ReturnsError() using (CreateTestContext()) { var command = new RunCommand { Script = "echo hello" }; - var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None); + var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None); Assert.Equal("error", result.Type); Assert.Contains("No execution context available", result.Result); @@ -233,5 +238,101 @@ public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly() Assert.False(result.ContainsKey("BAZ")); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CreateStepHost_NoContainer_ReturnsDefaultStepHost() + { + using (var hc = CreateTestContext()) + { + hc.EnqueueInstance(new DefaultStepHost()); + var context = CreateMockContext(); + var result = _executor.CreateStepHost(context.Object, isActionStep: true); + + Assert.IsType(result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CreateStepHost_WithContainer_ActionStep_ReturnsContainerStepHost() + { + using (var hc = CreateTestContext()) + { + hc.EnqueueInstance(new ContainerStepHost()); + var container = new ContainerInfo { ContainerId = "abc123" }; + var context = CreateMockContext(container: container); + var result = _executor.CreateStepHost(context.Object, isActionStep: true); + + Assert.IsType(result); + var containerHost = (ContainerStepHost)result; + Assert.Same(container, containerHost.Container); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CreateStepHost_WithContainer_InfrastructureStep_ReturnsDefaultStepHost() + { + using (var hc = CreateTestContext()) + { + hc.EnqueueInstance(new DefaultStepHost()); + var container = new ContainerInfo { ContainerId = "abc123" }; + var context = CreateMockContext(container: container); + var result = _executor.CreateStepHost(context.Object, isActionStep: false); + + Assert.IsType(result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CreateStepHost_ContainerWithoutId_NoHooks_ReturnsDefaultStepHost() + { + using (var hc = CreateTestContext()) + { + hc.EnqueueInstance(new DefaultStepHost()); + // Container exists but hasn't been started yet (no ContainerId) + var container = new ContainerInfo(); + var context = CreateMockContext(container: container); + var result = _executor.CreateStepHost(context.Object, isActionStep: true); + + Assert.IsType(result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CreateStepHost_ContainerWithoutId_HooksEnabled_ReturnsContainerStepHost() + { + using (var hc = CreateTestContext()) + { + hc.EnqueueInstance(new ContainerStepHost()); + // Container hooks need both the feature flag and the env var + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", "/some/hook/path"); + try + { + var container = new ContainerInfo(); + var context = CreateMockContext(container: container); + context.Object.Global.Variables = new Variables( + hc, + new Dictionary + { + { Constants.Runner.Features.AllowRunnerContainerHooks, new VariableValue("true") } + }); + var result = _executor.CreateStepHost(context.Object, isActionStep: true); + Assert.IsAssignableFrom(result); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", null); + } + } + } } } diff --git a/src/dev.sh b/src/dev.sh index fc732597243..fafdbffb360 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout" DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x" PACKAGE_DIR="$SCRIPT_DIR/../_package" DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk" -DOTNETSDK_VERSION="8.0.420" +DOTNETSDK_VERSION="8.0.421" DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION" RUNNER_VERSION=$(cat runnerversion) diff --git a/src/global.json b/src/global.json index 209f8518853..12e63e7d30c 100644 --- a/src/global.json +++ b/src/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.420" + "version": "8.0.421" } }