Compare commits

..

21 Commits

Author SHA1 Message Date
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
Tingluo Huang
3ff2186ec0 Allow disable node v8 maglev jit compiler on node24. (#4447) 2026-05-26 19:05:09 +00:00
github-actions[bot]
7c0b271d2e chore: update Node versions (#4452)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-25 14:30:15 +00:00
Driele Neves Ribeiro
0b3b8e0ba7 Update snapshot-if context and functions (#4443) 2026-05-21 15:49:31 -05:00
Francesco Renzi
ae2896c551 Send welcome message in debugger console on connect (#4419)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 16:14:37 +00:00
Francesco Renzi
ebf33710e8 Execute debugger REPL commands inside job container (#4420)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 16:07:25 +00:00
github-actions[bot]
a1ccd22030 Update Docker to v29.5.0 and Buildx to v0.34.0 (#4425)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 09:57:42 -04:00
50 changed files with 3681 additions and 171 deletions

3
.gitignore vendored
View File

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

View File

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.4.0
ARG BUILDX_VERSION=0.33.0
ARG DOCKER_VERSION=29.5.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.0

View File

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

View File

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

@@ -179,6 +179,7 @@ namespace GitHub.Runner.Common
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message";
}
// Node version migration related constants
@@ -205,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
@@ -307,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,366 @@
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();
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();
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
// -----------------------------------------------------------------
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);
}
// Report the merged result of all background steps; the caller merges this into the job result.
var result = TaskResult.Succeeded;
foreach (var (_, (step, _, _)) in _backgroundSteps)
{
if (step.ExecutionContext.Result.HasValue)
{
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;
}
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;
}
}
}

View File

@@ -16,6 +16,7 @@ using Microsoft.DevTunnels.Connections;
using Microsoft.DevTunnels.Contracts;
using Microsoft.DevTunnels.Management;
using Newtonsoft.Json;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
@@ -27,6 +28,7 @@ namespace GitHub.Runner.Worker.Dap
public string DisplayName { get; set; }
public TaskResult? Result { get; set; }
public int FrameId { get; set; }
public int? SourceLine { get; set; }
}
/// <summary>
@@ -54,6 +56,9 @@ namespace GitHub.Runner.Worker.Dap
// Frame IDs for completed steps start at 1000
private const int _completedFrameIdBase = 1000;
// Stable session-scoped source reference for the synthesized job step list.
private const int _jobStepsSourceReference = 1;
private TcpListener _listener;
private TcpClient _client;
private NetworkStream _stream;
@@ -63,6 +68,7 @@ namespace GitHub.Runner.Worker.Dap
private volatile DapSessionState _state = DapSessionState.NotStarted;
private CancellationTokenRegistration? _cancellationRegistration;
private bool _isFirstStep = true;
private bool _welcomeMessageSent;
// Dev Tunnel relay host for remote debugging
private TunnelRelayTunnelHost _tunnelRelayHost;
@@ -97,6 +103,8 @@ namespace GitHub.Runner.Worker.Dap
// Track completed steps for stack trace
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
private int _nextCompletedFrameId = _completedFrameIdBase;
private JobExecutionView _jobStepsSource;
private bool _jobCompleted;
// Client connection tracking for reconnection support
private volatile bool _isClientConnected;
@@ -239,6 +247,179 @@ namespace GitHub.Runner.Worker.Dap
}
}
public Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps)
{
if (!IsActive)
{
return Task.CompletedTask;
}
try
{
IExecutionContext jobContext;
lock (_stateLock)
{
if (_state != DapSessionState.Ready &&
_state != DapSessionState.Paused &&
_state != DapSessionState.Running)
{
return Task.CompletedTask;
}
jobContext = _jobContext;
}
var stepList = steps?.Where(step => step != null).ToList() ?? new List<IStep>();
var initialPostStepList = initialPostSteps?.Where(step => step != null).ToList() ?? new List<IStep>();
var jobId = jobContext?.GetGitHubContext("job");
var snapshot = new JobExecutionView(
jobId,
stepList,
initialPostStepList,
PredictPostSteps(jobContext, stepList, initialPostStepList));
lock (_stateLock)
{
_jobStepsSource = snapshot;
_jobCompleted = false;
}
Trace.Info("DAP job steps source initialized");
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobStepsInitialized error.");
Trace.Error(ex);
}
return Task.CompletedTask;
}
public void OnPostStepRegistered(IStep step)
{
try
{
if (step is IActionRunner postRunner && postRunner.Action != null)
{
JobExecutionView snapshot;
lock (_stateLock)
{
snapshot = _jobStepsSource;
}
var line = snapshot?.TryClaimPredictedStep(MatchKeyFor(postRunner.Action.Id), step);
if (line.HasValue)
{
Trace.Info($"DAP job steps source claimed predicted post step '{step.DisplayName}' at line {line.Value}.");
}
else
{
Trace.Info($"DAP job steps source had no predicted line for post step '{step.DisplayName}'.");
}
}
}
catch (Exception ex)
{
Trace.Warning("DAP OnPostStepRegistered error.");
Trace.Error(ex);
}
}
private IReadOnlyList<JobExecutionView.PredictedPostStep> PredictPostSteps(
IExecutionContext jobContext,
IReadOnlyList<IStep> steps,
IReadOnlyList<IStep> initialPostSteps)
{
if (jobContext == null || steps == null || steps.Count == 0)
{
return Array.Empty<JobExecutionView.PredictedPostStep>();
}
IActionManager actionManager;
try
{
actionManager = HostContext.GetService<IActionManager>();
}
catch (Exception ex)
{
Trace.Info($"DAP post-step predictor skipped because IActionManager is unavailable ({ex.Message}).");
return Array.Empty<JobExecutionView.PredictedPostStep>();
}
var predictions = new List<JobExecutionView.PredictedPostStep>();
var seenActionIds = new HashSet<Guid>();
if (initialPostSteps != null)
{
foreach (var postStep in initialPostSteps)
{
if (postStep is IActionRunner postRunner && postRunner.Action != null)
{
seenActionIds.Add(postRunner.Action.Id);
}
}
}
foreach (var step in steps)
{
if (step is not IActionRunner runner ||
runner.Stage == ActionRunStage.Post ||
runner.Action == null)
{
continue;
}
var action = runner.Action;
if (action.Reference is not Pipelines.RepositoryPathReference repoRef)
{
continue;
}
if (!seenActionIds.Add(action.Id))
{
continue;
}
Definition definition;
try
{
definition = actionManager.LoadAction(jobContext, action);
}
catch (Exception ex)
{
Trace.Info($"DAP post-step predictor could not load action '{repoRef.Name}' ({ex.Message}).");
continue;
}
if (definition?.Data?.Execution?.HasPost != true)
{
continue;
}
predictions.Add(new JobExecutionView.PredictedPostStep(
GetPostDisplayName(runner),
MatchKeyFor(action.Id)));
}
predictions.Reverse();
return predictions;
}
private static string GetPostDisplayName(IActionRunner runner)
{
var displayName = string.IsNullOrEmpty(runner.DisplayName) ? "step" : runner.DisplayName;
if (runner.Stage == ActionRunStage.Pre &&
displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase))
{
displayName = displayName.Substring("Pre ".Length);
}
return $"Post {displayName}";
}
private static string MatchKeyFor(Guid actionId)
{
return $"post:{actionId:N}";
}
public async Task OnJobCompletedAsync()
{
if (_state != DapSessionState.NotStarted)
@@ -252,6 +433,11 @@ namespace GitHub.Runner.Worker.Dap
if (_jobContext != null)
{
Trace.Info("Job completed — pausing for inspection");
lock (_stateLock)
{
_jobCompleted = true;
}
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
await WaitForCommandAsync(_jobContext.CancellationToken);
@@ -358,6 +544,7 @@ namespace GitHub.Runner.Worker.Dap
{
_state = DapSessionState.Terminated;
}
_jobStepsSource = null;
}
_isClientConnected = false;
@@ -416,7 +603,8 @@ namespace GitHub.Runner.Worker.Dap
{
DisplayName = step.DisplayName,
Result = result,
FrameId = _nextCompletedFrameId++
FrameId = _nextCompletedFrameId++,
SourceLine = _jobStepsSource?.TryGetLineForStep(step)
});
}
}
@@ -467,6 +655,7 @@ namespace GitHub.Runner.Worker.Dap
"next" => HandleNext(request),
"setBreakpoints" => HandleSetBreakpoints(request),
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
"source" => HandleSource(request),
"completions" => HandleCompletions(request),
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null),
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null),
@@ -490,6 +679,11 @@ namespace GitHub.Runner.Worker.Dap
});
Trace.Info("Sent initialized event");
}
if (request.Command == "configurationDone")
{
SendWelcomeMessage();
}
}
catch (Exception ex)
{
@@ -508,6 +702,7 @@ namespace GitHub.Runner.Worker.Dap
internal void HandleClientConnected()
{
_isClientConnected = true;
_welcomeMessageSent = false;
Trace.Info("Client connected to debug session");
// If we're paused, re-send the stopped event so the new client
@@ -818,10 +1013,39 @@ namespace GitHub.Runner.Worker.Dap
});
}
internal void SendWelcomeMessage()
{
if (_welcomeMessageSent)
{
return;
}
_welcomeMessageSent = true;
var debuggerConfig = _jobContext?.Global?.Debugger;
if (debuggerConfig?.OverrideWelcomeMessage == true)
{
if (!string.IsNullOrEmpty(debuggerConfig.WelcomeMessage))
{
SendOutput("console", debuggerConfig.WelcomeMessage);
Trace.Info("Sent custom welcome message");
}
else
{
Trace.Info("Welcome message suppressed by override");
}
}
else
{
SendOutput("console", DapReplParser.GetGeneralHelp());
Trace.Info("Sent default welcome message");
}
}
internal async Task OnStepStartingAsync(IStep step, bool isFirstStep)
{
bool pauseOnNextStep;
CancellationToken cancellationToken;
lock (_stateLock)
{
if (_state != DapSessionState.Ready &&
@@ -833,6 +1057,7 @@ namespace GitHub.Runner.Worker.Dap
_currentStep = step;
_currentStepIndex = _completedSteps.Count;
_jobCompleted = false;
pauseOnNextStep = _pauseOnNextStep;
cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None;
}
@@ -860,6 +1085,9 @@ namespace GitHub.Runner.Worker.Dap
// Send stopped event to debugger (only if client is connected)
SendStoppedEvent(reason, description);
// Emit a banner so the user knows where REPL commands will execute
SendExecutionContextBanner();
// Wait for debugger command
await WaitForCommandAsync(cancellationToken);
}
@@ -1012,29 +1240,46 @@ namespace GitHub.Runner.Worker.Dap
private Response HandleStackTrace(Request request)
{
IStep currentStep;
int currentStepIndex;
CompletedStepInfo[] completedSteps;
JobExecutionView jobStepsSource;
bool jobCompleted;
lock (_stateLock)
{
currentStep = _currentStep;
currentStepIndex = _currentStepIndex;
completedSteps = _completedSteps.ToArray();
jobStepsSource = _jobStepsSource;
jobCompleted = _jobCompleted;
}
var frames = new List<StackFrame>();
var source = jobStepsSource != null ? BuildJobStepsSource(jobStepsSource) : null;
// Add current step as the top frame
if (currentStep != null)
if (jobCompleted && jobStepsSource != null)
{
frames.Add(new StackFrame
{
Id = _currentFrameId,
Name = "Complete job [completed]",
Source = source,
Line = jobStepsSource.CompleteJobLine,
Column = 1,
PresentationHint = "normal"
});
}
else if (currentStep != null)
{
var resultIndicator = currentStep.ExecutionContext?.Result != null
? $" [{currentStep.ExecutionContext.Result}]"
: " [running]";
var currentSourceLine = jobStepsSource?.TryGetLineForStep(currentStep);
frames.Add(new StackFrame
{
Id = _currentFrameId,
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
Line = currentStepIndex + 1,
Source = currentSourceLine.HasValue ? source : null,
Line = currentSourceLine ?? 0,
Column = 1,
PresentationHint = "normal"
});
@@ -1060,7 +1305,8 @@ namespace GitHub.Runner.Worker.Dap
{
Id = completedStep.FrameId,
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
Line = 1,
Source = completedStep.SourceLine.HasValue ? source : null,
Line = completedStep.SourceLine ?? 0,
Column = 1,
PresentationHint = "subtle"
});
@@ -1075,6 +1321,76 @@ namespace GitHub.Runner.Worker.Dap
return CreateResponse(request, true, body: body);
}
private Source BuildJobStepsSource(JobExecutionView snapshot)
{
return new Source
{
Name = MaskUserVisibleText(snapshot.SourceFileName),
Path = MaskUserVisibleText($"{SanitizeSourcePathSegment(snapshot.JobId)}/{snapshot.SourceFileName}"),
SourceReference = _jobStepsSourceReference,
PresentationHint = "normal"
};
}
private static string SanitizeSourcePathSegment(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "job";
}
var builder = new StringBuilder(value.Length);
foreach (var character in value)
{
builder.Append(char.IsControl(character) || character == '/' || character == '\\'
? '_'
: character);
}
return builder.Length == 0 ? "job" : builder.ToString();
}
internal Response HandleSource(Request request)
{
SourceArguments args;
try
{
args = request.Arguments?.ToObject<SourceArguments>();
}
catch (Exception ex)
{
Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}");
return CreateResponse(request, false, "Invalid source arguments.", body: null);
}
var sourceReference = args?.Source?.SourceReference ?? args?.SourceReference;
if (!sourceReference.HasValue)
{
return CreateResponse(request, false, "Missing source reference.", body: null);
}
JobExecutionView snapshot;
lock (_stateLock)
{
snapshot = _jobStepsSource;
}
if (snapshot == null)
{
return CreateResponse(request, false, "Job steps source not yet available.", body: null);
}
if (sourceReference.Value != _jobStepsSourceReference)
{
return CreateResponse(request, false, $"Unknown source reference: {sourceReference.Value}.", body: null);
}
return CreateResponse(request, true, body: new SourceResponseBody
{
Content = MaskUserVisibleText(snapshot.Content)
});
}
private Response HandleScopes(Request request)
{
var args = request.Arguments?.ToObject<ScopesArguments>();
@@ -1195,7 +1511,12 @@ namespace GitHub.Runner.Worker.Dap
case RunCommand run:
var context = GetExecutionContextForFrame(frameId);
return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
bool isActionStep;
lock (_stateLock)
{
isActionStep = _currentStep is IActionRunner;
}
return await _replExecutor.ExecuteRunCommandAsync(run, context, isActionStep, cancellationToken);
default:
return new EvaluateResponseBody
@@ -1407,6 +1728,40 @@ namespace GitHub.Runner.Worker.Dap
});
}
/// <summary>
/// Emits a console output banner telling the user whether REPL
/// commands will execute on the host or inside the job container.
/// </summary>
private void SendExecutionContextBanner()
{
if (!_isClientConnected)
{
return;
}
bool isActionStep = _currentStep is IActionRunner;
var container = _jobContext?.Global?.Container;
string target;
if (isActionStep && container != null &&
(!string.IsNullOrEmpty(container.ContainerId) ||
FeatureManager.IsContainerHooksEnabled(_jobContext?.Global?.Variables)))
{
var image = container.ContainerImage ?? "container";
var shortId = !string.IsNullOrEmpty(container.ContainerId) && container.ContainerId.Length >= 12
? container.ContainerId.Substring(0, 12)
: container.ContainerId ?? "";
var idSuffix = !string.IsNullOrEmpty(shortId) ? $" ({shortId})" : "";
target = $"job container: {image}{idSuffix}";
}
else
{
target = "runner host";
}
SendOutput("console", $"\nCommands will run on {target}\n");
}
private string MaskUserVisibleText(string value)
{
if (string.IsNullOrEmpty(value))

View File

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

View File

@@ -9,6 +9,7 @@ using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Handlers;
namespace GitHub.Runner.Worker.Dap
@@ -43,6 +44,7 @@ namespace GitHub.Runner.Worker.Dap
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
if (context == null)
@@ -52,7 +54,7 @@ namespace GitHub.Runner.Worker.Dap
try
{
return await ExecuteScriptAsync(command, context, cancellationToken);
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
}
catch (Exception ex)
{
@@ -65,9 +67,17 @@ namespace GitHub.Runner.Worker.Dap
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
// 1. Resolve shell — same logic as ScriptHandler
// 1. Resolve step host — container or host, same as ActionRunner.
// Only action steps (user-defined run:/uses:) execute inside the
// container. Infrastructure steps (Set up job, Initialize
// containers, Complete job, etc.) always run on the host.
var stepHost = CreateStepHost(context, isActionStep);
var isContainerStepHost = stepHost is IContainerStepHost;
// 2. Resolve shell — same logic as ScriptHandler
string shellCommand;
string argFormat;
@@ -87,9 +97,9 @@ namespace GitHub.Runner.Worker.Dap
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
_trace.Info("Resolved REPL shell");
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
// 2. Expand ${{ }} expressions in the script body, just like
// 3. Expand ${{ }} expressions in the script body, just like
// ActionRunner evaluates step inputs before ScriptHandler sees them
var contents = ExpandExpressions(command.Script, context);
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
@@ -111,25 +121,47 @@ namespace GitHub.Runner.Worker.Dap
try
{
// 3. Format arguments with script path
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
// 4. Resolve script path — translate for container if needed
var resolvedPath = stepHost.ResolvePathForStepHost(context, scriptFilePath).Replace("\"", "\\\"");
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
{
return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'");
}
var arguments = string.Format(argFormat, resolvedPath);
// 4. Resolve shell command path
// 5. Resolve shell command path — for containers, use the shell
// name directly (it will be resolved inside the container);
// for host execution, resolve the full path on the host.
string prependPath = string.Join(
Path.PathSeparator.ToString(),
Enumerable.Reverse(context.Global.PrependPath));
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
?? shellCommand;
var fileName = isContainerStepHost
? shellCommand
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
// 5. Build environment — merge from execution context like a real step
// 6. Build environment — merge from execution context like a real step
var environment = BuildEnvironment(context, command.Env);
// 6. Resolve working directory
// 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment
if (context.Global.PrependPath.Count > 0)
{
if (stepHost is IContainerStepHost containerHost)
{
containerHost.PrependPath = prependPath;
}
else
{
string taskEnvPATH;
environment.TryGetValue(Constants.PathVariable, out taskEnvPATH);
string originalPath = context.Global.Variables?.Get(Constants.PathVariable) ?? // Prefer a job variable.
taskEnvPATH ?? // Then a task-environment variable.
System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? // Then an environment variable.
string.Empty;
environment[Constants.PathVariable] = PathUtil.PrependPath(prependPath, originalPath);
}
}
// 8. Resolve working directory — translate for container
var workingDirectory = command.WorkingDirectory;
if (string.IsNullOrEmpty(workingDirectory))
{
@@ -141,48 +173,60 @@ namespace GitHub.Runner.Worker.Dap
: null;
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
}
workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory);
_trace.Info("Executing REPL command");
// Stream execution info to debugger
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
int exitCode;
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
// NOTE: When container hooks are enabled, ContainerStepHost routes
// execution through IContainerHookManager which does not raise
// OutputDataReceived/ErrorDataReceived events. Output will not be
// streamed to the debug console in that mode.
if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables))
{
processInvoker.OutputDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stdout", masked + "\n");
}
};
processInvoker.ErrorDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stderr", masked + "\n");
}
};
exitCode = await processInvoker.ExecuteAsync(
workingDirectory: workingDirectory,
fileName: commandPath,
arguments: arguments,
environment: environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: true,
cancellationToken: cancellationToken);
const string hookWarning = "Container hooks are enabled. REPL output will not be streamed to the debug console for this command.";
_trace.Warning(hookWarning);
SendOutput("stderr", hookWarning + "\n");
}
// 9. Execute via IStepHost — handles docker exec for containers,
// direct process execution for host, and container hooks
stepHost.OutputDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stdout", masked + "\n");
}
};
stepHost.ErrorDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stderr", masked + "\n");
}
};
int exitCode = await stepHost.ExecuteAsync(
context: context,
workingDirectory: workingDirectory,
fileName: fileName,
arguments: arguments,
environment: environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: true,
inheritConsoleHandler: false,
standardInInput: null,
cancellationToken: cancellationToken);
_trace.Info($"REPL command exited with code {exitCode}");
// 8. Return only the exit code summary (output was already streamed)
// 10. Return only the exit code summary (output was already streamed)
return new EvaluateResponseBody
{
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
@@ -198,6 +242,43 @@ namespace GitHub.Runner.Worker.Dap
}
}
/// <summary>
/// Creates the appropriate <see cref="IStepHost"/> for the current
/// execution context, mirroring how <see cref="ActionRunner"/> decides
/// between host and container execution.
///
/// Only action steps (user-defined run:/uses: steps) run inside the
/// job container. Infrastructure steps like "Set up job", "Initialize
/// containers", "Stop containers", and "Complete job" always execute
/// on the host regardless of whether a container is configured.
/// </summary>
internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep)
{
if (!isActionStep)
{
_trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)");
return _hostContext.CreateService<IDefaultStepHost>();
}
var container = context?.Global?.Container;
if (container != null)
{
// Container hooks don't always set ContainerId, but the container
// step host handles that internally
var hooksEnabled = FeatureManager.IsContainerHooksEnabled(context.Global?.Variables);
if (hooksEnabled || !string.IsNullOrEmpty(container.ContainerId))
{
_trace.Info("Creating ContainerStepHost for REPL execution");
var containerStepHost = _hostContext.CreateService<IContainerStepHost>();
containerStepHost.Container = container;
return containerStepHost;
}
}
_trace.Info("Creating DefaultStepHost for REPL execution");
return _hostContext.CreateService<IDefaultStepHost>();
}
/// <summary>
/// Expands <c>${{ }}</c> expressions in the input string using the
/// runner's template evaluator — the same evaluation path that processes

View File

@@ -1,4 +1,4 @@
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
@@ -8,10 +8,12 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public sealed class DebuggerConfig
{
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
{
Enabled = enabled;
Tunnel = tunnel;
OverrideWelcomeMessage = overrideWelcomeMessage;
WelcomeMessage = welcomeMessage;
}
/// <summary>Whether the debugger is enabled for this job.</summary>
@@ -23,6 +25,19 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public DebuggerTunnelInfo Tunnel { get; }
/// <summary>
/// When true, the runner overrides the default welcome message with
/// <see cref="WelcomeMessage"/>. A null or empty <see cref="WelcomeMessage"/>
/// suppresses the message entirely. When false, the default help text is shown.
/// </summary>
public bool OverrideWelcomeMessage { get; }
/// <summary>
/// Optional welcome message content for the debugger console. Only used when
/// <see cref="OverrideWelcomeMessage"/> is true.
/// </summary>
public string WelcomeMessage { get; }
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
public bool HasValidTunnel => Tunnel != null
&& !string.IsNullOrEmpty(Tunnel.TunnelId)

View File

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

View File

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

View File

@@ -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,7 +361,25 @@ namespace GitHub.Runner.Worker
}
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
if (step is JobExtensionRunner)
{
step.ExecutionContext.StepTelemetry.Type = "runner";
step.ExecutionContext.StepTelemetry.Action = step.DisplayName.ToLowerInvariant().Replace(' ', '_');
}
Root.PostJobSteps.Push(step);
if (Root.Global.Debugger?.Enabled == true)
{
try
{
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
}
catch (Exception ex)
{
Trace.Warning("Failed to notify DAP debugger about registered post job step.");
Trace.Error(ex);
}
}
}
public IExecutionContext CreateChild(
@@ -355,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();
@@ -396,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);
@@ -508,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 =>
@@ -554,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.
@@ -634,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)
@@ -970,7 +1083,8 @@ namespace GitHub.Runner.Worker
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
// Debugger enabled flag (from acquire response).
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false;
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage);
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
@@ -1329,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

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

View File

@@ -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;
@@ -230,6 +231,12 @@ namespace GitHub.Runner.Worker
jobContext.JobSteps.Enqueue(step);
}
if (jobContext.Global.Debugger?.Enabled == true)
{
var dapDebugger = HostContext.GetService<IDapDebugger>();
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
}
await stepsRunner.RunAsync(jobContext);
}
catch (Exception ex)

View File

@@ -20,10 +20,10 @@
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.8" />
<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

@@ -267,6 +267,21 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
/// <summary>
/// Optional welcome message shown in the debugger console when a client connects.
/// Only used when the <c>actions_runner_override_debugger_welcome_message</c>
/// feature flag is set to <c>true</c> in the job variables. With the flag set,
/// a non-empty value is shown as-is and a null or empty value suppresses the
/// default welcome message. When the flag is not set, the runner shows its
/// built-in help text and this field is ignored.
/// </summary>
[DataMember(EmitDefaultValue = false)]
public string DebuggerWelcomeMessage
{
get;
set;
}
/// <summary>
/// Gets the workflow-level action dependencies (lockfile entries)
/// </summary>

View File

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

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

View File

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

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

View File

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

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Text;
@@ -17,13 +17,13 @@ public sealed class AgentJobRequestMessageL0
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
@@ -37,13 +37,13 @@ public sealed class AgentJobRequestMessageL0
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
@@ -57,13 +57,13 @@ public sealed class AgentJobRequestMessageL0
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
@@ -161,6 +161,26 @@ public sealed class AgentJobRequestMessageL0
Assert.Empty(recoveredMessage.ActionsDependencies);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerWelcomeMessageRoundTrips()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'DebuggerWelcomeMessage': 'Welcome to debugging!'}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.Equal("Welcome to debugging!", recoveredMessage.DebuggerWelcomeMessage);
}
private static string DoubleQuotify(string text)
{
return text.Replace('\'', '"');

View File

@@ -0,0 +1,620 @@
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 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
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
@@ -11,7 +11,9 @@ using Moq;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Common.Tests.Worker
{
@@ -236,7 +238,7 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null)
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null, bool overrideWelcomeMessage = false, string welcomeMessage = null)
{
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
{
@@ -245,7 +247,7 @@ namespace GitHub.Runner.Common.Tests.Worker
HostToken = "test-token",
Port = port
};
var debuggerConfig = new DebuggerConfig(true, tunnel);
var debuggerConfig = new DebuggerConfig(true, tunnel, overrideWelcomeMessage, welcomeMessage);
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
@@ -255,6 +257,78 @@ namespace GitHub.Runner.Common.Tests.Worker
return jobContext;
}
private static Mock<IStep> CreateStep(string displayName, ActionRunStage? stage = null)
{
var step = new Mock<IStep>();
step.Setup(s => s.DisplayName).Returns(displayName);
if (stage.HasValue)
{
var executionContext = new Mock<IExecutionContext>();
executionContext.Setup(x => x.Stage).Returns(stage.Value);
step.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
}
else
{
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
}
return step;
}
private static Mock<IActionRunner> CreateActionRunner(string displayName, ActionRunStage stage, Pipelines.ActionStep action)
{
var executionContext = new Mock<IExecutionContext>();
executionContext.Setup(x => x.Stage).Returns(stage);
var runner = new Mock<IActionRunner>();
runner.Setup(s => s.DisplayName).Returns(displayName);
runner.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
runner.Setup(s => s.Stage).Returns(stage);
runner.Setup(s => s.Action).Returns(action);
return runner;
}
private static Pipelines.ActionStep CreateRepositoryActionStep(string name)
{
return new Pipelines.ActionStep
{
Id = Guid.NewGuid(),
Name = name,
Reference = new Pipelines.RepositoryPathReference
{
Name = name,
Ref = "v1",
RepositoryType = Pipelines.RepositoryTypes.GitHub
}
};
}
private static Definition CreateActionDefinitionWithPost()
{
return new Definition
{
Data = new ActionDefinitionData
{
Execution = new NodeJSActionExecutionData
{
Script = "main.js",
Post = "post.js"
}
}
};
}
private static Request MakeRequest(string command, object arguments)
{
return new Request
{
Seq = 1,
Type = "request",
Command = command,
Arguments = JObject.FromObject(arguments)
};
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -718,6 +792,325 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleSourceReturnsJobStepsSource()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue("secret-step");
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var pre = CreateStep("Pre cache", ActionRunStage.Pre);
var checkout = CreateStep("Checkout");
var secret = CreateStep("secret-step");
var post = CreateStep("Post cache", ActionRunStage.Post);
await _debugger.OnJobStepsInitializedAsync(
new[] { pre.Object, checkout.Object, secret.Object },
new[] { post.Object });
var response = _debugger.HandleSource(MakeRequest(
"source",
new SourceArguments { SourceReference = 1 }));
Assert.True(response.Success);
var body = Assert.IsType<SourceResponseBody>(response.Body);
Assert.Equal(
"pre:\n - step: \"Set up job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n - step: \"***\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n",
body.Content);
Assert.Null(body.MimeType);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StackTraceUsesJobStepsSourceLine()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
var build = CreateStep("Build");
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object, build.Object },
Array.Empty<IStep>());
var stepTask = _debugger.OnStepStartingAsync(build.Object);
var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedEvent);
var bannerEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", bannerEvent);
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal(6, frame["line"].Value<int>());
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
Assert.Equal("execution.yml", frame["source"]["name"].Value<string>());
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await stepTask;
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StackTraceOmitsSourceForUnmappedCurrentStep()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
var build = CreateStep("Build");
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
Array.Empty<IStep>());
var stepTask = _debugger.OnStepStartingAsync(build.Object);
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal(0, frame["line"].Value<int>());
Assert.Null(frame["source"]);
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await stepTask;
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task PredictedPostStepIsServedAtInitializationAndClaimedAtRegistration()
{
using (var hc = CreateTestContext())
{
var action = CreateRepositoryActionStep("actions/cache");
var actionManager = new Mock<IActionManager>();
actionManager
.Setup(x => x.LoadAction(It.IsAny<IExecutionContext>(), action))
.Returns(CreateActionDefinitionWithPost());
hc.SetSingleton<IActionManager>(actionManager.Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action);
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
Array.Empty<IStep>());
var sourceResponse = _debugger.HandleSource(MakeRequest(
"source",
new SourceArguments { SourceReference = 1 }));
var sourceBody = Assert.IsType<SourceResponseBody>(sourceResponse.Body);
Assert.Equal(
"pre:\n - step: \"Set up job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n",
sourceBody.Content);
var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action);
_debugger.OnPostStepRegistered(post.Object);
var stepTask = _debugger.OnStepStartingAsync(post.Object);
var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedEvent);
var bannerEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", bannerEvent);
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal(8, frame["line"].Value<int>());
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await stepTask;
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StackTraceSanitizesSyntheticSourcePath()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, jobName: "my/job\\name");
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
Array.Empty<IStep>());
var stepTask = _debugger.OnStepStartingAsync(checkout.Object);
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal("my_job_name/execution.yml", frame["source"]["path"].Value<string>());
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await stepTask;
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -742,8 +1135,15 @@ namespace GitHub.Runner.Common.Tests.Worker
// Read the configurationDone response
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Read the welcome message output event
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
Array.Empty<IStep>());
// Complete the job — OnJobCompletedAsync pauses when stepping,
// so run it in the background and send continue to unblock.
var completedTask = _debugger.OnJobCompletedAsync();
@@ -752,11 +1152,27 @@ namespace GitHub.Runner.Common.Tests.Worker
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
// Send continue to unblock the pause
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal("Complete job [completed]", frame["name"].Value<string>());
Assert.Equal(8, frame["line"].Value<int>());
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
// Send continue to unblock the pause
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
@@ -775,6 +1191,68 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobCompletedUsesSyntheticCompleteJobLineWhenPostStepSharesName()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
var checkout = CreateStep("Checkout");
var realPost = CreateStep("Complete job", ActionRunStage.Post);
await _debugger.OnJobStepsInitializedAsync(
new[] { checkout.Object },
new[] { realPost.Object });
var completedTask = _debugger.OnJobCompletedAsync();
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "stackTrace"
});
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var stackTrace = JObject.Parse(stackTraceJson);
var frame = stackTrace["body"]?["stackFrames"]?[0];
Assert.NotNull(frame);
Assert.Equal("Complete job [completed]", frame["name"].Value<string>());
Assert.Equal(9, frame["line"].Value<int>());
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "continue"
});
await completedTask;
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -849,6 +1327,8 @@ namespace GitHub.Runner.Common.Tests.Worker
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Read the welcome message output event
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
@@ -867,5 +1347,224 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Equal(completedTask, finished);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSendsDefaultHelpWhenOverrideDisabled()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// First message: configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Second message: welcome output event with default help text
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("\"category\":\"console\"", welcomeMsg);
Assert.Contains("Actions Debug Console", welcomeMsg);
Assert.Contains("help", welcomeMsg);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageShowsCustomMessageWhenOverrideEnabled()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: "Welcome to debugging!");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// First: configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Second: custom welcome message
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("Welcome to debugging!", welcomeMsg);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithEmptyMessage()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: "");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// Read configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Send threads request — if welcome message was suppressed, this
// should be the next response (no output event in between)
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithNullMessage()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: null);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// Read configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Send threads request — if welcome message was suppressed, this
// should be the next response (no output event in between)
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSentOnlyOnce()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
// First configurationDone
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Welcome message should appear
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("Actions Debug Console", welcomeMsg);
// Second configurationDone — should NOT produce another welcome message
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "configurationDone"
});
var secondResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", secondResponse);
// Next message should be threads response, not another welcome output
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
}
}

View File

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

@@ -5,9 +5,12 @@ using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -40,7 +43,8 @@ namespace GitHub.Runner.Common.Tests.Worker
private Mock<IExecutionContext> CreateMockContext(
DictionaryContextData exprValues = null,
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
IDictionary<string, IDictionary<string, string>> jobDefaults = null,
ContainerInfo container = null)
{
var mock = new Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
@@ -51,6 +55,7 @@ namespace GitHub.Runner.Common.Tests.Worker
PrependPath = new List<string>(),
JobDefaults = jobDefaults
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
Container = container,
};
mock.Setup(x => x.Global).Returns(global);
@@ -65,7 +70,7 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var command = new RunCommand { Script = "echo hello" };
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None);
Assert.Equal("error", result.Type);
Assert.Contains("No execution context available", result.Result);
@@ -233,5 +238,101 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.False(result.ContainsKey("BAZ"));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_NoContainer_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
var context = CreateMockContext();
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_WithContainer_ActionStep_ReturnsContainerStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IContainerStepHost>(new ContainerStepHost());
var container = new ContainerInfo { ContainerId = "abc123" };
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<ContainerStepHost>(result);
var containerHost = (ContainerStepHost)result;
Assert.Same(container, containerHost.Container);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_WithContainer_InfrastructureStep_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
var container = new ContainerInfo { ContainerId = "abc123" };
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: false);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_ContainerWithoutId_NoHooks_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
// Container exists but hasn't been started yet (no ContainerId)
var container = new ContainerInfo();
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_ContainerWithoutId_HooksEnabled_ReturnsContainerStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IContainerStepHost>(new ContainerStepHost());
// Container hooks need both the feature flag and the env var
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", "/some/hook/path");
try
{
var container = new ContainerInfo();
var context = CreateMockContext(container: container);
context.Object.Global.Variables = new Variables(
hc,
new Dictionary<string, VariableValue>
{
{ Constants.Runner.Features.AllowRunnerContainerHooks, new VariableValue("true") }
});
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsAssignableFrom<IContainerStepHost>(result);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", null);
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,130 @@
using System;
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 JobExecutionViewL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RendersPreMainAndPostSections()
{
var pre = CreateStep("Pre cache", ActionRunStage.Pre);
var checkout = CreateStep("Checkout");
var post = CreateStep("Post cache", ActionRunStage.Post);
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 ClaimsPredictedPostStepWithoutChangingLine()
{
var action = CreateRepositoryActionStep("actions/cache");
var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action);
var predicted = new JobExecutionView.PredictedPostStep(
"Post Checkout",
MatchKeyFor(action.Id));
var view = new JobExecutionView(
"job",
new[] { checkout.Object },
Array.Empty<IStep>(),
new[] { predicted });
var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action);
var line = view.TryClaimPredictedStep(MatchKeyFor(action.Id), post.Object);
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 UsesSyntheticCompleteJobLineWhenPostStepSharesName()
{
var checkout = CreateStep("Checkout");
var realPost = CreateStep("Complete job", ActionRunStage.Post);
var view = new JobExecutionView(
"job",
new[] { checkout.Object },
new[] { realPost.Object });
Assert.Equal(8, view.TryGetLineForStep(realPost.Object));
Assert.Equal(9, view.CompleteJobLine);
}
private static Mock<IStep> CreateStep(string displayName, ActionRunStage? stage = null)
{
var step = new Mock<IStep>();
step.Setup(s => s.DisplayName).Returns(displayName);
if (stage.HasValue)
{
var executionContext = new Mock<IExecutionContext>();
executionContext.Setup(x => x.Stage).Returns(stage.Value);
step.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
}
else
{
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
}
return step;
}
private static Mock<IActionRunner> CreateActionRunner(string displayName, ActionRunStage stage, ActionStep action)
{
var executionContext = new Mock<IExecutionContext>();
executionContext.Setup(x => x.Stage).Returns(stage);
var runner = new Mock<IActionRunner>();
runner.Setup(s => s.DisplayName).Returns(displayName);
runner.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
runner.Setup(s => s.Stage).Returns(stage);
runner.Setup(s => s.Action).Returns(action);
return runner;
}
private static 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

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

@@ -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 +1 @@
2.334.0
2.335.0