Compare commits

..

9 Commits

Author SHA1 Message Date
Francesco Renzi
68ffb6e0c8 Merge branch 'main' into dap-execution-view-foundation 2026-05-18 10:25:38 +01:00
Francesco Renzi
c72457ad4a Address Copilot review feedback
- Remove unused `using System.Linq;` from JobExecutionViewRendererL0
    and `using System.Collections.Generic;` from StepEntryTranslatorL0.
  - StepEntryTranslator: read run-step inputs through
    `PipelineConstants.ScriptStepInputs.*` (`script`, `shell`,
    `workingDirectory`). The runner stores these keys in their
    constants-defined spelling — see ActionManifestManagerWrapper:244 —
    not their kebab-case workflow-YAML form, so the previous
    `working-directory` lookup never matched in practice. Tests
    updated to use the same constants.
  - TemplateTokenYamlAdapter / JobExecutionViewRenderer.FormatScalar:
    defensively also strip a `---\n` prefix on top of the existing
    `--- ` prefix. Not observed in any input I exercised, but cheap
    insurance against an Emitter quirk on some YAML configurations.
  - JobExecutionView.Append: reject passing both `stepIdentity` and
    `matchKey`. The combination would orphan the original step's
    line mapping the moment TryClaim overwrites `_stepIdentities`
    for a different step. Add an L0 test covering the precondition.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 04:22:59 -07:00
Francesco Renzi
79e0e5cbe6 Drop skipped-step annotation from execution view
Marking placeholders as skipped was a special-case treatment for
predicted Post placeholders whose Main step never executed. In practice
this is no different from any step that doesn't run: the debugger
simply never pauses on it, and the IStep→line mapping is only
populated when a placeholder is claimed.

Drop `IsSkipped` from `JobExecutionViewEntry`, the inline
`# (skipped — ...)` rendering, and `JobExecutionView.TryMarkSkipped`.
Placeholders that are never claimed simply stay in the view as
informational entries — the IStep→line dictionary excludes them,
so the debugger never tries to pause on them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 03:50:32 -07:00
Francesco Renzi
fedd9a3089 Force LF line breaks in DAP YAML emitters
`StringWriter` defaults to `Environment.NewLine`, which is CRLF on
Windows. YamlDotNet's `Emitter` calls `WriteLine` internally, so the
emitted YAML mixes CRLF (from the emitter) with the explicit `\n` we
append in the renderer skeleton. That breaks the document-end
stripping in `FormatScalar` and `TemplateTokenYamlAdapter.Serialize`
and corrupts the rendered view on Windows.

Set `sw.NewLine = "\n"` in both emitter entry points so the output
is always LF, regardless of host. Add regression tests asserting no
`\r` appears in the rendered view or in adapter output. The tests
are noop guards on Linux (where `Environment.NewLine` is already
\n) but catch any future regression on the Windows CI matrix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 03:18:06 -07:00
Francesco Renzi
e51d0dfba7 Render basic expressions through ToDisplayString
The workflow parser tokenizes a mixed scalar like
`${{ runner.os }}-primes` as a single BasicExpressionToken whose
internal expression is `format('{0}-primes', runner.os)`. The
adapter was driving TemplateWriter, which calls `ToString()` and
surfaces the parser's normalized rewrite verbatim.

Replace TemplateWriter.Write with a local walker that mirrors it
except for BasicExpressionToken — those go through
`ToDisplayString()`, reversing the format(...) rewrite back to the
authored form. All other token kinds delegate to the same writer
calls TemplateWriter would make.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 02:16:17 -07:00
Francesco Renzi
9619f0bf3a Add StepEntryTranslator for IStep to view entry mapping
Translates IStep instances (ActionRunner, JobExtensionRunner, etc.)
into JobExecutionViewEntry. Filters auto-generated step IDs so only
explicit IDs surface in the view, and emits step parameters via the
TemplateTokenYamlAdapter to preserve pre-evaluation expressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:26:02 -07:00
Francesco Renzi
f5185dd1a2 Add TemplateTokenYamlAdapter for pre-evaluation YAML rendering
IObjectWriter that bridges the runner's TemplateWriter to YamlDotNet
so TemplateToken trees can be serialized with ${{ }} expressions
preserved. Lets the execution view show step parameters (with:, env:,
if:, etc.) exactly as authored in the workflow file, before any
expression evaluation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:26:02 -07:00
Francesco Renzi
d6b52ac966 Add JobExecutionView state container
Thread-safe append-only container of JobExecutionViewEntry. Supports
predictive Post-step placeholders via TryClaim(matchKey, step) and
TryMarkSkipped(matchKey) so the rendered view can reflect Main steps
that are skipped by their if: condition. Provides line lookup by
IStep for DAP stack-trace frame construction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:26:02 -07:00
Francesco Renzi
c870c893b7 Add JobExecutionViewRenderer for DAP source
Pure renderer that emits a phase-keyed YAML view (setup/pre/main/
post/cleanup) of a job's runner execution plan. Tracks per-entry
start lines so the debugger can map IStep instances back to source
locations. Includes a skipped-step annotation for predicted Post
placeholders whose Main step never executes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:26:02 -07:00
23 changed files with 1939 additions and 790 deletions

View File

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

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.5.0
ARG BUILDX_VERSION=0.34.0
ARG DOCKER_VERSION=29.4.0
ARG BUILDX_VERSION=0.33.0
RUN apt update -y && apt install curl unzip -y

View File

@@ -179,7 +179,6 @@ 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,7 +63,6 @@ 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;
@@ -491,11 +490,6 @@ namespace GitHub.Runner.Worker.Dap
});
Trace.Info("Sent initialized event");
}
if (request.Command == "configurationDone")
{
SendWelcomeMessage();
}
}
catch (Exception ex)
{
@@ -514,7 +508,6 @@ 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
@@ -825,34 +818,6 @@ 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;
@@ -895,9 +860,6 @@ 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);
}
@@ -1233,12 +1195,7 @@ namespace GitHub.Runner.Worker.Dap
case RunCommand run:
var context = GetExecutionContextForFrame(frameId);
bool isActionStep;
lock (_stateLock)
{
isActionStep = _currentStep is IActionRunner;
}
return await _replExecutor.ExecuteRunCommandAsync(run, context, isActionStep, cancellationToken);
return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
default:
return new EvaluateResponseBody
@@ -1450,40 +1407,6 @@ 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,7 +9,6 @@ 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
@@ -44,7 +43,6 @@ namespace GitHub.Runner.Worker.Dap
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
if (context == null)
@@ -54,7 +52,7 @@ namespace GitHub.Runner.Worker.Dap
try
{
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
return await ExecuteScriptAsync(command, context, cancellationToken);
}
catch (Exception ex)
{
@@ -67,17 +65,9 @@ namespace GitHub.Runner.Worker.Dap
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
// 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
// 1. Resolve shell — same logic as ScriptHandler
string shellCommand;
string argFormat;
@@ -97,9 +87,9 @@ namespace GitHub.Runner.Worker.Dap
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
_trace.Info("Resolved REPL shell");
// 3. Expand ${{ }} expressions in the script body, just like
// 2. 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);
@@ -121,47 +111,25 @@ namespace GitHub.Runner.Worker.Dap
try
{
// 4. Resolve script path — translate for container if needed
var resolvedPath = stepHost.ResolvePathForStepHost(context, scriptFilePath).Replace("\"", "\\\"");
// 3. Format arguments with script path
var resolvedPath = 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);
// 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.
// 4. Resolve shell command path
string prependPath = string.Join(
Path.PathSeparator.ToString(),
Enumerable.Reverse(context.Global.PrependPath));
var fileName = isContainerStepHost
? shellCommand
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
?? shellCommand;
// 6. Build environment — merge from execution context like a real step
// 5. Build environment — merge from execution context like a real step
var environment = BuildEnvironment(context, command.Env);
// 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
// 6. Resolve working directory
var workingDirectory = command.WorkingDirectory;
if (string.IsNullOrEmpty(workingDirectory))
{
@@ -173,60 +141,48 @@ 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");
// 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))
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
int exitCode;
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
{
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");
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);
}
// 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}");
// 10. Return only the exit code summary (output was already streamed)
// 8. 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}.",
@@ -242,43 +198,6 @@ 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,12 +8,10 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public sealed class DebuggerConfig
{
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
{
Enabled = enabled;
Tunnel = tunnel;
OverrideWelcomeMessage = overrideWelcomeMessage;
WelcomeMessage = welcomeMessage;
}
/// <summary>Whether the debugger is enabled for this job.</summary>
@@ -25,19 +23,6 @@ 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,276 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Stateful, append-only container that wraps <see cref="JobExecutionViewRenderer"/>
/// for runtime use. Maintains a mutable list of entries, caches the rendered YAML,
/// and provides O(1) lookup from <see cref="IStep"/> identity to the current line
/// in the rendered YAML where that step's <c>- step:</c> key appears.
///
/// Each <see cref="Append"/> can register the entry in one of three modes:
/// - With a non-null <c>stepIdentity</c>: registers the IStep→line mapping
/// immediately. Used for entries whose real <see cref="IStep"/> is already
/// known at append time.
/// - With a non-null <c>matchKey</c>: registers an unclaimed placeholder
/// that a later <see cref="TryClaim"/> binds to a real <see cref="IStep"/>.
/// Used for entries whose <see cref="IStep"/> is materialized later. A
/// placeholder that is never claimed simply stays in the view and is never
/// paused on — the IStep→line mapping is only populated on claim.
/// - With neither: a static entry that needs no line lookup.
///
/// <see cref="Append"/> and <see cref="AppendRange"/> never remove or reorder
/// existing entries. <see cref="TryClaim"/> does not re-render. The IStep→line
/// mapping is rebuilt on every render, so lookups stay accurate even if a later
/// Append happens to shift previously-emitted entries.
/// </summary>
internal sealed class JobExecutionView
{
private readonly object _lock = new();
private readonly string _jobId;
private readonly List<JobExecutionViewEntry> _entries = new();
private readonly List<IStep> _stepIdentities = new();
private readonly Dictionary<IStep, int> _lineByStep =
new(ReferenceEqualityComparer.Instance);
// Map matchKey -> entry index for placeholders awaiting a future
// TryClaim. Removed when claimed.
private readonly Dictionary<string, int> _unclaimedByKey =
new(StringComparer.Ordinal);
private string _yaml;
private IReadOnlyList<int> _entryStartLines = Array.Empty<int>();
public JobExecutionView(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId))
{
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
}
_jobId = jobId;
Render();
}
public string JobId
{
get { return _jobId; }
}
/// <summary>
/// Currently rendered YAML. Always reflects all entries appended so far,
/// plus the synthetic Setup header and Cleanup footer emitted by the renderer.
/// </summary>
public string Yaml
{
get
{
lock (_lock)
{
return _yaml;
}
}
}
/// <summary>Number of entries (excludes synthetic Setup/Cleanup boundaries).</summary>
public int EntryCount
{
get
{
lock (_lock)
{
return _entries.Count;
}
}
}
/// <summary>
/// 1-based line where entry <paramref name="entryIndex"/>'s <c>- step:</c> key
/// currently appears in <see cref="Yaml"/>.
/// </summary>
public int GetLine(int entryIndex)
{
lock (_lock)
{
if (entryIndex < 0 || entryIndex >= _entries.Count)
{
throw new ArgumentOutOfRangeException(nameof(entryIndex));
}
return _entryStartLines[entryIndex];
}
}
/// <summary>
/// 1-based line for the entry whose <see cref="IStep"/> reference identity
/// matches <paramref name="step"/>. Returns null if <paramref name="step"/>
/// is null or has not been registered.
/// </summary>
public int? TryGetLineForStep(IStep step)
{
if (step == null)
{
return null;
}
lock (_lock)
{
if (_lineByStep.TryGetValue(step, out var line))
{
return line;
}
return null;
}
}
/// <summary>
/// Append a new entry. Exactly one of <paramref name="stepIdentity"/>
/// or <paramref name="matchKey"/> may be non-null (or both may be
/// null for a static entry that needs no line lookup):
/// - <paramref name="stepIdentity"/> non-null: registers the
/// IStep→line mapping immediately. Use when the real
/// <see cref="IStep"/> is known at append time.
/// - <paramref name="matchKey"/> non-null: registers an unclaimed
/// placeholder that a later <see cref="TryClaim"/> binds to a
/// real <see cref="IStep"/>.
/// Re-renders the YAML and updates the start-line table.
/// </summary>
/// <returns>1-based line number of the newly-appended entry's <c>- step:</c> key.</returns>
public int Append(JobExecutionViewEntry entry, IStep stepIdentity = null, string matchKey = null)
{
ArgUtil.NotNull(entry, nameof(entry));
if (stepIdentity != null && matchKey != null)
{
throw new ArgumentException(
"Append cannot register both a step identity and a placeholder match key on the same entry; pass at most one.");
}
lock (_lock)
{
if (stepIdentity != null && _lineByStep.ContainsKey(stepIdentity))
{
throw new InvalidOperationException("step already registered in execution view");
}
if (matchKey != null && _unclaimedByKey.ContainsKey(matchKey))
{
throw new InvalidOperationException($"matchKey already registered: {matchKey}");
}
_entries.Add(entry);
_stepIdentities.Add(stepIdentity);
Render();
int index = _entries.Count - 1;
if (matchKey != null)
{
_unclaimedByKey[matchKey] = index;
}
return _entryStartLines[index];
}
}
/// <summary>
/// Bind a previously-appended placeholder entry (registered via
/// <see cref="Append(JobExecutionViewEntry, IStep, string)"/> with
/// a non-null <c>matchKey</c>) to a real <see cref="IStep"/>.
/// Returns the 1-based line of the now-claimed entry on success.
/// Returns null when no unclaimed placeholder exists for
/// <paramref name="matchKey"/>, OR when <paramref name="stepIdentity"/>
/// is already registered for a different entry (defensive).
/// Does not re-render: claim only updates the IStep -> line index.
/// </summary>
public int? TryClaim(string matchKey, IStep stepIdentity)
{
if (matchKey == null)
{
throw new ArgumentNullException(nameof(matchKey));
}
if (stepIdentity == null)
{
throw new ArgumentNullException(nameof(stepIdentity));
}
lock (_lock)
{
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
{
return null;
}
if (_lineByStep.ContainsKey(stepIdentity))
{
// Bail rather than double-register the step.
return null;
}
_unclaimedByKey.Remove(matchKey);
_stepIdentities[index] = stepIdentity;
_lineByStep[stepIdentity] = _entryStartLines[index];
return _entryStartLines[index];
}
}
/// <summary>
/// Bulk-append for the initial population. Equivalent to calling
/// <see cref="Append"/> once per pair, but renders only once at the end.
/// State is left unchanged if any input is invalid.
/// </summary>
public void AppendRange(IEnumerable<(JobExecutionViewEntry entry, IStep stepIdentity)> items)
{
ArgUtil.NotNull(items, nameof(items));
// Materialize first so we don't enumerate twice.
var materialized = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>(items);
for (int i = 0; i < materialized.Count; i++)
{
if (materialized[i].entry == null)
{
throw new ArgumentException($"items[{i}].entry is null.", nameof(items));
}
}
lock (_lock)
{
// Validate no duplicates within the input or with existing identities,
// before mutating state.
var seen = new HashSet<IStep>(ReferenceEqualityComparer.Instance);
foreach (var (_, stepIdentity) in materialized)
{
if (stepIdentity == null)
{
continue;
}
if (_lineByStep.ContainsKey(stepIdentity) || !seen.Add(stepIdentity))
{
throw new InvalidOperationException("step already registered in execution view");
}
}
foreach (var (entry, stepIdentity) in materialized)
{
_entries.Add(entry);
_stepIdentities.Add(stepIdentity);
}
Render();
}
}
// Caller MUST hold _lock (constructor's call is safe — no concurrent access yet).
private void Render()
{
var result = JobExecutionViewRenderer.Render(_jobId, _entries.AsReadOnly());
_yaml = result.Yaml;
_entryStartLines = result.EntryStartLines;
_lineByStep.Clear();
for (int i = 0; i < _stepIdentities.Count; i++)
{
var step = _stepIdentities[i];
if (step != null)
{
_lineByStep[step] = _entryStartLines[i];
}
}
}
}
}

View File

@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
@@ -141,7 +144,7 @@ namespace GitHub.Runner.Worker.Dap
int newlinesEmitted = 0;
// Header (3 lines).
sb.Append("# Job: ").Append(YamlScalarFormatter.Format(jobId)).Append('\n');
sb.Append("# Job: ").Append(FormatScalar(jobId)).Append('\n');
sb.Append("# Runner execution plan — read-only.\n");
sb.Append('\n');
newlinesEmitted += 3;
@@ -200,7 +203,7 @@ namespace GitHub.Runner.Worker.Dap
// 1-based line of the `- step:` key for this entry.
startLines[i] = newlinesEmitted + 1;
sb.Append(" - step: ").Append(YamlScalarFormatter.Format(entry.DisplayName));
sb.Append(" - step: ").Append(FormatScalar(entry.DisplayName));
sb.Append('\n');
newlinesEmitted++;
@@ -210,7 +213,7 @@ namespace GitHub.Runner.Worker.Dap
case JobExecutionPhase.Post:
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" action: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
sb.Append(" action: ").Append(FormatScalar(entry.Uses)).Append('\n');
newlinesEmitted++;
}
// No source: annotation for pre/post.
@@ -219,19 +222,19 @@ namespace GitHub.Runner.Worker.Dap
case JobExecutionPhase.Main:
if (!string.IsNullOrEmpty(entry.Id))
{
sb.Append(" id: ").Append(YamlScalarFormatter.Format(entry.Id)).Append('\n');
sb.Append(" id: ").Append(FormatScalar(entry.Id)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" uses: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
sb.Append(" uses: ").Append(FormatScalar(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');
sb.Append(" run: ").Append(FormatScalar(entry.Run)).Append('\n');
newlinesEmitted++;
}
else
@@ -243,7 +246,7 @@ namespace GitHub.Runner.Worker.Dap
}
if (!string.IsNullOrEmpty(entry.If))
{
sb.Append(" if: ").Append(YamlScalarFormatter.Format(entry.If)).Append('\n');
sb.Append(" if: ").Append(FormatScalar(entry.If)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.ContinueOnError))
@@ -272,12 +275,12 @@ namespace GitHub.Runner.Worker.Dap
}
if (!string.IsNullOrEmpty(entry.Shell))
{
sb.Append(" shell: ").Append(YamlScalarFormatter.Format(entry.Shell)).Append('\n');
sb.Append(" shell: ").Append(FormatScalar(entry.Shell)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.WorkingDirectory))
{
sb.Append(" working-directory: ").Append(YamlScalarFormatter.Format(entry.WorkingDirectory)).Append('\n');
sb.Append(" working-directory: ").Append(FormatScalar(entry.WorkingDirectory)).Append('\n');
newlinesEmitted++;
}
if (entry.SourcePath != null)
@@ -332,5 +335,55 @@ namespace GitHub.Runner.Worker.Dap
}
return n;
}
/// <summary>
/// Formats a single string as a YAML 1.x flow scalar, delegating
/// quoting/escaping decisions to YamlDotNet. This avoids maintaining
/// our own escape table for every YAML-significant character: we
/// just emit the value through the YAML library and use whichever
/// scalar style (plain, single-quoted, double-quoted) it picks.
/// A new <see cref="Emitter"/> is created per call, so the helper
/// is safe to invoke concurrently.
/// </summary>
internal static string FormatScalar(string value)
{
if (value == null)
{
throw new ArgumentNullException(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.TrimEnd('\n');
}
}
}

View File

@@ -0,0 +1,240 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Translates runner <see cref="IStep"/> instances into pure-data
/// <see cref="JobExecutionViewEntry"/> records used by the DAP debugger
/// execution view. Filters out runner-internal steps (e.g.
/// <see cref="JobExtensionRunner"/>) so the rendered view only shows
/// user-visible workflow steps.
/// </summary>
internal static class StepEntryTranslator
{
// Run-step internals carried on ActionStep.Inputs that are NOT
// user-authored `with:` entries. The runner stores these under
// the keys defined in PipelineConstants.ScriptStepInputs, NOT
// their kebab-case workflow-YAML spellings.
private static readonly HashSet<string> RunStepInternalKeys = new(StringComparer.Ordinal)
{
PipelineConstants.ScriptStepInputs.Script,
PipelineConstants.ScriptStepInputs.Shell,
PipelineConstants.ScriptStepInputs.WorkingDirectory,
};
/// <summary>
/// Translate an IStep into a JobExecutionViewEntry.
/// </summary>
/// <param name="step">The IStep to translate. Must not be null.</param>
/// <returns>
/// A JobExecutionViewEntry, or null if the step is not user-visible
/// (JobExtensionRunner and any other non-IActionRunner IStep impls).
/// </returns>
public static JobExecutionViewEntry TryTranslate(IStep step)
{
ArgUtil.NotNull(step, nameof(step));
if (step is JobExtensionRunner)
{
return null;
}
if (step is not IActionRunner actionRunner)
{
return null;
}
var phase = actionRunner.Stage switch
{
ActionRunStage.Pre => JobExecutionPhase.Pre,
ActionRunStage.Post => JobExecutionPhase.Post,
_ => JobExecutionPhase.Main,
};
string displayName = actionRunner.DisplayName;
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = "run";
}
string uses = null;
string run = null;
string id = null;
string ifCond = null;
string continueOnError = null;
string timeoutMinutes = null;
string envYaml = null;
string withYaml = null;
string shell = null;
string workingDirectory = null;
var action = actionRunner.Action;
var reference = action?.Reference;
bool isScript = reference?.Type == ActionSourceType.Script;
if (reference != null && !isScript)
{
uses = FormatActionReference(reference);
}
// Only the user-visible Main entry surfaces authored params.
// Pre/Post stay minimal (step + action) — they reference the
// same Action as the Main entry, and duplicating params adds
// noise without information.
if (phase == JobExecutionPhase.Main && action != null)
{
id = FilterAuthoredId(action.ContextName);
if (!string.IsNullOrEmpty(action.Condition))
{
ifCond = action.Condition;
}
if (action.ContinueOnError != null)
{
continueOnError = TemplateTokenYamlAdapter.Serialize(action.ContinueOnError, indentSpaces: 0);
}
if (action.TimeoutInMinutes != null)
{
timeoutMinutes = TemplateTokenYamlAdapter.Serialize(action.TimeoutInMinutes, indentSpaces: 0);
}
if (action.Environment is MappingToken envMap && envMap.Count > 0)
{
envYaml = TemplateTokenYamlAdapter.Serialize(envMap, indentSpaces: 6);
}
else if (action.Environment != null && !(action.Environment is MappingToken))
{
// Unusual but possible: env: ${{ ... }} expression form.
envYaml = TemplateTokenYamlAdapter.Serialize(action.Environment, indentSpaces: 6);
}
if (isScript)
{
var inputs = action.Inputs as MappingToken;
if (inputs != null)
{
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Script, out var scriptTok) && scriptTok != null)
{
run = scriptTok.ToString();
}
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Shell, out var shellTok) && shellTok != null)
{
string shellText = shellTok.ToString();
if (!string.IsNullOrEmpty(shellText))
{
shell = shellText;
}
}
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.WorkingDirectory, out var wdTok) && wdTok != null)
{
string wdText = wdTok.ToString();
if (!string.IsNullOrEmpty(wdText))
{
workingDirectory = wdText;
}
}
}
}
else
{
// Action step: surface `with:` entries, filtering any
// run-step internal keys defensively.
if (action.Inputs is MappingToken withMap && withMap.Count > 0)
{
var filtered = FilterMapping(withMap, RunStepInternalKeys);
if (filtered != null && filtered.Count > 0)
{
withYaml = TemplateTokenYamlAdapter.Serialize(filtered, indentSpaces: 6);
}
}
}
}
// Source annotation (SourcePath/SourceLine) requires a public
// seam onto TemplateToken position info — not wired yet.
return new JobExecutionViewEntry(
phase: phase,
displayName: displayName,
uses: uses,
run: run,
sourcePath: null,
sourceLine: 0,
id: id,
@if: ifCond,
continueOnError: continueOnError,
timeoutMinutes: timeoutMinutes,
envYaml: envYaml,
withYaml: withYaml,
shell: shell,
workingDirectory: workingDirectory);
}
/// <summary>
/// Auto-generated step IDs are noise in the view: filter them out.
/// The runner's convention (see ExecutionContext) is that auto-
/// generated context names start with <c>__</c>. Only user-authored
/// IDs survive the filter.
/// </summary>
internal static string FilterAuthoredId(string contextName)
{
if (string.IsNullOrWhiteSpace(contextName))
{
return null;
}
if (contextName.StartsWith("__", StringComparison.Ordinal))
{
return null;
}
return contextName;
}
private static bool TryGetMapValue(MappingToken map, string key, out TemplateToken value)
{
foreach (var pair in map)
{
if (pair.Key is StringToken s && string.Equals(s.Value, key, StringComparison.Ordinal))
{
value = pair.Value;
return true;
}
}
value = null;
return false;
}
private static MappingToken FilterMapping(MappingToken source, HashSet<string> excludeKeys)
{
var copy = new MappingToken(source.FileId, source.Line, source.Column);
foreach (var pair in source)
{
if (pair.Key is StringToken sk && excludeKeys.Contains(sk.Value))
{
continue;
}
copy.Add(pair);
}
return copy;
}
internal static string FormatActionReference(ActionStepDefinitionReference reference)
{
switch (reference)
{
case RepositoryPathReference repo:
var path = string.IsNullOrEmpty(repo.Path) ? string.Empty : $"/{repo.Path}";
return string.IsNullOrEmpty(repo.Ref)
? $"{repo.Name}{path}"
: $"{repo.Name}{path}@{repo.Ref}";
case ContainerRegistryReference container:
return container.Image;
default:
return reference.ToString();
}
}
}
}

View File

@@ -0,0 +1,223 @@
using System;
using System.Globalization;
using System.IO;
using GitHub.DistributedTask.ObjectTemplating;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Adapts a YamlDotNet <see cref="IEmitter"/> as a DT
/// <see cref="IObjectWriter"/> so a <see cref="TemplateToken"/> DOM
/// can be serialized back to YAML preserving its pre-evaluation form
/// (basic <c>${{ }}</c> expressions are written through verbatim).
///
/// Used by the DAP execution view to surface user-authored step
/// parameters (<c>env:</c>, <c>with:</c>, <c>run:</c>, ...) without
/// any expression substitution.
/// </summary>
internal sealed class TemplateTokenYamlAdapter : IObjectWriter
{
private readonly IEmitter _emitter;
public TemplateTokenYamlAdapter(IEmitter emitter)
{
ArgUtil.NotNull(emitter, nameof(emitter));
_emitter = emitter;
}
public void WriteStart()
{
_emitter.Emit(new StreamStart());
_emitter.Emit(new DocumentStart(null, null, true));
}
public void WriteEnd()
{
_emitter.Emit(new DocumentEnd(true));
_emitter.Emit(new StreamEnd());
}
public void WriteNull() =>
_emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false));
public void WriteBoolean(bool value) =>
_emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false));
public void WriteNumber(double value) =>
_emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false));
public void WriteString(string value)
{
if (value == null)
{
WriteNull();
return;
}
// Multi-line strings render as block literal so embedded
// newlines survive the YAML round trip.
var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any;
_emitter.Emit(new Scalar(null, null, value, style, true, true));
}
public void WriteSequenceStart() =>
_emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any));
public void WriteSequenceEnd() =>
_emitter.Emit(new SequenceEnd());
public void WriteMappingStart() =>
_emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any));
public void WriteMappingEnd() =>
_emitter.Emit(new MappingEnd());
/// <summary>
/// Serialize a TemplateToken to a YAML fragment ready to embed
/// under a parent key. Each non-empty line is prefixed by
/// <paramref name="indentSpaces"/> spaces. Trailing newlines and
/// the YAML stream start/document markers are stripped, so the
/// caller controls line breaks.
/// </summary>
/// <remarks>
/// Empty mappings render as <c>{}</c> and empty sequences as
/// <c>[]</c> via YamlDotNet's flow style fallback for empty
/// collections.
/// </remarks>
internal static string Serialize(TemplateToken token, int indentSpaces)
{
if (indentSpaces < 0)
{
throw new ArgumentOutOfRangeException(nameof(indentSpaces));
}
using var sw = new StringWriter(CultureInfo.InvariantCulture);
// Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
// which would otherwise produce CRLF on Windows and corrupt
// both the document-end stripping below and the per-line
// indentation pass that follows.
sw.NewLine = "\n";
var emitter = new Emitter(sw);
var adapter = new TemplateTokenYamlAdapter(emitter);
adapter.WriteStart();
WriteToken(adapter, token);
adapter.WriteEnd();
string raw = sw.ToString();
// Strip YAML document markers. The Emitter most commonly elides
// these for our use (DocumentStart isImplicit=true), but emits
// them for some scalar edge cases (e.g. empty strings) and may
// emit them on their own line for collection roots under some
// settings. Strip both shapes defensively so callers never see
// a leaked marker leak into the embedded fragment.
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
else if (raw.StartsWith("---\n", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
const string DocEndMarker = "\n...";
if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1);
}
else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
}
raw = raw.TrimEnd('\n');
if (indentSpaces == 0)
{
return raw;
}
// Re-indent every non-empty line. Empty lines remain empty
// so YAML block-literal blank lines stay valid.
var pad = new string(' ', indentSpaces);
var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4);
int i = 0;
while (i < raw.Length)
{
int end = raw.IndexOf('\n', i);
int lineEnd = end < 0 ? raw.Length : end;
if (lineEnd > i)
{
sb.Append(pad);
sb.Append(raw, i, lineEnd - i);
}
if (end < 0)
{
break;
}
sb.Append('\n');
i = end + 1;
}
return sb.ToString();
}
/// <summary>
/// Mirrors <see cref="TemplateWriter"/>'s recursive walk, with one
/// behavioural change: <see cref="BasicExpressionToken"/> is emitted
/// via <c>ToDisplayString()</c> instead of <c>ToString()</c>.
/// </summary>
/// <remarks>
/// The workflow parser tokenizes a mixed scalar like
/// <c>${{ runner.os }}-primes</c> as a single
/// <see cref="BasicExpressionToken"/> whose internal expression is
/// <c>format('{0}-primes', runner.os)</c>. <c>ToString()</c> emits
/// the normalized form verbatim; <c>ToDisplayString()</c> reverses
/// the <c>format(...)</c> rewrite so the user sees the original
/// authored form. Other token kinds delegate to the same writer
/// calls <see cref="TemplateWriter"/> would make.
/// </remarks>
private static void WriteToken(IObjectWriter writer, TemplateToken token)
{
switch (token?.Type ?? TokenType.Null)
{
case TokenType.Null:
writer.WriteNull();
break;
case TokenType.Boolean:
writer.WriteBoolean(((BooleanToken)token).Value);
break;
case TokenType.Number:
writer.WriteNumber(((NumberToken)token).Value);
break;
case TokenType.String:
writer.WriteString(token.ToString());
break;
case TokenType.BasicExpression:
writer.WriteString(((BasicExpressionToken)token).ToDisplayString());
break;
case TokenType.InsertExpression:
writer.WriteString(token.ToString());
break;
case TokenType.Mapping:
writer.WriteMappingStart();
foreach (var pair in (MappingToken)token)
{
WriteToken(writer, pair.Key);
WriteToken(writer, pair.Value);
}
writer.WriteMappingEnd();
break;
case TokenType.Sequence:
writer.WriteSequenceStart();
foreach (var item in (SequenceToken)token)
{
WriteToken(writer, item);
}
writer.WriteSequenceEnd();
break;
default:
throw new NotSupportedException($"Unexpected token type '{token.GetType()}'.");
}
}
}
}

View File

@@ -1,63 +0,0 @@
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,8 +970,7 @@ namespace GitHub.Runner.Worker
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
// Debugger enabled flag (from acquire response).
var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false;
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage);
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;

View File

@@ -267,21 +267,6 @@ 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,26 +161,6 @@ 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, bool overrideWelcomeMessage = false, string welcomeMessage = null)
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = 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, overrideWelcomeMessage, welcomeMessage);
var debuggerConfig = new DebuggerConfig(true, tunnel);
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
@@ -742,8 +742,6 @@ 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,
@@ -851,8 +849,6 @@ 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;
@@ -871,224 +867,5 @@ 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,12 +5,9 @@ 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;
@@ -43,8 +40,7 @@ namespace GitHub.Runner.Common.Tests.Worker
private Mock<IExecutionContext> CreateMockContext(
DictionaryContextData exprValues = null,
IDictionary<string, IDictionary<string, string>> jobDefaults = null,
ContainerInfo container = null)
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
{
var mock = new Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
@@ -55,7 +51,6 @@ 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);
@@ -70,7 +65,7 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var command = new RunCommand { Script = "echo hello" };
var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None);
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
Assert.Equal("error", result.Type);
Assert.Contains("No execution context available", result.Result);
@@ -238,101 +233,5 @@ 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,442 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class JobExecutionViewL0
{
private static JobExecutionViewEntry MainEntry(string name)
{
return new JobExecutionViewEntry(JobExecutionPhase.Main, name, run: name);
}
private static IStep NewStep(string displayName = "step")
{
var mock = new Mock<IStep>();
mock.Setup(s => s.DisplayName).Returns(displayName);
return mock.Object;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Constructor_RendersEmptyView()
{
var view = new JobExecutionView("my-job");
Assert.Equal(0, view.EntryCount);
Assert.Contains("# Job: my-job", view.Yaml);
Assert.Contains("- step: Setup job", view.Yaml);
Assert.Contains("- step: Complete job", view.Yaml);
// Only the two synthetic boundaries appear.
int stepCount = view.Yaml.Split("- step: ").Length - 1;
Assert.Equal(2, stepCount);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_ThrowsOnInvalidJobId(string jobId)
{
Assert.Throws<ArgumentException>(() => new JobExecutionView(jobId));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_IncrementsEntryCount()
{
var view = new JobExecutionView("j");
int line0 = view.Append(MainEntry("a"));
int line1 = view.Append(MainEntry("b"));
int line2 = view.Append(MainEntry("c"));
Assert.Equal(3, view.EntryCount);
Assert.True(line0 < line1);
Assert.True(line1 < line2);
Assert.Equal(line0, view.GetLine(0));
Assert.Equal(line1, view.GetLine(1));
Assert.Equal(line2, view.GetLine(2));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_PreservesPriorEntryLines()
{
var view = new JobExecutionView("j");
int l0 = view.Append(MainEntry("a"));
int l1 = view.Append(MainEntry("b"));
int l2 = view.Append(MainEntry("c"));
view.Append(MainEntry("d"));
Assert.Equal(l0, view.GetLine(0));
Assert.Equal(l1, view.GetLine(1));
Assert.Equal(l2, view.GetLine(2));
view.Append(MainEntry("e"));
Assert.Equal(l0, view.GetLine(0));
Assert.Equal(l1, view.GetLine(1));
Assert.Equal(l2, view.GetLine(2));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_RegistersStepIdentity()
{
var view = new JobExecutionView("j");
var step = NewStep();
int line = view.Append(MainEntry("a"), step);
Assert.Equal(line, view.GetLine(0));
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_NullStepIdentity_StillAppends()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null);
Assert.Equal(1, view.EntryCount);
Assert.Null(view.TryGetLineForStep(null));
Assert.Contains("- step: a", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_DuplicateStepIdentity_Throws()
{
var view = new JobExecutionView("j");
var step = NewStep();
view.Append(MainEntry("a"), step);
Assert.Throws<InvalidOperationException>(() => view.Append(MainEntry("b"), step));
// State preserved: only the first entry is present.
Assert.Equal(1, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_NullEntry_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.Append(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_AppendsAllAndRendersOnce()
{
var view = new JobExecutionView("j");
var steps = Enumerable.Range(0, 5).Select(i => NewStep("s" + i)).ToList();
var items = steps
.Select((s, i) => (entry: MainEntry("e" + i), stepIdentity: s))
.ToList();
view.AppendRange(items);
Assert.Equal(5, view.EntryCount);
for (int i = 0; i < 5; i++)
{
int line = view.GetLine(i);
Assert.Equal(line, view.TryGetLineForStep(steps[i]));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_RejectsDuplicateInInput()
{
var view = new JobExecutionView("j");
var dup = NewStep();
var items = new List<(JobExecutionViewEntry, IStep)>
{
(MainEntry("a"), dup),
(MainEntry("b"), dup),
};
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
Assert.Equal(0, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_RejectsOverlapWithExisting()
{
var view = new JobExecutionView("j");
var step = NewStep();
view.Append(MainEntry("a"), step);
var items = new List<(JobExecutionViewEntry, IStep)>
{
(MainEntry("b"), step),
};
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
Assert.Equal(1, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_NullItems_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.AppendRange(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryGetLineForStep_NullStep_ReturnsNull()
{
var view = new JobExecutionView("j");
Assert.Null(view.TryGetLineForStep(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryGetLineForStep_UnknownStep_ReturnsNull()
{
var view = new JobExecutionView("j");
var step = NewStep();
Assert.Null(view.TryGetLineForStep(step));
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(-1)]
[InlineData(2)]
public void GetLine_OutOfRange_Throws(int index)
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"));
view.Append(MainEntry("b"));
Assert.Throws<ArgumentOutOfRangeException>(() => view.GetLine(index));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Yaml_UpdatesAfterAppend()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("first"));
string before = view.Yaml;
Assert.Contains("- step: first", before);
view.Append(MainEntry("second"));
string after = view.Yaml;
Assert.Contains("- step: first", after);
Assert.Contains("- step: second", after);
Assert.NotEqual(before, after);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Yaml_AlwaysEndsWithCleanupBoundary()
{
var view = new JobExecutionView("j");
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
view.Append(MainEntry("a"));
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
view.Append(MainEntry("b"));
view.Append(MainEntry("c"));
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_WithMatchKey_TracksUnclaimed()
{
var view = new JobExecutionView("j");
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
var step = NewStep("real");
int? claimed = view.TryClaim("k1", step);
Assert.Equal(line, claimed);
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_UnknownKey_ReturnsNull()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
Assert.Null(view.TryClaim("nope", NewStep()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_AlreadyClaimed_ReturnsNull()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
var first = NewStep("first");
Assert.NotNull(view.TryClaim("k1", first));
var second = NewStep("second");
Assert.Null(view.TryClaim("k1", second));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_StepAlreadyRegistered_ReturnsNull()
{
var view = new JobExecutionView("j");
var step = NewStep();
// Step is registered for the first entry.
view.Append(MainEntry("a"), step);
// A placeholder is registered for the second entry.
view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1");
// Trying to claim the placeholder with the already-registered
// step must return null (defensive — would otherwise double-bind).
Assert.Null(view.TryClaim("k1", step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_DuplicateMatchKey_Throws()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
Assert.Throws<InvalidOperationException>(
() => view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_MatchKeyNull_BehavesLikeOldOverload()
{
var view = new JobExecutionView("j");
var step = NewStep();
int line = view.Append(MainEntry("a"), step);
Assert.Equal(line, view.GetLine(0));
Assert.Equal(line, view.TryGetLineForStep(step));
// TryClaim with any key must return null since no matchKey was registered.
Assert.Null(view.TryClaim("anything", NewStep()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_AfterClaim_TryGetLineForStepResolves()
{
var view = new JobExecutionView("j");
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
var step = NewStep();
Assert.Equal(line, view.TryClaim("k1", step));
Assert.Equal(line, view.TryGetLineForStep(step));
// And a later Append doesn't lose the claim (Render rebuilds
// the IStep -> line map from the persisted identities).
view.Append(MainEntry("b"));
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_NullArgs_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.TryClaim(null, NewStep()));
Assert.Throws<ArgumentNullException>(() => view.TryClaim("k", null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ConcurrentAppends_DontCorruptState()
{
var view = new JobExecutionView("j");
const int N = 50;
var steps = Enumerable.Range(0, N).Select(i => NewStep("s" + i)).ToList();
var returnedLines = new ConcurrentBag<int>();
var tasks = Enumerable.Range(0, N).Select(i => Task.Run(() =>
{
int line = view.Append(MainEntry("e" + i), steps[i]);
returnedLines.Add(line);
})).ToArray();
await Task.WhenAll(tasks);
Assert.Equal(N, view.EntryCount);
Assert.Equal(N, returnedLines.Distinct().Count());
// Every step identity resolves to some line in [0, N).
var entryLines = Enumerable.Range(0, N).Select(view.GetLine).ToHashSet();
Assert.Equal(N, entryLines.Count);
foreach (var step in steps)
{
int? line = view.TryGetLineForStep(step);
Assert.NotNull(line);
Assert.Contains(line.Value, entryLines);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_RejectsBothStepIdentityAndMatchKey()
{
// Allowing both would orphan the IStep→line mapping the moment
// TryClaim overwrites _stepIdentities[index] for a different
// step, so the API rejects the combination at append time.
var view = new JobExecutionView("j");
var entry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
Assert.Throws<ArgumentException>(() =>
view.Append(entry, stepIdentity: NewStep("real"), matchKey: "k1"));
// State unchanged.
Assert.Equal(0, view.EntryCount);
}
}
}

View File

@@ -588,11 +588,23 @@ namespace GitHub.Runner.Common.Tests.Worker
{
// 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.
// FormatScalar / TemplateTokenYamlAdapter.Serialize must force
// LF so the rendered view round-trips regardless of platform.
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);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FormatScalar_AlwaysUsesLfLineBreaks()
{
// Direct check on FormatScalar to guard against future refactors
// that bypass the full Render path but still emit through
// YamlDotNet.
Assert.DoesNotContain("\r", JobExecutionViewRenderer.FormatScalar("with: colon"));
Assert.DoesNotContain("\r", JobExecutionViewRenderer.FormatScalar("hello"));
}
}
}

View File

@@ -0,0 +1,428 @@
using System;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class StepEntryTranslatorL0
{
private static StringToken Str(string s) => new(null, null, null, s);
private static MappingToken Map(params (string Key, TemplateToken Value)[] pairs)
{
var m = new MappingToken(null, null, null);
foreach (var (k, v) in pairs)
{
m.Add(Str(k), v);
}
return m;
}
private static Mock<IActionRunner> NewActionRunnerMock(
ActionRunStage stage,
string displayName,
ActionStepDefinitionReference reference,
ActionStep actionOverride = null)
{
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(actionOverride ?? new ActionStep
{
Reference = reference,
});
return mock;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_NullStep_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
StepEntryTranslator.TryTranslate(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_JobExtensionRunner_ReturnsNull()
{
var step = new JobExtensionRunner(
runAsync: (_, __) => System.Threading.Tasks.Task.CompletedTask,
condition: null,
displayName: "Set up job",
data: null);
Assert.Null(StepEntryTranslator.TryTranslate(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_OtherIStepType_ReturnsNull()
{
var mock = new Mock<IStep>();
mock.SetupGet(x => x.DisplayName).Returns("custom");
Assert.Null(StepEntryTranslator.TryTranslate(mock.Object));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerPre_ReturnsPreEntry()
{
var reference = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v4",
};
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre Run actions/checkout@v4", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
Assert.Equal("Pre Run actions/checkout@v4", entry.DisplayName);
Assert.Equal("actions/checkout@v4", entry.Uses);
Assert.Null(entry.Run);
Assert.Null(entry.SourcePath);
Assert.Equal(0, entry.SourceLine);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ReturnsMainEntryWithUses()
{
var reference = new RepositoryPathReference
{
Name = "actions/setup-node",
Path = "subdir",
Ref = "v3",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run actions/setup-node@v3", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
Assert.Equal("actions/setup-node/subdir@v3", entry.Uses);
Assert.Null(entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ScriptReference_LeavesUsesNull()
{
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo hi", new ScriptReference());
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
Assert.Null(entry.Uses);
Assert.Null(entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ContainerReference_UsesImage()
{
var reference = new ContainerRegistryReference { Image = "alpine:3.18" };
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run alpine", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("alpine:3.18", entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerPost_ReturnsPostEntry()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v3" };
var mock = NewActionRunnerMock(ActionRunStage.Post, "Post Run actions/cache@v3", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Post, entry.Phase);
Assert.Equal("Post Run actions/cache@v3", entry.DisplayName);
Assert.Equal("actions/cache@v3", entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunner_NullAction_LeavesUsesNull()
{
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(ActionRunStage.Main);
mock.SetupGet(x => x.DisplayName).Returns("anonymous");
mock.SetupGet(x => x.Action).Returns((ActionStep)null);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("anonymous", entry.DisplayName);
Assert.Null(entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsWith()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(("path", Str("prime-numbers")), ("key", Str("k"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.WithYaml);
Assert.Contains("path: prime-numbers", entry.WithYaml);
Assert.Contains("key: k", entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_PreservesExpressionInWith()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(("key", Str("${{ runner.os }}-primes"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Contains("${{ runner.os }}-primes", entry.WithYaml);
Assert.DoesNotContain("Linux", entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_RunStep_ExtractsScript()
{
var action = new ActionStep
{
Reference = new ScriptReference(),
Inputs = Map(("script", Str("echo hi"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo", new ScriptReference(), action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.Uses);
Assert.Equal("echo hi", entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_RunStep_ExtractsShellAndWorkingDirectory()
{
// The runner stores run-step inputs under the keys defined in
// PipelineConstants.ScriptStepInputs (camelCase), NOT their
// kebab-case workflow-YAML spellings — see
// ActionManifestManagerWrapper:244.
var action = new ActionStep
{
Reference = new ScriptReference(),
Inputs = Map(
(PipelineConstants.ScriptStepInputs.Script, Str("npm test")),
(PipelineConstants.ScriptStepInputs.Shell, Str("bash")),
(PipelineConstants.ScriptStepInputs.WorkingDirectory, Str("./api"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", new ScriptReference(), action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("npm test", entry.Run);
Assert.Equal("bash", entry.Shell);
Assert.Equal("./api", entry.WorkingDirectory);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_FiltersRunStepKeysFromWith()
{
// Defensive: an action step's Inputs should not contain
// run-step internal keys, but if it did, they must not
// surface in the with: rendering.
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(
("mode", Str("ci")),
(PipelineConstants.ScriptStepInputs.Script, Str("leak")),
(PipelineConstants.ScriptStepInputs.Shell, Str("leak")),
(PipelineConstants.ScriptStepInputs.WorkingDirectory, Str("leak"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.WithYaml);
Assert.Contains("mode: ci", entry.WithYaml);
Assert.DoesNotContain("leak", entry.WithYaml);
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Script, entry.WithYaml);
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Shell, entry.WithYaml);
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.WorkingDirectory, entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_OmitsEmptyEnv()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Environment = new MappingToken(null, null, null),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.EnvYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsEnv()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Environment = Map(("NODE_ENV", Str("production"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.EnvYaml);
Assert.Contains("NODE_ENV: production", entry.EnvYaml);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("__1")]
[InlineData("__123")]
public void Translate_FiltersAutoGeneratedId(string contextName)
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = contextName,
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.Id);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_PreservesUserId()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = "cache-primes",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("cache-primes", entry.Id);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsCondition()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Condition = "always()",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("always()", entry.If);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_PreEntry_OmitsUserParams()
{
// Pre entries stay minimal — they reference the same Action as
// Main, and duplicating params adds noise.
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = "user-id",
Condition = "always()",
Environment = Map(("X", Str("y"))),
Inputs = Map(("k", Str("v"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre a/b@v1", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
Assert.Null(entry.Id);
Assert.Null(entry.If);
Assert.Null(entry.EnvYaml);
Assert.Null(entry.WithYaml);
}
}
}

View File

@@ -0,0 +1,191 @@
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class TemplateTokenYamlAdapterL0
{
private static StringToken Str(string s) => new(null, null, null, s);
private static BooleanToken Bool(bool b) => new(null, null, null, b);
private static NumberToken Num(double n) => new(null, null, null, n);
private static BasicExpressionToken Expr(string s) => new(null, null, null, s);
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_StringScalar()
{
Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_BooleanScalar()
{
Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0));
Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NumberScalar()
{
Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NullToken_RendersAsNull()
{
Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_PreservesBasicExpression()
{
var token = Expr("runner.os");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Contains("${{ runner.os }}", yaml);
Assert.DoesNotContain("Linux", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_PreservesCompositeExpressionInStringToken()
{
// A StringToken constructed directly with the literal text
// round-trips unchanged. (The workflow parser does NOT produce
// a StringToken for this input — see
// Serialize_ReversesFormatRewriteForCompositeExpression — but
// direct StringToken construction must still preserve the
// literal verbatim.)
var token = Str("${{ runner.os }}-primes");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Contains("${{ runner.os }}-primes", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_ReversesFormatRewriteForCompositeExpression()
{
// The workflow parser tokenizes a mixed scalar like
// `${{ runner.os }}-primes` as a single BasicExpressionToken
// whose internal expression is `format('{0}-primes', runner.os)`.
// The adapter must surface the author-facing form, not the
// parser's normalized rewrite.
var token = Expr("format('{0}-primes', runner.os)");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Contains("${{ runner.os }}-primes", yaml);
Assert.DoesNotContain("format(", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NestedMapping()
{
var inner = new MappingToken(null, null, null);
inner.Add(Str("b"), Num(1));
inner.Add(Str("c"), Expr("x"));
var outer = new MappingToken(null, null, null);
outer.Add(Str("a"), inner);
string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0);
Assert.Contains("a:", yaml);
Assert.Contains("b: 1", yaml);
Assert.Contains("c: ${{ x }}", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_EmptyMapping()
{
var token = new MappingToken(null, null, null);
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Equal("{}", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_EmptySequence()
{
var token = new SequenceToken(null, null, null);
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Equal("[]", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_MultilineString_UsesBlockScalar()
{
var token = Str("line1\nline2\nline3");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
// Block-literal indicator `|` appears for multi-line scalars.
Assert.Contains("|", yaml);
Assert.Contains("line1", yaml);
Assert.Contains("line2", yaml);
Assert.Contains("line3", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_IndentLevel_PrefixesNonEmptyLines()
{
var map = new MappingToken(null, null, null);
map.Add(Str("k1"), Str("v1"));
map.Add(Str("k2"), Str("v2"));
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4);
foreach (var line in yaml.Split('\n'))
{
if (line.Length > 0)
{
Assert.StartsWith(" ", line);
}
}
Assert.Contains("k1: v1", yaml);
Assert.Contains("k2: v2", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NoTrailingNewline()
{
var token = Str("hello");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.False(yaml.EndsWith("\n"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_AlwaysUsesLfLineBreaks()
{
// Regression: YamlDotNet's Emitter calls WriteLine, which on
// Windows produces CRLF (the host's Environment.NewLine).
// Serialize must force LF so the rendered view round-trips
// regardless of platform.
var map = new MappingToken(null, null, null);
map.Add(Str("k1"), Str("v1"));
map.Add(Str("k2"), Num(2));
map.Add(Str("k3"), Bool(true));
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 2);
Assert.DoesNotContain("\r", yaml);
}
}
}

View File

@@ -1,119 +0,0 @@
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.421"
DOTNETSDK_VERSION="8.0.420"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)

View File

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