mirror of
https://github.com/actions/runner.git
synced 2026-07-04 11:42:21 +08:00
Compare commits
24 Commits
fix/normal
...
releases/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d737449ef | ||
|
|
0d310567ae | ||
|
|
1ccca7c073 | ||
|
|
cbaeeb89ea | ||
|
|
4e51e7980c | ||
|
|
39108f22e4 | ||
|
|
7e0ff4d3e4 | ||
|
|
4864bb5778 | ||
|
|
a3df03d35a | ||
|
|
e6c5af75be | ||
|
|
fb78489197 | ||
|
|
77d6014f58 | ||
|
|
9c2a004d07 | ||
|
|
5053d17b4e | ||
|
|
c6a124e184 | ||
|
|
1a6560294e | ||
|
|
3ff2186ec0 | ||
|
|
7c0b271d2e | ||
|
|
0b3b8e0ba7 | ||
|
|
ae2896c551 | ||
|
|
ebf33710e8 | ||
|
|
a1ccd22030 | ||
|
|
b549247bee | ||
|
|
d36839b001 |
@@ -4,7 +4,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/dotnet": {
|
||||
"version": "8.0.420"
|
||||
"version": "8.0.421"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,4 +27,5 @@ TestResults
|
||||
TestLogs
|
||||
.DS_Store
|
||||
.mono
|
||||
**/*.DotSettings.user
|
||||
**/*.DotSettings.user
|
||||
**/*.lscache
|
||||
@@ -25,11 +25,11 @@ The `installdependencies.sh` script should install all required dependencies on
|
||||
|
||||
Debian based OS (Debian, Ubuntu, Linux Mint)
|
||||
|
||||
- liblttng-ust1 or liblttng-ust0
|
||||
- liblttng-ust1t64, liblttng-ust1 or liblttng-ust0
|
||||
- libkrb5-3
|
||||
- zlib1g
|
||||
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
|
||||
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||
- libicu80, libicu79, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||
|
||||
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
<Update to ./src/runnerversion when creating release>
|
||||
2.335.1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -94,7 +94,7 @@ then
|
||||
fi
|
||||
}
|
||||
|
||||
apt_get_with_fallbacks liblttng-ust1 liblttng-ust0
|
||||
apt_get_with_fallbacks liblttng-ust1t64 liblttng-ust1 liblttng-ust0
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'$apt_get' failed with exit code '$?'"
|
||||
@@ -110,7 +110,7 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt_get_with_fallbacks libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
|
||||
apt_get_with_fallbacks libicu80 libicu79 libicu78 libicu77 libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'$apt_get' failed with exit code '$?'"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
src/Runner.Worker/BackgroundStepControlFlowData.cs
Normal file
21
src/Runner.Worker/BackgroundStepControlFlowData.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
394
src/Runner.Worker/BackgroundStepCoordinator.cs
Normal file
394
src/Runner.Worker/BackgroundStepCoordinator.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
{
|
||||
[ServiceLocator(Default = typeof(BackgroundStepCoordinator))]
|
||||
public interface IBackgroundStepCoordinator : IRunnerService
|
||||
{
|
||||
void InitializeCoordinator(int maxConcurrent);
|
||||
void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken);
|
||||
Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken);
|
||||
Task RunControlFlowAsync(IExecutionContext stepContext, object data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates background step execution, waiting, cancellation, and deferred state.
|
||||
/// Extracted from StepsRunner so the main step loop stays clean.
|
||||
/// </summary>
|
||||
public sealed class BackgroundStepCoordinator : RunnerService, IBackgroundStepCoordinator
|
||||
{
|
||||
private const int DefaultMaxBackgroundSteps = 10;
|
||||
private readonly Dictionary<string, (IStep Step, Task Task, CancellationTokenSource Cts)> _backgroundSteps = new();
|
||||
|
||||
// IDs of background steps that have already been completed (waited on or canceled).
|
||||
// Used to avoid waiting on or flushing the same step more than once.
|
||||
private readonly HashSet<string> _completedStepIds = new();
|
||||
|
||||
// IDs of background steps that were explicitly canceled via a `cancel` control step.
|
||||
// These steps are expected to be canceled, so their (Canceled) result must not be
|
||||
// merged into the overall job result.
|
||||
private readonly HashSet<string> _explicitlyCanceledStepIds = new();
|
||||
private SemaphoreSlim _backgroundSlotSemaphore = new SemaphoreSlim(DefaultMaxBackgroundSteps);
|
||||
|
||||
/// <summary>
|
||||
/// Reset per-job state. Call at the start of each job.
|
||||
/// </summary>
|
||||
public void InitializeCoordinator(int maxConcurrent)
|
||||
{
|
||||
_backgroundSteps.Clear();
|
||||
_completedStepIds.Clear();
|
||||
_explicitlyCanceledStepIds.Clear();
|
||||
var max = maxConcurrent > 0 ? maxConcurrent : DefaultMaxBackgroundSteps;
|
||||
_backgroundSlotSemaphore = new SemaphoreSlim(max);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Starting background steps
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Prepare and launch a background step. Does not block the caller.
|
||||
/// </summary>
|
||||
public void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken)
|
||||
{
|
||||
var stepId = step.ExecutionContext?.ContextName ?? step.DisplayName;
|
||||
|
||||
// Isolate GitHubContext so concurrent steps don't overwrite each other's GITHUB_OUTPUT paths
|
||||
if (step.ExecutionContext.ExpressionValues.TryGetValue("github", out var ghCtx) && ghCtx is GitHubContext sharedGitHub)
|
||||
{
|
||||
step.ExecutionContext.ExpressionValues["github"] = sharedGitHub.ShallowCopy();
|
||||
}
|
||||
|
||||
var bgCts = CancellationTokenSource.CreateLinkedTokenSource(jobCancellationToken);
|
||||
|
||||
// Evaluate timeout on the main thread (needs expression context)
|
||||
var timeoutMinutes = 0;
|
||||
try
|
||||
{
|
||||
var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator();
|
||||
timeoutMinutes = templateEvaluator.EvaluateStepTimeout(step.Timeout, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"Error determining timeout for background step '{stepId}': {ex.Message}");
|
||||
}
|
||||
|
||||
var task = ExecuteBackgroundStepCoreAsync(step, bgCts, stepId, timeoutMinutes);
|
||||
_backgroundSteps[stepId] = (step, task, bgCts);
|
||||
Trace.Info($"Background step '{stepId}' queued (slot will be acquired asynchronously).");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Safety net
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Drain any background steps that weren't already waited on by an explicit wait/cancel
|
||||
// control step, then merge the final results of all background steps into a single result
|
||||
// for the caller to fold into the job result.
|
||||
public async Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var unwaitedIds = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
|
||||
if (unwaitedIds.Count > 0)
|
||||
{
|
||||
Trace.Info($"Safety net: {unwaitedIds.Count} unwaited background step(s) at post-job boundary: {string.Join(", ", unwaitedIds)}");
|
||||
await WaitForStepTasksAsync(unwaitedIds, cancellationToken);
|
||||
CompleteWaitedSteps(unwaitedIds);
|
||||
}
|
||||
|
||||
var result = TaskResult.Succeeded;
|
||||
foreach (var (stepId, (step, _, _)) in _backgroundSteps)
|
||||
{
|
||||
// A step that succeeded does not set a Result by default, so a missing
|
||||
// value means the step succeeded and there is nothing to merge.
|
||||
if (!step.ExecutionContext.Result.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A step explicitly canceled via a `cancel` control step is expected to be canceled,
|
||||
// so a Canceled result must not influence the overall job result. However, if the step
|
||||
// failed (e.g. before the cancellation took effect), that failure should still count.
|
||||
if (_explicitlyCanceledStepIds.Contains(stepId) &&
|
||||
step.ExecutionContext.Result.Value == TaskResult.Canceled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.Result.Value);
|
||||
}
|
||||
|
||||
if (result != TaskResult.Succeeded)
|
||||
{
|
||||
Trace.Info($"Background steps reported result '{result}' to caller.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Control-flow step dispatch
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Execute a control-flow step (wait, wait-all, cancel) and propagate results.
|
||||
/// </summary>
|
||||
public async Task RunControlFlowAsync(IExecutionContext stepContext, object data)
|
||||
{
|
||||
var controlFlow = data as BackgroundStepControlFlowData;
|
||||
switch (controlFlow.Type)
|
||||
{
|
||||
case Pipelines.BackgroundControlTypes.Wait:
|
||||
{
|
||||
var ids = controlFlow.StepIds ?? Array.Empty<string>();
|
||||
stepContext.Output($"Waiting for background step(s) to complete: {DescribeSteps(ids)}");
|
||||
await WaitForStepTasksAsync(ids, stepContext.CancellationToken);
|
||||
stepContext.Result = CompleteWaitedSteps(ids);
|
||||
ReportCompletedSteps(stepContext, "Finished waiting for background step(s).", ids);
|
||||
break;
|
||||
}
|
||||
|
||||
case Pipelines.BackgroundControlTypes.WaitAll:
|
||||
{
|
||||
var remaining = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
|
||||
stepContext.Output(remaining.Count > 0
|
||||
? $"Waiting for all background step(s) to complete: {DescribeSteps(remaining)}"
|
||||
: "No background steps remaining to wait for.");
|
||||
await WaitForStepTasksAsync(remaining, stepContext.CancellationToken);
|
||||
stepContext.Result = CompleteWaitedSteps(remaining);
|
||||
ReportCompletedSteps(stepContext, "Finished waiting for all background step(s).", remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
case Pipelines.BackgroundControlTypes.Cancel:
|
||||
{
|
||||
var cancelIds = controlFlow.StepIds ?? Array.Empty<string>();
|
||||
stepContext.Output($"Cancelling background step(s): {DescribeSteps(cancelIds)}");
|
||||
await CancelStepsAsync(controlFlow.StepIds);
|
||||
stepContext.Result = TaskResult.Succeeded;
|
||||
ReportCompletedSteps(stepContext, "Finished cancelling background step(s).", cancelIds);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unknown background step control type '{controlFlow.Type}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Resolve background step IDs to their display names for customer-facing output.
|
||||
private string DescribeSteps(IEnumerable<string> stepIds)
|
||||
{
|
||||
var names = stepIds
|
||||
.Select(id => _backgroundSteps.TryGetValue(id, out var entry) ? entry.Step.DisplayName : id)
|
||||
.ToList();
|
||||
return names.Count > 0 ? string.Join(", ", names) : "(none)";
|
||||
}
|
||||
|
||||
// Emit a completion summary plus the final result of each affected background step.
|
||||
private void ReportCompletedSteps(IExecutionContext stepContext, string summary, IEnumerable<string> stepIds)
|
||||
{
|
||||
stepContext.Output(summary);
|
||||
foreach (var id in stepIds)
|
||||
{
|
||||
if (_backgroundSteps.TryGetValue(id, out var entry))
|
||||
{
|
||||
var result = entry.Step.ExecutionContext.Result?.ToString() ?? "Unknown";
|
||||
stepContext.Output($" {entry.Step.DisplayName}: {result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteBackgroundStepCoreAsync(
|
||||
IStep step, CancellationTokenSource bgCts,
|
||||
string stepId, int timeoutMinutes)
|
||||
{
|
||||
Trace.Info($"Background step '{stepId}' waiting for slot.");
|
||||
await _backgroundSlotSemaphore.WaitAsync(bgCts.Token);
|
||||
Trace.Info($"Background step '{stepId}' acquired slot.");
|
||||
|
||||
step.ExecutionContext.Start();
|
||||
|
||||
if (timeoutMinutes > 0)
|
||||
{
|
||||
step.ExecutionContext.SetTimeout(TimeSpan.FromMinutes(timeoutMinutes));
|
||||
}
|
||||
|
||||
using var cancelReg = bgCts.Token.Register(() =>
|
||||
{
|
||||
Trace.Info($"Background step '{stepId}': cancellation signalled, sending CancelToken to process.");
|
||||
step.ExecutionContext.CancelToken();
|
||||
});
|
||||
|
||||
TaskResult? result = null;
|
||||
try
|
||||
{
|
||||
await step.RunAsync();
|
||||
result = step.ExecutionContext.Result ?? TaskResult.Succeeded;
|
||||
}
|
||||
catch (OperationCanceledException) when (bgCts.Token.IsCancellationRequested)
|
||||
{
|
||||
result = TaskResult.Canceled;
|
||||
}
|
||||
catch (OperationCanceledException) when (step.ExecutionContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info($"Background step '{stepId}' timed out after {timeoutMinutes} minutes.");
|
||||
step.ExecutionContext.Error($"The background step '{step.DisplayName}' has timed out after {timeoutMinutes} minutes.");
|
||||
result = TaskResult.Failed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"Background step '{stepId}' failed: {ex.Message}");
|
||||
step.ExecutionContext.Error(ex);
|
||||
result = TaskResult.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_backgroundSlotSemaphore.Release();
|
||||
|
||||
if (step.ExecutionContext.CommandResult != null)
|
||||
{
|
||||
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.CommandResult.Value);
|
||||
}
|
||||
|
||||
step.ExecutionContext.Result = result;
|
||||
step.ExecutionContext.ApplyContinueOnError(step.ContinueOnError);
|
||||
|
||||
step.ExecutionContext.Complete(step.ExecutionContext.Result);
|
||||
Trace.Info($"Background step '{stepId}' completed with result: {step.ExecutionContext.Result}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelStepsAsync(string[] cancelStepIds)
|
||||
{
|
||||
if (cancelStepIds == null || cancelStepIds.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark these steps as expected-to-be-canceled so their result does not
|
||||
// affect the overall job result.
|
||||
foreach (var id in cancelStepIds)
|
||||
{
|
||||
_explicitlyCanceledStepIds.Add(id);
|
||||
}
|
||||
|
||||
var idsToCancel = cancelStepIds
|
||||
.Where(id => _backgroundSteps.ContainsKey(id) && !_backgroundSteps[id].Task.IsCompleted)
|
||||
.ToArray();
|
||||
|
||||
if (idsToCancel.Length > 0)
|
||||
{
|
||||
Trace.Info($"Cancelling {idsToCancel.Length} background step(s): {string.Join(", ", idsToCancel)}");
|
||||
await CancelWithGracePeriodAsync(idsToCancel);
|
||||
}
|
||||
|
||||
// Flush deferred state and mark canceled steps as completed.
|
||||
CompleteWaitedSteps(cancelStepIds);
|
||||
}
|
||||
|
||||
private async Task WaitForStepTasksAsync(IEnumerable<string> stepIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var ids = stepIds.ToList();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
foreach (var stepId in ids)
|
||||
{
|
||||
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
|
||||
{
|
||||
tasks.Add(entry.Task);
|
||||
}
|
||||
else if (!_backgroundSteps.ContainsKey(stepId))
|
||||
{
|
||||
Trace.Info($"Wait references unknown background step: {stepId}");
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.Count > 0)
|
||||
{
|
||||
Trace.Info($"Waiting for {tasks.Count} background step(s)...");
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(tasks).WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Wait interrupted by job cancellation — cancelling background steps.");
|
||||
await CancelWithGracePeriodAsync(ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelWithGracePeriodAsync(IEnumerable<string> stepIds, double graceSeconds = 7.5)
|
||||
{
|
||||
var cancelledSteps = new List<(string StepId, Task Task, IStep Step)>();
|
||||
foreach (var stepId in stepIds)
|
||||
{
|
||||
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
|
||||
{
|
||||
entry.Step.ExecutionContext.CancelToken();
|
||||
entry.Cts.Cancel();
|
||||
cancelledSteps.Add((stepId, entry.Task, entry.Step));
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelledSteps.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(cancelledSteps.Select(s => s.Task)).WaitAsync(TimeSpan.FromSeconds(graceSeconds));
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Trace.Info($"Some background steps did not terminate within {graceSeconds}s grace period.");
|
||||
|
||||
// The step tasks above never completed, so their finally block never ran and
|
||||
// their result was never set. Force-mark them as canceled so the abandoned
|
||||
// steps still report a terminal result.
|
||||
foreach (var (stepId, task, step) in cancelledSteps)
|
||||
{
|
||||
if (!task.IsCompleted && !step.ExecutionContext.Result.HasValue)
|
||||
{
|
||||
step.ExecutionContext.Result = TaskResult.Canceled;
|
||||
Trace.Info($"Background step '{stepId}' did not terminate within grace period; marking as canceled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TaskResult CompleteWaitedSteps(IEnumerable<string> stepIds)
|
||||
{
|
||||
var result = TaskResult.Succeeded;
|
||||
foreach (var id in stepIds)
|
||||
{
|
||||
_completedStepIds.Add(id);
|
||||
if (_backgroundSteps.TryGetValue(id, out var entry))
|
||||
{
|
||||
// Flush deferred state for the completed step.
|
||||
entry.Step.ExecutionContext.FlushDeferredOutputs();
|
||||
entry.Step.ExecutionContext.FlushDeferredEnvironment();
|
||||
entry.Step.ExecutionContext.FlushDeferredOutcomeConclusion();
|
||||
Trace.Info($"Flushed deferred state for background step '{id}'.");
|
||||
|
||||
if (entry.Step.ExecutionContext.Result.HasValue)
|
||||
{
|
||||
result = TaskResultUtil.MergeTaskResults(result, entry.Step.ExecutionContext.Result.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
358
src/Runner.Worker/Dap/JobExecutionView.cs
Normal file
358
src/Runner.Worker/Dap/JobExecutionView.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.48" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
57
src/Sdk/DTPipelines/Pipelines/BackgroundStepControl.cs
Normal file
57
src/Sdk/DTPipelines/Pipelines/BackgroundStepControl.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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": {}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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('\'', '"');
|
||||
|
||||
702
src/Test/L0/Worker/BackgroundStepsL0.cs
Normal file
702
src/Test/L0/Worker/BackgroundStepsL0.cs
Normal file
@@ -0,0 +1,702 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class BackgroundStepsL0
|
||||
{
|
||||
private Mock<IExecutionContext> _ec;
|
||||
private StepsRunner _stepsRunner;
|
||||
private Variables _variables;
|
||||
private Dictionary<string, string> _env;
|
||||
private DictionaryContextData _contexts;
|
||||
private JobContext _jobContext;
|
||||
private StepsContext _stepContext;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] String testName = "")
|
||||
{
|
||||
var hc = new TestHostContext(this, testName);
|
||||
Dictionary<string, VariableValue> variablesToCopy = new();
|
||||
_variables = new Variables(
|
||||
hostContext: hc,
|
||||
copy: variablesToCopy);
|
||||
_env = new Dictionary<string, string>()
|
||||
{
|
||||
{"env1", "1"},
|
||||
{"test", "github_actions"}
|
||||
};
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
_ec.SetupAllProperties();
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext { WriteDebug = true });
|
||||
_ec.Object.Global.Variables = _variables;
|
||||
_ec.Object.Global.EnvironmentVariables = _env;
|
||||
_ec.Object.Global.FileTable = new List<string>();
|
||||
|
||||
_contexts = new DictionaryContextData();
|
||||
_jobContext = new JobContext();
|
||||
_contexts["github"] = new GitHubContext();
|
||||
_contexts["runner"] = new DictionaryContextData();
|
||||
_contexts["job"] = _jobContext;
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(_contexts);
|
||||
_ec.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
_ec.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
_ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
|
||||
_stepContext = new StepsContext();
|
||||
_ec.Object.Global.StepsContext = _stepContext;
|
||||
|
||||
_ec.Setup(x => x.PostJobSteps).Returns(new Stack<IStep>());
|
||||
|
||||
var trace = hc.GetTrace();
|
||||
|
||||
// Mock CreateChild for implicit wait-all step injection
|
||||
_ec.Setup(x => x.CreateChild(
|
||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ActionRunStage>(),
|
||||
It.IsAny<Dictionary<string, string>>(), It.IsAny<int?>(), It.IsAny<IPagingLogger>(),
|
||||
It.IsAny<bool>(), It.IsAny<List<Issue>>(), It.IsAny<CancellationTokenSource>(),
|
||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(),
|
||||
It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string[]>(), It.IsAny<string>()))
|
||||
.Returns((Guid recordId, string displayName, string refName, string scopeName, string contextName,
|
||||
ActionRunStage stage, Dictionary<string, string> intraActionState, int? recordOrder, IPagingLogger logger,
|
||||
bool isEmbedded, List<Issue> issues, CancellationTokenSource cts, Guid embeddedId, string siblingScopeName, TimeSpan? timeout,
|
||||
bool isBackground, string backgroundControlType, string[] backgroundControlStepIds, string parallelGroupId) =>
|
||||
{
|
||||
var childEc = new Mock<IExecutionContext>();
|
||||
childEc.SetupAllProperties();
|
||||
childEc.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
childEc.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
|
||||
childEc.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
childEc.Setup(x => x.ContextName).Returns(contextName);
|
||||
childEc.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
childEc.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null) childEc.Object.Result = r;
|
||||
});
|
||||
childEc.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
return childEc.Object;
|
||||
});
|
||||
|
||||
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
|
||||
_stepsRunner = new StepsRunner();
|
||||
_stepsRunner.Initialize(hc);
|
||||
|
||||
var bgCoordinator = new BackgroundStepCoordinator();
|
||||
bgCoordinator.Initialize(hc);
|
||||
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
|
||||
|
||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||
hc.SetSingleton(mockDapDebugger.Object);
|
||||
|
||||
return hc;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task BackgroundStepRunsConcurrentlyWithForeground()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: background step that takes time, followed by a foreground step
|
||||
var executionOrder = new List<string>();
|
||||
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg-step", contextName: "bg", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
executionOrder.Add("bg-start");
|
||||
await Task.Delay(2000);
|
||||
executionOrder.Add("bg-end");
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "bg-step",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "bg",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var fgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "fg-step", contextName: "fg");
|
||||
fgStep.Setup(x => x.RunAsync()).Returns(() =>
|
||||
{
|
||||
executionOrder.Add("fg-run");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var waitAllStep = CreateWaitAllStep(hc);
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, fgStep.Object, waitAllStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: foreground step should start before background step finishes
|
||||
Assert.Contains("bg-start", executionOrder);
|
||||
Assert.Contains("fg-run", executionOrder);
|
||||
Assert.Contains("bg-end", executionOrder);
|
||||
var fgIndex = executionOrder.IndexOf("fg-run");
|
||||
var bgEndIndex = executionOrder.IndexOf("bg-end");
|
||||
Assert.True(fgIndex < bgEndIndex, "Foreground step should run before background step completes");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WaitStepBlocksUntilBackgroundCompletes()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange
|
||||
var bgCompleted = false;
|
||||
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "db", contextName: "db", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(100);
|
||||
bgCompleted = true;
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "db",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "db",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var waitStep = CreateWaitStep(hc, new[] { "db" });
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, waitStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: background step must have completed after wait
|
||||
Assert.True(bgCompleted, "Background step should have completed after wait");
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task BackgroundStepFailurePropagatesAtWait()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: background step that fails
|
||||
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "flaky", contextName: "flaky", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(() =>
|
||||
{
|
||||
throw new Exception("Service crashed");
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "flaky",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "flaky",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var waitStep = CreateWaitStep(hc, new[] { "flaky" });
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, waitStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: job should fail because background step failed
|
||||
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task CancelStepTerminatesBackgroundStep()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: background step that runs until cancelled via ExecutionContext.CancellationToken
|
||||
var stepCts = new CancellationTokenSource();
|
||||
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server");
|
||||
// Wire CancellationToken to our CTS so the cancel path can trigger it
|
||||
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
|
||||
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
|
||||
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stepCts.Token);
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "server",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "server",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var cancelStep = CreateCancelStep(hc, "server");
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, cancelStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: background step should have been cancelled
|
||||
// Note: the cancel mechanism uses the BackgroundStepContext.Cts, not bgCts
|
||||
// so wasCancelled may not be true in this mock, but the step should complete
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WaitAllWaitsForAllBackgroundSteps()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: two background steps
|
||||
var step1Done = false;
|
||||
var step2Done = false;
|
||||
|
||||
var bgStep1 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc1", contextName: "svc1", isBackground: true);
|
||||
bgStep1.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(50);
|
||||
step1Done = true;
|
||||
});
|
||||
bgStep1.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "svc1",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "svc1",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var bgStep2 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc2", contextName: "svc2", isBackground: true);
|
||||
bgStep2.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(100);
|
||||
step2Done = true;
|
||||
});
|
||||
bgStep2.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "svc2",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "svc2",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var waitAllStep = CreateWaitAllStep(hc);
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep1.Object, bgStep2.Object, waitAllStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert
|
||||
Assert.True(step1Done, "Background step 1 should have completed");
|
||||
Assert.True(step2Done, "Background step 2 should have completed");
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task CancelStepPublishesCanceledBackgroundExternalId()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "server",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "server",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var cancelStep = CreateCancelStep(hc, "server");
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, cancelStep
|
||||
}));
|
||||
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: cancel step completed without error
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task CanceledBackgroundStepDoesNotAffectJobResult()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: a background step that runs until explicitly canceled. When canceled it
|
||||
// reports TaskResult.Canceled, but since the cancellation is expected (driven by a
|
||||
// cancel control step), it must not impact the overall job result.
|
||||
using var stepCts = new CancellationTokenSource();
|
||||
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
|
||||
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
|
||||
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
|
||||
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stepCts.Token);
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "server",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "server",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var cancelStep = CreateCancelStep(hc, "server");
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, cancelStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: the canceled background step reported Canceled, but the job result is unaffected.
|
||||
Assert.Equal(TaskResult.Canceled, bgStep.Object.ExecutionContext.Result);
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task FailedBackgroundStepTargetedByCancelStillAffectsJobResult()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: a background step that fails (e.g. before the cancel takes effect). Even
|
||||
// though a cancel control step targets it, its Failed result must still propagate to
|
||||
// the overall job result.
|
||||
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "server", contextName: "server", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "server",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "server",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var cancelStep = CreateCancelStep(hc, "server");
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, cancelStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: the background step failed, so the job result reflects that failure.
|
||||
Assert.Equal(TaskResult.Failed, bgStep.Object.ExecutionContext.Result);
|
||||
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task StepsContextThreadSafety()
|
||||
{
|
||||
// Test that concurrent SetOutput/SetConclusion doesn't throw
|
||||
var stepsContext = new StepsContext();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var index = i;
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
stepsContext.SetOutput("", $"step{index}", "out", $"value{index}", out _);
|
||||
stepsContext.SetConclusion("", $"step{index}", ActionResult.Success);
|
||||
stepsContext.SetOutcome("", $"step{index}", ActionResult.Success);
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: all 100 steps should have their data set
|
||||
var scope = stepsContext.GetScope("");
|
||||
Assert.Equal(100, scope.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ControlFlowStepsRunEvenAfterFailure()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: a background step, a foreground step that fails, then a wait step
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg", contextName: "bg", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "bg",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "bg",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var failStep = CreateStep(hc, TaskResult.Failed, "success()", name: "fail", contextName: "fail");
|
||||
|
||||
// Wait step uses always() condition — should run even after failure
|
||||
var waitStep = CreateWaitStep(hc, new[] { "bg" });
|
||||
waitStep.Condition = $"{GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants.Always}()";
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, failStep.Object, waitStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: wait step should have run (not skipped) because it has always() condition
|
||||
Assert.NotNull(waitStep.ExecutionContext.Result);
|
||||
Assert.NotEqual(TaskResult.Skipped, waitStep.ExecutionContext.Result);
|
||||
}
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private Mock<IActionRunner> CreateStep(TestHostContext hc, TaskResult result, string condition, string name = "Test", string contextName = null, Guid? recordId = null, bool isBackground = false)
|
||||
{
|
||||
var stepRecordId = recordId ?? Guid.NewGuid();
|
||||
var step = new Mock<IActionRunner>();
|
||||
step.Setup(x => x.Condition).Returns(condition);
|
||||
step.Setup(x => x.ContinueOnError).Returns(new BooleanToken(null, null, null, false));
|
||||
step.Setup(x => x.Stage).Returns(ActionRunStage.Main);
|
||||
step.Setup(x => x.Action)
|
||||
.Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = name,
|
||||
Id = stepRecordId,
|
||||
ContextName = contextName ?? name,
|
||||
});
|
||||
|
||||
var stepContext = new Mock<IExecutionContext>();
|
||||
stepContext.SetupAllProperties();
|
||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
stepContext.Setup(x => x.IsBackground).Returns(isBackground);
|
||||
var expressionValues = new DictionaryContextData();
|
||||
foreach (var pair in _ec.Object.ExpressionValues)
|
||||
{
|
||||
expressionValues[pair.Key] = pair.Value;
|
||||
}
|
||||
stepContext.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
stepContext.Setup(x => x.Id).Returns(stepRecordId);
|
||||
stepContext.Setup(x => x.ContextName).Returns(step.Object.Action.ContextName);
|
||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null)
|
||||
{
|
||||
stepContext.Object.Result = r;
|
||||
}
|
||||
_stepContext.SetOutcome("", stepContext.Object.ContextName, (stepContext.Object.Outcome ?? stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
|
||||
_stepContext.SetConclusion("", stepContext.Object.ContextName, (stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
|
||||
});
|
||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||
stepContext.Setup(x => x.ApplyContinueOnError(It.IsAny<TemplateToken>()));
|
||||
stepContext.Setup(x => x.FlushDeferredOutputs()).Callback(() =>
|
||||
{
|
||||
if (stepContext.Object.DeferredOutputs != null)
|
||||
{
|
||||
foreach (var kvp in stepContext.Object.DeferredOutputs)
|
||||
{
|
||||
_stepContext.SetOutput("", stepContext.Object.ContextName, kvp.Key, kvp.Value, out _);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var trace = hc.GetTrace();
|
||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
stepContext.Object.Result = result;
|
||||
step.Setup(x => x.ExecutionContext).Returns(stepContext.Object);
|
||||
step.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
private JobExtensionRunner CreateWaitStep(TestHostContext hc, string[] stepIds, Dictionary<string, string> timelineVariables = null)
|
||||
{
|
||||
var waitData = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = Pipelines.BackgroundControlTypes.Wait,
|
||||
StepIds = stepIds,
|
||||
};
|
||||
var bgCoordinator = hc.GetService<IBackgroundStepCoordinator>();
|
||||
var waitRunner = new JobExtensionRunner(
|
||||
runAsync: bgCoordinator.RunControlFlowAsync,
|
||||
condition: "success()",
|
||||
displayName: "Wait",
|
||||
data: waitData);
|
||||
|
||||
var stepContext = new Mock<IExecutionContext>();
|
||||
stepContext.SetupAllProperties();
|
||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
var waitExprValues = new DictionaryContextData();
|
||||
foreach (var pair in _ec.Object.ExpressionValues) { waitExprValues[pair.Key] = pair.Value; }
|
||||
stepContext.Setup(x => x.ExpressionValues).Returns(waitExprValues);
|
||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
stepContext.Setup(x => x.ContextName).Returns("__wait");
|
||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null) stepContext.Object.Result = r;
|
||||
});
|
||||
var trace = hc.GetTrace();
|
||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
|
||||
waitRunner.ExecutionContext = stepContext.Object;
|
||||
return waitRunner;
|
||||
}
|
||||
|
||||
private JobExtensionRunner CreateWaitAllStep(TestHostContext hc, Dictionary<string, string> timelineVariables = null)
|
||||
{
|
||||
var waitAllData = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = Pipelines.BackgroundControlTypes.WaitAll,
|
||||
};
|
||||
var bgCoordinator2 = hc.GetService<IBackgroundStepCoordinator>();
|
||||
var waitAllRunner = new JobExtensionRunner(
|
||||
runAsync: bgCoordinator2.RunControlFlowAsync,
|
||||
condition: "success()",
|
||||
displayName: "Wait All",
|
||||
data: waitAllData);
|
||||
|
||||
var stepContext = new Mock<IExecutionContext>();
|
||||
stepContext.SetupAllProperties();
|
||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
var waitAllExprValues = new DictionaryContextData();
|
||||
foreach (var pair in _ec.Object.ExpressionValues) { waitAllExprValues[pair.Key] = pair.Value; }
|
||||
stepContext.Setup(x => x.ExpressionValues).Returns(waitAllExprValues);
|
||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
stepContext.Setup(x => x.ContextName).Returns("__wait-all");
|
||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null) stepContext.Object.Result = r;
|
||||
});
|
||||
var trace = hc.GetTrace();
|
||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
|
||||
waitAllRunner.ExecutionContext = stepContext.Object;
|
||||
return waitAllRunner;
|
||||
}
|
||||
|
||||
private JobExtensionRunner CreateCancelStep(TestHostContext hc, string cancelStepId, Dictionary<string, string> timelineVariables = null)
|
||||
{
|
||||
var cancelData = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = Pipelines.BackgroundControlTypes.Cancel,
|
||||
StepIds = new[] { cancelStepId },
|
||||
};
|
||||
var bgCoordinator3 = hc.GetService<IBackgroundStepCoordinator>();
|
||||
var cancelRunner = new JobExtensionRunner(
|
||||
runAsync: bgCoordinator3.RunControlFlowAsync,
|
||||
condition: "success()",
|
||||
displayName: "Cancel",
|
||||
data: cancelData);
|
||||
|
||||
var stepContext = new Mock<IExecutionContext>();
|
||||
stepContext.SetupAllProperties();
|
||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
var cancelExprValues = new DictionaryContextData();
|
||||
foreach (var pair in _ec.Object.ExpressionValues) { cancelExprValues[pair.Key] = pair.Value; }
|
||||
stepContext.Setup(x => x.ExpressionValues).Returns(cancelExprValues);
|
||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
stepContext.Setup(x => x.ContextName).Returns("__cancel");
|
||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null) stepContext.Object.Result = r;
|
||||
});
|
||||
var trace = hc.GetTrace();
|
||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
|
||||
cancelRunner.ExecutionContext = stepContext.Object;
|
||||
return cancelRunner;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
130
src/Test/L0/Worker/JobExecutionViewL0.cs
Normal file
130
src/Test/L0/Worker/JobExecutionViewL0.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
|
||||
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
|
||||
PACKAGE_DIR="$SCRIPT_DIR/../_package"
|
||||
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
|
||||
DOTNETSDK_VERSION="8.0.420"
|
||||
DOTNETSDK_VERSION="8.0.421"
|
||||
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
||||
RUNNER_VERSION=$(cat runnerversion)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.420"
|
||||
"version": "8.0.421"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.334.0
|
||||
2.335.1
|
||||
|
||||
Reference in New Issue
Block a user