mirror of
https://github.com/actions/runner.git
synced 2026-07-05 20:38:40 +08:00
Compare commits
5 Commits
dependabot
...
dap-yaml-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ec6749d4b | ||
|
|
699901f072 | ||
|
|
ae2896c551 | ||
|
|
ebf33710e8 | ||
|
|
a1ccd22030 |
@@ -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
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ namespace GitHub.Runner.Common
|
||||
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
|
||||
|
||||
@@ -63,6 +63,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
Trace.Info("Sent initialized event");
|
||||
}
|
||||
|
||||
if (request.Command == "configurationDone")
|
||||
{
|
||||
SendWelcomeMessage();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -508,6 +514,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
}
|
||||
|
||||
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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
// 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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a console output banner telling the user whether REPL
|
||||
/// commands will execute on the host or inside the job container.
|
||||
/// </summary>
|
||||
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))
|
||||
|
||||
@@ -9,6 +9,7 @@ using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
||||
RunCommand command,
|
||||
IExecutionContext context,
|
||||
bool isActionStep,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context == null)
|
||||
@@ -52,7 +54,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
try
|
||||
{
|
||||
return await ExecuteScriptAsync(command, context, cancellationToken);
|
||||
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -65,9 +67,17 @@ namespace GitHub.Runner.Worker.Dap
|
||||
private async Task<EvaluateResponseBody> 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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
: 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<IProcessInvoker>())
|
||||
// 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))
|
||||
{
|
||||
processInvoker.OutputDataReceived += (sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
|
||||
SendOutput("stdout", masked + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
processInvoker.ErrorDataReceived += (sender, args) =>
|
||||
{
|
||||
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);
|
||||
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) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
|
||||
SendOutput("stdout", masked + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
stepHost.ErrorDataReceived += (sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
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 @@ namespace GitHub.Runner.Worker.Dap
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the appropriate <see cref="IStepHost"/> for the current
|
||||
/// execution context, mirroring how <see cref="ActionRunner"/> 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.
|
||||
/// </summary>
|
||||
internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep)
|
||||
{
|
||||
if (!isActionStep)
|
||||
{
|
||||
_trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)");
|
||||
return _hostContext.CreateService<IDefaultStepHost>();
|
||||
}
|
||||
|
||||
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<IContainerStepHost>();
|
||||
containerStepHost.Container = container;
|
||||
return containerStepHost;
|
||||
}
|
||||
}
|
||||
|
||||
_trace.Info("Creating DefaultStepHost for REPL execution");
|
||||
return _hostContext.CreateService<IDefaultStepHost>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands <c>${{ }}</c> expressions in the input string using the
|
||||
/// runner's template evaluator — the same evaluation path that processes
|
||||
|
||||
@@ -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
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Whether the debugger is enabled for this job.</summary>
|
||||
@@ -23,6 +25,19 @@ namespace GitHub.Runner.Worker.Dap
|
||||
/// </summary>
|
||||
public DebuggerTunnelInfo Tunnel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the runner overrides the default welcome message with
|
||||
/// <see cref="WelcomeMessage"/>. A null or empty <see cref="WelcomeMessage"/>
|
||||
/// suppresses the message entirely. When false, the default help text is shown.
|
||||
/// </summary>
|
||||
public bool OverrideWelcomeMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional welcome message content for the debugger console. Only used when
|
||||
/// <see cref="OverrideWelcomeMessage"/> is true.
|
||||
/// </summary>
|
||||
public string WelcomeMessage { get; }
|
||||
|
||||
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
|
||||
public bool HasValidTunnel => Tunnel != null
|
||||
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
|
||||
|
||||
63
src/Runner.Worker/Dap/YamlScalarFormatter.cs
Normal file
63
src/Runner.Worker/Dap/YamlScalarFormatter.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using GitHub.Runner.Sdk;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Core.Events;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats a single string as a quote-safe YAML scalar by routing it
|
||||
/// through YamlDotNet's <see cref="Emitter"/>. The returned text is
|
||||
/// safe to splice into a hand-emitted YAML document fragment.
|
||||
///
|
||||
/// Caller responsibility: this only handles the scalar value; it does
|
||||
/// not emit a key, indent, or trailing newline.
|
||||
/// </summary>
|
||||
internal static class YamlScalarFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Return <paramref name="value"/> formatted as a YAML scalar:
|
||||
/// plain, single-quoted, or double-quoted as the emitter chooses,
|
||||
/// with no surrounding document markers or trailing newline.
|
||||
/// </summary>
|
||||
public static string Format(string value)
|
||||
{
|
||||
ArgUtil.NotNull(value, nameof(value));
|
||||
|
||||
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||
// Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
|
||||
// which would otherwise produce CRLF on Windows and break
|
||||
// both our document-end stripping below and downstream
|
||||
// consumers that assume a single line-break convention.
|
||||
sw.NewLine = "\n";
|
||||
var emitter = new Emitter(sw);
|
||||
emitter.Emit(new StreamStart());
|
||||
emitter.Emit(new DocumentStart(null, null, true));
|
||||
emitter.Emit(new Scalar(null, null, value, ScalarStyle.Any, true, true));
|
||||
emitter.Emit(new DocumentEnd(true));
|
||||
emitter.Emit(new StreamEnd());
|
||||
|
||||
string raw = sw.ToString();
|
||||
// Strip YAML document markers. Emitter elides these for most
|
||||
// scalars but emits "--- " (with space) for some edge cases
|
||||
// (e.g. empty strings). Defensively handle "---\n" too.
|
||||
if (raw.StartsWith("--- ", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(4);
|
||||
}
|
||||
else if (raw.StartsWith("---\n", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(4);
|
||||
}
|
||||
raw = raw.TrimEnd('\n');
|
||||
const string DocEndMarker = "\n...";
|
||||
if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -970,7 +970,8 @@ namespace GitHub.Runner.Worker
|
||||
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;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.8" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
|
||||
|
||||
@@ -267,6 +267,21 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional welcome message shown in the debugger console when a client connects.
|
||||
/// Only used when the <c>actions_runner_override_debugger_welcome_message</c>
|
||||
/// feature flag is set to <c>true</c> 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.
|
||||
/// </summary>
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string DebuggerWelcomeMessage
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow-level action dependencies (lockfile entries)
|
||||
/// </summary>
|
||||
|
||||
@@ -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 sealed class AgentJobRequestMessageL0
|
||||
// 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 sealed class AgentJobRequestMessageL0
|
||||
// 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 sealed class AgentJobRequestMessageL0
|
||||
// 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 sealed class AgentJobRequestMessageL0
|
||||
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('\'', '"');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
@@ -236,7 +236,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null)
|
||||
private static Mock<IExecutionContext> 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 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
HostToken = "test-token",
|
||||
Port = port
|
||||
};
|
||||
var debuggerConfig = new DebuggerConfig(true, tunnel);
|
||||
var debuggerConfig = new DebuggerConfig(true, tunnel, overrideWelcomeMessage, welcomeMessage);
|
||||
var jobContext = new Mock<IExecutionContext>();
|
||||
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
|
||||
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
|
||||
@@ -742,6 +742,8 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
// 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 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
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 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ using System.Threading;
|
||||
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 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
private Mock<IExecutionContext> CreateMockContext(
|
||||
DictionaryContextData exprValues = null,
|
||||
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
|
||||
IDictionary<string, IDictionary<string, string>> jobDefaults = null,
|
||||
ContainerInfo container = null)
|
||||
{
|
||||
var mock = new Mock<IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
|
||||
@@ -51,6 +55,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
PrependPath = new List<string>(),
|
||||
JobDefaults = jobDefaults
|
||||
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
|
||||
Container = container,
|
||||
};
|
||||
mock.Setup(x => x.Global).Returns(global);
|
||||
|
||||
@@ -65,7 +70,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
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 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Assert.False(result.ContainsKey("BAZ"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void CreateStepHost_NoContainer_ReturnsDefaultStepHost()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
|
||||
|
||||
Assert.IsType<DefaultStepHost>(result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void CreateStepHost_WithContainer_ActionStep_ReturnsContainerStepHost()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.EnqueueInstance<IContainerStepHost>(new ContainerStepHost());
|
||||
var container = new ContainerInfo { ContainerId = "abc123" };
|
||||
var context = CreateMockContext(container: container);
|
||||
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
|
||||
|
||||
Assert.IsType<ContainerStepHost>(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<IDefaultStepHost>(new DefaultStepHost());
|
||||
var container = new ContainerInfo { ContainerId = "abc123" };
|
||||
var context = CreateMockContext(container: container);
|
||||
var result = _executor.CreateStepHost(context.Object, isActionStep: false);
|
||||
|
||||
Assert.IsType<DefaultStepHost>(result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void CreateStepHost_ContainerWithoutId_NoHooks_ReturnsDefaultStepHost()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.EnqueueInstance<IDefaultStepHost>(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<DefaultStepHost>(result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void CreateStepHost_ContainerWithoutId_HooksEnabled_ReturnsContainerStepHost()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.EnqueueInstance<IContainerStepHost>(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<string, VariableValue>
|
||||
{
|
||||
{ Constants.Runner.Features.AllowRunnerContainerHooks, new VariableValue("true") }
|
||||
});
|
||||
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
|
||||
Assert.IsAssignableFrom<IContainerStepHost>(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
src/Test/L0/Worker/YamlScalarFormatterL0.cs
Normal file
119
src/Test/L0/Worker/YamlScalarFormatterL0.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Xunit;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class YamlScalarFormatterL0
|
||||
{
|
||||
private static readonly IDeserializer Deserializer = new DeserializerBuilder().Build();
|
||||
|
||||
// Embed the formatter output inside a minimal YAML mapping and
|
||||
// round-trip through YamlDotNet, asserting the parsed value equals
|
||||
// the original input. Decouples assertions from the emitter's
|
||||
// quoting choices (plain vs single- vs double-quoted).
|
||||
private static void AssertRoundTrips(string value)
|
||||
{
|
||||
string scalar = YamlScalarFormatter.Format(value);
|
||||
string yaml = $"k: {scalar}\n";
|
||||
|
||||
Dictionary<string, object> doc;
|
||||
try
|
||||
{
|
||||
doc = Deserializer.Deserialize<Dictionary<string, object>>(yaml);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"Formatted scalar did not round-trip as valid YAML.\nInput: '{value}'\nFormatted: '{scalar}'\nFull YAML:\n{yaml}\nError: {ex}");
|
||||
}
|
||||
Assert.NotNull(doc);
|
||||
Assert.True(doc.ContainsKey("k"), $"missing key in parsed doc. Formatted: '{scalar}'");
|
||||
Assert.Equal(value, doc["k"] as string);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData("hello")]
|
||||
[InlineData("with: colon")]
|
||||
[InlineData("with#hash")]
|
||||
[InlineData(" leading")]
|
||||
[InlineData("trailing ")]
|
||||
[InlineData("a\"b")]
|
||||
[InlineData("a\\b")]
|
||||
[InlineData("@at")]
|
||||
[InlineData("*star")]
|
||||
[InlineData("&")]
|
||||
[InlineData("?question")]
|
||||
[InlineData("!exclaim")]
|
||||
[InlineData("- dash")]
|
||||
[InlineData("{brace}")]
|
||||
[InlineData("[bracket]")]
|
||||
public void Format_RoundTripsThroughYamlDeserializer(string value)
|
||||
{
|
||||
// The formatter must produce output that, embedded under a key,
|
||||
// parses back to exactly the input. The emitter is free to
|
||||
// pick plain, single-quoted, or double-quoted style.
|
||||
AssertRoundTrips(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Format_PlainAscii_NoQuotingNeeded()
|
||||
{
|
||||
// Sanity check that the simple case stays plain.
|
||||
Assert.Equal("hello", YamlScalarFormatter.Format("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Format_NoTrailingNewline()
|
||||
{
|
||||
Assert.False(YamlScalarFormatter.Format("hello").EndsWith("\n"));
|
||||
Assert.False(YamlScalarFormatter.Format("with: colon").EndsWith("\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Format_NoDocumentMarkers()
|
||||
{
|
||||
// The emitter wraps the scalar in a document; the formatter
|
||||
// must strip both `--- ` (with space) and `---\n` (on its
|
||||
// own line) prefixes plus the `\n...` suffix.
|
||||
Assert.DoesNotContain("---", YamlScalarFormatter.Format("hello"));
|
||||
Assert.DoesNotContain("...", YamlScalarFormatter.Format("hello"));
|
||||
// Empty string is one of the cases where the emitter does
|
||||
// produce a document marker by default.
|
||||
Assert.DoesNotContain("---", YamlScalarFormatter.Format(""));
|
||||
Assert.DoesNotContain("...", YamlScalarFormatter.Format(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Format_AlwaysUsesLfLineBreaks()
|
||||
{
|
||||
// Regression: YamlDotNet's Emitter calls WriteLine, which on
|
||||
// Windows produces CRLF (the host's Environment.NewLine).
|
||||
// Format must force LF so the output round-trips regardless
|
||||
// of platform.
|
||||
Assert.DoesNotContain('\r', YamlScalarFormatter.Format("hello"));
|
||||
Assert.DoesNotContain('\r', YamlScalarFormatter.Format("with: colon"));
|
||||
Assert.DoesNotContain('\r', YamlScalarFormatter.Format(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Format_NullValue_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => YamlScalarFormatter.Format(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user