Compare commits

...

8 Commits

Author SHA1 Message Date
Francesco Renzi
c23ac2969d Add JobExecutionViewRenderer for DAP execution view
The DAP debugger serves a synthesized YAML document as the job's
`source`. That document is a 1:1 representation of how the runner
sees the job — not the workflow file — so pre and post action steps
appear as their own 'lines' that the user can pause on (and
eventually breakpoint, set in a follow-up PR).

This commit adds the core rendering algorithm: given a list of
phase-tagged entries (`JobExecutionViewEntry`), produce the
phase-keyed YAML plus a parallel array of 1-based line numbers
pointing at each entry's `- step:` key. The line numbers are what
later powers the DAP `stackTrace` handler.

Why hand-emit the skeleton instead of serializing a DTO?
Per-entry line offsets must be tracked at emission time. Using a
generic YAML serializer would force a second pass to scan the
output for `- step:` lines, which is fragile and breaks the moment
indentation conventions shift. Scalar values still go through the
library (via YamlScalarFormatter from #PR1a), so we don't carry
quoting rules.

Example output for a typical job (build, build, post step):

    # Job: build
    # Runner execution plan — read-only.

    setup:
      - step: Setup job

    main:
      - step: Run actions/checkout@v6
        uses: actions/checkout@v6
        if: success()
      - step: Cache Primes
        id: cache-primes
        uses: actions/cache@v5
        if: success()
        with:
          path: prime-numbers
          key: ${{ runner.os }}-primes

    post:
      - step: Post Cache Primes
        action: actions/cache@v5

    cleanup:
      - step: Complete job

This is part 2 of 5 splitting the previously-monolithic foundation
for review tractability. The wiring that turns runner state into
these entries lives in the next PRs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
1ec6749d4b Address Copilot review feedback
- Remove the redundant second `TrimEnd('\n')` from the return path.
  The earlier trim already removes any trailing newline before the
  `\n...` doc-end check; the marker-removal substring does not
  re-introduce one, so the second trim was dead code.
- Surface full exception (`ex.ToString()`) in the test round-trip
  helper so YAML parse failures show stack + inner exception, not
  just the top-level message.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
699901f072 Add YamlScalarFormatter for quote-safe YAML scalars
The upcoming DAP execution-view renderer serves a synthesized YAML
document as the job's debugger source. The skeleton is hand-emitted
so we can track per-step line offsets, but scalar values (step names,
action refs, etc.) need quote-safe formatting that respects YAML's
reserved chars, leading/trailing whitespace, and embedded `: `/`#`
sequences. Doing this by hand is bug-prone and easy to get wrong on
edge cases (empty strings, expressions, multiline content).

This commit adds a thin wrapper around YamlDotNet's `Emitter` that
emits a single scalar, strips the surrounding document markers, and
forces LF line breaks (`StringWriter` otherwise picks up Windows's
CRLF via `Environment.NewLine` and corrupts the document-end
stripping).

No caller yet — the renderer that uses it lands in a follow-up PR.
This is part 1 of 5 splitting the previously-monolithic foundation
for review tractability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
ae2896c551 Send welcome message in debugger console on connect (#4419)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 16:14:37 +00:00
Francesco Renzi
ebf33710e8 Execute debugger REPL commands inside job container (#4420)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 16:07:25 +00:00
github-actions[bot]
a1ccd22030 Update Docker to v29.5.0 and Buildx to v0.34.0 (#4425)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 09:57:42 -04:00
github-actions[bot]
b549247bee Update dotnet sdk to latest version @8.0.421 (#4428)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 13:31:42 +00:00
Daniel Valdivia
d36839b001 Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) (#4394)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:51:46 -04:00
19 changed files with 1717 additions and 67 deletions

View File

@@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.420"
"version": "8.0.421"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"

View File

@@ -25,11 +25,11 @@ The `installdependencies.sh` script should install all required dependencies on
Debian based OS (Debian, Ubuntu, Linux Mint)
- liblttng-ust1 or liblttng-ust0
- liblttng-ust1t64, liblttng-ust1 or liblttng-ust0
- libkrb5-3
- zlib1g
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
- libicu80, libicu79, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)

View File

@@ -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

View File

@@ -94,7 +94,7 @@ then
fi
}
apt_get_with_fallbacks liblttng-ust1 liblttng-ust0
apt_get_with_fallbacks liblttng-ust1t64 liblttng-ust1 liblttng-ust0
if [ $? -ne 0 ]
then
echo "'$apt_get' failed with exit code '$?'"
@@ -110,7 +110,7 @@ then
exit 1
fi
apt_get_with_fallbacks libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
apt_get_with_fallbacks libicu80 libicu79 libicu78 libicu77 libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
if [ $? -ne 0 ]
then
echo "'$apt_get' failed with exit code '$?'"

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,336 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Phase a step occupies in the runner's flat execution sequence.
/// Setup and Cleanup are NOT modeled here — they are synthetic
/// boundaries hard-coded by <see cref="JobExecutionViewRenderer"/>
/// and cannot be constructed by callers.
/// </summary>
internal enum JobExecutionPhase
{
Pre,
Main,
Post,
}
/// <summary>
/// One step in the rendered execution view. Pure data; no link to
/// any worker type. Phase 2 will translate runner step objects
/// into instances of this record.
/// </summary>
internal sealed class JobExecutionViewEntry
{
public JobExecutionViewEntry(
JobExecutionPhase phase,
string displayName,
string uses = null,
string run = null,
string sourcePath = null,
int sourceLine = 0,
string id = null,
string @if = null,
string continueOnError = null,
string timeoutMinutes = null,
string envYaml = null,
string withYaml = null,
string shell = null,
string workingDirectory = null)
{
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentException("displayName must not be null or whitespace.", nameof(displayName));
}
if (sourcePath != null && sourceLine < 1)
{
throw new ArgumentException(
"sourceLine must be >= 1 when sourcePath is provided.",
nameof(sourceLine));
}
Phase = phase;
DisplayName = displayName;
Uses = uses;
Run = run;
SourcePath = sourcePath;
SourceLine = sourceLine;
Id = id;
If = @if;
ContinueOnError = continueOnError;
TimeoutMinutes = timeoutMinutes;
EnvYaml = envYaml;
WithYaml = withYaml;
Shell = shell;
WorkingDirectory = workingDirectory;
}
public JobExecutionPhase Phase { get; }
public string DisplayName { get; }
public string Uses { get; }
public string Run { get; }
public string SourcePath { get; }
public int SourceLine { get; }
public string Id { get; }
public string If { get; }
public string ContinueOnError { get; }
public string TimeoutMinutes { get; }
// Pre-serialized YAML fragment, already indented for embedding
// under the entry's `env:` key (6-space child indent).
public string EnvYaml { get; }
public string WithYaml { get; }
public string Shell { get; }
public string WorkingDirectory { get; }
}
/// <summary>
/// Output of <see cref="JobExecutionViewRenderer.Render"/>: the YAML
/// document plus a parallel array of 1-based line numbers, one per
/// input entry, where each entry's <c>- step:</c> key appears.
/// Synthetic Setup/Cleanup boundaries are not tracked here.
/// </summary>
internal readonly struct RenderResult
{
public RenderResult(string yaml, IReadOnlyList<int> entryStartLines)
{
Yaml = yaml;
EntryStartLines = entryStartLines;
}
public string Yaml { get; }
public IReadOnlyList<int> EntryStartLines { get; }
}
/// <summary>
/// Renders a job's execution-view YAML. Pure function; no I/O,
/// no logging, no static state. Output format and Setup/Cleanup
/// boundaries are fixed; callers cannot influence them.
///
/// Output is structured as phase-keyed top-level sections:
/// <c>setup:</c>, <c>pre:</c>, <c>main:</c>, <c>post:</c>, <c>cleanup:</c>.
/// <c>setup:</c> and <c>cleanup:</c> always render; <c>pre:</c>,
/// <c>main:</c>, <c>post:</c> only render when they contain at least
/// one entry.
/// </summary>
internal static class JobExecutionViewRenderer
{
public static RenderResult Render(string jobId, IReadOnlyList<JobExecutionViewEntry> entries)
{
if (string.IsNullOrWhiteSpace(jobId))
{
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
}
ArgUtil.NotNull(entries, nameof(entries));
// Pre-validate non-null entries before any output, so partial
// state is never observed by callers.
for (int i = 0; i < entries.Count; i++)
{
if (entries[i] == null)
{
throw new ArgumentException($"entries[{i}] is null.", nameof(entries));
}
}
var sb = new StringBuilder();
var startLines = new int[entries.Count];
int newlinesEmitted = 0;
// Header (3 lines).
sb.Append("# Job: ").Append(YamlScalarFormatter.Format(jobId)).Append('\n');
sb.Append("# Runner execution plan — read-only.\n");
sb.Append('\n');
newlinesEmitted += 3;
// setup: section — always present.
sb.Append("setup:\n");
sb.Append(" - step: Setup job\n");
newlinesEmitted += 2;
// Render phase sections in fixed order. Each emits a leading
// blank line separator before its header.
EmitPhaseSection(sb, "pre", JobExecutionPhase.Pre, entries, startLines, ref newlinesEmitted);
EmitPhaseSection(sb, "main", JobExecutionPhase.Main, entries, startLines, ref newlinesEmitted);
EmitPhaseSection(sb, "post", JobExecutionPhase.Post, entries, startLines, ref newlinesEmitted);
// cleanup: section — always present, preceded by a blank line.
sb.Append('\n');
sb.Append("cleanup:\n");
sb.Append(" - step: Complete job\n");
return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines));
}
private static void EmitPhaseSection(
StringBuilder sb,
string sectionName,
JobExecutionPhase phase,
IReadOnlyList<JobExecutionViewEntry> entries,
int[] startLines,
ref int newlinesEmitted)
{
// Skip the section entirely if no entries belong to this phase.
bool any = false;
for (int i = 0; i < entries.Count; i++)
{
if (entries[i].Phase == phase) { any = true; break; }
}
if (!any)
{
return;
}
// Blank line separator + section header.
sb.Append('\n');
sb.Append(sectionName).Append(":\n");
newlinesEmitted += 2;
for (int i = 0; i < entries.Count; i++)
{
var entry = entries[i];
if (entry.Phase != phase)
{
continue;
}
// 1-based line of the `- step:` key for this entry.
startLines[i] = newlinesEmitted + 1;
sb.Append(" - step: ").Append(YamlScalarFormatter.Format(entry.DisplayName));
sb.Append('\n');
newlinesEmitted++;
switch (phase)
{
case JobExecutionPhase.Pre:
case JobExecutionPhase.Post:
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" action: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
newlinesEmitted++;
}
// No source: annotation for pre/post.
break;
case JobExecutionPhase.Main:
if (!string.IsNullOrEmpty(entry.Id))
{
sb.Append(" id: ").Append(YamlScalarFormatter.Format(entry.Id)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" uses: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Run))
{
if (entry.Run.IndexOf('\n') < 0)
{
sb.Append(" run: ").Append(YamlScalarFormatter.Format(entry.Run)).Append('\n');
newlinesEmitted++;
}
else
{
sb.Append(" run: |\n");
newlinesEmitted++;
newlinesEmitted += AppendIndentedBlock(sb, entry.Run, " ");
}
}
if (!string.IsNullOrEmpty(entry.If))
{
sb.Append(" if: ").Append(YamlScalarFormatter.Format(entry.If)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.ContinueOnError))
{
sb.Append(" continue-on-error: ").Append(entry.ContinueOnError).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.TimeoutMinutes))
{
sb.Append(" timeout-minutes: ").Append(entry.TimeoutMinutes).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.EnvYaml))
{
sb.Append(" env:\n");
newlinesEmitted++;
sb.Append(entry.EnvYaml).Append('\n');
newlinesEmitted += CountChar(entry.EnvYaml, '\n') + 1;
}
if (!string.IsNullOrEmpty(entry.WithYaml))
{
sb.Append(" with:\n");
newlinesEmitted++;
sb.Append(entry.WithYaml).Append('\n');
newlinesEmitted += CountChar(entry.WithYaml, '\n') + 1;
}
if (!string.IsNullOrEmpty(entry.Shell))
{
sb.Append(" shell: ").Append(YamlScalarFormatter.Format(entry.Shell)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.WorkingDirectory))
{
sb.Append(" working-directory: ").Append(YamlScalarFormatter.Format(entry.WorkingDirectory)).Append('\n');
newlinesEmitted++;
}
if (entry.SourcePath != null)
{
sb.Append(" source: ")
.Append(entry.SourcePath)
.Append(':')
.Append(entry.SourceLine.ToString(CultureInfo.InvariantCulture))
.Append('\n');
newlinesEmitted++;
}
break;
}
}
}
private static int AppendIndentedBlock(StringBuilder sb, string text, string indent)
{
int newlines = 0;
int i = 0;
while (i < text.Length)
{
int end = text.IndexOf('\n', i);
int lineEnd = end < 0 ? text.Length : end;
int trimEnd = lineEnd;
if (trimEnd > i && text[trimEnd - 1] == '\r')
{
trimEnd--;
}
if (trimEnd > i)
{
sb.Append(indent);
sb.Append(text, i, trimEnd - i);
}
sb.Append('\n');
newlines++;
if (end < 0)
{
break;
}
i = end + 1;
}
return newlines;
}
private static int CountChar(string s, char c)
{
int n = 0;
for (int i = 0; i < s.Length; i++)
{
if (s[i] == c) n++;
}
return n;
}
}
}

View 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;
}
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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('\'', '"');

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -0,0 +1,598 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class JobExecutionViewRendererL0
{
// Verbatim expected YAML for the design doc's "Worked example".
// The render output is structured as phase-keyed top-level sections;
// there is no per-entry `phase:` field. The setup: and cleanup:
// sections always render; pre:/main:/post: render only when
// they contain at least one entry. The Main entries surface
// user-authored step parameters pre-evaluation (no expression
// substitution); Pre/Post entries stay minimal.
private const string ExpectedWorkedExampleYaml =
"# Job: build\n" +
"# Runner execution plan — read-only.\n" +
"\n" +
"setup:\n" +
" - step: Setup job\n" +
"\n" +
"pre:\n" +
" - step: Pre actions/checkout@v4\n" +
" action: actions/checkout@v4\n" +
" - step: Pre actions/cache@v5\n" +
" action: actions/cache@v5\n" +
"\n" +
"main:\n" +
" - step: actions/checkout@v4\n" +
" uses: actions/checkout@v4\n" +
" source: .github/workflows/ci.yml:10\n" +
" - step: Cache Primes\n" +
" id: cache-primes\n" +
" uses: actions/cache@v5\n" +
" with:\n" +
" path: prime-numbers\n" +
" key: ${{ runner.os }}-primes\n" +
" source: .github/workflows/ci.yml:12\n" +
" - step: Run tests\n" +
" id: test\n" +
" run: |\n" +
" echo starting\n" +
" npm test\n" +
" if: ${{ github.event_name == 'push' }}\n" +
" env:\n" +
" NODE_ENV: production\n" +
" shell: bash\n" +
" working-directory: ./api\n" +
" source: .github/workflows/ci.yml:18\n" +
" - step: npm ci\n" +
" run: npm ci\n" +
" source: .github/workflows/ci.yml:28\n" +
"\n" +
"post:\n" +
" - step: Post actions/cache@v5\n" +
" action: actions/cache@v5\n" +
" - step: Post actions/checkout@v4\n" +
" action: actions/checkout@v4\n" +
"\n" +
"cleanup:\n" +
" - step: Complete job\n";
private static List<JobExecutionViewEntry> WorkedExampleEntries()
{
return new List<JobExecutionViewEntry>
{
new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/checkout@v4", uses: "actions/checkout@v4"),
new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/cache@v5", uses: "actions/cache@v5"),
new JobExecutionViewEntry(JobExecutionPhase.Main, "actions/checkout@v4", uses: "actions/checkout@v4", sourcePath: ".github/workflows/ci.yml", sourceLine: 10),
new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Cache Primes",
uses: "actions/cache@v5",
id: "cache-primes",
withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 12),
new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Run tests",
run: "echo starting\nnpm test",
id: "test",
@if: "${{ github.event_name == 'push' }}",
envYaml: " NODE_ENV: production",
shell: "bash",
workingDirectory: "./api",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 18),
new JobExecutionViewEntry(JobExecutionPhase.Main, "npm ci", run: "npm ci", sourcePath: ".github/workflows/ci.yml", sourceLine: 28),
new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/cache@v5", uses: "actions/cache@v5"),
new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/checkout@v4", uses: "actions/checkout@v4"),
};
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_MatchesDesignDocWorkedExample()
{
var entries = WorkedExampleEntries();
var result = JobExecutionViewRenderer.Render("build", entries);
Assert.Equal(ExpectedWorkedExampleYaml, result.Yaml);
Assert.Equal(8, result.EntryStartLines.Count);
var lines = result.Yaml.Split('\n');
for (int i = 0; i < entries.Count; i++)
{
Assert.StartsWith(" - step: ", lines[result.EntryStartLines[i] - 1]);
Assert.Contains(entries[i].DisplayName, lines[result.EntryStartLines[i] - 1]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_AlwaysEmitsSetupAndCleanup()
{
var result = JobExecutionViewRenderer.Render("job-1", new List<JobExecutionViewEntry>());
const string expected =
"# Job: job-1\n" +
"# Runner execution plan — read-only.\n" +
"\n" +
"setup:\n" +
" - step: Setup job\n" +
"\n" +
"cleanup:\n" +
" - step: Complete job\n";
Assert.Equal(expected, result.Yaml);
Assert.Empty(result.EntryStartLines);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_OmitsEmptyOptionalSections()
{
// Only a Main entry — pre:/post: must not appear.
var result = JobExecutionViewRenderer.Render("j", new[]
{
new JobExecutionViewEntry(JobExecutionPhase.Main, "echo", run: "echo hello"),
});
Assert.Contains("setup:\n", result.Yaml);
Assert.Contains("main:\n", result.Yaml);
Assert.Contains("cleanup:\n", result.Yaml);
Assert.DoesNotContain("\npre:\n", result.Yaml);
Assert.DoesNotContain("\npost:\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsPhaseSectionsInFixedOrder()
{
// Input order [Post, Pre, Main] should still render as setup → pre → main → post → cleanup.
var entries = new[]
{
new JobExecutionViewEntry(JobExecutionPhase.Post, "post-a", uses: "a/b@v1"),
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-a", uses: "a/b@v1"),
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a/b@v1"),
};
var result = JobExecutionViewRenderer.Render("j", entries);
string yaml = result.Yaml;
int setupIdx = yaml.IndexOf("setup:\n", StringComparison.Ordinal);
int preIdx = yaml.IndexOf("\npre:\n", StringComparison.Ordinal);
int mainIdx = yaml.IndexOf("\nmain:\n", StringComparison.Ordinal);
int postIdx = yaml.IndexOf("\npost:\n", StringComparison.Ordinal);
int cleanupIdx = yaml.IndexOf("\ncleanup:\n", StringComparison.Ordinal);
Assert.True(setupIdx >= 0 && preIdx > setupIdx && mainIdx > preIdx && postIdx > mainIdx && cleanupIdx > postIdx,
$"section ordering wrong: setup={setupIdx} pre={preIdx} main={mainIdx} post={postIdx} cleanup={cleanupIdx}");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_StartLinesAlignWithInputOrder()
{
// Input order is [Pre, Main, Post]; output order is also pre/main/post,
// but startLines must be indexed by INPUT position, not by section.
var entries = new[]
{
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-x", uses: "x/y@v1"), // index 0
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-x", uses: "x/y@v1"), // index 1
new JobExecutionViewEntry(JobExecutionPhase.Post, "post-x", uses: "x/y@v1"), // index 2
};
var result = JobExecutionViewRenderer.Render("j", entries);
var lines = result.Yaml.Split('\n');
Assert.StartsWith(" - step: pre-x", lines[result.EntryStartLines[0] - 1]);
Assert.StartsWith(" - step: main-x", lines[result.EntryStartLines[1] - 1]);
Assert.StartsWith(" - step: post-x", lines[result.EntryStartLines[2] - 1]);
// And input-order ordering of start lines is strictly increasing
// when phases are in declaration order matching the section order.
Assert.True(result.EntryStartLines[0] < result.EntryStartLines[1]);
Assert.True(result.EntryStartLines[1] < result.EntryStartLines[2]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_StartLinesFollowInputOrderEvenWhenPhasesAreInterleaved()
{
// Input order is [Main A, Pre B, Main C]: pre section will render
// first (Pre B) and main second (Main A then Main C). startLines
// must still be indexed by input order.
var entries = new[]
{
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a@v1"), // index 0 — renders in main section
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-b", uses: "b@v1"), // index 1 — renders in pre section
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-c", uses: "c@v1"), // index 2 — renders in main section
};
var result = JobExecutionViewRenderer.Render("j", entries);
var lines = result.Yaml.Split('\n');
Assert.StartsWith(" - step: main-a", lines[result.EntryStartLines[0] - 1]);
Assert.StartsWith(" - step: pre-b", lines[result.EntryStartLines[1] - 1]);
Assert.StartsWith(" - step: main-c", lines[result.EntryStartLines[2] - 1]);
// The pre section comes before main: input-index-1 entry's line is
// before input-index-0 entry's line.
Assert.True(result.EntryStartLines[1] < result.EntryStartLines[0]);
Assert.True(result.EntryStartLines[0] < result.EntryStartLines[2]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EntryStartLinesPointAtStepKeys()
{
var entries = WorkedExampleEntries();
var result = JobExecutionViewRenderer.Render("build", entries);
var lines = result.Yaml.Split('\n');
for (int i = 0; i < result.EntryStartLines.Count; i++)
{
int oneBased = result.EntryStartLines[i];
Assert.True(oneBased >= 1 && oneBased <= lines.Length, $"start line {oneBased} out of range");
Assert.StartsWith(" - step: ", lines[oneBased - 1]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EntryStartLinesExcludeSetupAndCleanup()
{
var entries = WorkedExampleEntries();
var result = JobExecutionViewRenderer.Render("build", entries);
var lines = result.Yaml.Split('\n');
int setupLine = -1, cleanupLine = -1;
for (int i = 0; i < lines.Length; i++)
{
if (lines[i] == " - step: Setup job") setupLine = i + 1;
if (lines[i] == " - step: Complete job") cleanupLine = i + 1;
}
Assert.True(setupLine > 0 && cleanupLine > 0, "Setup/Cleanup lines must exist");
Assert.DoesNotContain(setupLine, result.EntryStartLines);
Assert.DoesNotContain(cleanupLine, result.EntryStartLines);
}
[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")]
public void Render_QuotesSpecialChars(string displayName)
{
// Round-trip the rendered YAML through YamlDotNet's deserializer
// and assert the parsed step's display name matches the input.
// This decouples the test from any specific quoting style.
var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, displayName);
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build();
var doc = deserializer.Deserialize<Dictionary<string, List<Dictionary<string, object>>>>(result.Yaml);
Assert.NotNull(doc);
Assert.True(doc.ContainsKey("main"), "rendered YAML missing top-level 'main' key");
var mainSteps = doc["main"];
Assert.Single(mainSteps);
Assert.Equal(displayName, mainSteps[0]["step"] as string);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsSourceAnnotationForMainStep()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"npm ci",
run: "npm ci",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 42);
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.Contains(" source: .github/workflows/ci.yml:42\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_OmitsSourceAnnotationForPreAndPost()
{
var pre = new JobExecutionViewEntry(
JobExecutionPhase.Pre,
"Pre actions/checkout@v4",
uses: "actions/checkout@v4",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 9);
var post = new JobExecutionViewEntry(
JobExecutionPhase.Post,
"Post actions/checkout@v4",
uses: "actions/checkout@v4",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 9);
var result = JobExecutionViewRenderer.Render("j", new[] { pre, post });
Assert.DoesNotContain("source:", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsMultilineRunAsBlockScalar()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"multi",
run: "echo a\necho b\necho c");
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.Contains(" run: |\n", result.Yaml);
Assert.Contains(" echo a\n", result.Yaml);
Assert.Contains(" echo b\n", result.Yaml);
Assert.Contains(" echo c\n", result.Yaml);
Assert.DoesNotContain("truncated", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsAllUserAuthoredParamsForActionStep()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Run action",
uses: "actions/cache@v5",
id: "cache-primes",
@if: "${{ github.event_name == 'push' }}",
continueOnError: "true",
timeoutMinutes: "10",
envYaml: " NODE_ENV: production",
withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes",
sourcePath: "ci.yml",
sourceLine: 5);
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.Contains(" id: cache-primes\n", result.Yaml);
Assert.Contains(" uses: actions/cache@v5\n", result.Yaml);
Assert.Contains(" continue-on-error: true\n", result.Yaml);
Assert.Contains(" timeout-minutes: 10\n", result.Yaml);
Assert.Contains(" env:\n NODE_ENV: production\n", result.Yaml);
Assert.Contains(" with:\n path: prime-numbers\n key: ${{ runner.os }}-primes\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsRunStepWithShellAndWorkingDirectory()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Run tests",
run: "echo starting\nnpm test",
id: "test",
shell: "bash",
workingDirectory: "./api");
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.Contains(" run: |\n echo starting\n npm test\n", result.Yaml);
Assert.Contains(" shell: bash\n", result.Yaml);
Assert.Contains(" working-directory: ./api\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_PreservesExpressionsInRenderedYaml()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Cache",
uses: "actions/cache@v5",
withYaml: " key: ${{ runner.os }}-primes");
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
// Expressions render exactly as authored — no evaluation.
Assert.Contains("${{ runner.os }}-primes", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_PrePostStepsRemainMinimal()
{
// Even if a pre/post entry carries user-param fields (it shouldn't
// in production, but the renderer must defensively drop them),
// only step: + action: render for these phases.
var pre = new JobExecutionViewEntry(
JobExecutionPhase.Pre,
"Pre actions/cache@v5",
uses: "actions/cache@v5",
id: "should-not-appear",
envYaml: " X: y",
withYaml: " key: nope");
var post = new JobExecutionViewEntry(
JobExecutionPhase.Post,
"Post actions/cache@v5",
uses: "actions/cache@v5",
id: "should-not-appear",
envYaml: " X: y");
var result = JobExecutionViewRenderer.Render("j", new[] { pre, post });
Assert.DoesNotContain("id:", result.Yaml);
Assert.DoesNotContain("env:", result.Yaml);
Assert.DoesNotContain("with:", result.Yaml);
Assert.DoesNotContain("should-not-appear", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_FieldOrderIsStable()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Everything",
uses: "actions/cache@v5",
id: "x",
@if: "always()",
continueOnError: "false",
timeoutMinutes: "5",
envYaml: " A: 1",
withYaml: " key: k",
sourcePath: "ci.yml",
sourceLine: 1);
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
var y = result.Yaml;
int iStep = y.IndexOf(" - step: ", StringComparison.Ordinal) >= 0
? y.IndexOf("- step:", StringComparison.Ordinal) : y.IndexOf("- step:", StringComparison.Ordinal);
int iId = y.IndexOf(" id:", StringComparison.Ordinal);
int iUses = y.IndexOf(" uses:", StringComparison.Ordinal);
int iIf = y.IndexOf(" if:", StringComparison.Ordinal);
int iCoe = y.IndexOf(" continue-on-error:", StringComparison.Ordinal);
int iTm = y.IndexOf(" timeout-minutes:", StringComparison.Ordinal);
int iEnv = y.IndexOf(" env:", StringComparison.Ordinal);
int iWith = y.IndexOf(" with:", StringComparison.Ordinal);
int iSrc = y.IndexOf(" source:", StringComparison.Ordinal);
Assert.True(iId < iUses && iUses < iIf && iIf < iCoe && iCoe < iTm && iTm < iEnv && iEnv < iWith && iWith < iSrc,
$"order wrong: id={iId} uses={iUses} if={iIf} coe={iCoe} tm={iTm} env={iEnv} with={iWith} src={iSrc}");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_OmitsEmptyOptionalFields()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"bare",
uses: "a/b@v1");
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.DoesNotContain(" id:", result.Yaml);
Assert.DoesNotContain(" if:", result.Yaml);
Assert.DoesNotContain(" continue-on-error:", result.Yaml);
Assert.DoesNotContain(" timeout-minutes:", result.Yaml);
Assert.DoesNotContain(" env:", result.Yaml);
Assert.DoesNotContain(" with:", result.Yaml);
Assert.DoesNotContain(" shell:", result.Yaml);
Assert.DoesNotContain(" working-directory:", result.Yaml);
Assert.DoesNotContain(" source:", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_HandlesEmptyEntries()
{
var result = JobExecutionViewRenderer.Render("j", new List<JobExecutionViewEntry>());
Assert.Empty(result.EntryStartLines);
Assert.Contains(" - step: Setup job\n", result.Yaml);
Assert.Contains(" - step: Complete job\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_NoPerEntryPhaseField()
{
// The phase: <value> per-entry field is gone — the section
// header is the phase indicator. Guard against accidental
// regressions.
var result = JobExecutionViewRenderer.Render("build", WorkedExampleEntries());
Assert.DoesNotContain("phase:", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ThrowsOnNullJobId()
{
Assert.Throws<ArgumentException>(
() => JobExecutionViewRenderer.Render(null, new List<JobExecutionViewEntry>()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ThrowsOnWhitespaceJobId()
{
Assert.Throws<ArgumentException>(
() => JobExecutionViewRenderer.Render(" ", new List<JobExecutionViewEntry>()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ThrowsOnNullEntries()
{
Assert.Throws<ArgumentNullException>(
() => JobExecutionViewRenderer.Render("j", null));
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null, 1)]
[InlineData("", 1)]
[InlineData(" ", 1)]
public void Entry_Constructor_RejectsBadDisplayName(string displayName, int sourceLine)
{
Assert.Throws<ArgumentException>(
() => new JobExecutionViewEntry(JobExecutionPhase.Main, displayName, sourceLine: sourceLine));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Entry_Constructor_RejectsZeroLineWhenSourcePathSet()
{
Assert.Throws<ArgumentException>(
() => new JobExecutionViewEntry(
JobExecutionPhase.Main,
"ok",
sourcePath: "ci.yml",
sourceLine: 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_AlwaysUsesLfLineBreaks()
{
// Regression: YamlDotNet's Emitter calls WriteLine, which on
// Windows produces CRLF (the host's Environment.NewLine).
// The renderer's hand-emitted skeleton always uses '\n'; this
// test asserts the scalar formatter doesn't sneak CRLF in.
var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, "with: colon", id: "step-1", uses: "actions/checkout@v4");
var result = JobExecutionViewRenderer.Render("job-1", new[] { entry });
Assert.DoesNotContain("\r", result.Yaml);
}
}
}

View 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("&amp")]
[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));
}
}
}

View File

@@ -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)

View File

@@ -1,5 +1,5 @@
{
"sdk": {
"version": "8.0.420"
"version": "8.0.421"
}
}