Compare commits

..

8 Commits

Author SHA1 Message Date
Francesco Renzi
06ae0d86a3 Add DAP execution view coverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-03 13:47:12 +01:00
Francesco Renzi
e615bd8a80 Add DAP source message coverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-03 13:47:12 +01:00
Francesco Renzi
d8a18c194c Add DAP job execution view source
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-03 13:47:12 +01:00
Driele Neves Ribeiro
c6a124e184 Populate telemetry for non-action post-job steps (#4463)
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
2026-05-28 17:15:49 +00:00
Salman Chishti
1a6560294e Update Node 24 default date to June 16th, 2026 (#4462) 2026-05-28 16:43:55 +01:00
Tingluo Huang
3ff2186ec0 Allow disable node v8 maglev jit compiler on node24. (#4447) 2026-05-26 19:05:09 +00:00
github-actions[bot]
7c0b271d2e chore: update Node versions (#4452)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-25 14:30:15 +00:00
Driele Neves Ribeiro
0b3b8e0ba7 Update snapshot-if context and functions (#4443) 2026-05-21 15:49:31 -05:00
20 changed files with 1378 additions and 1131 deletions

3
.gitignore vendored
View File

@@ -27,4 +27,5 @@ TestResults
TestLogs
.DS_Store
.mono
**/*.DotSettings.user
**/*.DotSettings.user
**/*.lscache

View File

@@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
NODE20_VERSION="20.20.2"
NODE24_VERSION="24.15.0"
NODE24_VERSION="24.16.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args

View File

@@ -206,7 +206,7 @@ namespace GitHub.Runner.Common
public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/";
// Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables)
public static readonly string Node24DefaultDate = "June 2nd, 2026";
public static readonly string Node24DefaultDate = "June 16th, 2026";
public static readonly string Node20RemovalDate = "September 16th, 2026";
// Variable keys for server-overridable dates

View File

@@ -16,6 +16,7 @@ using Microsoft.DevTunnels.Connections;
using Microsoft.DevTunnels.Contracts;
using Microsoft.DevTunnels.Management;
using Newtonsoft.Json;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
@@ -27,6 +28,7 @@ namespace GitHub.Runner.Worker.Dap
public string DisplayName { get; set; }
public TaskResult? Result { get; set; }
public int FrameId { get; set; }
public int? SourceLine { get; set; }
}
/// <summary>
@@ -54,6 +56,9 @@ namespace GitHub.Runner.Worker.Dap
// Frame IDs for completed steps start at 1000
private const int _completedFrameIdBase = 1000;
// Stable session-scoped source reference for the synthesized job step list.
private const int _jobStepsSourceReference = 1;
private TcpListener _listener;
private TcpClient _client;
private NetworkStream _stream;
@@ -98,6 +103,8 @@ namespace GitHub.Runner.Worker.Dap
// Track completed steps for stack trace
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
private int _nextCompletedFrameId = _completedFrameIdBase;
private JobExecutionView _jobStepsSource;
private bool _jobCompleted;
// Client connection tracking for reconnection support
private volatile bool _isClientConnected;
@@ -240,6 +247,179 @@ namespace GitHub.Runner.Worker.Dap
}
}
public Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps)
{
if (!IsActive)
{
return Task.CompletedTask;
}
try
{
IExecutionContext jobContext;
lock (_stateLock)
{
if (_state != DapSessionState.Ready &&
_state != DapSessionState.Paused &&
_state != DapSessionState.Running)
{
return Task.CompletedTask;
}
jobContext = _jobContext;
}
var stepList = steps?.Where(step => step != null).ToList() ?? new List<IStep>();
var initialPostStepList = initialPostSteps?.Where(step => step != null).ToList() ?? new List<IStep>();
var jobId = jobContext?.GetGitHubContext("job");
var snapshot = new JobExecutionView(
jobId,
stepList,
initialPostStepList,
PredictPostSteps(jobContext, stepList, initialPostStepList));
lock (_stateLock)
{
_jobStepsSource = snapshot;
_jobCompleted = false;
}
Trace.Info("DAP job steps source initialized");
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobStepsInitialized error.");
Trace.Error(ex);
}
return Task.CompletedTask;
}
public void OnPostStepRegistered(IStep step)
{
try
{
if (step is IActionRunner postRunner && postRunner.Action != null)
{
JobExecutionView snapshot;
lock (_stateLock)
{
snapshot = _jobStepsSource;
}
var line = snapshot?.TryClaimPredictedStep(MatchKeyFor(postRunner.Action.Id), step);
if (line.HasValue)
{
Trace.Info($"DAP job steps source claimed predicted post step '{step.DisplayName}' at line {line.Value}.");
}
else
{
Trace.Info($"DAP job steps source had no predicted line for post step '{step.DisplayName}'.");
}
}
}
catch (Exception ex)
{
Trace.Warning("DAP OnPostStepRegistered error.");
Trace.Error(ex);
}
}
private IReadOnlyList<JobExecutionView.PredictedPostStep> PredictPostSteps(
IExecutionContext jobContext,
IReadOnlyList<IStep> steps,
IReadOnlyList<IStep> initialPostSteps)
{
if (jobContext == null || steps == null || steps.Count == 0)
{
return Array.Empty<JobExecutionView.PredictedPostStep>();
}
IActionManager actionManager;
try
{
actionManager = HostContext.GetService<IActionManager>();
}
catch (Exception ex)
{
Trace.Info($"DAP post-step predictor skipped because IActionManager is unavailable ({ex.Message}).");
return Array.Empty<JobExecutionView.PredictedPostStep>();
}
var predictions = new List<JobExecutionView.PredictedPostStep>();
var seenActionIds = new HashSet<Guid>();
if (initialPostSteps != null)
{
foreach (var postStep in initialPostSteps)
{
if (postStep is IActionRunner postRunner && postRunner.Action != null)
{
seenActionIds.Add(postRunner.Action.Id);
}
}
}
foreach (var step in steps)
{
if (step is not IActionRunner runner ||
runner.Stage == ActionRunStage.Post ||
runner.Action == null)
{
continue;
}
var action = runner.Action;
if (action.Reference is not Pipelines.RepositoryPathReference repoRef)
{
continue;
}
if (!seenActionIds.Add(action.Id))
{
continue;
}
Definition definition;
try
{
definition = actionManager.LoadAction(jobContext, action);
}
catch (Exception ex)
{
Trace.Info($"DAP post-step predictor could not load action '{repoRef.Name}' ({ex.Message}).");
continue;
}
if (definition?.Data?.Execution?.HasPost != true)
{
continue;
}
predictions.Add(new JobExecutionView.PredictedPostStep(
GetPostDisplayName(runner),
MatchKeyFor(action.Id)));
}
predictions.Reverse();
return predictions;
}
private static string GetPostDisplayName(IActionRunner runner)
{
var displayName = string.IsNullOrEmpty(runner.DisplayName) ? "step" : runner.DisplayName;
if (runner.Stage == ActionRunStage.Pre &&
displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase))
{
displayName = displayName.Substring("Pre ".Length);
}
return $"Post {displayName}";
}
private static string MatchKeyFor(Guid actionId)
{
return $"post:{actionId:N}";
}
public async Task OnJobCompletedAsync()
{
if (_state != DapSessionState.NotStarted)
@@ -253,6 +433,11 @@ namespace GitHub.Runner.Worker.Dap
if (_jobContext != null)
{
Trace.Info("Job completed — pausing for inspection");
lock (_stateLock)
{
_jobCompleted = true;
}
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
await WaitForCommandAsync(_jobContext.CancellationToken);
@@ -359,6 +544,7 @@ namespace GitHub.Runner.Worker.Dap
{
_state = DapSessionState.Terminated;
}
_jobStepsSource = null;
}
_isClientConnected = false;
@@ -417,7 +603,8 @@ namespace GitHub.Runner.Worker.Dap
{
DisplayName = step.DisplayName,
Result = result,
FrameId = _nextCompletedFrameId++
FrameId = _nextCompletedFrameId++,
SourceLine = _jobStepsSource?.TryGetLineForStep(step)
});
}
}
@@ -468,6 +655,7 @@ namespace GitHub.Runner.Worker.Dap
"next" => HandleNext(request),
"setBreakpoints" => HandleSetBreakpoints(request),
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
"source" => HandleSource(request),
"completions" => HandleCompletions(request),
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null),
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null),
@@ -857,6 +1045,7 @@ namespace GitHub.Runner.Worker.Dap
{
bool pauseOnNextStep;
CancellationToken cancellationToken;
lock (_stateLock)
{
if (_state != DapSessionState.Ready &&
@@ -868,6 +1057,7 @@ namespace GitHub.Runner.Worker.Dap
_currentStep = step;
_currentStepIndex = _completedSteps.Count;
_jobCompleted = false;
pauseOnNextStep = _pauseOnNextStep;
cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None;
}
@@ -1050,29 +1240,46 @@ namespace GitHub.Runner.Worker.Dap
private Response HandleStackTrace(Request request)
{
IStep currentStep;
int currentStepIndex;
CompletedStepInfo[] completedSteps;
JobExecutionView jobStepsSource;
bool jobCompleted;
lock (_stateLock)
{
currentStep = _currentStep;
currentStepIndex = _currentStepIndex;
completedSteps = _completedSteps.ToArray();
jobStepsSource = _jobStepsSource;
jobCompleted = _jobCompleted;
}
var frames = new List<StackFrame>();
var source = jobStepsSource != null ? BuildJobStepsSource(jobStepsSource) : null;
// Add current step as the top frame
if (currentStep != null)
if (jobCompleted && jobStepsSource != null)
{
frames.Add(new StackFrame
{
Id = _currentFrameId,
Name = "Complete job [completed]",
Source = source,
Line = jobStepsSource.CompleteJobLine,
Column = 1,
PresentationHint = "normal"
});
}
else if (currentStep != null)
{
var resultIndicator = currentStep.ExecutionContext?.Result != null
? $" [{currentStep.ExecutionContext.Result}]"
: " [running]";
var currentSourceLine = jobStepsSource?.TryGetLineForStep(currentStep);
frames.Add(new StackFrame
{
Id = _currentFrameId,
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
Line = currentStepIndex + 1,
Source = currentSourceLine.HasValue ? source : null,
Line = currentSourceLine ?? 0,
Column = 1,
PresentationHint = "normal"
});
@@ -1098,7 +1305,8 @@ namespace GitHub.Runner.Worker.Dap
{
Id = completedStep.FrameId,
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
Line = 1,
Source = completedStep.SourceLine.HasValue ? source : null,
Line = completedStep.SourceLine ?? 0,
Column = 1,
PresentationHint = "subtle"
});
@@ -1113,6 +1321,76 @@ namespace GitHub.Runner.Worker.Dap
return CreateResponse(request, true, body: body);
}
private Source BuildJobStepsSource(JobExecutionView snapshot)
{
return new Source
{
Name = MaskUserVisibleText(snapshot.SourceFileName),
Path = MaskUserVisibleText($"{SanitizeSourcePathSegment(snapshot.JobId)}/{snapshot.SourceFileName}"),
SourceReference = _jobStepsSourceReference,
PresentationHint = "normal"
};
}
private static string SanitizeSourcePathSegment(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "job";
}
var builder = new StringBuilder(value.Length);
foreach (var character in value)
{
builder.Append(char.IsControl(character) || character == '/' || character == '\\'
? '_'
: character);
}
return builder.Length == 0 ? "job" : builder.ToString();
}
internal Response HandleSource(Request request)
{
SourceArguments args;
try
{
args = request.Arguments?.ToObject<SourceArguments>();
}
catch (Exception ex)
{
Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}");
return CreateResponse(request, false, "Invalid source arguments.", body: null);
}
var sourceReference = args?.Source?.SourceReference ?? args?.SourceReference;
if (!sourceReference.HasValue)
{
return CreateResponse(request, false, "Missing source reference.", body: null);
}
JobExecutionView snapshot;
lock (_stateLock)
{
snapshot = _jobStepsSource;
}
if (snapshot == null)
{
return CreateResponse(request, false, "Job steps source not yet available.", body: null);
}
if (sourceReference.Value != _jobStepsSourceReference)
{
return CreateResponse(request, false, $"Unknown source reference: {sourceReference.Value}.", body: null);
}
return CreateResponse(request, true, body: new SourceResponseBody
{
Content = MaskUserVisibleText(snapshot.Content)
});
}
private Response HandleScopes(Request request)
{
var args = request.Arguments?.ToObject<ScopesArguments>();

View File

@@ -537,6 +537,46 @@ namespace GitHub.Runner.Worker.Dap
#endregion
#region Source Request/Response
/// <summary>
/// Arguments for 'source' request.
/// </summary>
public class SourceArguments
{
/// <summary>
/// Source descriptor. Some clients send sourceReference only here.
/// </summary>
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
public Source Source { get; set; }
/// <summary>
/// The reference to the source.
/// </summary>
[JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)]
public int? SourceReference { get; set; }
}
/// <summary>
/// Response body for 'source' request.
/// </summary>
public class SourceResponseBody
{
/// <summary>
/// Content of the source as a string.
/// </summary>
[JsonProperty("content")]
public string Content { get; set; }
/// <summary>
/// Optional content type / mime type of the source.
/// </summary>
[JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)]
public string MimeType { get; set; }
}
#endregion
#region Scopes Request/Response
/// <summary>

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
@@ -19,6 +20,8 @@ namespace GitHub.Runner.Worker.Dap
{
Task StartAsync(IExecutionContext jobContext);
Task WaitUntilReadyAsync();
Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps);
void OnPostStepRegistered(IStep step);
Task OnStepStartingAsync(IStep step);
void OnStepCompleted(IStep step);
Task OnJobCompletedAsync();

View File

@@ -0,0 +1,358 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace GitHub.Runner.Worker.Dap
{
internal sealed class JobExecutionView
{
private const string _sourceFileName = "execution.yml";
private readonly object _lock = new object();
private readonly List<SourceEntry> _preEntries = new List<SourceEntry>();
private readonly List<SourceEntry> _mainEntries = new List<SourceEntry>();
private readonly List<SourceEntry> _postEntries = new List<SourceEntry>();
private readonly List<StepLine> _lineByStep = new List<StepLine>();
private string _content;
private int _completeJobLine;
public JobExecutionView(
string jobId,
IEnumerable<IStep> steps,
IEnumerable<IStep> initialPostSteps,
IEnumerable<PredictedPostStep> predictedPostSteps = null)
{
JobId = string.IsNullOrWhiteSpace(jobId) ? "job" : jobId;
_preEntries.Add(new SourceEntry("Setup job"));
AddSteps(steps);
AddPredictedPostSteps(predictedPostSteps);
AddSteps(initialPostSteps);
_postEntries.Add(SourceEntry.CreateSyntheticCompleteJob());
Render();
}
public string JobId { get; }
public string SourceFileName => _sourceFileName;
public string Content
{
get
{
lock (_lock)
{
return _content;
}
}
}
public int CompleteJobLine
{
get
{
lock (_lock)
{
return _completeJobLine;
}
}
}
public int? TryClaimPredictedStep(string matchKey, IStep step)
{
if (string.IsNullOrEmpty(matchKey) || step == null)
{
return null;
}
lock (_lock)
{
var existingLine = TryGetLineForStepNoLock(step);
if (existingLine.HasValue)
{
return existingLine;
}
foreach (var entry in _postEntries)
{
if (!string.Equals(entry.MatchKey, matchKey, StringComparison.Ordinal))
{
continue;
}
if (entry.Step != null && !ReferenceEquals(entry.Step, step))
{
return null;
}
entry.Step = step;
Render();
return TryGetLineForStepNoLock(step);
}
return null;
}
}
public int? TryGetLineForStep(IStep step)
{
if (step == null)
{
return null;
}
lock (_lock)
{
return TryGetLineForStepNoLock(step);
}
}
private int? TryGetLineForStepNoLock(IStep step)
{
foreach (var stepLine in _lineByStep)
{
if (ReferenceEquals(stepLine.Step, step))
{
return stepLine.Line;
}
}
return null;
}
private void AddSteps(IEnumerable<IStep> steps)
{
if (steps == null)
{
return;
}
foreach (var step in steps)
{
if (step == null)
{
continue;
}
GetEntries(GetSection(step)).Add(new SourceEntry(step));
}
}
private void AddPredictedPostSteps(IEnumerable<PredictedPostStep> steps)
{
if (steps == null)
{
return;
}
foreach (var step in steps)
{
if (step == null)
{
continue;
}
_postEntries.Add(new SourceEntry(step.DisplayName, step.MatchKey));
}
}
private List<SourceEntry> GetEntries(SourceSection section)
{
switch (section)
{
case SourceSection.Pre:
return _preEntries;
case SourceSection.Post:
return _postEntries;
default:
return _mainEntries;
}
}
private static SourceSection GetSection(IStep step)
{
if (step is IActionRunner actionRunner)
{
return GetSection(actionRunner.Stage);
}
if (step.ExecutionContext != null)
{
return GetSection(step.ExecutionContext.Stage);
}
return SourceSection.Main;
}
private static SourceSection GetSection(ActionRunStage stage)
{
switch (stage)
{
case ActionRunStage.Pre:
return SourceSection.Pre;
case ActionRunStage.Post:
return SourceSection.Post;
default:
return SourceSection.Main;
}
}
private void Render()
{
_lineByStep.Clear();
_completeJobLine = 0;
var sb = new StringBuilder();
var line = 1;
AppendSection(sb, "pre", _preEntries, ref line, appendSeparatorLine: true);
AppendSection(sb, "main", _mainEntries, ref line, appendSeparatorLine: true);
AppendSection(sb, "post", _postEntries, ref line, appendSeparatorLine: false);
_content = sb.ToString();
}
private void AppendSection(
StringBuilder sb,
string sectionName,
IReadOnlyList<SourceEntry> entries,
ref int line,
bool appendSeparatorLine)
{
sb.Append(sectionName).Append(":\n");
line++;
foreach (var entry in entries)
{
if (entry.Step != null && TryGetLineForStepNoLock(entry.Step) == null)
{
_lineByStep.Add(new StepLine(entry.Step, line));
}
sb.Append(" - step: ");
sb.Append(FormatYamlString(entry.DisplayName));
sb.Append('\n');
if (entry.IsSyntheticCompleteJob)
{
_completeJobLine = line;
}
line++;
}
if (appendSeparatorLine)
{
sb.Append('\n');
line++;
}
}
private static string FormatYamlString(string value)
{
var sb = new StringBuilder();
sb.Append('"');
foreach (var c in value)
{
switch (c)
{
case '\\':
sb.Append(@"\\");
break;
case '"':
sb.Append("\\\"");
break;
case '\r':
sb.Append(@"\r");
break;
case '\n':
sb.Append(@"\n");
break;
case '\t':
sb.Append(@"\t");
break;
default:
if (char.IsControl(c))
{
sb.Append(@"\u");
sb.Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
}
else
{
sb.Append(c);
}
break;
}
}
sb.Append('"');
return sb.ToString();
}
internal sealed class PredictedPostStep
{
public PredictedPostStep(string displayName, string matchKey)
{
DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName;
MatchKey = matchKey;
}
public string DisplayName { get; }
public string MatchKey { get; }
}
private sealed class StepLine
{
public StepLine(IStep step, int line)
{
Step = step;
Line = line;
}
public IStep Step { get; }
public int Line { get; }
}
private sealed class SourceEntry
{
public SourceEntry(string displayName)
{
DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName;
}
public SourceEntry(string displayName, string matchKey)
: this(displayName)
{
MatchKey = matchKey;
}
public SourceEntry(IStep step)
{
Step = step;
DisplayName = string.IsNullOrEmpty(step.DisplayName) ? "step" : step.DisplayName;
}
private SourceEntry(string displayName, bool isSyntheticCompleteJob)
: this(displayName)
{
IsSyntheticCompleteJob = isSyntheticCompleteJob;
}
public static SourceEntry CreateSyntheticCompleteJob()
{
return new SourceEntry("Complete job", isSyntheticCompleteJob: true);
}
public IStep Step { get; set; }
public string DisplayName { get; }
public string MatchKey { get; }
public bool IsSyntheticCompleteJob { get; }
}
private enum SourceSection
{
Pre,
Main,
Post
}
}
}

View File

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

View File

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

@@ -337,7 +337,25 @@ namespace GitHub.Runner.Worker
}
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
if (step is JobExtensionRunner)
{
step.ExecutionContext.StepTelemetry.Type = "runner";
step.ExecutionContext.StepTelemetry.Action = step.DisplayName.ToLowerInvariant().Replace(' ', '_');
}
Root.PostJobSteps.Push(step);
if (Root.Global.Debugger?.Enabled == true)
{
try
{
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
}
catch (Exception ex)
{
Trace.Warning("Failed to notify DAP debugger about registered post job step.");
Trace.Error(ex);
}
}
}
public IExecutionContext CreateChild(

View File

@@ -12,6 +12,7 @@ using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Container.ContainerHooks;
using GitHub.Services.Common;
namespace GitHub.Runner.Worker.Handlers
{
@@ -128,6 +129,15 @@ namespace GitHub.Runner.Worker.Handlers
// file name character on Linux.
string arguments = StepHost.ResolvePathForStepHost(ExecutionContext, StringUtil.Format(@"""{0}""", target.Replace(@"""", @"\""")));
// Disable maglev jit compiler in node.js 24.x.x on x64 Windows until the node.js bug is fixed.
// https://github.com/nodejs/node/issues/62260
if (nodeRuntimeVersion.StartsWith("node24", StringComparison.OrdinalIgnoreCase) &&
(StringUtil.ConvertToBoolean(System.Environment.GetEnvironmentVariable("ACTIONS_RUNNER_DISABLE_NODE_MAGLEV")) || StringUtil.ConvertToBoolean(Environment.GetValueOrDefault("ACTIONS_RUNNER_DISABLE_NODE_MAGLEV"))))
{
Trace.Info("Disable maglev jit compiler in node.js");
arguments = $"--no-maglev {arguments}";
}
#if OS_WINDOWS
// It appears that node.exe outputs UTF8 when not in TTY mode.
Encoding outputEncoding = Encoding.UTF8;

View File

@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
using Sdk.RSWebApi.Contracts;
@@ -230,6 +231,12 @@ namespace GitHub.Runner.Worker
jobContext.JobSteps.Enqueue(step);
}
if (jobContext.Global.Debugger?.Enabled == true)
{
var dapDebugger = HostContext.GetService<IDapDebugger>();
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
}
await stepsRunner.RunAsync(jobContext);
}
catch (Exception ex)

View File

@@ -186,7 +186,16 @@
"vars",
"needs",
"strategy",
"matrix"
"matrix",
"steps",
"job",
"runner",
"env",
"always(0,0)",
"failure(0,0)",
"cancelled(0,0)",
"success(0,0)",
"hashFiles(1,255)"
],
"string": {}
},

View File

@@ -2291,6 +2291,10 @@ namespace GitHub.Actions.WorkflowParser.Conversion
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Needs),
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Strategy),
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Matrix),
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Steps),
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Job),
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Runner),
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Env),
};
private static readonly IFunctionInfo[] s_jobConditionFunctions = new IFunctionInfo[]
{
@@ -2307,6 +2311,13 @@ namespace GitHub.Actions.WorkflowParser.Conversion
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
};
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = null;
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = new IFunctionInfo[]
{
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Always, 0, 0),
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Cancelled, 0, 0),
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Failure, 0, 0),
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
};
}
}

View File

@@ -2196,7 +2196,16 @@
"vars",
"needs",
"strategy",
"matrix"
"matrix",
"steps",
"job",
"runner",
"env",
"always(0,0)",
"failure(0,0)",
"cancelled(0,0)",
"success(0,0)",
"hashFiles(1,255)"
],
"description": "Use the if conditional to prevent a snapshot from being taken unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
"string": {

View File

@@ -11,7 +11,9 @@ using Moq;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Common.Tests.Worker
{
@@ -255,6 +257,78 @@ namespace GitHub.Runner.Common.Tests.Worker
return jobContext;
}
private static Mock<IStep> CreateStep(string displayName, ActionRunStage? stage = null)
{
var step = new Mock<IStep>();
step.Setup(s => s.DisplayName).Returns(displayName);
if (stage.HasValue)
{
var executionContext = new Mock<IExecutionContext>();
executionContext.Setup(x => x.Stage).Returns(stage.Value);
step.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
}
else
{
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
}
return step;
}
private static Mock<IActionRunner> CreateActionRunner(string displayName, ActionRunStage stage, Pipelines.ActionStep action)
{
var executionContext = new Mock<IExecutionContext>();
executionContext.Setup(x => x.Stage).Returns(stage);
var runner = new Mock<IActionRunner>();
runner.Setup(s => s.DisplayName).Returns(displayName);
runner.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
runner.Setup(s => s.Stage).Returns(stage);
runner.Setup(s => s.Action).Returns(action);
return runner;
}
private static Pipelines.ActionStep CreateRepositoryActionStep(string name)
{
return new Pipelines.ActionStep
{
Id = Guid.NewGuid(),
Name = name,
Reference = new Pipelines.RepositoryPathReference
{
Name = name,
Ref = "v1",
RepositoryType = Pipelines.RepositoryTypes.GitHub
}
};
}
private static Definition CreateActionDefinitionWithPost()
{
return new Definition
{
Data = new ActionDefinitionData
{
Execution = new NodeJSActionExecutionData
{
Script = "main.js",
Post = "post.js"
}
}
};
}
private static Request MakeRequest(string command, object arguments)
{
return new Request
{
Seq = 1,
Type = "request",
Command = command,
Arguments = JObject.FromObject(arguments)
};
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -718,6 +792,325 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleSourceReturnsJobStepsSource()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue("secret-step");
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var pre = CreateStep("Pre cache", ActionRunStage.Pre);
var checkout = CreateStep("Checkout");
var secret = CreateStep("secret-step");
var post = CreateStep("Post cache", ActionRunStage.Post);
await _debugger.OnJobStepsInitializedAsync(
new[] { pre.Object, checkout.Object, secret.Object },
new[] { post.Object });
var response = _debugger.HandleSource(MakeRequest(
"source",
new SourceArguments { SourceReference = 1 }));
Assert.True(response.Success);
var body = Assert.IsType<SourceResponseBody>(response.Body);
Assert.Equal(
"pre:\n - step: \"Setup job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n - step: \"***\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n",
body.Content);
Assert.Null(body.MimeType);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StackTraceUsesJobStepsSourceLine()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
var build = CreateStep("Build");
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object, build.Object },
Array.Empty<IStep>());
var stepTask = _debugger.OnStepStartingAsync(build.Object);
var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedEvent);
var bannerEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", bannerEvent);
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal(6, frame["line"].Value<int>());
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
Assert.Equal("execution.yml", frame["source"]["name"].Value<string>());
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await stepTask;
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StackTraceOmitsSourceForUnmappedCurrentStep()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
var build = CreateStep("Build");
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
Array.Empty<IStep>());
var stepTask = _debugger.OnStepStartingAsync(build.Object);
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal(0, frame["line"].Value<int>());
Assert.Null(frame["source"]);
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await stepTask;
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task PredictedPostStepIsServedAtInitializationAndClaimedAtRegistration()
{
using (var hc = CreateTestContext())
{
var action = CreateRepositoryActionStep("actions/cache");
var actionManager = new Mock<IActionManager>();
actionManager
.Setup(x => x.LoadAction(It.IsAny<IExecutionContext>(), action))
.Returns(CreateActionDefinitionWithPost());
hc.SetSingleton<IActionManager>(actionManager.Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action);
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
Array.Empty<IStep>());
var sourceResponse = _debugger.HandleSource(MakeRequest(
"source",
new SourceArguments { SourceReference = 1 }));
var sourceBody = Assert.IsType<SourceResponseBody>(sourceResponse.Body);
Assert.Equal(
"pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n",
sourceBody.Content);
var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action);
_debugger.OnPostStepRegistered(post.Object);
var stepTask = _debugger.OnStepStartingAsync(post.Object);
var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedEvent);
var bannerEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", bannerEvent);
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal(8, frame["line"].Value<int>());
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await stepTask;
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StackTraceSanitizesSyntheticSourcePath()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, jobName: "my/job\\name");
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
Array.Empty<IStep>());
var stepTask = _debugger.OnStepStartingAsync(checkout.Object);
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal("my_job_name/execution.yml", frame["source"]["path"].Value<string>());
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await stepTask;
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -746,6 +1139,11 @@ namespace GitHub.Runner.Common.Tests.Worker
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
Array.Empty<IStep>());
// Complete the job — OnJobCompletedAsync pauses when stepping,
// so run it in the background and send continue to unblock.
var completedTask = _debugger.OnJobCompletedAsync();
@@ -754,11 +1152,27 @@ namespace GitHub.Runner.Common.Tests.Worker
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
// Send continue to unblock the pause
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal("Complete job [completed]", frame["name"].Value<string>());
Assert.Equal(8, frame["line"].Value<int>());
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
// Send continue to unblock the pause
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
@@ -777,6 +1191,68 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobCompletedUsesSyntheticCompleteJobLineWhenPostStepSharesName()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
var realPost = CreateStep("Complete job", ActionRunStage.Post);
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
new[] { realPost.Object });
var completedTask = _debugger.OnJobCompletedAsync();
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal("Complete job [completed]", frame["name"].Value<string>());
Assert.Equal(9, frame["line"].Value<int>());
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await completedTask;
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
@@ -171,6 +171,36 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Equal("normal", deserialized.PresentationHint);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SourceRequestAndResponseSerialization()
{
var args = new SourceArguments
{
Source = new Source
{
SourceReference = 1
}
};
var argsJson = JsonConvert.SerializeObject(args);
var deserializedArgs = JsonConvert.DeserializeObject<SourceArguments>(argsJson);
Assert.Equal(1, deserializedArgs.Source.SourceReference);
var body = new SourceResponseBody
{
Content = "pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Complete job\"\n"
};
var bodyJson = JsonConvert.SerializeObject(body);
var deserializedBody = JsonConvert.DeserializeObject<SourceResponseBody>(bodyJson);
Assert.Equal("pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Complete job\"\n", deserializedBody.Content);
Assert.Null(deserializedBody.MimeType);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]

View File

@@ -361,6 +361,119 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobStep_JobExtensionRunner_DefaultsRunnerTelemetry()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message.
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger1 = new Mock<IPagingLogger>();
var pagingLogger2 = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
hc.EnqueueInstance(pagingLogger1.Object);
hc.EnqueueInstance(pagingLogger2.Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
// Act.
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var extensionStep = new JobExtensionRunner(
runAsync: (_, _) => System.Threading.Tasks.Task.CompletedTask,
condition: "always()",
displayName: "Create Custom Image",
data: null);
jobContext.RegisterPostJobStep(extensionStep);
// Assert: telemetry defaults are populated for non-action post-job steps.
Assert.NotNull(extensionStep.ExecutionContext);
Assert.Equal("runner", extensionStep.ExecutionContext.StepTelemetry.Type);
Assert.Equal("create_custom_image", extensionStep.ExecutionContext.StepTelemetry.Action);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobStep_ActionRunner_DoesNotOverrideTelemetry()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message.
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger1 = new Mock<IPagingLogger>();
var pagingLogger2 = new Mock<IPagingLogger>();
var pagingLogger3 = new Mock<IPagingLogger>();
var pagingLogger4 = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
hc.EnqueueInstance(pagingLogger1.Object);
hc.EnqueueInstance(pagingLogger2.Object);
hc.EnqueueInstance(pagingLogger3.Object);
hc.EnqueueInstance(pagingLogger4.Object);
hc.EnqueueInstance(actionRunner as IActionRunner);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
// Act.
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var action = jobContext.CreateChild(Guid.NewGuid(), "action", "action", null, null, 0);
var postRunner = hc.CreateService<IActionRunner>();
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post Action", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
postRunner.Stage = ActionRunStage.Post;
postRunner.Condition = "always()";
postRunner.DisplayName = "Post Action";
action.RegisterPostJobStep(postRunner);
// Assert: action post-step telemetry is left for the handler to fill in,
// so RegisterPostJobStep should NOT pre-populate runner-owned defaults.
Assert.NotNull(postRunner.ExecutionContext);
Assert.Null(postRunner.ExecutionContext.StepTelemetry.Type);
Assert.Null(postRunner.ExecutionContext.StepTelemetry.Action);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]

View File

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

View File

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