Compare commits

..

16 Commits

Author SHA1 Message Date
Lokesh Gopu
7d737449ef Bump to 2.335.1 (#4484)
Co-authored-by: Francesco Renzi <rentziass@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 21:28:02 -04:00
Francesco Renzi
0d310567ae Update releaseVersion 2026-06-08 18:04:53 +01:00
Francesco Renzi
1ccca7c073 Prepping runner release 2.335.0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 17:47:11 +01:00
dependabot[bot]
cbaeeb89ea Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs (#4369)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 15:44:51 +00:00
dependabot[bot]
4e51e7980c Bump Microsoft.DevTunnels.Connections from 1.3.39 to 1.3.48 (#4441)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 15:32:12 +00:00
Stewart Webb
39108f22e4 Add new env var to allow single-prefix multiline logs on stdout (#4424)
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
2026-06-08 11:23:45 -04:00
Tingluo Huang
7e0ff4d3e4 BrokerServer should not retry on 401. (#4445) 2026-06-08 13:50:35 +00:00
github-actions[bot]
4864bb5778 Update Docker to v29.5.2 and Buildx to v0.34.1 (#4451)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-08 09:45:49 -04:00
Lokesh Gopu
a3df03d35a Background steps execution engine (#4476) 2026-06-07 02:59:13 -04:00
Francesco Renzi
e6c5af75be Wire job execution view into DAP (#4471)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 15:04:19 +00:00
Lokesh Gopu
fb78489197 Add background step deferral infrastructure and metadata plumbing (#4479) 2026-06-04 17:45:53 -04:00
Lokesh Gopu
77d6014f58 Add thread-safety locks to StepsContext (#4475) 2026-06-04 14:08:05 -04:00
Francesco Renzi
9c2a004d07 Add job execution view model (#4470) 2026-06-04 14:03:54 +00:00
Lokesh Gopu
5053d17b4e Add SDK types and results plumbing for background step control (#4472) 2026-06-03 18:14:41 -04:00
Driele Neves Ribeiro
c6a124e184 Populate telemetry for non-action post-job steps (#4463)
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
2026-05-28 17:15:49 +00:00
Salman Chishti
1a6560294e Update Node 24 default date to June 16th, 2026 (#4462) 2026-05-28 16:43:55 +01:00
49 changed files with 2997 additions and 4915 deletions

View File

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.5.0
ARG BUILDX_VERSION=0.34.0
ARG DOCKER_VERSION=29.5.3
ARG BUILDX_VERSION=0.34.1
RUN apt update -y && apt install curl unzip -y

View File

@@ -1,36 +1,40 @@
## What's Changed
* Bump flatted from 3.2.7 to 3.4.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4307
* Add DAP server by @rentziass in https://github.com/actions/runner/pull/4298
* Bump @typescript-eslint/eslint-plugin from 8.57.1 to 8.57.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4310
* Remove AllowCaseFunction feature flag by @ericsciple in https://github.com/actions/runner/pull/4316
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4319
* Batch and deduplicate action resolution across composite depths by @stefanpenner in https://github.com/actions/runner/pull/4296
* Add support for Bearer token in action archive downloads by @TingluoHuang in https://github.com/actions/runner/pull/4321
* Bump brace-expansion in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4318
* Add devtunnel connection for debugger jobs by @rentziass in https://github.com/actions/runner/pull/4317
* Update Docker to v29.3.1 and Buildx to v0.33.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4324
* Bump @typescript-eslint/eslint-plugin from 8.57.2 to 8.58.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4327
* Bump actions/github-script from 8 to 9 by @dependabot[bot] in https://github.com/actions/runner/pull/4331
* Bump typescript from 5.9.3 to 6.0.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4329
* fix: only show changed versions in node upgrade PR description by @salmanmkc in https://github.com/actions/runner/pull/4332
* Bump System.Formats.Asn1, Cryptography.Pkcs, ProtectedData, ServiceController, CodePages, Threading.Channels, @actions/glob, @typescript-eslint/parser, lint-staged, picomatch by @Copilot in https://github.com/actions/runner/pull/4333
* feat: add `job.workflow_*` typed accessors to JobContext by @salmanmkc in https://github.com/actions/runner/pull/4335
* Add WS bridge over DAP TCP server by @rentziass in https://github.com/actions/runner/pull/4328
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4355
* Bump Docker version to 29.4.0 by @Copilot in https://github.com/actions/runner/pull/4352
* Update dotnet sdk to latest version @8.0.420 by @github-actions[bot] in https://github.com/actions/runner/pull/4356
* Bump @typescript-eslint/parser from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4360
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4362
* Add vulnerability-alerts permission by @salmanmkc in https://github.com/actions/runner/pull/4350
* Bump @typescript-eslint/eslint-plugin from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4359
* Bump System.ServiceProcess.ServiceController from 10.0.3 to 10.0.6 by @dependabot[bot] in https://github.com/actions/runner/pull/4358
* Bump typescript from 6.0.2 to 6.0.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4353
* Bump Microsoft.DevTunnels.Connections from 1.3.16 to 1.3.39 by @dependabot[bot] in https://github.com/actions/runner/pull/4339
* Bump System.ServiceProcess.ServiceController from 10.0.6 to 10.0.7 by @dependabot[bot] in https://github.com/actions/runner/pull/4370
* Bump @actions/glob from 0.6.1 to 0.7.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4367
* feat: propagate actions dependencies by @nodeselector in https://github.com/actions/runner/pull/4372
* Not retry and report action download 403. by @TingluoHuang in https://github.com/actions/runner/pull/4391
* Update setup job starting logs by @GitPaulo in https://github.com/actions/runner/pull/4383
* fix: expand commit hash regex to support SHA-256 (64-char) hashes by @yaananth in https://github.com/actions/runner/pull/4347
* Move dap setup to setup job step by @rentziass in https://github.com/actions/runner/pull/4403
* Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) by @dvaldivia in https://github.com/actions/runner/pull/4394
* Update dotnet sdk to latest version @8.0.421 by @github-actions[bot] in https://github.com/actions/runner/pull/4428
* Update Docker to v29.5.0 and Buildx to v0.34.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4425
* Execute debugger REPL commands inside job container by @rentziass in https://github.com/actions/runner/pull/4420
* Send welcome message in debugger console on connect by @rentziass in https://github.com/actions/runner/pull/4419
* Update snapshot-if context and functions by @drielenr in https://github.com/actions/runner/pull/4443
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4452
* Allow disable node v8 maglev jit compiler on node24. by @TingluoHuang in https://github.com/actions/runner/pull/4447
* Update Node 24 default date to June 16th, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4462
* Populate telemetry for non-action post-job steps by @drielenr in https://github.com/actions/runner/pull/4463
* Add SDK types and results plumbing for background step control by @lokesh755 in https://github.com/actions/runner/pull/4472
* Add job execution view model by @rentziass in https://github.com/actions/runner/pull/4470
* Add thread-safety locks to StepsContext by @lokesh755 in https://github.com/actions/runner/pull/4475
* Add background step deferral infrastructure and metadata plumbing by @lokesh755 in https://github.com/actions/runner/pull/4479
* Wire job execution view into DAP by @rentziass in https://github.com/actions/runner/pull/4471
* Background steps execution engine by @lokesh755 in https://github.com/actions/runner/pull/4476
* Update Docker to v29.5.2 and Buildx to v0.34.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4451
* BrokerServer should not retry on 401. by @TingluoHuang in https://github.com/actions/runner/pull/4445
* Add new env var to allow single-prefix multiline logs on stdout by @nuclearpidgeon in https://github.com/actions/runner/pull/4424
* Bump Microsoft.DevTunnels.Connections from 1.3.39 to 1.3.48 by @dependabot[bot] in https://github.com/actions/runner/pull/4441
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4369
## New Contributors
* @stefanpenner made their first contribution in https://github.com/actions/runner/pull/4296
* @GitPaulo made their first contribution in https://github.com/actions/runner/pull/4383
* @dvaldivia made their first contribution in https://github.com/actions/runner/pull/4394
* @drielenr made their first contribution in https://github.com/actions/runner/pull/4443
* @nuclearpidgeon made their first contribution in https://github.com/actions/runner/pull/4424
**Full Changelog**: https://github.com/actions/runner/compare/v2.333.1...v2.334.0
**Full Changelog**: https://github.com/actions/runner/compare/v2.334.0...v2.335.0
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.

View File

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

View File

@@ -108,7 +108,7 @@ namespace GitHub.Runner.Common
public bool ShouldRetryException(Exception ex)
{
if (ex is AccessDeniedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException)
if (ex is AccessDeniedException || ex is VssUnauthorizedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException)
{
return false;
}

View File

@@ -206,7 +206,7 @@ namespace GitHub.Runner.Common
public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/";
// Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables)
public static readonly string Node24DefaultDate = "June 2nd, 2026";
public static readonly string Node24DefaultDate = "June 16th, 2026";
public static readonly string Node20RemovalDate = "September 16th, 2026";
// Variable keys for server-overridable dates
@@ -308,6 +308,7 @@ namespace GitHub.Runner.Common
public static readonly string ForcedInternalNodeVersion = "ACTIONS_RUNNER_FORCED_INTERNAL_NODE_VERSION";
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
public static readonly string DisableStdoutMultilineLogPrefixing = "ACTIONS_RUNNER_DISABLE_STDOUT_MULTILINE_LOG_PREFIXING";
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";

View File

@@ -837,6 +837,15 @@ namespace GitHub.Runner.Common
timelineRecord.Variables[variable.Key] = variable.Value.Clone();
}
}
// Merge background step metadata
if (rec.IsBackground)
{
timelineRecord.IsBackground = rec.IsBackground;
}
timelineRecord.BackgroundControlType = rec.BackgroundControlType ?? timelineRecord.BackgroundControlType;
timelineRecord.BackgroundControlStepIds = rec.BackgroundControlStepIds ?? timelineRecord.BackgroundControlStepIds;
timelineRecord.ParallelGroupId = rec.ParallelGroupId ?? timelineRecord.ParallelGroupId;
}
else
{

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@@ -9,10 +9,12 @@ namespace GitHub.Runner.Common
public sealed class StdoutTraceListener : ConsoleTraceListener
{
private readonly string _hostType;
private readonly bool _disablePrefixMultilineLogs = false;
public StdoutTraceListener(string hostType)
{
this._hostType = hostType;
this._disablePrefixMultilineLogs = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Agent.DisableStdoutMultilineLogPrefixing));
}
// Copied and modified slightly from .Net Core source code. Modification was required to make it compile.
@@ -26,11 +28,20 @@ namespace GitHub.Runner.Common
if (!string.IsNullOrEmpty(message))
{
var messageLines = message.Split(Environment.NewLine);
foreach (var messageLine in messageLines)
if (!this._disablePrefixMultilineLogs)
{
var messageLines = message.Split(Environment.NewLine);
foreach (var messageLine in messageLines)
{
WriteHeader(source, eventType, id);
WriteLine(messageLine);
WriteFooter(eventCache);
}
}
else
{
WriteHeader(source, eventType, id);
WriteLine(messageLine);
WriteLine(message);
WriteFooter(eventCache);
}
}

View File

@@ -282,8 +282,15 @@ namespace GitHub.Runner.Worker
}
}
context.Global.EnvironmentVariables[envName] = command.Data;
context.SetEnvContext(envName, command.Data);
if (context.DeferredEnvironmentVariables != null)
{
context.DeferredEnvironmentVariables[envName] = command.Data;
}
else
{
context.Global.EnvironmentVariables[envName] = command.Data;
context.SetEnvContext(envName, command.Data);
}
context.Debug($"{envName}='{command.Data}'");
}
@@ -334,8 +341,15 @@ namespace GitHub.Runner.Worker
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
}
context.SetOutput(outputName, command.Data, out var reference);
context.Debug($"{reference}='{command.Data}'");
if (context.DeferredOutputs != null)
{
context.DeferredOutputs[outputName] = command.Data;
}
else
{
context.SetOutput(outputName, command.Data, out var reference);
context.Debug($"{reference}='{command.Data}'");
}
}
private static class SetOutputCommandProperties
@@ -465,8 +479,16 @@ namespace GitHub.Runner.Worker
}
ArgUtil.NotNullOrEmpty(command.Data, "path");
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
context.Global.PrependPath.Add(command.Data);
if (context.DeferredPrependPath != null)
{
context.DeferredPrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
context.DeferredPrependPath.Add(command.Data);
}
else
{
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
context.Global.PrependPath.Add(command.Data);
}
}
}

View File

@@ -0,0 +1,21 @@
using System;
namespace GitHub.Runner.Worker
{
/// <summary>
/// Pure data for control-flow steps (wait, wait-all, cancel).
/// Type uses Pipelines.BackgroundControlTypes string constants.
/// </summary>
public sealed class BackgroundStepControlFlowData
{
public string Type { get; set; }
public Guid StepId { get; set; }
public string StepName { get; set; }
// Target step IDs (for wait: steps to wait for; for cancel: steps to cancel)
public string[] StepIds { get; set; }
// Parallel group ID for grouping steps in the UI
public string ParallelGroupId { get; set; }
}
}

View File

@@ -0,0 +1,394 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(BackgroundStepCoordinator))]
public interface IBackgroundStepCoordinator : IRunnerService
{
void InitializeCoordinator(int maxConcurrent);
void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken);
Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken);
Task RunControlFlowAsync(IExecutionContext stepContext, object data);
}
/// <summary>
/// Coordinates background step execution, waiting, cancellation, and deferred state.
/// Extracted from StepsRunner so the main step loop stays clean.
/// </summary>
public sealed class BackgroundStepCoordinator : RunnerService, IBackgroundStepCoordinator
{
private const int DefaultMaxBackgroundSteps = 10;
private readonly Dictionary<string, (IStep Step, Task Task, CancellationTokenSource Cts)> _backgroundSteps = new();
// IDs of background steps that have already been completed (waited on or canceled).
// Used to avoid waiting on or flushing the same step more than once.
private readonly HashSet<string> _completedStepIds = new();
// IDs of background steps that were explicitly canceled via a `cancel` control step.
// These steps are expected to be canceled, so their (Canceled) result must not be
// merged into the overall job result.
private readonly HashSet<string> _explicitlyCanceledStepIds = new();
private SemaphoreSlim _backgroundSlotSemaphore = new SemaphoreSlim(DefaultMaxBackgroundSteps);
/// <summary>
/// Reset per-job state. Call at the start of each job.
/// </summary>
public void InitializeCoordinator(int maxConcurrent)
{
_backgroundSteps.Clear();
_completedStepIds.Clear();
_explicitlyCanceledStepIds.Clear();
var max = maxConcurrent > 0 ? maxConcurrent : DefaultMaxBackgroundSteps;
_backgroundSlotSemaphore = new SemaphoreSlim(max);
}
// -----------------------------------------------------------------
// Starting background steps
// -----------------------------------------------------------------
/// <summary>
/// Prepare and launch a background step. Does not block the caller.
/// </summary>
public void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken)
{
var stepId = step.ExecutionContext?.ContextName ?? step.DisplayName;
// Isolate GitHubContext so concurrent steps don't overwrite each other's GITHUB_OUTPUT paths
if (step.ExecutionContext.ExpressionValues.TryGetValue("github", out var ghCtx) && ghCtx is GitHubContext sharedGitHub)
{
step.ExecutionContext.ExpressionValues["github"] = sharedGitHub.ShallowCopy();
}
var bgCts = CancellationTokenSource.CreateLinkedTokenSource(jobCancellationToken);
// Evaluate timeout on the main thread (needs expression context)
var timeoutMinutes = 0;
try
{
var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator();
timeoutMinutes = templateEvaluator.EvaluateStepTimeout(step.Timeout, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions);
}
catch (Exception ex)
{
Trace.Info($"Error determining timeout for background step '{stepId}': {ex.Message}");
}
var task = ExecuteBackgroundStepCoreAsync(step, bgCts, stepId, timeoutMinutes);
_backgroundSteps[stepId] = (step, task, bgCts);
Trace.Info($"Background step '{stepId}' queued (slot will be acquired asynchronously).");
}
// -----------------------------------------------------------------
// Safety net
// -----------------------------------------------------------------
// Drain any background steps that weren't already waited on by an explicit wait/cancel
// control step, then merge the final results of all background steps into a single result
// for the caller to fold into the job result.
public async Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken)
{
var unwaitedIds = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
if (unwaitedIds.Count > 0)
{
Trace.Info($"Safety net: {unwaitedIds.Count} unwaited background step(s) at post-job boundary: {string.Join(", ", unwaitedIds)}");
await WaitForStepTasksAsync(unwaitedIds, cancellationToken);
CompleteWaitedSteps(unwaitedIds);
}
var result = TaskResult.Succeeded;
foreach (var (stepId, (step, _, _)) in _backgroundSteps)
{
// A step that succeeded does not set a Result by default, so a missing
// value means the step succeeded and there is nothing to merge.
if (!step.ExecutionContext.Result.HasValue)
{
continue;
}
// A step explicitly canceled via a `cancel` control step is expected to be canceled,
// so a Canceled result must not influence the overall job result. However, if the step
// failed (e.g. before the cancellation took effect), that failure should still count.
if (_explicitlyCanceledStepIds.Contains(stepId) &&
step.ExecutionContext.Result.Value == TaskResult.Canceled)
{
continue;
}
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.Result.Value);
}
if (result != TaskResult.Succeeded)
{
Trace.Info($"Background steps reported result '{result}' to caller.");
}
return result;
}
// -----------------------------------------------------------------
// Control-flow step dispatch
// -----------------------------------------------------------------
/// <summary>
/// Execute a control-flow step (wait, wait-all, cancel) and propagate results.
/// </summary>
public async Task RunControlFlowAsync(IExecutionContext stepContext, object data)
{
var controlFlow = data as BackgroundStepControlFlowData;
switch (controlFlow.Type)
{
case Pipelines.BackgroundControlTypes.Wait:
{
var ids = controlFlow.StepIds ?? Array.Empty<string>();
stepContext.Output($"Waiting for background step(s) to complete: {DescribeSteps(ids)}");
await WaitForStepTasksAsync(ids, stepContext.CancellationToken);
stepContext.Result = CompleteWaitedSteps(ids);
ReportCompletedSteps(stepContext, "Finished waiting for background step(s).", ids);
break;
}
case Pipelines.BackgroundControlTypes.WaitAll:
{
var remaining = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
stepContext.Output(remaining.Count > 0
? $"Waiting for all background step(s) to complete: {DescribeSteps(remaining)}"
: "No background steps remaining to wait for.");
await WaitForStepTasksAsync(remaining, stepContext.CancellationToken);
stepContext.Result = CompleteWaitedSteps(remaining);
ReportCompletedSteps(stepContext, "Finished waiting for all background step(s).", remaining);
break;
}
case Pipelines.BackgroundControlTypes.Cancel:
{
var cancelIds = controlFlow.StepIds ?? Array.Empty<string>();
stepContext.Output($"Cancelling background step(s): {DescribeSteps(cancelIds)}");
await CancelStepsAsync(controlFlow.StepIds);
stepContext.Result = TaskResult.Succeeded;
ReportCompletedSteps(stepContext, "Finished cancelling background step(s).", cancelIds);
break;
}
default:
throw new ArgumentException($"Unknown background step control type '{controlFlow.Type}'.");
}
}
// -----------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------
// Resolve background step IDs to their display names for customer-facing output.
private string DescribeSteps(IEnumerable<string> stepIds)
{
var names = stepIds
.Select(id => _backgroundSteps.TryGetValue(id, out var entry) ? entry.Step.DisplayName : id)
.ToList();
return names.Count > 0 ? string.Join(", ", names) : "(none)";
}
// Emit a completion summary plus the final result of each affected background step.
private void ReportCompletedSteps(IExecutionContext stepContext, string summary, IEnumerable<string> stepIds)
{
stepContext.Output(summary);
foreach (var id in stepIds)
{
if (_backgroundSteps.TryGetValue(id, out var entry))
{
var result = entry.Step.ExecutionContext.Result?.ToString() ?? "Unknown";
stepContext.Output($" {entry.Step.DisplayName}: {result}");
}
}
}
private async Task ExecuteBackgroundStepCoreAsync(
IStep step, CancellationTokenSource bgCts,
string stepId, int timeoutMinutes)
{
Trace.Info($"Background step '{stepId}' waiting for slot.");
await _backgroundSlotSemaphore.WaitAsync(bgCts.Token);
Trace.Info($"Background step '{stepId}' acquired slot.");
step.ExecutionContext.Start();
if (timeoutMinutes > 0)
{
step.ExecutionContext.SetTimeout(TimeSpan.FromMinutes(timeoutMinutes));
}
using var cancelReg = bgCts.Token.Register(() =>
{
Trace.Info($"Background step '{stepId}': cancellation signalled, sending CancelToken to process.");
step.ExecutionContext.CancelToken();
});
TaskResult? result = null;
try
{
await step.RunAsync();
result = step.ExecutionContext.Result ?? TaskResult.Succeeded;
}
catch (OperationCanceledException) when (bgCts.Token.IsCancellationRequested)
{
result = TaskResult.Canceled;
}
catch (OperationCanceledException) when (step.ExecutionContext.CancellationToken.IsCancellationRequested)
{
Trace.Info($"Background step '{stepId}' timed out after {timeoutMinutes} minutes.");
step.ExecutionContext.Error($"The background step '{step.DisplayName}' has timed out after {timeoutMinutes} minutes.");
result = TaskResult.Failed;
}
catch (Exception ex)
{
Trace.Info($"Background step '{stepId}' failed: {ex.Message}");
step.ExecutionContext.Error(ex);
result = TaskResult.Failed;
}
finally
{
_backgroundSlotSemaphore.Release();
if (step.ExecutionContext.CommandResult != null)
{
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.CommandResult.Value);
}
step.ExecutionContext.Result = result;
step.ExecutionContext.ApplyContinueOnError(step.ContinueOnError);
step.ExecutionContext.Complete(step.ExecutionContext.Result);
Trace.Info($"Background step '{stepId}' completed with result: {step.ExecutionContext.Result}");
}
}
private async Task CancelStepsAsync(string[] cancelStepIds)
{
if (cancelStepIds == null || cancelStepIds.Length == 0)
{
return;
}
// Mark these steps as expected-to-be-canceled so their result does not
// affect the overall job result.
foreach (var id in cancelStepIds)
{
_explicitlyCanceledStepIds.Add(id);
}
var idsToCancel = cancelStepIds
.Where(id => _backgroundSteps.ContainsKey(id) && !_backgroundSteps[id].Task.IsCompleted)
.ToArray();
if (idsToCancel.Length > 0)
{
Trace.Info($"Cancelling {idsToCancel.Length} background step(s): {string.Join(", ", idsToCancel)}");
await CancelWithGracePeriodAsync(idsToCancel);
}
// Flush deferred state and mark canceled steps as completed.
CompleteWaitedSteps(cancelStepIds);
}
private async Task WaitForStepTasksAsync(IEnumerable<string> stepIds, CancellationToken cancellationToken)
{
var ids = stepIds.ToList();
var tasks = new List<Task>();
foreach (var stepId in ids)
{
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
{
tasks.Add(entry.Task);
}
else if (!_backgroundSteps.ContainsKey(stepId))
{
Trace.Info($"Wait references unknown background step: {stepId}");
}
}
if (tasks.Count > 0)
{
Trace.Info($"Waiting for {tasks.Count} background step(s)...");
try
{
await Task.WhenAll(tasks).WaitAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Trace.Info("Wait interrupted by job cancellation — cancelling background steps.");
await CancelWithGracePeriodAsync(ids);
}
}
}
private async Task CancelWithGracePeriodAsync(IEnumerable<string> stepIds, double graceSeconds = 7.5)
{
var cancelledSteps = new List<(string StepId, Task Task, IStep Step)>();
foreach (var stepId in stepIds)
{
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
{
entry.Step.ExecutionContext.CancelToken();
entry.Cts.Cancel();
cancelledSteps.Add((stepId, entry.Task, entry.Step));
}
}
if (cancelledSteps.Count > 0)
{
try
{
await Task.WhenAll(cancelledSteps.Select(s => s.Task)).WaitAsync(TimeSpan.FromSeconds(graceSeconds));
}
catch (TimeoutException)
{
Trace.Info($"Some background steps did not terminate within {graceSeconds}s grace period.");
// The step tasks above never completed, so their finally block never ran and
// their result was never set. Force-mark them as canceled so the abandoned
// steps still report a terminal result.
foreach (var (stepId, task, step) in cancelledSteps)
{
if (!task.IsCompleted && !step.ExecutionContext.Result.HasValue)
{
step.ExecutionContext.Result = TaskResult.Canceled;
Trace.Info($"Background step '{stepId}' did not terminate within grace period; marking as canceled.");
}
}
}
}
}
private TaskResult CompleteWaitedSteps(IEnumerable<string> stepIds)
{
var result = TaskResult.Succeeded;
foreach (var id in stepIds)
{
_completedStepIds.Add(id);
if (_backgroundSteps.TryGetValue(id, out var entry))
{
// Flush deferred state for the completed step.
entry.Step.ExecutionContext.FlushDeferredOutputs();
entry.Step.ExecutionContext.FlushDeferredEnvironment();
entry.Step.ExecutionContext.FlushDeferredOutcomeConclusion();
Trace.Info($"Flushed deferred state for background step '{id}'.");
if (entry.Step.ExecutionContext.Result.HasValue)
{
result = TaskResultUtil.MergeTaskResults(result, entry.Step.ExecutionContext.Result.Value);
}
}
}
return result;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -545,16 +545,16 @@ namespace GitHub.Runner.Worker.Dap
public class SourceArguments
{
/// <summary>
/// Source descriptor (optional, redundant with sourceReference).
/// Source descriptor. Some clients send sourceReference only here.
/// </summary>
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
public Source Source { get; set; }
/// <summary>
/// The reference to the source. Required by DAP spec.
/// The reference to the source.
/// </summary>
[JsonProperty("sourceReference")]
public int SourceReference { get; set; }
[JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)]
public int? SourceReference { get; set; }
}
/// <summary>
@@ -577,92 +577,6 @@ namespace GitHub.Runner.Worker.Dap
#endregion
#region LoadedSources Request/Response
/// <summary>
/// Response body for 'loadedSources' request.
/// </summary>
public class LoadedSourcesResponseBody
{
[JsonProperty("sources")]
public List<Source> Sources { get; set; } = new List<Source>();
}
/// <summary>
/// Body for 'loadedSource' event.
/// </summary>
public class LoadedSourceEventBody
{
/// <summary>
/// "new" | "changed" | "removed"
/// </summary>
[JsonProperty("reason")]
public string Reason { get; set; }
[JsonProperty("source")]
public Source Source { get; set; }
}
#endregion
#region SetBreakpoints Request/Response
/// <summary>
/// Arguments for 'setBreakpoints' request.
/// </summary>
public class SetBreakpointsArguments
{
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
public Source Source { get; set; }
[JsonProperty("breakpoints")]
public List<SourceBreakpoint> Breakpoints { get; set; } = new List<SourceBreakpoint>();
}
/// <summary>
/// Properties of a breakpoint passed to the setBreakpoints request.
/// </summary>
public class SourceBreakpoint
{
[JsonProperty("line")]
public int Line { get; set; }
[JsonProperty("condition", NullValueHandling = NullValueHandling.Ignore)]
public string Condition { get; set; }
[JsonProperty("logMessage", NullValueHandling = NullValueHandling.Ignore)]
public string LogMessage { get; set; }
}
/// <summary>
/// Response body for 'setBreakpoints' request.
/// </summary>
public class SetBreakpointsResponseBody
{
[JsonProperty("breakpoints")]
public List<Breakpoint> Breakpoints { get; set; } = new List<Breakpoint>();
}
/// <summary>
/// Information about a breakpoint created in setBreakpoints.
/// </summary>
public class Breakpoint
{
[JsonProperty("verified")]
public bool Verified { get; set; }
[JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)]
public int? Line { get; set; }
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
public Source Source { get; set; }
[JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)]
public string Message { get; set; }
}
#endregion
#region Scopes Request/Response
/// <summary>

View File

@@ -20,25 +20,10 @@ namespace GitHub.Runner.Worker.Dap
{
Task StartAsync(IExecutionContext jobContext);
Task WaitUntilReadyAsync();
Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps);
void OnPostStepRegistered(IStep step);
Task OnStepStartingAsync(IStep step);
void OnStepCompleted(IStep step);
/// <summary>
/// Called after JobExtension.InitializeJob has returned and the initial
/// step queue + post-step stack have been populated. The debugger uses
/// these snapshots to build the synthesized job execution view served
/// via the DAP source request.
/// </summary>
Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack);
/// <summary>
/// Called from ExecutionContext.RegisterPostJobStep after a post-step
/// is pushed onto the post-job stack. The debugger appends the step
/// to the running execution view so the rendered YAML reflects the
/// newly-known post-step.
/// </summary>
void OnPostStepRegistered(IStep step);
Task OnJobCompletedAsync();
Task StopAsync();
}

View File

@@ -1,82 +1,52 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Sdk;
using System.Globalization;
using System.Text;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Stateful, append-only container that wraps <see cref="JobExecutionViewRenderer"/>
/// for runtime use. Maintains a mutable list of entries, caches the rendered YAML,
/// and provides O(1) lookup from <see cref="IStep"/> identity to the current line
/// in the rendered YAML where that step's <c>- step:</c> key appears.
///
/// Each <see cref="Append"/> can register the entry in one of three modes:
/// - With a non-null <c>stepIdentity</c>: registers the IStep→line mapping
/// immediately. Used for entries whose real <see cref="IStep"/> is already
/// known at append time.
/// - With a non-null <c>matchKey</c>: registers an unclaimed placeholder
/// that a later <see cref="TryClaim"/> binds to a real <see cref="IStep"/>.
/// Used for entries whose <see cref="IStep"/> is materialized later. A
/// placeholder that is never claimed simply stays in the view and is never
/// paused on — the IStep→line mapping is only populated on claim.
/// - With neither: a static entry that needs no line lookup.
///
/// <see cref="Append"/> and <see cref="AppendRange"/> never remove or reorder
/// existing entries. <see cref="TryClaim"/> does not re-render. The IStep→line
/// mapping is rebuilt on every render, so lookups stay accurate even if a later
/// Append happens to shift previously-emitted entries.
/// </summary>
internal sealed class JobExecutionView
{
private readonly object _lock = new();
private readonly string _jobId;
private readonly List<JobExecutionViewEntry> _entries = new();
private readonly List<IStep> _stepIdentities = new();
private readonly Dictionary<IStep, int> _lineByStep =
new(ReferenceEqualityComparer.Instance);
// Map matchKey -> entry index for placeholders awaiting a future
// TryClaim. Removed when claimed.
private readonly Dictionary<string, int> _unclaimedByKey =
new(StringComparer.Ordinal);
private string _yaml;
private IReadOnlyList<int> _entryStartLines = Array.Empty<int>();
private const string _sourceFileName = "execution.yml";
private readonly object _lock = new object();
private readonly List<SourceEntry> _preEntries = new List<SourceEntry>();
private readonly List<SourceEntry> _mainEntries = new List<SourceEntry>();
private readonly List<SourceEntry> _postEntries = new List<SourceEntry>();
private readonly List<StepLine> _lineByStep = new List<StepLine>();
private string _content;
private int _completeJobLine;
public JobExecutionView(string jobId)
public JobExecutionView(
string jobId,
IEnumerable<IStep> steps,
IEnumerable<IStep> initialPostSteps,
IEnumerable<PredictedPostStep> predictedPostSteps = null)
{
if (string.IsNullOrWhiteSpace(jobId))
{
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
}
JobId = string.IsNullOrWhiteSpace(jobId) ? "job" : jobId;
_jobId = jobId;
_preEntries.Add(new SourceEntry("Set up job"));
AddSteps(steps);
AddPredictedPostSteps(predictedPostSteps);
AddSteps(initialPostSteps);
_postEntries.Add(SourceEntry.CreateSyntheticCompleteJob());
Render();
}
public string JobId
{
get { return _jobId; }
}
public string JobId { get; }
public string SourceFileName => _sourceFileName;
/// <summary>
/// Currently rendered YAML. Always reflects all entries appended so far,
/// plus the synthetic Setup header and Cleanup footer emitted by the renderer.
/// </summary>
public string Yaml
public string Content
{
get
{
lock (_lock)
{
return _yaml;
return _content;
}
}
}
/// <summary>
/// 1-based line where the synthetic <c>- step: Complete job</c> entry
/// appears in <see cref="Yaml"/>. Always non-zero — Cleanup is always emitted.
/// </summary>
public int CompleteJobLine
{
get
@@ -88,40 +58,42 @@ namespace GitHub.Runner.Worker.Dap
}
}
/// <summary>Number of entries (excludes synthetic Setup/Cleanup boundaries).</summary>
public int EntryCount
public int? TryClaimPredictedStep(string matchKey, IStep step)
{
get
if (string.IsNullOrEmpty(matchKey) || step == null)
{
lock (_lock)
{
return _entries.Count;
}
return null;
}
}
/// <summary>
/// 1-based line where entry <paramref name="entryIndex"/>'s <c>- step:</c> key
/// currently appears in <see cref="Yaml"/>.
/// </summary>
public int GetLine(int entryIndex)
{
lock (_lock)
{
if (entryIndex < 0 || entryIndex >= _entries.Count)
var existingLine = TryGetLineForStepNoLock(step);
if (existingLine.HasValue)
{
throw new ArgumentOutOfRangeException(nameof(entryIndex));
return existingLine;
}
return _entryStartLines[entryIndex];
foreach (var entry in _postEntries)
{
if (!string.Equals(entry.MatchKey, matchKey, StringComparison.Ordinal))
{
continue;
}
if (entry.Step != null && !ReferenceEquals(entry.Step, step))
{
return null;
}
entry.Step = step;
Render();
return TryGetLineForStepNoLock(step);
}
return null;
}
}
/// <summary>
/// 1-based line for the entry whose <see cref="IStep"/> reference identity
/// matches <paramref name="step"/>. Returns null if <paramref name="step"/>
/// is null or has not been registered.
/// </summary>
public int? TryGetLineForStep(IStep step)
{
if (step == null)
@@ -131,163 +103,256 @@ namespace GitHub.Runner.Worker.Dap
lock (_lock)
{
if (_lineByStep.TryGetValue(step, out var line))
{
return line;
}
return null;
return TryGetLineForStepNoLock(step);
}
}
/// <summary>
/// Append a new entry. Exactly one of <paramref name="stepIdentity"/>
/// or <paramref name="matchKey"/> may be non-null (or both may be
/// null for a static entry that needs no line lookup):
/// - <paramref name="stepIdentity"/> non-null: registers the
/// IStep→line mapping immediately. Use when the real
/// <see cref="IStep"/> is known at append time.
/// - <paramref name="matchKey"/> non-null: registers an unclaimed
/// placeholder that a later <see cref="TryClaim"/> binds to a
/// real <see cref="IStep"/>.
/// Re-renders the YAML and updates the start-line table.
/// </summary>
/// <returns>1-based line number of the newly-appended entry's <c>- step:</c> key.</returns>
public int Append(JobExecutionViewEntry entry, IStep stepIdentity = null, string matchKey = null)
private int? TryGetLineForStepNoLock(IStep step)
{
ArgUtil.NotNull(entry, nameof(entry));
if (stepIdentity != null && matchKey != null)
foreach (var stepLine in _lineByStep)
{
throw new ArgumentException(
"Append cannot register both a step identity and a placeholder match key on the same entry; pass at most one.");
if (ReferenceEquals(stepLine.Step, step))
{
return stepLine.Line;
}
}
lock (_lock)
{
if (stepIdentity != null && _lineByStep.ContainsKey(stepIdentity))
{
throw new InvalidOperationException("step already registered in execution view");
}
if (matchKey != null && _unclaimedByKey.ContainsKey(matchKey))
{
throw new InvalidOperationException($"matchKey already registered: {matchKey}");
}
_entries.Add(entry);
_stepIdentities.Add(stepIdentity);
Render();
int index = _entries.Count - 1;
if (matchKey != null)
{
_unclaimedByKey[matchKey] = index;
}
return _entryStartLines[index];
}
return null;
}
/// <summary>
/// Bind a previously-appended placeholder entry (registered via
/// <see cref="Append(JobExecutionViewEntry, IStep, string)"/> with
/// a non-null <c>matchKey</c>) to a real <see cref="IStep"/>.
/// Returns the 1-based line of the now-claimed entry on success.
/// Returns null when no unclaimed placeholder exists for
/// <paramref name="matchKey"/>, OR when <paramref name="stepIdentity"/>
/// is already registered for a different entry (defensive).
/// Does not re-render: claim only updates the IStep -> line index.
/// </summary>
public int? TryClaim(string matchKey, IStep stepIdentity)
private void AddSteps(IEnumerable<IStep> steps)
{
if (matchKey == null)
if (steps == null)
{
throw new ArgumentNullException(nameof(matchKey));
}
if (stepIdentity == null)
{
throw new ArgumentNullException(nameof(stepIdentity));
return;
}
lock (_lock)
foreach (var step in steps)
{
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
if (step == null)
{
return null;
}
if (_lineByStep.ContainsKey(stepIdentity))
{
// Bail rather than double-register the step.
return null;
continue;
}
_unclaimedByKey.Remove(matchKey);
_stepIdentities[index] = stepIdentity;
_lineByStep[stepIdentity] = _entryStartLines[index];
return _entryStartLines[index];
GetEntries(GetSection(step)).Add(new SourceEntry(step));
}
}
/// <summary>
/// Bulk-append for the initial population. Equivalent to calling
/// <see cref="Append"/> once per pair, but renders only once at the end.
/// State is left unchanged if any input is invalid.
/// </summary>
public void AppendRange(IEnumerable<(JobExecutionViewEntry entry, IStep stepIdentity)> items)
private void AddPredictedPostSteps(IEnumerable<PredictedPostStep> steps)
{
ArgUtil.NotNull(items, nameof(items));
// Materialize first so we don't enumerate twice.
var materialized = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>(items);
for (int i = 0; i < materialized.Count; i++)
if (steps == null)
{
if (materialized[i].entry == null)
{
throw new ArgumentException($"items[{i}].entry is null.", nameof(items));
}
return;
}
lock (_lock)
foreach (var step in steps)
{
// Validate no duplicates within the input or with existing identities,
// before mutating state.
var seen = new HashSet<IStep>(ReferenceEqualityComparer.Instance);
foreach (var (_, stepIdentity) in materialized)
if (step == null)
{
if (stepIdentity == null)
{
continue;
}
if (_lineByStep.ContainsKey(stepIdentity) || !seen.Add(stepIdentity))
{
throw new InvalidOperationException("step already registered in execution view");
}
continue;
}
foreach (var (entry, stepIdentity) in materialized)
{
_entries.Add(entry);
_stepIdentities.Add(stepIdentity);
}
Render();
_postEntries.Add(new SourceEntry(step.DisplayName, step.MatchKey));
}
}
private List<SourceEntry> GetEntries(SourceSection section)
{
switch (section)
{
case SourceSection.Pre:
return _preEntries;
case SourceSection.Post:
return _postEntries;
default:
return _mainEntries;
}
}
private static SourceSection GetSection(IStep step)
{
if (step is IActionRunner actionRunner)
{
return GetSection(actionRunner.Stage);
}
if (step.ExecutionContext != null)
{
return GetSection(step.ExecutionContext.Stage);
}
return SourceSection.Main;
}
private static SourceSection GetSection(ActionRunStage stage)
{
switch (stage)
{
case ActionRunStage.Pre:
return SourceSection.Pre;
case ActionRunStage.Post:
return SourceSection.Post;
default:
return SourceSection.Main;
}
}
// Caller MUST hold _lock (constructor's call is safe — no concurrent access yet).
private void Render()
{
var result = JobExecutionViewRenderer.Render(_jobId, _entries.AsReadOnly());
_yaml = result.Yaml;
_entryStartLines = result.EntryStartLines;
_completeJobLine = result.CompleteJobLine;
_lineByStep.Clear();
for (int i = 0; i < _stepIdentities.Count; i++)
_completeJobLine = 0;
var sb = new StringBuilder();
var line = 1;
AppendSection(sb, "pre", _preEntries, ref line, appendSeparatorLine: true);
AppendSection(sb, "main", _mainEntries, ref line, appendSeparatorLine: true);
AppendSection(sb, "post", _postEntries, ref line, appendSeparatorLine: false);
_content = sb.ToString();
}
private void AppendSection(
StringBuilder sb,
string sectionName,
IReadOnlyList<SourceEntry> entries,
ref int line,
bool appendSeparatorLine)
{
sb.Append(sectionName).Append(":\n");
line++;
foreach (var entry in entries)
{
var step = _stepIdentities[i];
if (step != null)
if (entry.Step != null && TryGetLineForStepNoLock(entry.Step) == null)
{
_lineByStep[step] = _entryStartLines[i];
_lineByStep.Add(new StepLine(entry.Step, line));
}
sb.Append(" - step: ");
sb.Append(FormatYamlString(entry.DisplayName));
sb.Append('\n');
if (entry.IsSyntheticCompleteJob)
{
_completeJobLine = line;
}
line++;
}
if (appendSeparatorLine)
{
sb.Append('\n');
line++;
}
}
private static string FormatYamlString(string value)
{
var sb = new StringBuilder();
sb.Append('"');
foreach (var c in value)
{
switch (c)
{
case '\\':
sb.Append(@"\\");
break;
case '"':
sb.Append("\\\"");
break;
case '\r':
sb.Append(@"\r");
break;
case '\n':
sb.Append(@"\n");
break;
case '\t':
sb.Append(@"\t");
break;
default:
if (char.IsControl(c))
{
sb.Append(@"\u");
sb.Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
}
else
{
sb.Append(c);
}
break;
}
}
sb.Append('"');
return sb.ToString();
}
internal sealed class PredictedPostStep
{
public PredictedPostStep(string displayName, string matchKey)
{
DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName;
MatchKey = matchKey;
}
public string DisplayName { get; }
public string MatchKey { get; }
}
private sealed class StepLine
{
public StepLine(IStep step, int line)
{
Step = step;
Line = line;
}
public IStep Step { get; }
public int Line { get; }
}
private sealed class SourceEntry
{
public SourceEntry(string displayName)
{
DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName;
}
public SourceEntry(string displayName, string matchKey)
: this(displayName)
{
MatchKey = matchKey;
}
public SourceEntry(IStep step)
{
Step = step;
DisplayName = string.IsNullOrEmpty(step.DisplayName) ? "step" : step.DisplayName;
}
private SourceEntry(string displayName, bool isSyntheticCompleteJob)
: this(displayName)
{
IsSyntheticCompleteJob = isSyntheticCompleteJob;
}
public static SourceEntry CreateSyntheticCompleteJob()
{
return new SourceEntry("Complete job", isSyntheticCompleteJob: true);
}
public IStep Step { get; set; }
public string DisplayName { get; }
public string MatchKey { get; }
public bool IsSyntheticCompleteJob { get; }
}
private enum SourceSection
{
Pre,
Main,
Post
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,63 +0,0 @@
using System;
using System.Globalization;
using System.IO;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Formats a single string as a quote-safe YAML scalar by routing it
/// through YamlDotNet's <see cref="Emitter"/>. The returned text is
/// safe to splice into a hand-emitted YAML document fragment.
///
/// Caller responsibility: this only handles the scalar value; it does
/// not emit a key, indent, or trailing newline.
/// </summary>
internal static class YamlScalarFormatter
{
/// <summary>
/// Return <paramref name="value"/> formatted as a YAML scalar:
/// plain, single-quoted, or double-quoted as the emitter chooses,
/// with no surrounding document markers or trailing newline.
/// </summary>
public static string Format(string value)
{
ArgUtil.NotNull(value, nameof(value));
using var sw = new StringWriter(CultureInfo.InvariantCulture);
// Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
// which would otherwise produce CRLF on Windows and break
// both our document-end stripping below and downstream
// consumers that assume a single line-break convention.
sw.NewLine = "\n";
var emitter = new Emitter(sw);
emitter.Emit(new StreamStart());
emitter.Emit(new DocumentStart(null, null, true));
emitter.Emit(new Scalar(null, null, value, ScalarStyle.Any, true, true));
emitter.Emit(new DocumentEnd(true));
emitter.Emit(new StreamEnd());
string raw = sw.ToString();
// Strip YAML document markers. Emitter elides these for most
// scalars but emits "--- " (with space) for some edge cases
// (e.g. empty strings). Defensively handle "---\n" too.
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
else if (raw.StartsWith("---\n", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
raw = raw.TrimEnd('\n');
const string DocEndMarker = "\n...";
if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
}
return raw;
}
}
}

View File

@@ -77,14 +77,23 @@ namespace GitHub.Runner.Worker
List<string> StepEnvironmentOverrides { get; }
bool IsBackground { get; }
IExecutionContext Root { get; }
// Initialize
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
void CancelToken();
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null);
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null, bool isBackground = false, string backgroundControlType = null, string[] backgroundControlStepIds = null, string parallelGroupId = null);
IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary<string, string> intraActionState = null, string siblingScopeName = null);
// Background step deferral properties
Dictionary<string, string> DeferredOutputs { get; set; }
Dictionary<string, string> DeferredEnvironmentVariables { get; set; }
List<string> DeferredPrependPath { get; set; }
bool DeferOutcomeConclusion { get; set; }
// logging
long Write(string tag, string message);
void QueueAttachFile(string type, string name, string filePath);
@@ -100,6 +109,12 @@ namespace GitHub.Runner.Worker
void SetGitHubContext(string name, string value);
void SetOutput(string name, string value, out string reference);
void SetTimeout(TimeSpan? timeout);
// Background step deferral flush methods
void FlushDeferredOutputs();
void FlushDeferredEnvironment();
void FlushDeferredOutcomeConclusion();
void AddIssue(Issue issue, ExecutionContextLogOptions logOptions);
void Progress(int percentage, string currentOperation = null);
void UpdateDetailTimelineRecord(TimelineRecord record);
@@ -216,6 +231,9 @@ namespace GitHub.Runner.Worker
public bool EchoOnActionCommand { get; set; }
// Whether this step runs in the background
public bool IsBackground => _record.IsBackground;
// An embedded execution context shares the same record ID, record name, and logger
// as its enclosing execution context.
public bool IsEmbedded { get; private init; }
@@ -279,6 +297,12 @@ namespace GitHub.Runner.Worker
public List<string> StepEnvironmentOverrides { get; } = new List<string>();
// Background step deferral properties
public Dictionary<string, string> DeferredOutputs { get; set; }
public Dictionary<string, string> DeferredEnvironmentVariables { get; set; }
public List<string> DeferredPrependPath { get; set; }
public bool DeferOutcomeConclusion { get; set; }
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
@@ -337,14 +361,24 @@ namespace GitHub.Runner.Worker
}
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
Root.PostJobSteps.Push(step);
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
if (Global.Debugger?.Enabled == true)
if (step is JobExtensionRunner)
{
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
step.ExecutionContext.StepTelemetry.Type = "runner";
step.ExecutionContext.StepTelemetry.Action = step.DisplayName.ToLowerInvariant().Replace(' ', '_');
}
Root.PostJobSteps.Push(step);
if (Root.Global.Debugger?.Enabled == true)
{
try
{
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
}
catch (Exception ex)
{
Trace.Warning("Failed to notify DAP debugger about registered post job step.");
Trace.Error(ex);
}
}
}
@@ -363,7 +397,11 @@ namespace GitHub.Runner.Worker
CancellationTokenSource cancellationTokenSource = null,
Guid embeddedId = default(Guid),
string siblingScopeName = null,
TimeSpan? timeout = null)
TimeSpan? timeout = null,
bool isBackground = false,
string backgroundControlType = null,
string[] backgroundControlStepIds = null,
string parallelGroupId = null)
{
Trace.Entering();
@@ -404,6 +442,24 @@ namespace GitHub.Runner.Worker
child.EchoOnActionCommand = EchoOnActionCommand;
// Set background step metadata before InitializeTimelineRecord so it's included in the first update
if (isBackground || backgroundControlType != null || parallelGroupId != null)
{
child._record.IsBackground = isBackground;
child._record.BackgroundControlType = backgroundControlType;
child._record.BackgroundControlStepIds = backgroundControlStepIds;
child._record.ParallelGroupId = parallelGroupId;
// Initialize deferred state for background steps — flushed at wait/wait-all
if (isBackground)
{
child.DeferredOutputs = new Dictionary<string, string>();
child.DeferredEnvironmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
child.DeferredPrependPath = new List<string>();
child.DeferOutcomeConclusion = true;
}
}
if (recordOrder != null)
{
child.InitializeTimelineRecord(_mainTimelineId, recordId, _record.Id, ExecutionContextType.Task, displayName, refName, recordOrder, embedded: isEmbedded);
@@ -516,7 +572,11 @@ namespace GitHub.Runner.Worker
Type = StepTelemetry?.Type,
StartedAt = _record.StartTime,
CompletedAt = _record.FinishTime,
Annotations = new List<Annotation>()
Annotations = new List<Annotation>(),
// Populate background step metadata from timeline record fields
IsBackground = _record.IsBackground,
BackgroundControlType = _record.BackgroundControlType,
BackgroundControlStepIds = _record.BackgroundControlStepIds
};
_record.Issues?.ForEach(issue =>
@@ -562,11 +622,22 @@ namespace GitHub.Runner.Worker
_logger.End();
UpdateGlobalStepsContext();
if (!DeferOutcomeConclusion)
{
UpdateGlobalStepsContext();
}
return Result.Value;
}
public void FlushDeferredOutcomeConclusion()
{
if (DeferOutcomeConclusion)
{
UpdateGlobalStepsContext();
}
}
public void UpdateGlobalStepsContext()
{
// Skip if generated context name. Generated context names start with "__". After 3.2 the server will never send an empty context name.
@@ -642,6 +713,40 @@ namespace GitHub.Runner.Worker
Global.StepsContext.SetOutput(ScopeName, ContextName, name, value, out reference);
}
public void FlushDeferredOutputs()
{
if (DeferredOutputs == null || DeferredOutputs.Count == 0)
{
return;
}
foreach (var kvp in DeferredOutputs)
{
Global.StepsContext.SetOutput(ScopeName, ContextName, kvp.Key, kvp.Value, out _);
}
}
public void FlushDeferredEnvironment()
{
if (DeferredEnvironmentVariables != null)
{
foreach (var kvp in DeferredEnvironmentVariables)
{
Global.EnvironmentVariables[kvp.Key] = kvp.Value;
SetEnvContext(kvp.Key, kvp.Value);
}
}
if (DeferredPrependPath != null)
{
foreach (var path in DeferredPrependPath)
{
Global.PrependPath.RemoveAll(x => string.Equals(x, path, StringComparison.CurrentCulture));
Global.PrependPath.Add(path);
}
}
}
public void SetTimeout(TimeSpan? timeout)
{
if (timeout != null)
@@ -1338,7 +1443,10 @@ namespace GitHub.Runner.Worker
Trace.Info($"Updated step result (continue on error)");
}
UpdateGlobalStepsContext();
if (!DeferOutcomeConclusion)
{
UpdateGlobalStepsContext();
}
}
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)

View File

@@ -122,8 +122,16 @@ namespace GitHub.Runner.Worker
{
continue;
}
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
context.Global.PrependPath.Add(line);
if (context.DeferredPrependPath != null)
{
context.DeferredPrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
context.DeferredPrependPath.Add(line);
}
else
{
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
context.Global.PrependPath.Add(line);
}
}
}
}
@@ -172,8 +180,15 @@ namespace GitHub.Runner.Worker
string name,
string value)
{
context.Global.EnvironmentVariables[name] = value;
context.SetEnvContext(name, value);
if (context.DeferredEnvironmentVariables != null)
{
context.DeferredEnvironmentVariables[name] = value;
}
else
{
context.Global.EnvironmentVariables[name] = value;
context.SetEnvContext(name, value);
}
context.Debug($"{name}='{value}'");
}
@@ -302,7 +317,14 @@ namespace GitHub.Runner.Worker
var pairs = new EnvFileKeyValuePairs(context, filePath);
foreach (var pair in pairs)
{
context.SetOutput(pair.Key, pair.Value, out var reference);
if (context.DeferredOutputs != null)
{
context.DeferredOutputs[pair.Key] = pair.Value;
}
else
{
context.SetOutput(pair.Key, pair.Value, out var reference);
}
context.Debug($"Set output {pair.Key} = {pair.Value}");
}
}

View File

@@ -345,6 +345,38 @@ namespace GitHub.Runner.Worker
preJobSteps.Add(preStep);
}
}
else if (step.Type == Pipelines.StepType.BackgroundStepControl)
{
var ctrl = step as Pipelines.BackgroundStepControl;
Trace.Info($"Adding {ctrl.ControlType} step for: {string.Join(", ", ctrl.StepIds ?? Array.Empty<string>())}");
var controlType = ctrl.ControlType;
if (string.IsNullOrEmpty(controlType))
{
throw new ArgumentException($"Background step control '{step.Name}' has no control type.");
}
if (controlType != Pipelines.BackgroundControlTypes.Wait &&
controlType != Pipelines.BackgroundControlTypes.WaitAll &&
controlType != Pipelines.BackgroundControlTypes.Cancel)
{
throw new ArgumentException($"Unknown background step control type '{controlType}' for step '{step.Name}'.");
}
var displayName = (ctrl.DisplayNameToken as GitHub.DistributedTask.ObjectTemplating.Tokens.StringToken)?.Value
?? step.DisplayName ?? step.Name ?? ctrl.ControlType;
var data = new BackgroundStepControlFlowData
{
Type = controlType,
StepId = step.Id,
StepName = step.Name,
StepIds = ctrl.StepIds,
ParallelGroupId = ctrl.ParallelGroupId,
};
var bgCoord = HostContext.GetService<IBackgroundStepCoordinator>();
jobSteps.Add(new JobExtensionRunner(
runAsync: bgCoord.RunControlFlowAsync,
condition: $"{PipelineTemplateConstants.Always}()",
displayName: displayName,
data: data));
}
}
if (message.Variables.TryGetValue("system.workflowFileFullPath", out VariableValue workflowFileFullPath))
@@ -400,13 +432,107 @@ namespace GitHub.Runner.Worker
}
// Create execution context for job steps
// Build mapping of logical step ID (ContextName) → external ID (timeline record GUID)
// so wait/cancel steps can reference background steps by external ID.
var contextNameToExternalId = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var hasBackgroundSteps = false;
var backgroundStepExternalIds = new List<string>();
// Track which background steps are explicitly covered by wait/wait-all/cancel
var coveredBackgroundIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var step in jobSteps)
{
if (step is IActionRunner actionStep)
{
ArgUtil.NotNull(actionStep, step.DisplayName);
intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState);
actionStep.ExecutionContext = jobContext.CreateChild(actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name, null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState);
var isBg = actionStep.Action?.Background == true;
actionStep.ExecutionContext = jobContext.CreateChild(
actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name,
null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState,
isBackground: isBg,
parallelGroupId: isBg ? actionStep.Action.ParallelGroupId : null);
if (isBg)
{
hasBackgroundSteps = true;
var externalId = actionStep.Action.Id.ToString("N");
contextNameToExternalId[actionStep.Action.ContextName] = externalId;
backgroundStepExternalIds.Add(externalId);
}
}
else if (step is JobExtensionRunner runnerStep && runnerStep.Data is BackgroundStepControlFlowData cf)
{
// Resolve step IDs to external IDs and track coverage
string[] externalIds = null;
if (cf.StepIds != null && cf.StepIds.Length > 0)
{
foreach (var id in cf.StepIds)
{
coveredBackgroundIds.Add(id);
}
externalIds = cf.StepIds
.Where(id => contextNameToExternalId.ContainsKey(id))
.Select(id => contextNameToExternalId[id])
.ToArray();
}
if (cf.Type == Pipelines.BackgroundControlTypes.WaitAll)
{
externalIds = backgroundStepExternalIds.Count > 0 ? backgroundStepExternalIds.ToArray() : null;
foreach (var id in contextNameToExternalId.Keys)
{
coveredBackgroundIds.Add(id);
}
}
step.ExecutionContext = jobContext.CreateChild(
cf.StepId, step.DisplayName, cf.StepName,
null, cf.StepName, ActionRunStage.Main,
backgroundControlType: cf.Type,
backgroundControlStepIds: externalIds,
parallelGroupId: cf.ParallelGroupId);
}
}
// Add implicit wait-all only if there are background steps not covered by any wait/wait-all/cancel
var allBackgroundIds = contextNameToExternalId.Keys;
var hasUncoveredBackgroundSteps = allBackgroundIds.Any(id => !coveredBackgroundIds.Contains(id));
if (hasBackgroundSteps)
{
// Initialize coordinator only when there are background steps
var bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
var maxBgSteps = jobContext.Global.Variables.GetInt("system.runner.maxbackgroundsteps");
var maxConcurrent = (maxBgSteps.HasValue && maxBgSteps.Value > 0) ? maxBgSteps.Value : 10;
bgCoordinator.InitializeCoordinator(maxConcurrent);
// Add implicit wait-all only if there are uncovered background steps
if (hasUncoveredBackgroundSteps)
{
var implicitStepId = Guid.NewGuid();
var implicitWaitAllData = new BackgroundStepControlFlowData
{
Type = Pipelines.BackgroundControlTypes.WaitAll,
StepId = implicitStepId,
StepName = "__implicit_wait_all",
};
var implicitWaitAll = new JobExtensionRunner(
runAsync: bgCoordinator.RunControlFlowAsync,
condition: $"{PipelineTemplateConstants.Always}()",
displayName: "Wait for all background steps",
data: implicitWaitAllData);
var uncoveredExternalIds = contextNameToExternalId
.Where(kvp => !coveredBackgroundIds.Contains(kvp.Key))
.Select(kvp => kvp.Value)
.ToArray();
implicitWaitAll.ExecutionContext = jobContext.CreateChild(
implicitStepId, implicitWaitAll.DisplayName, "__implicit_wait_all",
null, "__implicit_wait_all", ActionRunStage.Main,
backgroundControlType: Pipelines.BackgroundControlTypes.WaitAll,
backgroundControlStepIds: uncoveredExternalIds.Length > 0 ? uncoveredExternalIds : null);
jobSteps.Add(implicitWaitAll);
}
}

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;
@@ -232,20 +233,8 @@ namespace GitHub.Runner.Worker
if (jobContext.Global.Debugger?.Enabled == true)
{
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
var dapDebugger = HostContext.GetService<Dap.IDapDebugger>();
try
{
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobStepsInitialized error; continuing without DAP view.");
Trace.Error(ex);
}
var dapDebugger = HostContext.GetService<IDapDebugger>();
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
}
await stepsRunner.RunAsync(jobContext);

View File

@@ -23,7 +23,7 @@
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.48" />
</ItemGroup>
<ItemGroup>

View File

@@ -18,6 +18,7 @@ namespace GitHub.Runner.Worker
{
private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
private readonly DictionaryContextData _contextData = new();
private readonly object _lock = new();
/// <summary>
/// Clears memory for a composite action's isolated "steps" context, after the action
@@ -25,9 +26,12 @@ namespace GitHub.Runner.Worker
/// </summary>
public void ClearScope(string scopeName)
{
if (_contextData.TryGetValue(scopeName, out _))
lock (_lock)
{
_contextData[scopeName] = new DictionaryContextData();
if (_contextData.TryGetValue(scopeName, out _))
{
_contextData[scopeName] = new DictionaryContextData();
}
}
}
@@ -41,23 +45,26 @@ namespace GitHub.Runner.Worker
/// </summary>
public DictionaryContextData GetScope(string scopeName)
{
if (scopeName == null)
lock (_lock)
{
scopeName = string.Empty;
}
if (scopeName == null)
{
scopeName = string.Empty;
}
var scope = default(DictionaryContextData);
if (_contextData.TryGetValue(scopeName, out var scopeValue))
{
scope = scopeValue.AssertDictionary("scope");
}
else
{
scope = new DictionaryContextData();
_contextData.Add(scopeName, scope);
}
var scope = default(DictionaryContextData);
if (_contextData.TryGetValue(scopeName, out var scopeValue))
{
scope = scopeValue.AssertDictionary("scope");
}
else
{
scope = new DictionaryContextData();
_contextData.Add(scopeName, scope);
}
return scope;
return scope;
}
}
public void SetOutput(
@@ -67,16 +74,19 @@ namespace GitHub.Runner.Worker
string value,
out string reference)
{
var step = GetStep(scopeName, stepName);
var outputs = step["outputs"].AssertDictionary("outputs");
outputs[outputName] = new StringContextData(value);
if (_propertyRegex.IsMatch(outputName))
lock (_lock)
{
reference = $"steps.{stepName}.outputs.{outputName}";
}
else
{
reference = $"steps['{stepName}']['outputs']['{outputName}']";
var step = GetStep(scopeName, stepName);
var outputs = step["outputs"].AssertDictionary("outputs");
outputs[outputName] = new StringContextData(value);
if (_propertyRegex.IsMatch(outputName))
{
reference = $"steps.{stepName}.outputs.{outputName}";
}
else
{
reference = $"steps['{stepName}']['outputs']['{outputName}']";
}
}
}
@@ -85,8 +95,11 @@ namespace GitHub.Runner.Worker
string stepName,
ActionResult conclusion)
{
var step = GetStep(scopeName, stepName);
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
lock (_lock)
{
var step = GetStep(scopeName, stepName);
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
}
}
public void SetOutcome(
@@ -94,8 +107,11 @@ namespace GitHub.Runner.Worker
string stepName,
ActionResult outcome)
{
var step = GetStep(scopeName, stepName);
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
lock (_lock)
{
var step = GetStep(scopeName, stepName);
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
}
}
private DictionaryContextData GetStep(string scopeName, string stepName)

View File

@@ -41,6 +41,8 @@ namespace GitHub.Runner.Worker
ArgUtil.NotNull(jobContext, nameof(jobContext));
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));
var _bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
// TaskResult:
// Abandoned (Server set this.)
// Canceled
@@ -57,6 +59,15 @@ namespace GitHub.Runner.Worker
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
{
checkPostJobActions = true;
// Safety net: wait for any unwaited background steps before post-hooks
var backgroundResult = await _bgCoordinator.WaitForUnwaitedStepsAsync(jobContext.CancellationToken);
if (backgroundResult != TaskResult.Succeeded)
{
jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, backgroundResult);
jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
}
while (jobContext.PostJobSteps.TryPop(out var postStep))
{
jobContext.JobSteps.Enqueue(postStep);
@@ -72,8 +83,11 @@ namespace GitHub.Runner.Worker
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));
// Start
step.ExecutionContext.Start();
// Start — defer for background steps until the slot is acquired
if (!step.ExecutionContext.IsBackground)
{
step.ExecutionContext.Start();
}
// Expression functions
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
@@ -228,14 +242,22 @@ namespace GitHub.Runner.Worker
}
else
{
// Pause for DAP debugger before step execution
await dapDebugger?.OnStepStartingAsync(step);
if (step.ExecutionContext.IsBackground)
{
// Queue the background step via coordinator
_bgCoordinator.StartBackgroundStep(step, jobContext.CancellationToken);
}
else
{
// Pause for DAP debugger before step execution
await dapDebugger?.OnStepStartingAsync(step);
// Run the step
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);
// Run the step synchronously (normal behavior)
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);
dapDebugger?.OnStepCompleted(step);
dapDebugger?.OnStepCompleted(step);
}
}
}
finally

View File

@@ -25,6 +25,7 @@ namespace GitHub.DistributedTask.Pipelines
Inputs = actionToClone.Inputs?.Clone();
ContextName = actionToClone?.ContextName;
DisplayNameToken = actionToClone.DisplayNameToken?.Clone();
Background = actionToClone.Background;
}
public override StepType Type => StepType.Action;
@@ -49,6 +50,9 @@ namespace GitHub.DistributedTask.Pipelines
[DataMember(EmitDefaultValue = false)]
public TemplateToken Inputs { get; set; }
[DataMember(EmitDefaultValue = false)]
public bool Background { get; set; }
public override Step Clone()
{
return new ActionStep(this);

View File

@@ -0,0 +1,57 @@
using System.ComponentModel;
using System.Runtime.Serialization;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using Newtonsoft.Json;
namespace GitHub.DistributedTask.Pipelines
{
/// <summary>
/// Known control-flow types for background step control steps.
/// Wire values must match run-service constants (wait, wait-all, cancel).
/// </summary>
public static class BackgroundControlTypes
{
public const string Wait = "wait";
public const string WaitAll = "wait-all";
public const string Cancel = "cancel";
}
/// <summary>
/// Represents a unified background step control-flow step (wait, wait-all, cancel).
/// </summary>
[DataContract]
[EditorBrowsable(EditorBrowsableState.Never)]
public class BackgroundStepControl : JobStep
{
[JsonConstructor]
public BackgroundStepControl()
{
}
private BackgroundStepControl(BackgroundStepControl stepToClone)
: base(stepToClone)
{
this.ControlType = stepToClone.ControlType;
this.StepIds = stepToClone.StepIds != null
? (string[])stepToClone.StepIds.Clone()
: null;
this.DisplayNameToken = stepToClone.DisplayNameToken?.Clone();
}
public override StepType Type => StepType.BackgroundStepControl;
[DataMember(EmitDefaultValue = false)]
public string ControlType { get; set; }
[DataMember(EmitDefaultValue = false)]
public string[] StepIds { get; set; }
[DataMember(EmitDefaultValue = false)]
public TemplateToken DisplayNameToken { get; set; }
public override Step Clone()
{
return new BackgroundStepControl(this);
}
}
}

View File

@@ -22,6 +22,7 @@ namespace GitHub.DistributedTask.Pipelines
this.Condition = stepToClone.Condition;
this.ContinueOnError = stepToClone.ContinueOnError?.Clone();
this.TimeoutInMinutes = stepToClone.TimeoutInMinutes?.Clone();
this.ParallelGroupId = stepToClone.ParallelGroupId;
}
[DataMember(EmitDefaultValue = false)]
@@ -44,5 +45,8 @@ namespace GitHub.DistributedTask.Pipelines
get;
set;
}
[DataMember(EmitDefaultValue = false)]
public string ParallelGroupId { get; set; }
}
}

View File

@@ -7,6 +7,7 @@ namespace GitHub.DistributedTask.Pipelines
{
[DataContract]
[KnownType(typeof(ActionStep))]
[KnownType(typeof(BackgroundStepControl))]
[JsonConverter(typeof(StepConverter))]
[EditorBrowsable(EditorBrowsableState.Never)]
public abstract class Step
@@ -68,5 +69,7 @@ namespace GitHub.DistributedTask.Pipelines
{
[DataMember]
Action = 4,
[DataMember]
BackgroundStepControl = 5,
}
}

View File

@@ -51,6 +51,9 @@ namespace GitHub.DistributedTask.Pipelines
case StepType.Action:
stepObject = new ActionStep();
break;
case StepType.BackgroundStepControl:
stepObject = new BackgroundStepControl();
break;
}
using (var objectReader = value.CreateReader())

View File

@@ -43,6 +43,10 @@ namespace GitHub.DistributedTask.WebApi
this.WarningCount = recordToBeCloned.WarningCount;
this.NoticeCount = recordToBeCloned.NoticeCount;
this.AgentPlatform = recordToBeCloned.AgentPlatform;
this.IsBackground = recordToBeCloned.IsBackground;
this.BackgroundControlType = recordToBeCloned.BackgroundControlType;
this.BackgroundControlStepIds = recordToBeCloned.BackgroundControlStepIds;
this.ParallelGroupId = recordToBeCloned.ParallelGroupId;
if (recordToBeCloned.Log != null)
{
@@ -289,6 +293,34 @@ namespace GitHub.DistributedTask.WebApi
set;
}
[DataMember(Order = 140, EmitDefaultValue = false)]
public bool IsBackground
{
get;
set;
}
[DataMember(Order = 141, EmitDefaultValue = false)]
public string BackgroundControlType
{
get;
set;
}
[DataMember(Order = 142, EmitDefaultValue = false)]
public string[] BackgroundControlStepIds
{
get;
set;
}
[DataMember(Order = 144, EmitDefaultValue = false)]
public string ParallelGroupId
{
get;
set;
}
public IList<TimelineAttempt> PreviousAttempts
{
get

View File

@@ -50,5 +50,14 @@ namespace GitHub.Actions.RunService.WebApi
[DataMember(Name = "annotations", EmitDefaultValue = false)]
public List<Annotation> Annotations { get; set; }
[DataMember(Name = "is_background", EmitDefaultValue = false)]
public bool IsBackground { get; set; }
[DataMember(Name = "background_control_type", EmitDefaultValue = false)]
public string BackgroundControlType { get; set; }
[DataMember(Name = "background_control_step_ids", EmitDefaultValue = false)]
public string[] BackgroundControlStepIds { get; set; }
}
}

View File

@@ -23,14 +23,14 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.6" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.7" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="Minimatch" Version="2.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.6" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.7" />
</ItemGroup>
<ItemGroup>

View File

@@ -179,6 +179,14 @@ namespace GitHub.Services.Results.Contracts
public string CompletedAt;
[DataMember]
public Conclusion Conclusion;
[DataMember(EmitDefaultValue = false)]
public bool IsBackground;
[DataMember(EmitDefaultValue = false)]
public string BackgroundControlType;
[DataMember(EmitDefaultValue = false)]
public string[] BackgroundControlStepIds;
[DataMember(EmitDefaultValue = false)]
public string ParallelGroupId;
}
public enum Status

View File

@@ -514,7 +514,7 @@ namespace GitHub.Services.Results.Client
private Step ConvertTimelineRecordToStep(TimelineRecord r)
{
return new Step()
var step = new Step()
{
ExternalId = r.Id.ToString(),
Number = r.Order.GetValueOrDefault(),
@@ -522,8 +522,25 @@ namespace GitHub.Services.Results.Client
Status = ConvertStateToStatus(r.State.GetValueOrDefault()),
StartedAt = r.StartTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
CompletedAt = r.FinishTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
Conclusion = ConvertResultToConclusion(r.Result)
Conclusion = ConvertResultToConclusion(r.Result),
IsBackground = r.IsBackground,
};
// Set background control type directly (no enum mapping needed)
if (!string.IsNullOrEmpty(r.BackgroundControlType))
{
step.BackgroundControlType = r.BackgroundControlType;
}
if (r.BackgroundControlStepIds != null)
{
step.BackgroundControlStepIds = r.BackgroundControlStepIds;
}
if (!string.IsNullOrEmpty(r.ParallelGroupId))
{
step.ParallelGroupId = r.ParallelGroupId;
}
return step;
}
private Status ConvertStateToStatus(TimelineRecordState s)

View File

@@ -0,0 +1,702 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Xunit;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class BackgroundStepsL0
{
private Mock<IExecutionContext> _ec;
private StepsRunner _stepsRunner;
private Variables _variables;
private Dictionary<string, string> _env;
private DictionaryContextData _contexts;
private JobContext _jobContext;
private StepsContext _stepContext;
private TestHostContext CreateTestContext([CallerMemberName] String testName = "")
{
var hc = new TestHostContext(this, testName);
Dictionary<string, VariableValue> variablesToCopy = new();
_variables = new Variables(
hostContext: hc,
copy: variablesToCopy);
_env = new Dictionary<string, string>()
{
{"env1", "1"},
{"test", "github_actions"}
};
_ec = new Mock<IExecutionContext>();
_ec.SetupAllProperties();
_ec.Setup(x => x.Global).Returns(new GlobalContext { WriteDebug = true });
_ec.Object.Global.Variables = _variables;
_ec.Object.Global.EnvironmentVariables = _env;
_ec.Object.Global.FileTable = new List<string>();
_contexts = new DictionaryContextData();
_jobContext = new JobContext();
_contexts["github"] = new GitHubContext();
_contexts["runner"] = new DictionaryContextData();
_contexts["job"] = _jobContext;
_ec.Setup(x => x.ExpressionValues).Returns(_contexts);
_ec.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
_ec.Setup(x => x.JobContext).Returns(_jobContext);
_ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
_stepContext = new StepsContext();
_ec.Object.Global.StepsContext = _stepContext;
_ec.Setup(x => x.PostJobSteps).Returns(new Stack<IStep>());
var trace = hc.GetTrace();
// Mock CreateChild for implicit wait-all step injection
_ec.Setup(x => x.CreateChild(
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ActionRunStage>(),
It.IsAny<Dictionary<string, string>>(), It.IsAny<int?>(), It.IsAny<IPagingLogger>(),
It.IsAny<bool>(), It.IsAny<List<Issue>>(), It.IsAny<CancellationTokenSource>(),
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(),
It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string[]>(), It.IsAny<string>()))
.Returns((Guid recordId, string displayName, string refName, string scopeName, string contextName,
ActionRunStage stage, Dictionary<string, string> intraActionState, int? recordOrder, IPagingLogger logger,
bool isEmbedded, List<Issue> issues, CancellationTokenSource cts, Guid embeddedId, string siblingScopeName, TimeSpan? timeout,
bool isBackground, string backgroundControlType, string[] backgroundControlStepIds, string parallelGroupId) =>
{
var childEc = new Mock<IExecutionContext>();
childEc.SetupAllProperties();
childEc.Setup(x => x.Global).Returns(() => _ec.Object.Global);
childEc.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
childEc.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
childEc.Setup(x => x.ContextName).Returns(contextName);
childEc.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
childEc.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
{
if (r != null) childEc.Object.Result = r;
});
childEc.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
return childEc.Object;
});
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
_stepsRunner = new StepsRunner();
_stepsRunner.Initialize(hc);
var bgCoordinator = new BackgroundStepCoordinator();
bgCoordinator.Initialize(hc);
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
var mockDapDebugger = new Mock<IDapDebugger>();
hc.SetSingleton(mockDapDebugger.Object);
return hc;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BackgroundStepRunsConcurrentlyWithForeground()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: background step that takes time, followed by a foreground step
var executionOrder = new List<string>();
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg-step", contextName: "bg", isBackground: true);
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
{
executionOrder.Add("bg-start");
await Task.Delay(2000);
executionOrder.Add("bg-end");
});
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "bg-step",
Id = Guid.NewGuid(),
ContextName = "bg",
Background = true,
});
var fgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "fg-step", contextName: "fg");
fgStep.Setup(x => x.RunAsync()).Returns(() =>
{
executionOrder.Add("fg-run");
return Task.CompletedTask;
});
var waitAllStep = CreateWaitAllStep(hc);
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, fgStep.Object, waitAllStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: foreground step should start before background step finishes
Assert.Contains("bg-start", executionOrder);
Assert.Contains("fg-run", executionOrder);
Assert.Contains("bg-end", executionOrder);
var fgIndex = executionOrder.IndexOf("fg-run");
var bgEndIndex = executionOrder.IndexOf("bg-end");
Assert.True(fgIndex < bgEndIndex, "Foreground step should run before background step completes");
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitStepBlocksUntilBackgroundCompletes()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange
var bgCompleted = false;
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "db", contextName: "db", isBackground: true);
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
{
await Task.Delay(100);
bgCompleted = true;
});
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "db",
Id = Guid.NewGuid(),
ContextName = "db",
Background = true,
});
var waitStep = CreateWaitStep(hc, new[] { "db" });
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, waitStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: background step must have completed after wait
Assert.True(bgCompleted, "Background step should have completed after wait");
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BackgroundStepFailurePropagatesAtWait()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: background step that fails
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "flaky", contextName: "flaky", isBackground: true);
bgStep.Setup(x => x.RunAsync()).Returns(() =>
{
throw new Exception("Service crashed");
});
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "flaky",
Id = Guid.NewGuid(),
ContextName = "flaky",
Background = true,
});
var waitStep = CreateWaitStep(hc, new[] { "flaky" });
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, waitStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: job should fail because background step failed
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CancelStepTerminatesBackgroundStep()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: background step that runs until cancelled via ExecutionContext.CancellationToken
var stepCts = new CancellationTokenSource();
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server");
// Wire CancellationToken to our CTS so the cancel path can trigger it
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(5), stepCts.Token);
});
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "server",
Id = Guid.NewGuid(),
ContextName = "server",
Background = true,
});
var cancelStep = CreateCancelStep(hc, "server");
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, cancelStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: background step should have been cancelled
// Note: the cancel mechanism uses the BackgroundStepContext.Cts, not bgCts
// so wasCancelled may not be true in this mock, but the step should complete
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitAllWaitsForAllBackgroundSteps()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: two background steps
var step1Done = false;
var step2Done = false;
var bgStep1 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc1", contextName: "svc1", isBackground: true);
bgStep1.Setup(x => x.RunAsync()).Returns(async () =>
{
await Task.Delay(50);
step1Done = true;
});
bgStep1.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "svc1",
Id = Guid.NewGuid(),
ContextName = "svc1",
Background = true,
});
var bgStep2 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc2", contextName: "svc2", isBackground: true);
bgStep2.Setup(x => x.RunAsync()).Returns(async () =>
{
await Task.Delay(100);
step2Done = true;
});
bgStep2.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "svc2",
Id = Guid.NewGuid(),
ContextName = "svc2",
Background = true,
});
var waitAllStep = CreateWaitAllStep(hc);
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep1.Object, bgStep2.Object, waitAllStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert
Assert.True(step1Done, "Background step 1 should have completed");
Assert.True(step2Done, "Background step 2 should have completed");
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CancelStepPublishesCanceledBackgroundExternalId()
{
using (TestHostContext hc = CreateTestContext())
{
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "server",
Id = Guid.NewGuid(),
ContextName = "server",
Background = true,
});
var cancelStep = CreateCancelStep(hc, "server");
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, cancelStep
}));
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: cancel step completed without error
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CanceledBackgroundStepDoesNotAffectJobResult()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: a background step that runs until explicitly canceled. When canceled it
// reports TaskResult.Canceled, but since the cancellation is expected (driven by a
// cancel control step), it must not impact the overall job result.
using var stepCts = new CancellationTokenSource();
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2), stepCts.Token);
});
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "server",
Id = Guid.NewGuid(),
ContextName = "server",
Background = true,
});
var cancelStep = CreateCancelStep(hc, "server");
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, cancelStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: the canceled background step reported Canceled, but the job result is unaffected.
Assert.Equal(TaskResult.Canceled, bgStep.Object.ExecutionContext.Result);
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task FailedBackgroundStepTargetedByCancelStillAffectsJobResult()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: a background step that fails (e.g. before the cancel takes effect). Even
// though a cancel control step targets it, its Failed result must still propagate to
// the overall job result.
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "server", contextName: "server", isBackground: true);
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "server",
Id = Guid.NewGuid(),
ContextName = "server",
Background = true,
});
var cancelStep = CreateCancelStep(hc, "server");
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, cancelStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: the background step failed, so the job result reflects that failure.
Assert.Equal(TaskResult.Failed, bgStep.Object.ExecutionContext.Result);
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StepsContextThreadSafety()
{
// Test that concurrent SetOutput/SetConclusion doesn't throw
var stepsContext = new StepsContext();
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
var index = i;
tasks.Add(Task.Run(() =>
{
stepsContext.SetOutput("", $"step{index}", "out", $"value{index}", out _);
stepsContext.SetConclusion("", $"step{index}", ActionResult.Success);
stepsContext.SetOutcome("", $"step{index}", ActionResult.Success);
}));
}
await Task.WhenAll(tasks);
// Assert: all 100 steps should have their data set
var scope = stepsContext.GetScope("");
Assert.Equal(100, scope.Count);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ControlFlowStepsRunEvenAfterFailure()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: a background step, a foreground step that fails, then a wait step
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg", contextName: "bg", isBackground: true);
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "bg",
Id = Guid.NewGuid(),
ContextName = "bg",
Background = true,
});
var failStep = CreateStep(hc, TaskResult.Failed, "success()", name: "fail", contextName: "fail");
// Wait step uses always() condition — should run even after failure
var waitStep = CreateWaitStep(hc, new[] { "bg" });
waitStep.Condition = $"{GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants.Always}()";
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, failStep.Object, waitStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: wait step should have run (not skipped) because it has always() condition
Assert.NotNull(waitStep.ExecutionContext.Result);
Assert.NotEqual(TaskResult.Skipped, waitStep.ExecutionContext.Result);
}
}
#region Helpers
private Mock<IActionRunner> CreateStep(TestHostContext hc, TaskResult result, string condition, string name = "Test", string contextName = null, Guid? recordId = null, bool isBackground = false)
{
var stepRecordId = recordId ?? Guid.NewGuid();
var step = new Mock<IActionRunner>();
step.Setup(x => x.Condition).Returns(condition);
step.Setup(x => x.ContinueOnError).Returns(new BooleanToken(null, null, null, false));
step.Setup(x => x.Stage).Returns(ActionRunStage.Main);
step.Setup(x => x.Action)
.Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = name,
Id = stepRecordId,
ContextName = contextName ?? name,
});
var stepContext = new Mock<IExecutionContext>();
stepContext.SetupAllProperties();
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
stepContext.Setup(x => x.IsBackground).Returns(isBackground);
var expressionValues = new DictionaryContextData();
foreach (var pair in _ec.Object.ExpressionValues)
{
expressionValues[pair.Key] = pair.Value;
}
stepContext.Setup(x => x.ExpressionValues).Returns(expressionValues);
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
stepContext.Setup(x => x.Id).Returns(stepRecordId);
stepContext.Setup(x => x.ContextName).Returns(step.Object.Action.ContextName);
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
{
if (r != null)
{
stepContext.Object.Result = r;
}
_stepContext.SetOutcome("", stepContext.Object.ContextName, (stepContext.Object.Outcome ?? stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
_stepContext.SetConclusion("", stepContext.Object.ContextName, (stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
});
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
stepContext.Setup(x => x.ApplyContinueOnError(It.IsAny<TemplateToken>()));
stepContext.Setup(x => x.FlushDeferredOutputs()).Callback(() =>
{
if (stepContext.Object.DeferredOutputs != null)
{
foreach (var kvp in stepContext.Object.DeferredOutputs)
{
_stepContext.SetOutput("", stepContext.Object.ContextName, kvp.Key, kvp.Value, out _);
}
}
});
var trace = hc.GetTrace();
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
stepContext.Object.Result = result;
step.Setup(x => x.ExecutionContext).Returns(stepContext.Object);
step.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
return step;
}
private JobExtensionRunner CreateWaitStep(TestHostContext hc, string[] stepIds, Dictionary<string, string> timelineVariables = null)
{
var waitData = new BackgroundStepControlFlowData
{
Type = Pipelines.BackgroundControlTypes.Wait,
StepIds = stepIds,
};
var bgCoordinator = hc.GetService<IBackgroundStepCoordinator>();
var waitRunner = new JobExtensionRunner(
runAsync: bgCoordinator.RunControlFlowAsync,
condition: "success()",
displayName: "Wait",
data: waitData);
var stepContext = new Mock<IExecutionContext>();
stepContext.SetupAllProperties();
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
var waitExprValues = new DictionaryContextData();
foreach (var pair in _ec.Object.ExpressionValues) { waitExprValues[pair.Key] = pair.Value; }
stepContext.Setup(x => x.ExpressionValues).Returns(waitExprValues);
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
stepContext.Setup(x => x.ContextName).Returns("__wait");
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
stepContext.Setup(x => x.ScopeName).Returns((string)null);
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
{
if (r != null) stepContext.Object.Result = r;
});
var trace = hc.GetTrace();
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
waitRunner.ExecutionContext = stepContext.Object;
return waitRunner;
}
private JobExtensionRunner CreateWaitAllStep(TestHostContext hc, Dictionary<string, string> timelineVariables = null)
{
var waitAllData = new BackgroundStepControlFlowData
{
Type = Pipelines.BackgroundControlTypes.WaitAll,
};
var bgCoordinator2 = hc.GetService<IBackgroundStepCoordinator>();
var waitAllRunner = new JobExtensionRunner(
runAsync: bgCoordinator2.RunControlFlowAsync,
condition: "success()",
displayName: "Wait All",
data: waitAllData);
var stepContext = new Mock<IExecutionContext>();
stepContext.SetupAllProperties();
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
var waitAllExprValues = new DictionaryContextData();
foreach (var pair in _ec.Object.ExpressionValues) { waitAllExprValues[pair.Key] = pair.Value; }
stepContext.Setup(x => x.ExpressionValues).Returns(waitAllExprValues);
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
stepContext.Setup(x => x.ContextName).Returns("__wait-all");
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
stepContext.Setup(x => x.ScopeName).Returns((string)null);
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
{
if (r != null) stepContext.Object.Result = r;
});
var trace = hc.GetTrace();
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
waitAllRunner.ExecutionContext = stepContext.Object;
return waitAllRunner;
}
private JobExtensionRunner CreateCancelStep(TestHostContext hc, string cancelStepId, Dictionary<string, string> timelineVariables = null)
{
var cancelData = new BackgroundStepControlFlowData
{
Type = Pipelines.BackgroundControlTypes.Cancel,
StepIds = new[] { cancelStepId },
};
var bgCoordinator3 = hc.GetService<IBackgroundStepCoordinator>();
var cancelRunner = new JobExtensionRunner(
runAsync: bgCoordinator3.RunControlFlowAsync,
condition: "success()",
displayName: "Cancel",
data: cancelData);
var stepContext = new Mock<IExecutionContext>();
stepContext.SetupAllProperties();
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
var cancelExprValues = new DictionaryContextData();
foreach (var pair in _ec.Object.ExpressionValues) { cancelExprValues[pair.Key] = pair.Value; }
stepContext.Setup(x => x.ExpressionValues).Returns(cancelExprValues);
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
stepContext.Setup(x => x.ContextName).Returns("__cancel");
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
stepContext.Setup(x => x.ScopeName).Returns((string)null);
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
{
if (r != null) stepContext.Object.Result = r;
});
var trace = hc.GetTrace();
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
cancelRunner.ExecutionContext = stepContext.Object;
return cancelRunner;
}
#endregion
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,7 +7,6 @@ using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -362,6 +361,119 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobStep_JobExtensionRunner_DefaultsRunnerTelemetry()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message.
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger1 = new Mock<IPagingLogger>();
var pagingLogger2 = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
hc.EnqueueInstance(pagingLogger1.Object);
hc.EnqueueInstance(pagingLogger2.Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
// Act.
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var extensionStep = new JobExtensionRunner(
runAsync: (_, _) => System.Threading.Tasks.Task.CompletedTask,
condition: "always()",
displayName: "Create Custom Image",
data: null);
jobContext.RegisterPostJobStep(extensionStep);
// Assert: telemetry defaults are populated for non-action post-job steps.
Assert.NotNull(extensionStep.ExecutionContext);
Assert.Equal("runner", extensionStep.ExecutionContext.StepTelemetry.Type);
Assert.Equal("create_custom_image", extensionStep.ExecutionContext.StepTelemetry.Action);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobStep_ActionRunner_DoesNotOverrideTelemetry()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message.
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger1 = new Mock<IPagingLogger>();
var pagingLogger2 = new Mock<IPagingLogger>();
var pagingLogger3 = new Mock<IPagingLogger>();
var pagingLogger4 = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
hc.EnqueueInstance(pagingLogger1.Object);
hc.EnqueueInstance(pagingLogger2.Object);
hc.EnqueueInstance(pagingLogger3.Object);
hc.EnqueueInstance(pagingLogger4.Object);
hc.EnqueueInstance(actionRunner as IActionRunner);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
// Act.
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var action = jobContext.CreateChild(Guid.NewGuid(), "action", "action", null, null, 0);
var postRunner = hc.CreateService<IActionRunner>();
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post Action", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
postRunner.Stage = ActionRunStage.Post;
postRunner.Condition = "always()";
postRunner.DisplayName = "Post Action";
action.RegisterPostJobStep(postRunner);
// Assert: action post-step telemetry is left for the handler to fill in,
// so RegisterPostJobStep should NOT pre-populate runner-owned defaults.
Assert.NotNull(postRunner.ExecutionContext);
Assert.Null(postRunner.ExecutionContext.StepTelemetry.Type);
Assert.Null(postRunner.ExecutionContext.StepTelemetry.Action);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -406,7 +518,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -505,7 +616,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -547,75 +657,6 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobAction_DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message with EnableDebugger left at the default (false).
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()));
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(actionRunner as IActionRunner);
// Register a strict mock IDapDebugger. If the production code calls
// ANY method on it, the test fails — proving the containment guard
// short-circuited before HostContext.GetService<IDapDebugger>().
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var action = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null, 0);
var postRunner = hc.CreateService<IActionRunner>();
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
postRunner.Stage = ActionRunStage.Post;
postRunner.Condition = "always()";
postRunner.DisplayName = "post";
// Sanity: ensure the production code path actually believes the debugger is disabled.
Assert.True(jobContext.Global.Debugger == null || jobContext.Global.Debugger.Enabled == false);
// Act.
action.RegisterPostJobStep(postRunner);
// Assert: the debugger was never consulted on the non-debug path.
dapMock.VerifyNoOtherCalls();
Assert.Equal(1, jobContext.PostJobSteps.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]

View File

@@ -1,8 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
@@ -12,431 +9,122 @@ namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class JobExecutionViewL0
{
private static JobExecutionViewEntry MainEntry(string name)
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RendersPreMainAndPostSections()
{
return new JobExecutionViewEntry(JobExecutionPhase.Main, name, run: name);
}
var pre = CreateStep("Pre cache", ActionRunStage.Pre);
var checkout = CreateStep("Checkout");
var post = CreateStep("Post cache", ActionRunStage.Post);
private static IStep NewStep(string displayName = "step")
{
var mock = new Mock<IStep>();
mock.Setup(s => s.DisplayName).Returns(displayName);
return mock.Object;
var view = new JobExecutionView(
"job",
new[] { pre.Object, checkout.Object },
new[] { post.Object });
Assert.Equal(
"pre:\n - step: \"Set up job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n",
view.Content);
Assert.Equal(3, view.TryGetLineForStep(pre.Object));
Assert.Equal(6, view.TryGetLineForStep(checkout.Object));
Assert.Equal(9, view.TryGetLineForStep(post.Object));
Assert.Equal(10, view.CompleteJobLine);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Constructor_RendersEmptyView()
public void ClaimsPredictedPostStepWithoutChangingLine()
{
var view = new JobExecutionView("my-job");
var action = CreateRepositoryActionStep("actions/cache");
var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action);
var predicted = new JobExecutionView.PredictedPostStep(
"Post Checkout",
MatchKeyFor(action.Id));
Assert.Equal(0, view.EntryCount);
Assert.Contains("# Job: my-job", view.Yaml);
Assert.Contains("- step: Setup job", view.Yaml);
Assert.Contains("- step: Complete job", view.Yaml);
var view = new JobExecutionView(
"job",
new[] { checkout.Object },
Array.Empty<IStep>(),
new[] { predicted });
// Only the two synthetic boundaries appear.
int stepCount = view.Yaml.Split("- step: ").Length - 1;
Assert.Equal(2, stepCount);
}
var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action);
var line = view.TryClaimPredictedStep(MatchKeyFor(action.Id), post.Object);
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_ThrowsOnInvalidJobId(string jobId)
{
Assert.Throws<ArgumentException>(() => new JobExecutionView(jobId));
Assert.Equal(8, line);
Assert.Equal(8, view.TryGetLineForStep(post.Object));
Assert.Equal(
"pre:\n - step: \"Set up job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n",
view.Content);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_IncrementsEntryCount()
public void UsesSyntheticCompleteJobLineWhenPostStepSharesName()
{
var view = new JobExecutionView("j");
var checkout = CreateStep("Checkout");
var realPost = CreateStep("Complete job", ActionRunStage.Post);
int line0 = view.Append(MainEntry("a"));
int line1 = view.Append(MainEntry("b"));
int line2 = view.Append(MainEntry("c"));
var view = new JobExecutionView(
"job",
new[] { checkout.Object },
new[] { realPost.Object });
Assert.Equal(3, view.EntryCount);
Assert.True(line0 < line1);
Assert.True(line1 < line2);
Assert.Equal(line0, view.GetLine(0));
Assert.Equal(line1, view.GetLine(1));
Assert.Equal(line2, view.GetLine(2));
Assert.Equal(8, view.TryGetLineForStep(realPost.Object));
Assert.Equal(9, view.CompleteJobLine);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_PreservesPriorEntryLines()
private static Mock<IStep> CreateStep(string displayName, ActionRunStage? stage = null)
{
var view = new JobExecutionView("j");
int l0 = view.Append(MainEntry("a"));
int l1 = view.Append(MainEntry("b"));
int l2 = view.Append(MainEntry("c"));
view.Append(MainEntry("d"));
Assert.Equal(l0, view.GetLine(0));
Assert.Equal(l1, view.GetLine(1));
Assert.Equal(l2, view.GetLine(2));
view.Append(MainEntry("e"));
Assert.Equal(l0, view.GetLine(0));
Assert.Equal(l1, view.GetLine(1));
Assert.Equal(l2, view.GetLine(2));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_RegistersStepIdentity()
{
var view = new JobExecutionView("j");
var step = NewStep();
int line = view.Append(MainEntry("a"), step);
Assert.Equal(line, view.GetLine(0));
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_NullStepIdentity_StillAppends()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null);
Assert.Equal(1, view.EntryCount);
Assert.Null(view.TryGetLineForStep(null));
Assert.Contains("- step: a", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_DuplicateStepIdentity_Throws()
{
var view = new JobExecutionView("j");
var step = NewStep();
view.Append(MainEntry("a"), step);
Assert.Throws<InvalidOperationException>(() => view.Append(MainEntry("b"), step));
// State preserved: only the first entry is present.
Assert.Equal(1, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_NullEntry_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.Append(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_AppendsAllAndRendersOnce()
{
var view = new JobExecutionView("j");
var steps = Enumerable.Range(0, 5).Select(i => NewStep("s" + i)).ToList();
var items = steps
.Select((s, i) => (entry: MainEntry("e" + i), stepIdentity: s))
.ToList();
view.AppendRange(items);
Assert.Equal(5, view.EntryCount);
for (int i = 0; i < 5; i++)
var step = new Mock<IStep>();
step.Setup(s => s.DisplayName).Returns(displayName);
if (stage.HasValue)
{
int line = view.GetLine(i);
Assert.Equal(line, view.TryGetLineForStep(steps[i]));
var executionContext = new Mock<IExecutionContext>();
executionContext.Setup(x => x.Stage).Returns(stage.Value);
step.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_RejectsDuplicateInInput()
{
var view = new JobExecutionView("j");
var dup = NewStep();
var items = new List<(JobExecutionViewEntry, IStep)>
else
{
(MainEntry("a"), dup),
(MainEntry("b"), dup),
};
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
Assert.Equal(0, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_RejectsOverlapWithExisting()
{
var view = new JobExecutionView("j");
var step = NewStep();
view.Append(MainEntry("a"), step);
var items = new List<(JobExecutionViewEntry, IStep)>
{
(MainEntry("b"), step),
};
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
Assert.Equal(1, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_NullItems_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.AppendRange(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryGetLineForStep_NullStep_ReturnsNull()
{
var view = new JobExecutionView("j");
Assert.Null(view.TryGetLineForStep(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryGetLineForStep_UnknownStep_ReturnsNull()
{
var view = new JobExecutionView("j");
var step = NewStep();
Assert.Null(view.TryGetLineForStep(step));
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(-1)]
[InlineData(2)]
public void GetLine_OutOfRange_Throws(int index)
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"));
view.Append(MainEntry("b"));
Assert.Throws<ArgumentOutOfRangeException>(() => view.GetLine(index));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Yaml_UpdatesAfterAppend()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("first"));
string before = view.Yaml;
Assert.Contains("- step: first", before);
view.Append(MainEntry("second"));
string after = view.Yaml;
Assert.Contains("- step: first", after);
Assert.Contains("- step: second", after);
Assert.NotEqual(before, after);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Yaml_AlwaysEndsWithCleanupBoundary()
{
var view = new JobExecutionView("j");
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
view.Append(MainEntry("a"));
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
view.Append(MainEntry("b"));
view.Append(MainEntry("c"));
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_WithMatchKey_TracksUnclaimed()
{
var view = new JobExecutionView("j");
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
var step = NewStep("real");
int? claimed = view.TryClaim("k1", step);
Assert.Equal(line, claimed);
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_UnknownKey_ReturnsNull()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
Assert.Null(view.TryClaim("nope", NewStep()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_AlreadyClaimed_ReturnsNull()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
var first = NewStep("first");
Assert.NotNull(view.TryClaim("k1", first));
var second = NewStep("second");
Assert.Null(view.TryClaim("k1", second));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_StepAlreadyRegistered_ReturnsNull()
{
var view = new JobExecutionView("j");
var step = NewStep();
// Step is registered for the first entry.
view.Append(MainEntry("a"), step);
// A placeholder is registered for the second entry.
view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1");
// Trying to claim the placeholder with the already-registered
// step must return null (defensive — would otherwise double-bind).
Assert.Null(view.TryClaim("k1", step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_DuplicateMatchKey_Throws()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
Assert.Throws<InvalidOperationException>(
() => view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_MatchKeyNull_BehavesLikeOldOverload()
{
var view = new JobExecutionView("j");
var step = NewStep();
int line = view.Append(MainEntry("a"), step);
Assert.Equal(line, view.GetLine(0));
Assert.Equal(line, view.TryGetLineForStep(step));
// TryClaim with any key must return null since no matchKey was registered.
Assert.Null(view.TryClaim("anything", NewStep()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_AfterClaim_TryGetLineForStepResolves()
{
var view = new JobExecutionView("j");
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
var step = NewStep();
Assert.Equal(line, view.TryClaim("k1", step));
Assert.Equal(line, view.TryGetLineForStep(step));
// And a later Append doesn't lose the claim (Render rebuilds
// the IStep -> line map from the persisted identities).
view.Append(MainEntry("b"));
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_NullArgs_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.TryClaim(null, NewStep()));
Assert.Throws<ArgumentNullException>(() => view.TryClaim("k", null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ConcurrentAppends_DontCorruptState()
{
var view = new JobExecutionView("j");
const int N = 50;
var steps = Enumerable.Range(0, N).Select(i => NewStep("s" + i)).ToList();
var returnedLines = new ConcurrentBag<int>();
var tasks = Enumerable.Range(0, N).Select(i => Task.Run(() =>
{
int line = view.Append(MainEntry("e" + i), steps[i]);
returnedLines.Add(line);
})).ToArray();
await Task.WhenAll(tasks);
Assert.Equal(N, view.EntryCount);
Assert.Equal(N, returnedLines.Distinct().Count());
// Every step identity resolves to some line in [0, N).
var entryLines = Enumerable.Range(0, N).Select(view.GetLine).ToHashSet();
Assert.Equal(N, entryLines.Count);
foreach (var step in steps)
{
int? line = view.TryGetLineForStep(step);
Assert.NotNull(line);
Assert.Contains(line.Value, entryLines);
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
}
return step;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_RejectsBothStepIdentityAndMatchKey()
private static Mock<IActionRunner> CreateActionRunner(string displayName, ActionRunStage stage, ActionStep action)
{
// Allowing both would orphan the IStep→line mapping the moment
// TryClaim overwrites _stepIdentities[index] for a different
// step, so the API rejects the combination at append time.
var view = new JobExecutionView("j");
var entry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
Assert.Throws<ArgumentException>(() =>
view.Append(entry, stepIdentity: NewStep("real"), matchKey: "k1"));
// State unchanged.
Assert.Equal(0, view.EntryCount);
var executionContext = new Mock<IExecutionContext>();
executionContext.Setup(x => x.Stage).Returns(stage);
var runner = new Mock<IActionRunner>();
runner.Setup(s => s.DisplayName).Returns(displayName);
runner.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
runner.Setup(s => s.Stage).Returns(stage);
runner.Setup(s => s.Action).Returns(action);
return runner;
}
private static ActionStep CreateRepositoryActionStep(string name)
{
return new ActionStep
{
Id = Guid.NewGuid(),
Name = name,
Reference = new RepositoryPathReference
{
Name = name,
Ref = "v1",
RepositoryType = RepositoryTypes.GitHub
}
};
}
private static string MatchKeyFor(Guid actionId)
{
return $"post:{actionId:N}";
}
}
}

View File

@@ -1,660 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Pipelines;
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 JobExecutionViewLifecycleL0
{
private DapDebugger _debugger;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_debugger = new DapDebugger();
_debugger.Initialize(hc);
_debugger.SkipTunnelRelay = true;
_debugger.SkipWebSocketBridge = true;
return hc;
}
private static ushort GetFreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return (ushort)((IPEndPoint)listener.LocalEndpoint).Port;
}
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = "ci-job")
{
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = port
};
var debuggerConfig = new DebuggerConfig(true, tunnel);
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
jobContext
.Setup(x => x.GetGitHubContext(It.IsAny<string>()))
.Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null);
return jobContext;
}
private static async Task DriveToReadyAsync(DapDebugger debugger, int port)
{
var waitTask = debugger.WaitUntilReadyAsync();
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port);
var stream = client.GetStream();
var request = new Request { Seq = 1, Type = "request", Command = "configurationDone" };
var json = JsonConvert.SerializeObject(request);
var body = Encoding.UTF8.GetBytes(json);
var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n");
await stream.WriteAsync(header, 0, header.Length);
await stream.WriteAsync(body, 0, body.Length);
await stream.FlushAsync();
await waitTask;
// Keep client alive by holding a reference via GC root in caller scope.
// We deliberately don't dispose here; tests dispose the context.
_ = client;
}
private static Mock<IActionRunner> NewActionRunner(ActionRunStage stage, string displayName, string actionName = "actions/checkout", string actionRef = "v4", Guid actionId = default)
{
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(new ActionStep
{
Id = actionId,
Reference = new RepositoryPathReference { Name = actionName, Ref = actionRef },
});
return mock;
}
private static Mock<IActionRunner> NewSelfActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
{
// RepositoryType = "self" — the predictor must skip these.
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(new ActionStep
{
Id = actionId,
Reference = new RepositoryPathReference
{
RepositoryType = GitHub.DistributedTask.Pipelines.PipelineConstants.SelfAlias,
Path = "./.github/actions/local",
},
});
return mock;
}
private static Mock<IActionRunner> NewScriptActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
{
// ScriptReference — a `run:` step. Not a RepositoryPathReference,
// so the predictor's pattern match falls through.
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(new ActionStep
{
Id = actionId,
Reference = new ScriptReference(),
});
return mock;
}
// IActionManager mock that returns specific Definitions per action by
// matching on the action's reference Name. Actions whose name is not
// in the map get a Definition with HasPost = false.
private static Mock<IActionManager> NewActionManagerWithPost(params string[] actionNamesWithPost)
{
var withPost = new HashSet<string>(actionNamesWithPost, StringComparer.Ordinal);
var mock = new Mock<IActionManager>();
mock.Setup(x => x.LoadAction(It.IsAny<IExecutionContext>(), It.IsAny<ActionStep>()))
.Returns((IExecutionContext _, ActionStep step) =>
{
var name = (step.Reference as RepositoryPathReference)?.Name ?? "";
return new Definition
{
Data = new ActionDefinitionData
{
Execution = withPost.Contains(name)
? new NodeJSActionExecutionData { Post = "post.js" }
: new NodeJSActionExecutionData(),
},
};
});
return mock;
}
private static IStep NewJobExtensionRunner(string displayName)
{
return new JobExtensionRunner(
runAsync: (_, __) => Task.CompletedTask,
condition: null,
displayName: displayName,
data: null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_NotActive_NoOps()
{
using (CreateTestContext())
{
var step = NewActionRunner(ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
Assert.Null(_debugger.ExecutionView);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_NotActive_NoOps()
{
using (CreateTestContext())
{
var step = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
_debugger.OnPostStepRegistered(step); // must not throw
Assert.Null(_debugger.ExecutionView);
await Task.CompletedTask;
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_Active_BuildsView()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object;
var main2 = NewActionRunner(ActionRunStage.Main, "Run actions/setup-node@v3", "actions/setup-node", "v3").Object;
var jobExt = NewJobExtensionRunner("Set up job");
var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/checkout@v4").Object;
await _debugger.OnJobStepsInitializedAsync(
new IStep[] { main1, jobExt, main2 },
new IStep[] { post1 });
var view = _debugger.ExecutionView;
Assert.NotNull(view);
Assert.Equal(3, view.EntryCount); // jobExt filtered out
Assert.Contains("Run actions/checkout@v4", view.Yaml);
Assert.Contains("Run actions/setup-node@v3", view.Yaml);
Assert.Contains("Post Run actions/checkout@v4", view.Yaml);
Assert.NotNull(view.TryGetLineForStep(main1));
Assert.NotNull(view.TryGetLineForStep(main2));
Assert.NotNull(view.TryGetLineForStep(post1));
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_PreservesQueueOrder()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var s1 = NewActionRunner(ActionRunStage.Main, "Step 1", "a/b", "v1").Object;
var s2 = NewActionRunner(ActionRunStage.Main, "Step 2", "c/d", "v2").Object;
var s3 = NewActionRunner(ActionRunStage.Main, "Step 3", "e/f", "v3").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { s1, s2, s3 }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
Assert.Equal(3, view.EntryCount);
var l1 = view.TryGetLineForStep(s1);
var l2 = view.TryGetLineForStep(s2);
var l3 = view.TryGetLineForStep(s3);
Assert.NotNull(l1);
Assert.NotNull(l2);
Assert.NotNull(l3);
Assert.True(l1 < l2);
Assert.True(l2 < l3);
Assert.Equal(view.GetLine(0), l1);
Assert.Equal(view.GetLine(1), l2);
Assert.Equal(view.GetLine(2), l3);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_AppendsToView()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { main1 }, Array.Empty<IStep>());
Assert.Equal(1, _debugger.ExecutionView.EntryCount);
var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/cache@v3", "actions/cache", "v3").Object;
_debugger.OnPostStepRegistered(post1);
var view = _debugger.ExecutionView;
Assert.Equal(2, view.EntryCount);
Assert.Contains("Post Run actions/cache@v3", view.Yaml);
Assert.NotNull(view.TryGetLineForStep(post1));
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_BeforeViewBuilt_NoOps()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
_debugger.OnPostStepRegistered(post); // must not throw
Assert.Null(_debugger.ExecutionView);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_DuplicateStep_DoesNotThrow()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
await _debugger.OnJobStepsInitializedAsync(Array.Empty<IStep>(), Array.Empty<IStep>());
var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
_debugger.OnPostStepRegistered(post);
_debugger.OnPostStepRegistered(post); // duplicate, must be silently ignored
Assert.Equal(1, _debugger.ExecutionView.EntryCount);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_FilteredStep_NoOps()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
await _debugger.OnJobStepsInitializedAsync(Array.Empty<IStep>(), Array.Empty<IStep>());
var before = _debugger.ExecutionView.EntryCount;
_debugger.OnPostStepRegistered(NewJobExtensionRunner("Cleanup"));
Assert.Equal(before, _debugger.ExecutionView.EntryCount);
}
finally
{
await _debugger.StopAsync();
}
}
}
// ---- Predictive Post-step synthesis ----
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_PredictsPostForActionsWithHasPost()
{
using (var hc = CreateTestContext())
{
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var withPost = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: Guid.NewGuid()).Object;
var noPost = NewActionRunner(ActionRunStage.Main, "Run actions/no-post@v1", "actions/no-post", "v1", actionId: Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { withPost, noPost }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
Assert.NotNull(view);
// 2 main entries + 1 predicted post placeholder.
Assert.Equal(3, view.EntryCount);
Assert.Contains("post:\n", view.Yaml);
Assert.Contains("Post Run actions/has-post@v1", view.Yaml);
Assert.DoesNotContain("Post Run actions/no-post@v1", view.Yaml);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_PostPredictionsInReverseOrder()
{
using (var hc = CreateTestContext())
{
// Both actions have post — predictions must render in
// reverse declaration order to mirror the runner's LIFO
// post-execution order.
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/a", "actions/b").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var aMain = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object;
var bMain = NewActionRunner(ActionRunStage.Main, "Run actions/b@v1", "actions/b", "v1", actionId: Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { aMain, bMain }, Array.Empty<IStep>());
string yaml = _debugger.ExecutionView.Yaml;
int idxPostB = yaml.IndexOf("Post Run actions/b@v1", StringComparison.Ordinal);
int idxPostA = yaml.IndexOf("Post Run actions/a@v1", StringComparison.Ordinal);
Assert.True(idxPostB > 0 && idxPostA > 0, "both post placeholders expected");
// Reverse declaration order: Post B appears BEFORE Post A.
Assert.True(idxPostB < idxPostA, $"expected Post B before Post A (b={idxPostB} a={idxPostA})");
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_SkipsScriptSteps()
{
using (var hc = CreateTestContext())
{
// Even if the action manager would say HasPost, the predictor
// must skip script run-steps because their reference is not
// a RepositoryPathReference.
hc.SetSingleton<IActionManager>(NewActionManagerWithPost(/* nothing */).Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var script = NewScriptActionRunner(ActionRunStage.Main, "Run script", Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { script }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
Assert.NotNull(view);
Assert.DoesNotContain("post:\n", view.Yaml);
Assert.DoesNotContain("Post ", view.Yaml);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_SkipsSelfActions()
{
using (var hc = CreateTestContext())
{
// Self-action: ActionRunner.cs:106 guards against creating a
// Post for self-repository references. The predictor mirrors
// that, regardless of what the manifest reports.
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("anything").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var selfRunner = NewSelfActionRunner(ActionRunStage.Main, "Run ./local-action", Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { selfRunner }, Array.Empty<IStep>());
Assert.DoesNotContain("post:\n", _debugger.ExecutionView.Yaml);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_ClaimsExistingPlaceholder()
{
using (var hc = CreateTestContext())
{
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var actionId = Guid.NewGuid();
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
int before = view.EntryCount;
Assert.Equal(2, before); // main + predicted post placeholder
// The real Post IActionRunner shares the same Action.Id
// as the Main runner (ActionRunner.cs:131).
var postRunner = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
_debugger.OnPostStepRegistered(postRunner);
// No new entry: the placeholder was claimed.
Assert.Equal(before, view.EntryCount);
Assert.NotNull(view.TryGetLineForStep(postRunner));
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_UnpredictedFallsBackToAppend()
{
using (var hc = CreateTestContext())
{
// Manager returns no HasPost — no predictions made.
hc.SetSingleton<IActionManager>(NewActionManagerWithPost(/* nothing */).Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
int before = view.EntryCount;
Assert.Equal(1, before); // just main, no predicted post
var unpredictedPost = NewActionRunner(ActionRunStage.Post, "Post Surprise", "actions/surprise", "v1", actionId: Guid.NewGuid()).Object;
_debugger.OnPostStepRegistered(unpredictedPost);
// Falls back to Append.
Assert.Equal(before + 1, view.EntryCount);
Assert.NotNull(view.TryGetLineForStep(unpredictedPost));
Assert.Contains("Post Surprise", view.Yaml);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_DuplicateClaim_NoDoubleEntry()
{
using (var hc = CreateTestContext())
{
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var actionId = Guid.NewGuid();
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
// First registration claims the placeholder.
var post1 = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
_debugger.OnPostStepRegistered(post1);
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
// Second registration with the same Action.Id but a
// different IStep: TryClaim returns null (already
// claimed). Falls through to Append. But the entry
// it builds matches no existing step, so a new entry
// would be added — UNLESS we constructed the second
// post as a duplicate IStep registration of the same
// step. Here we intentionally pass the same `post1`
// step a second time — Append will reject the
// already-registered step, the handler swallows it.
_debugger.OnPostStepRegistered(post1);
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
}
finally
{
await _debugger.StopAsync();
}
}
}
}
}

View File

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

View File

@@ -141,7 +141,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SetSingleton(_diagnosticLogManager.Object);
hc.SetSingleton(_jobHookProvider.Object);
hc.SetSingleton(_snapshotOperationProvider.Object);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // JobExecutionContext
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // job start hook
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // Initial Job
@@ -550,6 +549,10 @@ namespace GitHub.Runner.Common.Tests.Worker
var _stepsRunner = new StepsRunner();
_stepsRunner.Initialize(hc);
var bgCoordinator = new BackgroundStepCoordinator();
bgCoordinator.Initialize(hc);
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
var mockDapDebugger = new Mock<IDapDebugger>();
hc.SetSingleton(mockDapDebugger.Object);

View File

@@ -1,6 +1,5 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using System;
using System.Collections.Generic;
@@ -84,7 +83,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SetSingleton(_extensions.Object);
hc.SetSingleton(_temp.Object);
hc.SetSingleton(_diagnosticLogManager.Object);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.EnqueueInstance<IExecutionContext>(_jobEc);
hc.EnqueueInstance<IPagingLogger>(_logger.Object);
hc.EnqueueInstance<IJobExtension>(_jobExtension.Object);
@@ -177,29 +175,5 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Override the lenient IDapDebugger singleton from CreateTestContext
// with a strict mock. If the containment guard fails, the production
// code will call OnJobStepsInitializedAsync and the strict mock will throw.
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
var message = GetMessage();
// EnableDebugger defaults to false on AgentJobRequestMessage.
Assert.False(message.EnableDebugger);
await _jobRunner.RunAsync(message, _tokenSource.Token);
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
dapMock.VerifyNoOtherCalls();
}
}
}
}

View File

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

View File

@@ -63,6 +63,10 @@ namespace GitHub.Runner.Common.Tests.Worker
_stepsRunner = new StepsRunner();
_stepsRunner.Initialize(hc);
var bgCoordinator = new BackgroundStepCoordinator();
bgCoordinator.Initialize(hc);
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
var mockDapDebugger = new Mock<IDapDebugger>();
hc.SetSingleton(mockDapDebugger.Object);

View File

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

View File

@@ -1,119 +0,0 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Worker.Dap;
using Xunit;
using YamlDotNet.Serialization;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class YamlScalarFormatterL0
{
private static readonly IDeserializer Deserializer = new DeserializerBuilder().Build();
// Embed the formatter output inside a minimal YAML mapping and
// round-trip through YamlDotNet, asserting the parsed value equals
// the original input. Decouples assertions from the emitter's
// quoting choices (plain vs single- vs double-quoted).
private static void AssertRoundTrips(string value)
{
string scalar = YamlScalarFormatter.Format(value);
string yaml = $"k: {scalar}\n";
Dictionary<string, object> doc;
try
{
doc = Deserializer.Deserialize<Dictionary<string, object>>(yaml);
}
catch (Exception ex)
{
throw new Xunit.Sdk.XunitException(
$"Formatted scalar did not round-trip as valid YAML.\nInput: '{value}'\nFormatted: '{scalar}'\nFull YAML:\n{yaml}\nError: {ex}");
}
Assert.NotNull(doc);
Assert.True(doc.ContainsKey("k"), $"missing key in parsed doc. Formatted: '{scalar}'");
Assert.Equal(value, doc["k"] as string);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData("hello")]
[InlineData("with: colon")]
[InlineData("with#hash")]
[InlineData(" leading")]
[InlineData("trailing ")]
[InlineData("a\"b")]
[InlineData("a\\b")]
[InlineData("@at")]
[InlineData("*star")]
[InlineData("&amp")]
[InlineData("?question")]
[InlineData("!exclaim")]
[InlineData("- dash")]
[InlineData("{brace}")]
[InlineData("[bracket]")]
public void Format_RoundTripsThroughYamlDeserializer(string value)
{
// The formatter must produce output that, embedded under a key,
// parses back to exactly the input. The emitter is free to
// pick plain, single-quoted, or double-quoted style.
AssertRoundTrips(value);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_PlainAscii_NoQuotingNeeded()
{
// Sanity check that the simple case stays plain.
Assert.Equal("hello", YamlScalarFormatter.Format("hello"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NoTrailingNewline()
{
Assert.False(YamlScalarFormatter.Format("hello").EndsWith("\n"));
Assert.False(YamlScalarFormatter.Format("with: colon").EndsWith("\n"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NoDocumentMarkers()
{
// The emitter wraps the scalar in a document; the formatter
// must strip both `--- ` (with space) and `---\n` (on its
// own line) prefixes plus the `\n...` suffix.
Assert.DoesNotContain("---", YamlScalarFormatter.Format("hello"));
Assert.DoesNotContain("...", YamlScalarFormatter.Format("hello"));
// Empty string is one of the cases where the emitter does
// produce a document marker by default.
Assert.DoesNotContain("---", YamlScalarFormatter.Format(""));
Assert.DoesNotContain("...", YamlScalarFormatter.Format(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_AlwaysUsesLfLineBreaks()
{
// Regression: YamlDotNet's Emitter calls WriteLine, which on
// Windows produces CRLF (the host's Environment.NewLine).
// Format must force LF so the output round-trips regardless
// of platform.
Assert.DoesNotContain('\r', YamlScalarFormatter.Format("hello"));
Assert.DoesNotContain('\r', YamlScalarFormatter.Format("with: colon"));
Assert.DoesNotContain('\r', YamlScalarFormatter.Format(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NullValue_Throws()
{
Assert.Throws<ArgumentNullException>(() => YamlScalarFormatter.Format(null));
}
}
}

View File

@@ -1 +1 @@
2.334.0
2.335.1