Compare commits

..

13 Commits

Author SHA1 Message Date
Francesco Renzi
915e13c842 Integrate DAP debugger into JobRunner and StepsRunner 2026-03-11 08:56:08 -07:00
Francesco Renzi
17b05ddaa4 Add minimal DAP debug session with next/continue support 2026-03-11 08:55:54 -07:00
Francesco Renzi
9737dfadd5 Add DAP TCP server with reconnection support 2026-03-11 08:55:41 -07:00
Francesco Renzi
cca15de3b3 Add DAP protocol message types and service interfaces 2026-03-11 08:55:17 -07:00
Francesco Renzi
8b1b23b5ce Get EnableDebugger from job context 2026-03-10 04:13:39 -07:00
eric sciple
20111cbfda Support entrypoint and command for service containers (#4276) 2026-03-04 23:36:45 +00:00
Max Horstmann
8f01257663 Devcontainer: bump base image Ubuntu version (#4277) 2026-03-04 20:17:25 +00:00
eric sciple
8a73bccebb Fix parser comparison mismatches (#4273) 2026-03-03 05:38:16 +00:00
Tingluo Huang
a9a07a6553 Avoid throw in SelfUpdaters. (#4274) 2026-03-02 22:44:14 -05:00
github-actions[bot]
60a9422599 chore: update Node versions (#4272)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 13:51:11 +00:00
dependabot[bot]
985a06fcca Bump actions/download-artifact from 7 to 8 (#4269)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 09:18:13 +00:00
eric sciple
ae09a9d7b5 Fix composite post-step marker display names (#4267) 2026-02-26 08:36:55 -06:00
Tingluo Huang
7650fc432e Log inner exception message. (#4265) 2026-02-25 15:44:27 -05:00
39 changed files with 4204 additions and 72 deletions

View File

@@ -1,8 +1,8 @@
{
"name": "Actions Runner Devcontainer",
"image": "mcr.microsoft.com/devcontainers/base:focal",
"image": "mcr.microsoft.com/devcontainers/base:noble",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.418"
},

View File

@@ -133,37 +133,37 @@ jobs:
# Download runner package tar.gz/zip produced by 'build' job
- name: Download Artifact (win-x64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-win-x64
path: ./
- name: Download Artifact (win-arm64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-win-arm64
path: ./
- name: Download Artifact (osx-x64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-osx-x64
path: ./
- name: Download Artifact (osx-arm64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-osx-arm64
path: ./
- name: Download Artifact (linux-x64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-linux-x64
path: ./
- name: Download Artifact (linux-arm)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-linux-arm
path: ./
- name: Download Artifact (linux-arm64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-linux-arm64
path: ./

View File

@@ -1 +1 @@
2.332.0
<Update to ./src/runnerversion when creating release>

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.0"
NODE24_VERSION="24.13.1"
NODE24_VERSION="24.14.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args

View File

@@ -172,6 +172,7 @@ namespace GitHub.Runner.Common
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser";
public static readonly string ServiceContainerCommand = "actions_service_container_command";
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";

View File

@@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener
}
catch (Exception ex)
{
Trace.Error(ex);
_terminal.WriteError($"Runner update failed: {ex.Message}");
_updateTrace.Enqueue(ex.ToString());
throw;
return false;
}
finally
{

View File

@@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener
}
catch (Exception ex)
{
Trace.Error(ex);
_terminal.WriteError($"Runner update failed: {ex.Message}");
_updateTrace.Enqueue(ex.ToString());
throw;
return false;
}
finally
{

View File

@@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker
{
// Log the error and fail the PrepareActionsAsync Initialization.
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
executionContext.InfrastructureError(ex.Message, category: "resolve_action");
executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "resolve_action");
executionContext.Result = TaskResult.Failed;
throw;
}
@@ -818,7 +818,7 @@ namespace GitHub.Runner.Worker
try
{
Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'");
// repository archive from github always contains a nested folder
var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories();
if (nestedDirectories.Length != 1)
@@ -832,14 +832,14 @@ namespace GitHub.Runner.Worker
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName);
}
executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'");
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
{
Type = JobTelemetryType.General,
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink"
});
Trace.Info("Finished getting action repository.");
return;
}

View File

@@ -36,6 +36,8 @@ namespace GitHub.Runner.Worker.Container
this.ContainerImage = containerImage;
this.ContainerDisplayName = $"{container.Alias}_{Pipelines.Validation.NameValidation.Sanitize(containerImage)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}";
this.ContainerCreateOptions = container.Options;
this.ContainerEntryPoint = container.Entrypoint;
this.ContainerEntryPointArgs = container.Command;
_environmentVariables = container.Environment;
this.IsJobContainer = isJobContainer;
this.ContainerNetworkAlias = networkAlias;

View File

@@ -0,0 +1,644 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using Newtonsoft.Json;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Stores information about a completed step for stack trace display.
/// </summary>
internal sealed class CompletedStepInfo
{
public string DisplayName { get; set; }
public TaskResult? Result { get; set; }
public int FrameId { get; set; }
}
/// <summary>
/// Minimal production DAP debug session.
/// Handles step-level breakpoints with next/continue flow control,
/// client reconnection, and cancellation signal propagation.
///
/// Scope inspection, REPL, step manipulation, and time-travel debugging
/// are intentionally deferred to future iterations.
/// </summary>
public sealed class DapDebugSession : RunnerService, IDapDebugSession
{
// Thread ID for the single job execution thread
private const int JobThreadId = 1;
// Frame ID for the current step (always 1)
private const int CurrentFrameId = 1;
// Frame IDs for completed steps start at 1000
private const int CompletedFrameIdBase = 1000;
private IDapServer _server;
private DapSessionState _state = DapSessionState.WaitingForConnection;
// Synchronization for step execution
private TaskCompletionSource<DapCommand> _commandTcs;
private readonly object _stateLock = new object();
// Handshake completion — signaled when configurationDone is received
private readonly TaskCompletionSource<bool> _handshakeTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
// Whether to pause before the next step (set by 'next' command)
private bool _pauseOnNextStep = true;
// Current execution context
private IStep _currentStep;
private IExecutionContext _jobContext;
private int _currentStepIndex;
// Track completed steps for stack trace
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
private int _nextCompletedFrameId = CompletedFrameIdBase;
// Client connection tracking for reconnection support
private volatile bool _isClientConnected;
public bool IsActive =>
_state == DapSessionState.Ready ||
_state == DapSessionState.Paused ||
_state == DapSessionState.Running;
public DapSessionState State => _state;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
Trace.Info("DapDebugSession initialized");
}
public void SetDapServer(IDapServer server)
{
_server = server;
Trace.Info("DAP server reference set");
}
public async Task WaitForHandshakeAsync(CancellationToken cancellationToken)
{
Trace.Info("Waiting for DAP handshake (configurationDone)...");
using (cancellationToken.Register(() => _handshakeTcs.TrySetCanceled()))
{
await _handshakeTcs.Task;
}
Trace.Info("DAP handshake complete, session is ready");
}
#region Message Dispatch
public async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken)
{
Request request = null;
try
{
request = JsonConvert.DeserializeObject<Request>(messageJson);
if (request == null)
{
Trace.Warning("Failed to deserialize DAP request");
return;
}
Trace.Info($"Handling DAP request: {request.Command}");
var response = request.Command switch
{
"initialize" => HandleInitialize(request),
"attach" => HandleAttach(request),
"configurationDone" => HandleConfigurationDone(request),
"disconnect" => HandleDisconnect(request),
"threads" => HandleThreads(request),
"stackTrace" => HandleStackTrace(request),
"scopes" => HandleScopes(request),
"variables" => HandleVariables(request),
"continue" => HandleContinue(request),
"next" => HandleNext(request),
"setBreakpoints" => HandleSetBreakpoints(request),
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
_ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null)
};
response.RequestSeq = request.Seq;
response.Command = request.Command;
_server?.SendResponse(response);
}
catch (Exception ex)
{
Trace.Error($"Error handling request '{request?.Command}': {ex}");
if (request != null)
{
var errorResponse = CreateResponse(request, false, ex.Message, body: null);
errorResponse.RequestSeq = request.Seq;
errorResponse.Command = request.Command;
_server?.SendResponse(errorResponse);
}
}
await Task.CompletedTask;
}
#endregion
#region DAP Request Handlers
private Response HandleInitialize(Request request)
{
if (request.Arguments != null)
{
try
{
var clientCaps = request.Arguments.ToObject<InitializeRequestArguments>();
Trace.Info($"Client: {clientCaps?.ClientName ?? clientCaps?.ClientId ?? "unknown"}");
}
catch (Exception ex)
{
Trace.Warning($"Failed to parse initialize arguments: {ex.Message}");
}
}
_state = DapSessionState.Initializing;
// Build capabilities — MVP only supports configurationDone
var capabilities = new Capabilities
{
SupportsConfigurationDoneRequest = true,
// All other capabilities are false for MVP
SupportsFunctionBreakpoints = false,
SupportsConditionalBreakpoints = false,
SupportsEvaluateForHovers = false,
SupportsStepBack = false,
SupportsSetVariable = false,
SupportsRestartFrame = false,
SupportsGotoTargetsRequest = false,
SupportsStepInTargetsRequest = false,
SupportsCompletionsRequest = false,
SupportsModulesRequest = false,
SupportsTerminateRequest = false,
SupportTerminateDebuggee = false,
SupportsDelayedStackTraceLoading = false,
SupportsLoadedSourcesRequest = false,
SupportsProgressReporting = false,
SupportsRunInTerminalRequest = false,
SupportsCancelRequest = false,
SupportsExceptionOptions = false,
SupportsValueFormattingOptions = false,
SupportsExceptionInfoRequest = false,
};
// Send initialized event after a brief delay to ensure the
// response is delivered first (DAP spec requirement)
_ = Task.Run(async () =>
{
await Task.Delay(50);
_server?.SendEvent(new Event
{
EventType = "initialized"
});
Trace.Info("Sent initialized event");
});
Trace.Info("Initialize request handled, capabilities sent");
return CreateResponse(request, true, body: capabilities);
}
private Response HandleAttach(Request request)
{
Trace.Info("Attach request handled");
return CreateResponse(request, true, body: null);
}
private Response HandleConfigurationDone(Request request)
{
lock (_stateLock)
{
_state = DapSessionState.Ready;
}
_handshakeTcs.TrySetResult(true);
Trace.Info("Configuration done, debug session is ready");
return CreateResponse(request, true, body: null);
}
private Response HandleDisconnect(Request request)
{
Trace.Info("Disconnect request received");
lock (_stateLock)
{
_state = DapSessionState.Terminated;
// Release any blocked step execution
_commandTcs?.TrySetResult(DapCommand.Disconnect);
}
return CreateResponse(request, true, body: null);
}
private Response HandleThreads(Request request)
{
var body = new ThreadsResponseBody
{
Threads = new List<Thread>
{
new Thread
{
Id = JobThreadId,
Name = _jobContext != null
? $"Job: {_jobContext.GetGitHubContext("job") ?? "workflow job"}"
: "Job Thread"
}
}
};
return CreateResponse(request, true, body: body);
}
private Response HandleStackTrace(Request request)
{
var frames = new List<StackFrame>();
// Add current step as the top frame
if (_currentStep != null)
{
var resultIndicator = _currentStep.ExecutionContext?.Result != null
? $" [{_currentStep.ExecutionContext.Result}]"
: " [running]";
frames.Add(new StackFrame
{
Id = CurrentFrameId,
Name = $"{_currentStep.DisplayName ?? "Current Step"}{resultIndicator}",
Line = _currentStepIndex + 1,
Column = 1,
PresentationHint = "normal"
});
}
else
{
frames.Add(new StackFrame
{
Id = CurrentFrameId,
Name = "(no step executing)",
Line = 0,
Column = 1,
PresentationHint = "subtle"
});
}
// Add completed steps as additional frames (most recent first)
for (int i = _completedSteps.Count - 1; i >= 0; i--)
{
var completedStep = _completedSteps[i];
var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : "";
frames.Add(new StackFrame
{
Id = completedStep.FrameId,
Name = $"{completedStep.DisplayName}{resultStr}",
Line = 1,
Column = 1,
PresentationHint = "subtle"
});
}
var body = new StackTraceResponseBody
{
StackFrames = frames,
TotalFrames = frames.Count
};
return CreateResponse(request, true, body: body);
}
private Response HandleScopes(Request request)
{
// MVP: return empty scopes — scope inspection deferred
return CreateResponse(request, true, body: new ScopesResponseBody
{
Scopes = new List<Scope>()
});
}
private Response HandleVariables(Request request)
{
// MVP: return empty variables — variable inspection deferred
return CreateResponse(request, true, body: new VariablesResponseBody
{
Variables = new List<Variable>()
});
}
private Response HandleContinue(Request request)
{
Trace.Info("Continue command received");
lock (_stateLock)
{
if (_state == DapSessionState.Paused)
{
_state = DapSessionState.Running;
_pauseOnNextStep = false;
_commandTcs?.TrySetResult(DapCommand.Continue);
}
}
return CreateResponse(request, true, body: new ContinueResponseBody
{
AllThreadsContinued = true
});
}
private Response HandleNext(Request request)
{
Trace.Info("Next (step over) command received");
lock (_stateLock)
{
if (_state == DapSessionState.Paused)
{
_state = DapSessionState.Running;
_pauseOnNextStep = true;
_commandTcs?.TrySetResult(DapCommand.Next);
}
}
return CreateResponse(request, true, body: null);
}
private Response HandleSetBreakpoints(Request request)
{
// MVP: acknowledge but don't process breakpoints
// All steps pause automatically via _pauseOnNextStep
return CreateResponse(request, true, body: new { breakpoints = Array.Empty<object>() });
}
private Response HandleSetExceptionBreakpoints(Request request)
{
// MVP: acknowledge but don't process exception breakpoints
return CreateResponse(request, true, body: null);
}
#endregion
#region Step Lifecycle
public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken)
{
if (!IsActive)
{
return;
}
_currentStep = step;
_jobContext = jobContext;
_currentStepIndex = _completedSteps.Count;
// Determine if we should pause
bool shouldPause = isFirstStep || _pauseOnNextStep;
if (!shouldPause)
{
Trace.Info($"Step starting (not pausing): {step.DisplayName}");
return;
}
var reason = isFirstStep ? "entry" : "step";
var description = isFirstStep
? $"Stopped at job entry: {step.DisplayName}"
: $"Stopped before step: {step.DisplayName}";
Trace.Info($"Step starting: {step.DisplayName} (reason: {reason})");
// Send stopped event to debugger (only if client is connected)
SendStoppedEvent(reason, description);
// Wait for debugger command
await WaitForCommandAsync(cancellationToken);
}
public void OnStepCompleted(IStep step)
{
if (!IsActive)
{
return;
}
var result = step.ExecutionContext?.Result;
Trace.Info($"Step completed: {step.DisplayName}, result: {result}");
// Add to completed steps list for stack trace
_completedSteps.Add(new CompletedStepInfo
{
DisplayName = step.DisplayName,
Result = result,
FrameId = _nextCompletedFrameId++
});
}
public void OnJobCompleted()
{
if (!IsActive)
{
return;
}
Trace.Info("Job completed, sending terminated event");
lock (_stateLock)
{
_state = DapSessionState.Terminated;
}
_server?.SendEvent(new Event
{
EventType = "terminated",
Body = new TerminatedEventBody()
});
var exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1;
_server?.SendEvent(new Event
{
EventType = "exited",
Body = new ExitedEventBody
{
ExitCode = exitCode
}
});
}
public void CancelSession()
{
Trace.Info("CancelSession called - terminating debug session");
lock (_stateLock)
{
if (_state == DapSessionState.Terminated)
{
Trace.Info("Session already terminated, ignoring CancelSession");
return;
}
_state = DapSessionState.Terminated;
}
// Send terminated event to debugger so it updates its UI
_server?.SendEvent(new Event
{
EventType = "terminated",
Body = new TerminatedEventBody()
});
// Send exited event with cancellation exit code (130 = SIGINT convention)
_server?.SendEvent(new Event
{
EventType = "exited",
Body = new ExitedEventBody { ExitCode = 130 }
});
// Release any pending command waits
_commandTcs?.TrySetResult(DapCommand.Disconnect);
// Release handshake wait if still pending
_handshakeTcs.TrySetCanceled();
Trace.Info("Debug session cancelled");
}
#endregion
#region Client Connection Tracking
public void HandleClientConnected()
{
_isClientConnected = true;
Trace.Info("Client connected to debug session");
// If we're paused, re-send the stopped event so the new client
// knows the current state (important for reconnection)
lock (_stateLock)
{
if (_state == DapSessionState.Paused && _currentStep != null)
{
Trace.Info("Re-sending stopped event to reconnected client");
var description = $"Stopped before step: {_currentStep.DisplayName}";
SendStoppedEvent("step", description);
}
}
}
public void HandleClientDisconnected()
{
_isClientConnected = false;
Trace.Info("Client disconnected from debug session");
// Intentionally do NOT release the command TCS here.
// The session stays paused, waiting for a client to reconnect.
// The server's connection loop will accept a new client and
// call HandleClientConnected, which re-sends the stopped event.
}
#endregion
#region Private Helpers
/// <summary>
/// Blocks the step execution thread until a debugger command is received
/// or the job is cancelled.
/// </summary>
private async Task WaitForCommandAsync(CancellationToken cancellationToken)
{
lock (_stateLock)
{
_state = DapSessionState.Paused;
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
}
Trace.Info("Waiting for debugger command...");
using (cancellationToken.Register(() =>
{
Trace.Info("Job cancellation detected, releasing debugger wait");
_commandTcs?.TrySetResult(DapCommand.Disconnect);
}))
{
var command = await _commandTcs.Task;
Trace.Info($"Received command: {command}");
lock (_stateLock)
{
if (_state == DapSessionState.Paused)
{
_state = DapSessionState.Running;
}
}
// Send continued event for normal flow commands
if (!cancellationToken.IsCancellationRequested &&
(command == DapCommand.Continue || command == DapCommand.Next))
{
_server?.SendEvent(new Event
{
EventType = "continued",
Body = new ContinuedEventBody
{
ThreadId = JobThreadId,
AllThreadsContinued = true
}
});
}
}
}
/// <summary>
/// Sends a stopped event to the connected client.
/// Silently no-ops if no client is connected.
/// </summary>
private void SendStoppedEvent(string reason, string description)
{
if (!_isClientConnected)
{
Trace.Info($"No client connected, deferring stopped event: {description}");
return;
}
_server?.SendEvent(new Event
{
EventType = "stopped",
Body = new StoppedEventBody
{
Reason = reason,
Description = description,
ThreadId = JobThreadId,
AllThreadsStopped = true
}
});
}
/// <summary>
/// Creates a DAP response with common fields pre-populated.
/// </summary>
private Response CreateResponse(Request request, bool success, string message = null, object body = null)
{
return new Response
{
Type = "response",
RequestSeq = request.Seq,
Command = request.Command,
Success = success,
Message = success ? null : message,
Body = body
};
}
#endregion
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,466 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
using Newtonsoft.Json;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Production TCP server for the Debug Adapter Protocol.
/// Handles Content-Length message framing, JSON serialization,
/// client reconnection, and graceful shutdown.
/// </summary>
public sealed class DapServer : RunnerService, IDapServer
{
private const string ContentLengthHeader = "Content-Length: ";
private TcpListener _listener;
private TcpClient _client;
private NetworkStream _stream;
private IDapDebugSession _session;
private CancellationTokenSource _cts;
private TaskCompletionSource<bool> _connectionTcs;
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
private int _nextSeq = 1;
private volatile bool _acceptConnections = true;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
Trace.Info("DapServer initialized");
}
public void SetSession(IDapDebugSession session)
{
_session = session;
Trace.Info("Debug session set");
}
public async Task StartAsync(int port, CancellationToken cancellationToken)
{
Trace.Info($"Starting DAP server on port {port}");
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_connectionTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Start();
Trace.Info($"DAP server listening on 127.0.0.1:{port}");
// Start the connection loop in the background
_ = ConnectionLoopAsync(_cts.Token);
await Task.CompletedTask;
}
/// <summary>
/// Accepts client connections in a loop, supporting reconnection.
/// When a client disconnects, the server waits for a new connection
/// without blocking step execution.
/// </summary>
private async Task ConnectionLoopAsync(CancellationToken cancellationToken)
{
while (_acceptConnections && !cancellationToken.IsCancellationRequested)
{
try
{
Trace.Info("Waiting for debug client connection...");
using (cancellationToken.Register(() =>
{
try { _listener?.Stop(); }
catch { /* listener already stopped */ }
}))
{
_client = await _listener.AcceptTcpClientAsync();
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
_stream = _client.GetStream();
var remoteEndPoint = _client.Client.RemoteEndPoint;
Trace.Info($"Debug client connected from {remoteEndPoint}");
// Signal first connection (no-op on subsequent connections)
_connectionTcs.TrySetResult(true);
// Notify session of new client
_session?.HandleClientConnected();
// Process messages until client disconnects
await ProcessMessagesAsync(cancellationToken);
// Client disconnected — notify session and clean up
Trace.Info("Client disconnected, waiting for reconnection...");
_session?.HandleClientDisconnected();
CleanupConnection();
}
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (SocketException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
Trace.Warning($"Connection error: {ex.Message}");
CleanupConnection();
if (!_acceptConnections || cancellationToken.IsCancellationRequested)
{
break;
}
// Brief delay before accepting next connection
try
{
await Task.Delay(100, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
_connectionTcs.TrySetCanceled();
Trace.Info("Connection loop ended");
}
/// <summary>
/// Cleans up the current client connection without stopping the listener.
/// </summary>
private void CleanupConnection()
{
try { _stream?.Close(); } catch { /* best effort */ }
try { _client?.Close(); } catch { /* best effort */ }
_stream = null;
_client = null;
}
public async Task WaitForConnectionAsync(CancellationToken cancellationToken)
{
Trace.Info("Waiting for debug client to connect...");
using (cancellationToken.Register(() => _connectionTcs.TrySetCanceled()))
{
await _connectionTcs.Task;
}
Trace.Info("Debug client connected");
}
public async Task StopAsync()
{
Trace.Info("Stopping DAP server");
_acceptConnections = false;
_cts?.Cancel();
CleanupConnection();
try { _listener?.Stop(); }
catch { /* best effort */ }
await Task.CompletedTask;
Trace.Info("DAP server stopped");
}
private async Task ProcessMessagesAsync(CancellationToken cancellationToken)
{
Trace.Info("Starting DAP message processing loop");
try
{
while (!cancellationToken.IsCancellationRequested && _client?.Connected == true)
{
var json = await ReadMessageAsync(cancellationToken);
if (json == null)
{
Trace.Info("Client disconnected (end of stream)");
break;
}
await ProcessSingleMessageAsync(json, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Trace.Info("Message processing cancelled");
}
catch (IOException ex)
{
Trace.Info($"Connection closed: {ex.Message}");
}
catch (Exception ex)
{
Trace.Error($"Error in message loop: {ex}");
}
Trace.Info("DAP message processing loop ended");
}
private async Task ProcessSingleMessageAsync(string json, CancellationToken cancellationToken)
{
Request request = null;
try
{
request = JsonConvert.DeserializeObject<Request>(json);
if (request == null || request.Type != "request")
{
Trace.Warning($"Received non-request message: {json}");
return;
}
Trace.Info($"Received request: seq={request.Seq}, command={request.Command}");
if (_session == null)
{
Trace.Error("No debug session configured");
SendErrorResponse(request, "No debug session configured");
return;
}
// Pass raw JSON to session — session handles deserialization, dispatch,
// and calls back to SendResponse when done.
await _session.HandleMessageAsync(json, cancellationToken);
}
catch (JsonException ex)
{
Trace.Error($"Failed to parse request: {ex.Message}");
}
catch (Exception ex)
{
Trace.Error($"Error processing request: {ex}");
if (request != null)
{
SendErrorResponse(request, ex.Message);
}
}
}
private void SendErrorResponse(Request request, string message)
{
var response = new Response
{
Type = "response",
RequestSeq = request.Seq,
Command = request.Command,
Success = false,
Message = message,
Body = new ErrorResponseBody
{
Error = new Message
{
Id = 1,
Format = message,
ShowUser = true
}
}
};
SendResponse(response);
}
/// <summary>
/// Reads a DAP message using Content-Length framing.
/// Format: Content-Length: N\r\n\r\n{json}
/// </summary>
private async Task<string> ReadMessageAsync(CancellationToken cancellationToken)
{
int contentLength = -1;
while (true)
{
var line = await ReadLineAsync(cancellationToken);
if (line == null)
{
return null;
}
if (line.Length == 0)
{
break;
}
if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase))
{
var lengthStr = line.Substring(ContentLengthHeader.Length).Trim();
if (!int.TryParse(lengthStr, out contentLength))
{
throw new InvalidDataException($"Invalid Content-Length: {lengthStr}");
}
}
}
if (contentLength < 0)
{
throw new InvalidDataException("Missing Content-Length header");
}
var buffer = new byte[contentLength];
var totalRead = 0;
while (totalRead < contentLength)
{
var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken);
if (bytesRead == 0)
{
throw new EndOfStreamException("Connection closed while reading message body");
}
totalRead += bytesRead;
}
var json = Encoding.UTF8.GetString(buffer);
Trace.Verbose($"Received: {json}");
return json;
}
/// <summary>
/// Reads a line terminated by \r\n from the network stream.
/// </summary>
private async Task<string> ReadLineAsync(CancellationToken cancellationToken)
{
var lineBuilder = new StringBuilder();
var buffer = new byte[1];
var previousWasCr = false;
while (true)
{
var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken);
if (bytesRead == 0)
{
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
}
var c = (char)buffer[0];
if (c == '\n' && previousWasCr)
{
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
{
lineBuilder.Length--;
}
return lineBuilder.ToString();
}
previousWasCr = (c == '\r');
lineBuilder.Append(c);
}
}
/// <summary>
/// Serializes and writes a DAP message with Content-Length framing.
/// Must be called within the _sendLock.
/// </summary>
private void SendMessageInternal(ProtocolMessage message)
{
var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
});
var bodyBytes = Encoding.UTF8.GetBytes(json);
var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
_stream.Write(headerBytes, 0, headerBytes.Length);
_stream.Write(bodyBytes, 0, bodyBytes.Length);
_stream.Flush();
Trace.Verbose($"Sent: {json}");
}
public void SendMessage(ProtocolMessage message)
{
if (_stream == null)
{
return;
}
try
{
_sendLock.Wait();
try
{
message.Seq = _nextSeq++;
SendMessageInternal(message);
}
finally
{
_sendLock.Release();
}
}
catch (Exception ex)
{
Trace.Warning($"Failed to send message: {ex.Message}");
}
}
public void SendEvent(Event evt)
{
if (_stream == null)
{
Trace.Warning($"Cannot send event '{evt.EventType}': no client connected");
return;
}
try
{
_sendLock.Wait();
try
{
evt.Seq = _nextSeq++;
SendMessageInternal(evt);
}
finally
{
_sendLock.Release();
}
Trace.Info($"Sent event: {evt.EventType}");
}
catch (Exception ex)
{
Trace.Warning($"Failed to send event '{evt.EventType}': {ex.Message}");
}
}
public void SendResponse(Response response)
{
if (_stream == null)
{
Trace.Warning($"Cannot send response for '{response.Command}': no client connected");
return;
}
try
{
_sendLock.Wait();
try
{
response.Seq = _nextSeq++;
SendMessageInternal(response);
}
finally
{
_sendLock.Release();
}
Trace.Info($"Sent response: seq={response.Seq}, command={response.Command}, success={response.Success}");
}
catch (Exception ex)
{
Trace.Warning($"Failed to send response for '{response.Command}': {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
public enum DapSessionState
{
WaitingForConnection,
Initializing,
Ready,
Paused,
Running,
Terminated
}
[ServiceLocator(Default = typeof(DapDebugSession))]
public interface IDapDebugSession : IRunnerService
{
bool IsActive { get; }
DapSessionState State { get; }
void SetDapServer(IDapServer server);
Task WaitForHandshakeAsync(CancellationToken cancellationToken);
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken);
void OnStepCompleted(IStep step);
void OnJobCompleted();
void CancelSession();
void HandleClientConnected();
void HandleClientDisconnected();
Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
[ServiceLocator(Default = typeof(DapServer))]
public interface IDapServer : IRunnerService
{
void SetSession(IDapDebugSession session);
Task StartAsync(int port, CancellationToken cancellationToken);
Task WaitForConnectionAsync(CancellationToken cancellationToken);
Task StopAsync();
void SendMessage(ProtocolMessage message);
void SendEvent(Event evt);
void SendResponse(Response response);
}
}

View File

@@ -968,6 +968,9 @@ namespace GitHub.Runner.Worker
// Verbosity (from GitHub.Step_Debug).
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
// Debugger enabled flag (from acquire response).
Global.EnableDebugger = message.EnableDebugger;
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
}
@@ -1328,9 +1331,9 @@ namespace GitHub.Runner.Worker
UpdateGlobalStepsContext();
}
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null)
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
{
return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter);
return new PipelineTemplateEvaluatorWrapper(HostContext, this, allowServiceContainerCommand, traceWriter);
}
private static void NoOp()
@@ -1418,10 +1421,13 @@ namespace GitHub.Runner.Worker
public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
{
var allowServiceContainerCommand = (context.Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_SERVICE_CONTAINER_COMMAND"));
// Create wrapper?
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")))
{
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter);
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(allowServiceContainerCommand, traceWriter);
}
// Legacy
@@ -1433,6 +1439,7 @@ namespace GitHub.Runner.Worker
return new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
{
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
AllowServiceContainerCommand = allowServiceContainerCommand,
};
}

View File

@@ -27,6 +27,7 @@ namespace GitHub.Runner.Worker
public StepsContext StepsContext { get; set; }
public Variables Variables { get; set; }
public bool WriteDebug { get; set; }
public bool EnableDebugger { get; set; }
public string InfrastructureFailureCategory { get; set; }
public JObject ContainerHookState { get; set; }
public bool HasTemplateEvaluatorMismatch { get; set; }

View File

@@ -312,7 +312,14 @@ namespace GitHub.Runner.Worker.Handlers
// Emit start marker after full context setup so display name expressions resolve correctly
if (emitCompositeMarkers)
{
step.TryUpdateDisplayName(out _);
try
{
step.EvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext, out _);
}
catch (Exception ex)
{
Trace.Warning("Caught exception while evaluating embedded step display name. {0}", ex);
}
ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]");
stepStopwatch = Stopwatch.StartNew();
}

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;
@@ -112,6 +113,9 @@ namespace GitHub.Runner.Worker
IExecutionContext jobContext = null;
CancellationTokenRegistration? runnerShutdownRegistration = null;
IDapServer dapServer = null;
IDapDebugSession debugSession = null;
CancellationTokenRegistration? dapCancellationRegistration = null;
try
{
// Create the job execution context.
@@ -121,6 +125,36 @@ namespace GitHub.Runner.Worker
jobContext.Start();
jobContext.Debug($"Starting: {message.JobDisplayName}");
if (jobContext.Global.EnableDebugger)
{
Trace.Info("Debugger enabled for this job run");
try
{
var port = 4711;
var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT");
if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort))
{
port = customPort;
}
dapServer = HostContext.GetService<IDapServer>();
debugSession = HostContext.GetService<IDapDebugSession>();
dapServer.SetSession(debugSession);
debugSession.SetDapServer(dapServer);
await dapServer.StartAsync(port, jobRequestCancellationToken);
Trace.Info($"DAP server started on port {port}, listening for debugger client");
}
catch (Exception ex)
{
Trace.Warning($"Failed to start DAP server: {ex.Message}. Job will continue without debugging.");
dapServer = null;
debugSession = null;
}
}
runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() =>
{
// log an issue, then runner get shutdown by Ctrl-C or Ctrl-Break.
@@ -219,6 +253,39 @@ namespace GitHub.Runner.Worker
await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000));
}
// Wait for DAP debugger client connection and handshake after "Set up job"
// so the job page shows the setup step before we block on the debugger
if (dapServer != null && debugSession != null)
{
try
{
Trace.Info("Waiting for debugger client connection...");
await dapServer.WaitForConnectionAsync(jobRequestCancellationToken);
Trace.Info("Debugger client connected.");
await debugSession.WaitForHandshakeAsync(jobRequestCancellationToken);
Trace.Info("DAP handshake complete.");
dapCancellationRegistration = jobRequestCancellationToken.Register(() =>
{
Trace.Info("Job cancellation requested, cancelling debug session.");
debugSession.CancelSession();
});
}
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
{
Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger.");
dapServer = null;
debugSession = null;
}
catch (Exception ex)
{
Trace.Warning($"Failed to complete DAP handshake: {ex.Message}. Job will continue without debugging.");
dapServer = null;
debugSession = null;
}
}
// Run all job steps
Trace.Info("Run all job steps.");
var stepsRunner = HostContext.GetService<IStepsRunner>();
@@ -259,6 +326,25 @@ namespace GitHub.Runner.Worker
runnerShutdownRegistration = null;
}
if (dapCancellationRegistration.HasValue)
{
dapCancellationRegistration.Value.Dispose();
dapCancellationRegistration = null;
}
if (dapServer != null)
{
try
{
Trace.Info("Stopping DAP server");
await dapServer.StopAsync();
}
catch (Exception ex)
{
Trace.Warning($"Error stopping DAP server: {ex.Message}");
}
}
await ShutdownQueue(throwOnFailure: false);
}
}

View File

@@ -23,6 +23,7 @@ namespace GitHub.Runner.Worker
public PipelineTemplateEvaluatorWrapper(
IHostContext hostContext,
IExecutionContext context,
bool allowServiceContainerCommand,
ObjectTemplating.ITraceWriter traceWriter = null)
{
ArgUtil.NotNull(hostContext, nameof(hostContext));
@@ -40,11 +41,14 @@ namespace GitHub.Runner.Worker
_legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
{
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
AllowServiceContainerCommand = allowServiceContainerCommand,
};
// New evaluator
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null)
var features = WorkflowFeatures.GetDefaults();
features.AllowServiceContainerCommand = allowServiceContainerCommand;
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features)
{
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
};
@@ -401,6 +405,18 @@ namespace GitHub.Runner.Worker
return false;
}
if (!string.Equals(legacyResult.Entrypoint, newResult.Entrypoint, StringComparison.Ordinal))
{
_trace.Info($"CompareJobContainer mismatch - Entrypoint differs (legacy='{legacyResult.Entrypoint}', new='{newResult.Entrypoint}')");
return false;
}
if (!string.Equals(legacyResult.Command, newResult.Command, StringComparison.Ordinal))
{
_trace.Info($"CompareJobContainer mismatch - Command differs (legacy='{legacyResult.Command}', new='{newResult.Command}')");
return false;
}
if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment"))
{
return false;

View File

@@ -10,6 +10,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.Runner.Worker.Expressions;
namespace GitHub.Runner.Worker
@@ -50,6 +51,16 @@ namespace GitHub.Runner.Worker
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
bool checkPostJobActions = false;
IDapDebugSession debugSession = null;
try
{
debugSession = HostContext.GetService<IDapDebugSession>();
}
catch
{
// Debug session not available — continue without debugging
}
bool isFirstStep = true;
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
{
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
@@ -226,9 +237,35 @@ namespace GitHub.Runner.Worker
}
else
{
// Pause for DAP debugger before step execution
if (debugSession?.IsActive == true)
{
try
{
await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken);
isFirstStep = false;
}
catch (Exception ex)
{
Trace.Warning($"DAP OnStepStarting error: {ex.Message}");
}
}
// Run the step
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);
if (debugSession?.IsActive == true)
{
try
{
debugSession.OnStepCompleted(step);
}
catch (Exception ex)
{
Trace.Warning($"DAP OnStepCompleted error: {ex.Message}");
}
}
}
}
finally
@@ -255,6 +292,18 @@ namespace GitHub.Runner.Worker
Trace.Info($"Current state: job state = '{jobContext.Result}'");
}
if (debugSession?.IsActive == true)
{
try
{
debugSession.OnJobCompleted();
}
catch (Exception ex)
{
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
}
}
}
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)

View File

@@ -253,6 +253,13 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
[DataMember(EmitDefaultValue = false)]
public bool EnableDebugger
{
get;
set;
}
/// <summary>
/// Gets the collection of variables associated with the current context.
/// </summary>

View File

@@ -39,6 +39,24 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
/// <summary>
/// Gets or sets the container entrypoint override.
/// </summary>
public String Entrypoint
{
get;
set;
}
/// <summary>
/// Gets or sets the container command and args (after the image name).
/// </summary>
public String Command
{
get;
set;
}
/// <summary>
/// Gets or sets the volumes which are mounted into the container.
/// </summary>

View File

@@ -47,6 +47,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
public const String NumberStrategyContext = "number-strategy-context";
public const String On = "on";
public const String Options = "options";
public const String Entrypoint = "entrypoint";
public const String Command = "command";
public const String Outputs = "outputs";
public const String OutputsPattern = "needs.*.outputs";
public const String Password = "password";

View File

@@ -237,7 +237,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
internal static JobContainer ConvertToJobContainer(
TemplateContext context,
TemplateToken value,
bool allowExpressions = false)
bool allowExpressions = false,
bool allowServiceContainerCommand = false)
{
var result = new JobContainer();
if (allowExpressions && value.Traverse().Any(x => x is ExpressionToken))
@@ -280,6 +281,22 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
case PipelineTemplateConstants.Options:
result.Options = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
break;
case PipelineTemplateConstants.Entrypoint:
if (!allowServiceContainerCommand)
{
context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Entrypoint}' is not allowed");
break;
}
result.Entrypoint = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
break;
case PipelineTemplateConstants.Command:
if (!allowServiceContainerCommand)
{
context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Command}' is not allowed");
break;
}
result.Command = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
break;
case PipelineTemplateConstants.Ports:
var ports = containerPropertyPair.Value.AssertSequence($"{PipelineTemplateConstants.Container} {propertyName}");
var portList = new List<String>(ports.Count);
@@ -326,7 +343,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
internal static List<KeyValuePair<String, JobContainer>> ConvertToJobServiceContainers(
TemplateContext context,
TemplateToken services,
bool allowExpressions = false)
bool allowExpressions = false,
bool allowServiceContainerCommand = false)
{
var result = new List<KeyValuePair<String, JobContainer>>();
@@ -340,7 +358,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
foreach (var servicePair in servicesMapping)
{
var networkAlias = servicePair.Key.AssertString("services key").Value;
var container = ConvertToJobContainer(context, servicePair.Value);
var container = ConvertToJobContainer(context, servicePair.Value, allowExpressions, allowServiceContainerCommand);
result.Add(new KeyValuePair<String, JobContainer>(networkAlias, container));
}

View File

@@ -51,6 +51,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
public Int32 MaxResultSize { get; set; } = 10 * 1024 * 1024; // 10 mb
public bool AllowServiceContainerCommand { get; set; }
public Boolean EvaluateStepContinueOnError(
TemplateToken token,
DictionaryContextData contextData,
@@ -357,7 +359,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
{
token = TemplateEvaluator.Evaluate(context, PipelineTemplateConstants.Services, token, 0, null, omitHeader: true);
context.Errors.Check();
result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token);
result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token, allowServiceContainerCommand: AllowServiceContainerCommand);
}
catch (Exception ex) when (!(ex is TemplateValidationException))
{

View File

@@ -430,6 +430,21 @@
}
},
"service-container-mapping": {
"mapping": {
"properties": {
"image": "string",
"options": "string",
"entrypoint": "string",
"command": "string",
"env": "container-env",
"ports": "sequence-of-non-empty-string",
"volumes": "sequence-of-non-empty-string",
"credentials": "container-registry-credentials"
}
}
},
"services": {
"context": [
"github",
@@ -454,7 +469,7 @@
],
"one-of": [
"string",
"container-mapping"
"service-container-mapping"
]
},

View File

@@ -62,6 +62,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion
public const String NumberStrategyContext = "number-strategy-context";
public const String On = "on";
public const String Options = "options";
public const String Entrypoint = "entrypoint";
public const String Command = "command";
public const String Org = "org";
public const String Organization = "organization";
public const String Outputs = "outputs";

View File

@@ -1079,7 +1079,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion
internal static JobContainer ConvertToJobContainer(
TemplateContext context,
TemplateToken value,
bool isEarlyValidation = false)
bool isEarlyValidation = false,
bool isServiceContainer = false)
{
var result = new JobContainer();
if (isEarlyValidation && value.Traverse().Any(x => x is ExpressionToken))
@@ -1089,11 +1090,34 @@ namespace GitHub.Actions.WorkflowParser.Conversion
if (value is StringToken containerLiteral)
{
if (String.IsNullOrEmpty(containerLiteral.Value))
// Trim "docker://"
var trimmedImage = containerLiteral.Value;
var hasDockerPrefix = containerLiteral.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal);
if (hasDockerPrefix)
{
trimmedImage = trimmedImage.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
}
// Empty shorthand after trimming "docker://" ?
if (String.IsNullOrEmpty(trimmedImage))
{
// Error at parse-time for:
// 1. container: 'docker://'
// 2. services.foo: ''
// 3. services.foo: 'docker://'
//
// Do not error for:
// 1. container: ''
if (isEarlyValidation && (hasDockerPrefix || isServiceContainer))
{
context.Error(value, "Container image cannot be empty");
}
// Short-circuit
return null;
}
// Store original, trimmed further below
result.Image = containerLiteral.Value;
}
else
@@ -1122,6 +1146,22 @@ namespace GitHub.Actions.WorkflowParser.Conversion
case WorkflowTemplateConstants.Options:
result.Options = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
break;
case WorkflowTemplateConstants.Entrypoint:
if (!context.GetFeatures().AllowServiceContainerCommand)
{
context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Entrypoint}' is not allowed");
break;
}
result.Entrypoint = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
break;
case WorkflowTemplateConstants.Command:
if (!context.GetFeatures().AllowServiceContainerCommand)
{
context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Command}' is not allowed");
break;
}
result.Command = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
break;
case WorkflowTemplateConstants.Ports:
var ports = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}");
var portList = new List<String>(ports.Count);
@@ -1152,22 +1192,30 @@ namespace GitHub.Actions.WorkflowParser.Conversion
}
}
// Trim "docker://"
var hadDockerPrefix = false;
if (!String.IsNullOrEmpty(result.Image) && result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
{
hadDockerPrefix = true;
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
}
if (String.IsNullOrEmpty(result.Image))
{
// Only error during early validation (parse time)
// At runtime (expression evaluation), empty image = no container
if (isEarlyValidation)
// Error at parse-time for:
// 1. container: {image: 'docker://'}
// 2. services.foo: {image: ''}
// 3. services.foo: {image: 'docker://'}
//
// Do not error for:
// 1. container: {image: ''}
if (isEarlyValidation && (hadDockerPrefix || isServiceContainer))
{
context.Error(value, "Container image cannot be empty");
}
return null;
}
if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
{
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
}
return result;
}
@@ -1188,7 +1236,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
foreach (var servicePair in servicesMapping)
{
var networkAlias = servicePair.Key.AssertString("services key").Value;
var container = ConvertToJobContainer(context, servicePair.Value);
var container = ConvertToJobContainer(context, servicePair.Value, isEarlyValidation, isServiceContainer: true);
result.Add(new KeyValuePair<String, JobContainer>(networkAlias, container));
}

View File

@@ -35,6 +35,24 @@ namespace GitHub.Actions.WorkflowParser
set;
}
/// <summary>
/// Gets or sets the container entrypoint override.
/// </summary>
public String Entrypoint
{
get;
set;
}
/// <summary>
/// Gets or sets the container command and args (after the image name).
/// </summary>
public String Command
{
get;
set;
}
/// <summary>
/// Gets or sets the volumes which are mounted into the container.
/// </summary>

View File

@@ -48,6 +48,13 @@ namespace GitHub.Actions.WorkflowParser
[DataMember(EmitDefaultValue = false)]
public bool StrictJsonParsing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether service containers may specify "entrypoint" and "command".
/// Used during parsing and evaluation.
/// </summary>
[DataMember(EmitDefaultValue = false)]
public bool AllowServiceContainerCommand { get; set; }
/// <summary>
/// Gets the default workflow features.
/// </summary>
@@ -60,6 +67,7 @@ namespace GitHub.Actions.WorkflowParser
Snapshot = false, // Default to false since this feature is still in an experimental phase
StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only
AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments
AllowServiceContainerCommand = false, // Default to false since this feature is gated by actions_service_container_command
};
}

View File

@@ -2589,21 +2589,53 @@
"mapping": {
"properties": {
"image": {
"type": "non-empty-string",
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
"type": "string",
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
},
"options": {
"type": "string",
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
"description": "Additional Docker container resource options."
},
"env": "container-env",
"ports": {
"type": "sequence-of-non-empty-string",
"description": "Use `jobs.<job_id>.container.ports` to set an array of ports to expose on the container."
"description": "An array of ports to expose on the container."
},
"volumes": {
"type": "sequence-of-non-empty-string",
"description": "Use `jobs.<job_id>.container.volumes` to set an array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
},
"credentials": "container-registry-credentials"
}
}
},
"service-container-mapping": {
"mapping": {
"properties": {
"image": {
"type": "string",
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
},
"options": {
"type": "string",
"description": "Additional Docker container resource options."
},
"entrypoint": {
"type": "string",
"description": "Override the default ENTRYPOINT in the service container image."
},
"command": {
"type": "string",
"description": "Override the default CMD in the service container image."
},
"env": "container-env",
"ports": {
"type": "sequence-of-non-empty-string",
"description": "An array of ports to expose on the container."
},
"volumes": {
"type": "sequence-of-non-empty-string",
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
},
"credentials": "container-registry-credentials"
}
@@ -2634,12 +2666,12 @@
"matrix"
],
"one-of": [
"non-empty-string",
"container-mapping"
"string",
"service-container-mapping"
]
},
"container-registry-credentials": {
"description": "If the image's container registry requires authentication to pull the image, you can use `jobs.<job_id>.container.credentials` to set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.",
"description": "If the container registry requires authentication to pull the image, set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.",
"context": [
"github",
"inputs",
@@ -2655,7 +2687,7 @@
}
},
"container-env": {
"description": "Use `jobs.<job_id>.container.env` to set a map of variables in the container.",
"description": "A map of environment variables to set in the container.",
"mapping": {
"loose-key-type": "non-empty-string",
"loose-value-type": "string-runner-context"

View File

@@ -228,8 +228,8 @@ namespace GitHub.Runner.Common.Tests.Listener
.Returns(Task.FromResult(new TaskAgent()));
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message);
var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
Assert.False(result);
}
}
finally
@@ -281,8 +281,8 @@ namespace GitHub.Runner.Common.Tests.Listener
.Returns(Task.FromResult(new TaskAgent()));
var ex = await Assert.ThrowsAsync<Exception>(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
Assert.Contains("did not match expected Runner Hash", ex.Message);
var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
Assert.False(result);
}
}
finally

View File

@@ -170,8 +170,8 @@ namespace GitHub.Runner.Common.Tests.Listener
DownloadUrl = "https://github.com/actions/runner/notexists"
};
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message);
var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
Assert.False(result);
}
}
finally
@@ -220,8 +220,8 @@ namespace GitHub.Runner.Common.Tests.Listener
SHA256Checksum = "badhash"
};
var ex = await Assert.ThrowsAsync<Exception>(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
Assert.Contains("did not match expected Runner Hash", ex.Message);
var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
Assert.False(result);
}
}
finally

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Text;
using Xunit;
using GitHub.DistributedTask.Pipelines;
namespace GitHub.Actions.RunService.WebApi.Tests;
public sealed class AgentJobRequestMessageL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyEnableDebuggerDeserialization_WithTrue()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyEnableDebuggerDeserialization_DefaultToFalse()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyEnableDebuggerDeserialization_WithFalse()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
}
private static string DoubleQuotify(string text)
{
return text.Replace('\'', '"');
}
}

View File

@@ -0,0 +1,611 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapDebugSessionL0
{
private DapDebugSession _session;
private Mock<IDapServer> _mockServer;
private List<Event> _sentEvents;
private List<Response> _sentResponses;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_session = new DapDebugSession();
_session.Initialize(hc);
_sentEvents = new List<Event>();
_sentResponses = new List<Response>();
_mockServer = new Mock<IDapServer>();
_mockServer.Setup(x => x.SendEvent(It.IsAny<Event>()))
.Callback<Event>(e => _sentEvents.Add(e));
_mockServer.Setup(x => x.SendResponse(It.IsAny<Response>()))
.Callback<Response>(r => _sentResponses.Add(r));
_session.SetDapServer(_mockServer.Object);
return hc;
}
private Mock<IStep> CreateMockStep(string displayName, TaskResult? result = null)
{
var mockEc = new Mock<IExecutionContext>();
mockEc.SetupAllProperties();
mockEc.Object.Result = result;
var mockStep = new Mock<IStep>();
mockStep.Setup(x => x.DisplayName).Returns(displayName);
mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object);
return mockStep;
}
private Mock<IExecutionContext> CreateMockJobContext()
{
var mockJobContext = new Mock<IExecutionContext>();
mockJobContext.Setup(x => x.GetGitHubContext("job")).Returns("test-job");
return mockJobContext;
}
private async Task InitializeSessionAsync()
{
var initJson = JsonConvert.SerializeObject(new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
await _session.HandleMessageAsync(initJson, CancellationToken.None);
var attachJson = JsonConvert.SerializeObject(new Request
{
Seq = 2,
Type = "request",
Command = "attach"
});
await _session.HandleMessageAsync(attachJson, CancellationToken.None);
var configJson = JsonConvert.SerializeObject(new Request
{
Seq = 3,
Type = "request",
Command = "configurationDone"
});
await _session.HandleMessageAsync(configJson, CancellationToken.None);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitialStateIsWaitingForConnection()
{
using (CreateTestContext())
{
Assert.Equal(DapSessionState.WaitingForConnection, _session.State);
Assert.False(_session.IsActive);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task InitializeHandlerSetsInitializingState()
{
using (CreateTestContext())
{
var json = JsonConvert.SerializeObject(new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
await _session.HandleMessageAsync(json, CancellationToken.None);
Assert.Equal(DapSessionState.Initializing, _session.State);
Assert.Single(_sentResponses);
Assert.True(_sentResponses[0].Success);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ConfigurationDoneSetsReadyState()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
Assert.Equal(DapSessionState.Ready, _session.State);
Assert.True(_session.IsActive);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnStepStartingPausesAndSendsStoppedEvent()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
// Wait for the async initialized event to arrive, then clear
await Task.Delay(200);
_sentEvents.Clear();
var step = CreateMockStep("Checkout code");
var jobContext = CreateMockJobContext();
var cts = new CancellationTokenSource();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token);
await Task.Delay(100);
Assert.False(stepTask.IsCompleted);
Assert.Equal(DapSessionState.Paused, _session.State);
var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped");
Assert.Single(stoppedEvents);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task NextCommandPausesOnFollowingStep()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
var step1 = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
var nextJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "next"
});
await _session.HandleMessageAsync(nextJson, CancellationToken.None);
await Task.WhenAny(step1Task, Task.Delay(5000));
Assert.True(step1Task.IsCompleted);
_session.OnStepCompleted(step1.Object);
_sentEvents.Clear();
var step2 = CreateMockStep("Step 2");
var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None);
await Task.Delay(100);
Assert.False(step2Task.IsCompleted);
Assert.Equal(DapSessionState.Paused, _session.State);
var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped");
Assert.Single(stoppedEvents);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 11,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(step2Task, Task.Delay(5000));
Assert.True(step2Task.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ContinueCommandSkipsNextPause()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
var step1 = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(step1Task, Task.Delay(5000));
Assert.True(step1Task.IsCompleted);
_session.OnStepCompleted(step1.Object);
_sentEvents.Clear();
var step2 = CreateMockStep("Step 2");
var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None);
await Task.WhenAny(step2Task, Task.Delay(5000));
Assert.True(step2Task.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CancellationUnblocksPausedStep()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
var step = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var cts = new CancellationTokenSource();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token);
await Task.Delay(100);
Assert.False(stepTask.IsCompleted);
Assert.Equal(DapSessionState.Paused, _session.State);
cts.Cancel();
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CancelSessionSendsTerminatedAndExitedEvents()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_sentEvents.Clear();
_session.CancelSession();
Assert.Equal(DapSessionState.Terminated, _session.State);
Assert.False(_session.IsActive);
var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated");
var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited");
Assert.Single(terminatedEvents);
Assert.Single(exitedEvents);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CancelSessionReleasesBlockedStep()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
var step = CreateMockStep("Blocked Step");
var jobContext = CreateMockJobContext();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
await Task.Delay(100);
Assert.False(stepTask.IsCompleted);
_session.CancelSession();
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
Assert.Equal(DapSessionState.Terminated, _session.State);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ReconnectionResendStoppedEvent()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
// Wait for the async initialized event to arrive, then clear
await Task.Delay(200);
_sentEvents.Clear();
var step = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
await Task.Delay(100);
Assert.Equal(DapSessionState.Paused, _session.State);
var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped");
Assert.Single(stoppedEvents);
_session.HandleClientDisconnected();
Assert.Equal(DapSessionState.Paused, _session.State);
_sentEvents.Clear();
_session.HandleClientConnected();
Assert.Single(_sentEvents);
Assert.Equal("stopped", _sentEvents[0].EventType);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 20,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DisconnectCommandTerminatesSession()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
var disconnectJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "disconnect"
});
await _session.HandleMessageAsync(disconnectJson, CancellationToken.None);
Assert.Equal(DapSessionState.Terminated, _session.State);
Assert.False(_session.IsActive);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnStepCompletedTracksCompletedSteps()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
var step1 = CreateMockStep("Step 1");
step1.Object.ExecutionContext.Result = TaskResult.Succeeded;
var jobContext = CreateMockJobContext();
var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(step1Task, Task.Delay(5000));
_session.OnStepCompleted(step1.Object);
var stackTraceJson = JsonConvert.SerializeObject(new Request
{
Seq = 11,
Type = "request",
Command = "stackTrace"
});
_sentResponses.Clear();
await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None);
Assert.Single(_sentResponses);
Assert.True(_sentResponses[0].Success);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobCompletedSendsTerminatedAndExitedEvents()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_sentEvents.Clear();
_session.OnJobCompleted();
Assert.Equal(DapSessionState.Terminated, _session.State);
var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated");
var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited");
Assert.Single(terminatedEvents);
Assert.Single(exitedEvents);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnStepStartingNoOpWhenNotActive()
{
using (CreateTestContext())
{
var step = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var task = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
await Task.WhenAny(task, Task.Delay(5000));
Assert.True(task.IsCompleted);
_mockServer.Verify(x => x.SendEvent(It.IsAny<Event>()), Times.Never);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ThreadsCommandReturnsJobThread()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
var threadsJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "threads"
});
_sentResponses.Clear();
await _session.HandleMessageAsync(threadsJson, CancellationToken.None);
Assert.Single(_sentResponses);
Assert.True(_sentResponses[0].Success);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task UnsupportedCommandReturnsErrorResponse()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
var json = JsonConvert.SerializeObject(new Request
{
Seq = 99,
Type = "request",
Command = "stepIn"
});
_sentResponses.Clear();
await _session.HandleMessageAsync(json, CancellationToken.None);
Assert.Single(_sentResponses);
Assert.False(_sentResponses[0].Success);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task FullFlowInitAttachConfigStepContinueComplete()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
_sentResponses.Clear();
Assert.Equal(DapSessionState.Ready, _session.State);
var step = CreateMockStep("Run tests");
var jobContext = CreateMockJobContext();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
await Task.Delay(100);
Assert.Equal(DapSessionState.Paused, _session.State);
var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped");
Assert.Single(stoppedEvents);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
var continuedEvents = _sentEvents.FindAll(e => e.EventType == "continued");
Assert.Single(continuedEvents);
step.Object.ExecutionContext.Result = TaskResult.Succeeded;
_session.OnStepCompleted(step.Object);
_sentEvents.Clear();
_session.OnJobCompleted();
Assert.Equal(DapSessionState.Terminated, _session.State);
var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated");
var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited");
Assert.Single(terminatedEvents);
Assert.Single(exitedEvents);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DoubleCancelSessionIsIdempotent()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_sentEvents.Clear();
_session.CancelSession();
_session.CancelSession();
Assert.Equal(DapSessionState.Terminated, _session.State);
var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated");
Assert.Single(terminatedEvents);
}
}
}
}

View File

@@ -0,0 +1,233 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
using GitHub.Runner.Worker.Dap;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapMessagesL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RequestSerializesCorrectly()
{
var request = new Request
{
Seq = 1,
Type = "request",
Command = "initialize",
Arguments = JObject.FromObject(new { clientID = "test-client" })
};
var json = JsonConvert.SerializeObject(request);
var deserialized = JsonConvert.DeserializeObject<Request>(json);
Assert.Equal(1, deserialized.Seq);
Assert.Equal("request", deserialized.Type);
Assert.Equal("initialize", deserialized.Command);
Assert.Equal("test-client", deserialized.Arguments["clientID"].ToString());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResponseSerializesCorrectly()
{
var response = new Response
{
Seq = 2,
Type = "response",
RequestSeq = 1,
Success = true,
Command = "initialize",
Body = new Capabilities { SupportsConfigurationDoneRequest = true }
};
var json = JsonConvert.SerializeObject(response);
var deserialized = JsonConvert.DeserializeObject<Response>(json);
Assert.Equal(2, deserialized.Seq);
Assert.Equal("response", deserialized.Type);
Assert.Equal(1, deserialized.RequestSeq);
Assert.True(deserialized.Success);
Assert.Equal("initialize", deserialized.Command);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EventSerializesWithCorrectType()
{
var evt = new Event
{
EventType = "stopped",
Body = new StoppedEventBody
{
Reason = "entry",
Description = "Stopped at entry",
ThreadId = 1,
AllThreadsStopped = true
}
};
Assert.Equal("event", evt.Type);
var json = JsonConvert.SerializeObject(evt);
Assert.Contains("\"type\":\"event\"", json);
Assert.Contains("\"event\":\"stopped\"", json);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StoppedEventBodyOmitsNullFields()
{
var body = new StoppedEventBody
{
Reason = "step"
};
var json = JsonConvert.SerializeObject(body);
Assert.Contains("\"reason\":\"step\"", json);
Assert.DoesNotContain("\"threadId\"", json);
Assert.DoesNotContain("\"allThreadsStopped\"", json);
Assert.DoesNotContain("\"description\"", json);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CapabilitiesMvpDefaults()
{
var caps = new Capabilities
{
SupportsConfigurationDoneRequest = true,
SupportsFunctionBreakpoints = false,
SupportsStepBack = false
};
var json = JsonConvert.SerializeObject(caps);
var deserialized = JsonConvert.DeserializeObject<Capabilities>(json);
Assert.True(deserialized.SupportsConfigurationDoneRequest);
Assert.False(deserialized.SupportsFunctionBreakpoints);
Assert.False(deserialized.SupportsStepBack);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ContinueResponseBodySerialization()
{
var body = new ContinueResponseBody { AllThreadsContinued = true };
var json = JsonConvert.SerializeObject(body);
var deserialized = JsonConvert.DeserializeObject<ContinueResponseBody>(json);
Assert.True(deserialized.AllThreadsContinued);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ThreadsResponseBodySerialization()
{
var body = new ThreadsResponseBody
{
Threads = new List<Thread>
{
new Thread { Id = 1, Name = "Job Thread" }
}
};
var json = JsonConvert.SerializeObject(body);
var deserialized = JsonConvert.DeserializeObject<ThreadsResponseBody>(json);
Assert.Single(deserialized.Threads);
Assert.Equal(1, deserialized.Threads[0].Id);
Assert.Equal("Job Thread", deserialized.Threads[0].Name);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StackFrameSerialization()
{
var frame = new StackFrame
{
Id = 1,
Name = "Step: Checkout",
Line = 1,
Column = 1,
PresentationHint = "normal"
};
var json = JsonConvert.SerializeObject(frame);
var deserialized = JsonConvert.DeserializeObject<StackFrame>(json);
Assert.Equal(1, deserialized.Id);
Assert.Equal("Step: Checkout", deserialized.Name);
Assert.Equal("normal", deserialized.PresentationHint);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExitedEventBodySerialization()
{
var body = new ExitedEventBody { ExitCode = 130 };
var json = JsonConvert.SerializeObject(body);
var deserialized = JsonConvert.DeserializeObject<ExitedEventBody>(json);
Assert.Equal(130, deserialized.ExitCode);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void DapCommandEnumValues()
{
Assert.Equal(0, (int)DapCommand.Continue);
Assert.Equal(1, (int)DapCommand.Next);
Assert.Equal(4, (int)DapCommand.Disconnect);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RequestDeserializesFromRawJson()
{
var json = @"{""seq"":5,""type"":""request"",""command"":""continue"",""arguments"":{""threadId"":1}}";
var request = JsonConvert.DeserializeObject<Request>(json);
Assert.Equal(5, request.Seq);
Assert.Equal("request", request.Type);
Assert.Equal("continue", request.Command);
Assert.Equal(1, request.Arguments["threadId"].Value<int>());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ErrorResponseBodySerialization()
{
var body = new ErrorResponseBody
{
Error = new Message
{
Id = 1,
Format = "Something went wrong",
ShowUser = true
}
};
var json = JsonConvert.SerializeObject(body);
var deserialized = JsonConvert.DeserializeObject<ErrorResponseBody>(json);
Assert.Equal(1, deserialized.Error.Id);
Assert.Equal("Something went wrong", deserialized.Error.Format);
Assert.True(deserialized.Error.ShowUser);
}
}
}

View File

@@ -0,0 +1,170 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapServerL0
{
private DapServer _server;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_server = new DapServer();
_server.Initialize(hc);
return hc;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeSucceeds()
{
using (CreateTestContext())
{
Assert.NotNull(_server);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SetSessionAcceptsMock()
{
using (CreateTestContext())
{
var mockSession = new Mock<IDapDebugSession>();
_server.SetSession(mockSession.Object);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SendEventNoClientDoesNotThrow()
{
using (CreateTestContext())
{
var evt = new Event
{
EventType = "stopped",
Body = new StoppedEventBody
{
Reason = "entry",
ThreadId = 1,
AllThreadsStopped = true
}
};
_server.SendEvent(evt);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SendResponseNoClientDoesNotThrow()
{
using (CreateTestContext())
{
var response = new Response
{
Type = "response",
RequestSeq = 1,
Command = "initialize",
Success = true
};
_server.SendResponse(response);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SendMessageNoClientDoesNotThrow()
{
using (CreateTestContext())
{
var msg = new ProtocolMessage
{
Type = "response",
Seq = 1
};
_server.SendMessage(msg);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StopWithoutStartDoesNotThrow()
{
using (CreateTestContext())
{
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAndStopOnAvailablePort()
{
using (CreateTestContext())
{
var cts = new CancellationTokenSource();
await _server.StartAsync(0, cts.Token);
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitForConnectionCancelledByCancellationToken()
{
using (CreateTestContext())
{
var cts = new CancellationTokenSource();
await _server.StartAsync(0, cts.Token);
var waitTask = _server.WaitForConnectionAsync(cts.Token);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await waitTask;
});
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAndStopMultipleTimesDoesNotThrow()
{
using (CreateTestContext())
{
var cts1 = new CancellationTokenSource();
await _server.StartAsync(0, cts1.Token);
await _server.StopAsync();
_server = new DapServer();
_server.Initialize(CreateTestContext());
var cts2 = new CancellationTokenSource();
await _server.StartAsync(0, cts2.Token);
await _server.StopAsync();
}
}
}
}

View File

@@ -1,14 +1,18 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Newtonsoft.Json.Linq;
using Xunit;
using DTWebApi = GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Common.Tests.Worker.Handlers
{
@@ -250,6 +254,66 @@ namespace GitHub.Runner.Common.Tests.Worker.Handlers
Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void PostStepMarker_UsesEvaluatedDisplayName()
{
// Arrange: create an ActionRunner with a RepositoryPathReference (simulating actions/cache@v4)
// and Stage = Post. Verify that EvaluateDisplayName produces the correct display name
// so the composite marker emits "Run actions/cache@v4" instead of the fallback "run".
var hc = new TestHostContext(this, nameof(PostStepMarker_UsesEvaluatedDisplayName));
var actionManifestLegacy = new ActionManifestManagerLegacy();
actionManifestLegacy.Initialize(hc);
hc.SetSingleton<IActionManifestManagerLegacy>(actionManifestLegacy);
var actionManifestNew = new ActionManifestManager();
actionManifestNew.Initialize(hc);
hc.SetSingleton<IActionManifestManager>(actionManifestNew);
var actionManifestManager = new ActionManifestManagerWrapper();
actionManifestManager.Initialize(hc);
hc.SetSingleton<IActionManifestManagerWrapper>(actionManifestManager);
var ec = new Mock<IExecutionContext>();
var contextData = new DictionaryContextData();
var githubContext = new GitHubContext();
githubContext.Add("event", JToken.Parse("{\"foo\":\"bar\"}").ToPipelineContextData());
contextData.Add("github", githubContext);
#if OS_WINDOWS
contextData["env"] = new DictionaryContextData();
#else
contextData["env"] = new CaseSensitiveDictionaryContextData();
#endif
ec.Setup(x => x.Global).Returns(new GlobalContext());
ec.Setup(x => x.ExpressionValues).Returns(contextData);
ec.Setup(x => x.ExpressionFunctions).Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()));
ec.Object.Global.Variables = new Variables(hc, new Dictionary<string, VariableValue>());
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
actionRunner.ExecutionContext = ec.Object;
actionRunner.Stage = ActionRunStage.Post;
actionRunner.Action = new Pipelines.ActionStep()
{
Name = "cache",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "actions/cache",
Ref = "v4"
}
};
// Act: call EvaluateDisplayName directly, which is what CompositeActionHandler now does
// for embedded steps (including Post stage) instead of TryUpdateDisplayName.
var result = actionRunner.EvaluateDisplayName(contextData, ec.Object, out bool updated);
// Assert: display name should be "Run actions/cache@v4", not the fallback "run"
Assert.True(result);
Assert.True(updated);
Assert.Equal("Run actions/cache@v4", actionRunner.DisplayName);
}
// Helper methods that call the real production code
private static string EscapeProperty(string value) =>
CompositeActionHandler.EscapeProperty(value);

View File

@@ -36,7 +36,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new StringToken(null, null, null, "test-value");
var contextData = new DictionaryContextData();
@@ -63,7 +63,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
// Call EvaluateAndCompare directly: the new evaluator cancels the token
// and returns a different value, forcing hasMismatch = true.
@@ -98,7 +98,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
// Different results without cancellation — mismatch SHOULD be recorded.
var result = wrapper.EvaluateAndCompare<string, string>(
@@ -130,7 +130,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new BooleanToken(null, null, null, true);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -156,7 +156,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new MappingToken(null, null, null);
token.Add(new StringToken(null, null, null, "FOO"), new StringToken(null, null, null, "bar"));
var contextData = new DictionaryContextData();
@@ -184,7 +184,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new BasicExpressionToken(null, null, null, "true");
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -211,7 +211,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new MappingToken(null, null, null);
token.Add(new StringToken(null, null, null, "input1"), new StringToken(null, null, null, "val1"));
var contextData = new DictionaryContextData();
@@ -239,7 +239,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new NumberToken(null, null, null, 10);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -265,7 +265,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new StringToken(null, null, null, "");
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -281,6 +281,140 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobContainer_DockerPrefixOnly_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new StringToken(null, null, null, "docker://");
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
Assert.Null(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobContainer_DockerPrefixOnlyMapping_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new MappingToken(null, null, null);
token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://"));
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
Assert.Null(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobContainer_EmptyImageMapping_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new MappingToken(null, null, null);
token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, ""));
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
Assert.Null(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobContainer_ValidImage_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new StringToken(null, null, null, "ubuntu:latest");
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
Assert.NotNull(result);
Assert.Equal("ubuntu:latest", result.Image);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobContainer_DockerPrefixWithImage_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new StringToken(null, null, null, "docker://ubuntu:latest");
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
Assert.NotNull(result);
Assert.Equal("ubuntu:latest", result.Image);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -291,7 +425,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new MappingToken(null, null, null);
token.Add(new StringToken(null, null, null, "out1"), new StringToken(null, null, null, "val1"));
var contextData = new DictionaryContextData();
@@ -319,7 +453,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new StringToken(null, null, null, "https://example.com");
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -348,7 +482,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new MappingToken(null, null, null);
token.Add(new StringToken(null, null, null, "shell"), new StringToken(null, null, null, "bash"));
var contextData = new DictionaryContextData();
@@ -376,7 +510,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -391,6 +525,213 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobServiceContainers_EmptyImage_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
// Build a services mapping token with one service whose image is empty string
// Similar to: services: { db: { image: '' } }
var servicesMapping = new MappingToken(null, null, null);
var serviceMapping = new MappingToken(null, null, null);
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, ""));
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
// Should get a list with one entry where the container is null (empty image = no container)
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("db", result[0].Key);
Assert.Null(result[0].Value);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobServiceContainers_DockerPrefixOnlyImage_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var servicesMapping = new MappingToken(null, null, null);
var serviceMapping = new MappingToken(null, null, null);
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://"));
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("db", result[0].Key);
Assert.Null(result[0].Value);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobServiceContainers_ExpressionEvalsToEmpty_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
// Simulates: services: { db: { image: ${{ condition && 'img' || '' }} } }
// where the expression evaluates to '' at runtime
var servicesMapping = new MappingToken(null, null, null);
var serviceMapping = new MappingToken(null, null, null);
serviceMapping.Add(new StringToken(null, null, null, "image"), new BasicExpressionToken(null, null, null, "''"));
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("db", result[0].Key);
Assert.Null(result[0].Value);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobServiceContainers_ValidImage_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var servicesMapping = new MappingToken(null, null, null);
var serviceMapping = new MappingToken(null, null, null);
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("db", result[0].Key);
Assert.NotNull(result[0].Value);
Assert.Equal("postgres:latest", result[0].Value.Image);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobServiceContainers_EntrypointAndCommand_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var servicesMapping = new MappingToken(null, null, null);
var serviceMapping = new MappingToken(null, null, null);
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash"));
serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi"));
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: true);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("db", result[0].Key);
Assert.NotNull(result[0].Value);
Assert.Equal("postgres:latest", result[0].Value.Image);
Assert.Equal("/bin/bash", result[0].Value.Entrypoint);
Assert.Equal("-lc echo hi", result[0].Value.Command);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobServiceContainers_EntrypointAndCommand_FlagOff_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var servicesMapping = new MappingToken(null, null, null);
var serviceMapping = new MappingToken(null, null, null);
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash"));
serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi"));
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
Assert.Throws<GitHub.DistributedTask.ObjectTemplating.TemplateValidationException>(() =>
wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions));
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -401,7 +742,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -430,7 +771,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
// Both throw JsonReaderException with different messages — should be treated as equivalent
var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken from JsonReader. Path '', line 0, position 0.");
@@ -461,7 +802,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
// Legacy throws Newtonsoft JsonReaderException, new throws System.Text.Json.JsonException
var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken");
@@ -492,7 +833,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
// Both throw non-JSON exceptions with different messages — should record mismatch
var legacyEx = new InvalidOperationException("some error");