mirror of
https://github.com/actions/runner.git
synced 2026-07-05 20:38:40 +08:00
Compare commits
10 Commits
dependabot
...
dapsetup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ecc21590 | ||
|
|
0387c108c0 | ||
|
|
3275feadce | ||
|
|
387a1befd7 | ||
|
|
e179495f1f | ||
|
|
941e43aea5 | ||
|
|
89b6b32e3e | ||
|
|
da5bc7d859 | ||
|
|
c4c1b25b56 | ||
|
|
2d81971188 |
@@ -4,7 +4,7 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||||
"ghcr.io/devcontainers/features/dotnet": {
|
"ghcr.io/devcontainers/features/dotnet": {
|
||||||
"version": "8.0.421"
|
"version": "8.0.420"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/node:1": {
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
"version": "20"
|
"version": "20"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,5 +27,4 @@ TestResults
|
|||||||
TestLogs
|
TestLogs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.mono
|
.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)
|
Debian based OS (Debian, Ubuntu, Linux Mint)
|
||||||
|
|
||||||
- liblttng-ust1t64, liblttng-ust1 or liblttng-ust0
|
- liblttng-ust1 or liblttng-ust0
|
||||||
- libkrb5-3
|
- libkrb5-3
|
||||||
- zlib1g
|
- zlib1g
|
||||||
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
|
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
|
||||||
- libicu80, libicu79, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||||
|
|
||||||
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)
|
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ ARG TARGETOS
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG RUNNER_VERSION
|
ARG RUNNER_VERSION
|
||||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||||
ARG DOCKER_VERSION=29.5.3
|
ARG DOCKER_VERSION=29.4.0
|
||||||
ARG BUILDX_VERSION=0.34.1
|
ARG BUILDX_VERSION=0.33.0
|
||||||
|
|
||||||
RUN apt update -y && apt install curl unzip -y
|
RUN apt update -y && apt install curl unzip -y
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,36 @@
|
|||||||
## What's Changed
|
## What's Changed
|
||||||
* Bump System.ServiceProcess.ServiceController from 10.0.6 to 10.0.7 by @dependabot[bot] in https://github.com/actions/runner/pull/4370
|
* 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
|
||||||
* 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
|
* Add DAP server by @rentziass in https://github.com/actions/runner/pull/4298
|
||||||
* feat: propagate actions dependencies by @nodeselector in https://github.com/actions/runner/pull/4372
|
* 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
|
||||||
* Not retry and report action download 403. by @TingluoHuang in https://github.com/actions/runner/pull/4391
|
* Remove AllowCaseFunction feature flag by @ericsciple in https://github.com/actions/runner/pull/4316
|
||||||
* Update setup job starting logs by @GitPaulo in https://github.com/actions/runner/pull/4383
|
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4319
|
||||||
* fix: expand commit hash regex to support SHA-256 (64-char) hashes by @yaananth in https://github.com/actions/runner/pull/4347
|
* Batch and deduplicate action resolution across composite depths by @stefanpenner in https://github.com/actions/runner/pull/4296
|
||||||
* Move dap setup to setup job step by @rentziass in https://github.com/actions/runner/pull/4403
|
* Add support for Bearer token in action archive downloads by @TingluoHuang in https://github.com/actions/runner/pull/4321
|
||||||
* Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) by @dvaldivia in https://github.com/actions/runner/pull/4394
|
* Bump brace-expansion in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4318
|
||||||
* Update dotnet sdk to latest version @8.0.421 by @github-actions[bot] in https://github.com/actions/runner/pull/4428
|
* Add devtunnel connection for debugger jobs by @rentziass in https://github.com/actions/runner/pull/4317
|
||||||
* Update Docker to v29.5.0 and Buildx to v0.34.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4425
|
* Update Docker to v29.3.1 and Buildx to v0.33.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4324
|
||||||
* Execute debugger REPL commands inside job container by @rentziass in https://github.com/actions/runner/pull/4420
|
* 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
|
||||||
* Send welcome message in debugger console on connect by @rentziass in https://github.com/actions/runner/pull/4419
|
* Bump actions/github-script from 8 to 9 by @dependabot[bot] in https://github.com/actions/runner/pull/4331
|
||||||
* Update snapshot-if context and functions by @drielenr in https://github.com/actions/runner/pull/4443
|
* 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
|
||||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4452
|
* fix: only show changed versions in node upgrade PR description by @salmanmkc in https://github.com/actions/runner/pull/4332
|
||||||
* Allow disable node v8 maglev jit compiler on node24. by @TingluoHuang in https://github.com/actions/runner/pull/4447
|
* 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
|
||||||
* Update Node 24 default date to June 16th, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4462
|
* feat: add `job.workflow_*` typed accessors to JobContext by @salmanmkc in https://github.com/actions/runner/pull/4335
|
||||||
* Populate telemetry for non-action post-job steps by @drielenr in https://github.com/actions/runner/pull/4463
|
* Add WS bridge over DAP TCP server by @rentziass in https://github.com/actions/runner/pull/4328
|
||||||
* Add SDK types and results plumbing for background step control by @lokesh755 in https://github.com/actions/runner/pull/4472
|
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4355
|
||||||
* Add job execution view model by @rentziass in https://github.com/actions/runner/pull/4470
|
* Bump Docker version to 29.4.0 by @Copilot in https://github.com/actions/runner/pull/4352
|
||||||
* Add thread-safety locks to StepsContext by @lokesh755 in https://github.com/actions/runner/pull/4475
|
* Update dotnet sdk to latest version @8.0.420 by @github-actions[bot] in https://github.com/actions/runner/pull/4356
|
||||||
* Add background step deferral infrastructure and metadata plumbing by @lokesh755 in https://github.com/actions/runner/pull/4479
|
* 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
|
||||||
* Wire job execution view into DAP by @rentziass in https://github.com/actions/runner/pull/4471
|
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4362
|
||||||
* Background steps execution engine by @lokesh755 in https://github.com/actions/runner/pull/4476
|
* Add vulnerability-alerts permission by @salmanmkc in https://github.com/actions/runner/pull/4350
|
||||||
* Update Docker to v29.5.2 and Buildx to v0.34.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4451
|
* 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
|
||||||
* BrokerServer should not retry on 401. by @TingluoHuang in https://github.com/actions/runner/pull/4445
|
* Bump System.ServiceProcess.ServiceController from 10.0.3 to 10.0.6 by @dependabot[bot] in https://github.com/actions/runner/pull/4358
|
||||||
* Add new env var to allow single-prefix multiline logs on stdout by @nuclearpidgeon in https://github.com/actions/runner/pull/4424
|
* 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.39 to 1.3.48 by @dependabot[bot] in https://github.com/actions/runner/pull/4441
|
* 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.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4369
|
|
||||||
|
|
||||||
## New Contributors
|
## New Contributors
|
||||||
* @GitPaulo made their first contribution in https://github.com/actions/runner/pull/4383
|
* @stefanpenner made their first contribution in https://github.com/actions/runner/pull/4296
|
||||||
* @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.334.0...v2.335.0
|
**Full Changelog**: https://github.com/actions/runner/compare/v2.333.1...v2.334.0
|
||||||
|
|
||||||
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
|
_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.
|
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.
|
||||||
|
|||||||
@@ -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.
|
# 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
|
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
|
||||||
NODE20_VERSION="20.20.2"
|
NODE20_VERSION="20.20.2"
|
||||||
NODE24_VERSION="24.16.0"
|
NODE24_VERSION="24.15.0"
|
||||||
|
|
||||||
get_abs_path() {
|
get_abs_path() {
|
||||||
# exploits the fact that pwd will print abs path when no args
|
# exploits the fact that pwd will print abs path when no args
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ then
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
apt_get_with_fallbacks liblttng-ust1t64 liblttng-ust1 liblttng-ust0
|
apt_get_with_fallbacks liblttng-ust1 liblttng-ust0
|
||||||
if [ $? -ne 0 ]
|
if [ $? -ne 0 ]
|
||||||
then
|
then
|
||||||
echo "'$apt_get' failed with exit code '$?'"
|
echo "'$apt_get' failed with exit code '$?'"
|
||||||
@@ -110,7 +110,7 @@ then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
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
|
apt_get_with_fallbacks libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
|
||||||
if [ $? -ne 0 ]
|
if [ $? -ne 0 ]
|
||||||
then
|
then
|
||||||
echo "'$apt_get' failed with exit code '$?'"
|
echo "'$apt_get' failed with exit code '$?'"
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ namespace GitHub.Runner.Common
|
|||||||
|
|
||||||
public bool ShouldRetryException(Exception ex)
|
public bool ShouldRetryException(Exception ex)
|
||||||
{
|
{
|
||||||
if (ex is AccessDeniedException || ex is VssUnauthorizedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException)
|
if (ex is AccessDeniedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ namespace GitHub.Runner.Common
|
|||||||
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
||||||
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
||||||
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
|
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
|
// Node version migration related constants
|
||||||
@@ -206,7 +205,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/";
|
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)
|
// Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables)
|
||||||
public static readonly string Node24DefaultDate = "June 16th, 2026";
|
public static readonly string Node24DefaultDate = "June 2nd, 2026";
|
||||||
public static readonly string Node20RemovalDate = "September 16th, 2026";
|
public static readonly string Node20RemovalDate = "September 16th, 2026";
|
||||||
|
|
||||||
// Variable keys for server-overridable dates
|
// Variable keys for server-overridable dates
|
||||||
@@ -308,7 +307,6 @@ namespace GitHub.Runner.Common
|
|||||||
public static readonly string ForcedInternalNodeVersion = "ACTIONS_RUNNER_FORCED_INTERNAL_NODE_VERSION";
|
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 ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
|
||||||
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
|
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 ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
|
||||||
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
|
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
|
||||||
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
|
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
|
||||||
|
|||||||
@@ -837,15 +837,6 @@ namespace GitHub.Runner.Common
|
|||||||
timelineRecord.Variables[variable.Key] = variable.Value.Clone();
|
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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -9,12 +9,10 @@ namespace GitHub.Runner.Common
|
|||||||
public sealed class StdoutTraceListener : ConsoleTraceListener
|
public sealed class StdoutTraceListener : ConsoleTraceListener
|
||||||
{
|
{
|
||||||
private readonly string _hostType;
|
private readonly string _hostType;
|
||||||
private readonly bool _disablePrefixMultilineLogs = false;
|
|
||||||
|
|
||||||
public StdoutTraceListener(string hostType)
|
public StdoutTraceListener(string hostType)
|
||||||
{
|
{
|
||||||
this._hostType = 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.
|
// Copied and modified slightly from .Net Core source code. Modification was required to make it compile.
|
||||||
@@ -28,20 +26,11 @@ namespace GitHub.Runner.Common
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(message))
|
if (!string.IsNullOrEmpty(message))
|
||||||
{
|
{
|
||||||
if (!this._disablePrefixMultilineLogs)
|
var messageLines = message.Split(Environment.NewLine);
|
||||||
{
|
foreach (var messageLine in messageLines)
|
||||||
var messageLines = message.Split(Environment.NewLine);
|
|
||||||
foreach (var messageLine in messageLines)
|
|
||||||
{
|
|
||||||
WriteHeader(source, eventType, id);
|
|
||||||
WriteLine(messageLine);
|
|
||||||
WriteFooter(eventCache);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
WriteHeader(source, eventType, id);
|
WriteHeader(source, eventType, id);
|
||||||
WriteLine(message);
|
WriteLine(messageLine);
|
||||||
WriteFooter(eventCache);
|
WriteFooter(eventCache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.9" />
|
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -282,15 +282,8 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.DeferredEnvironmentVariables != null)
|
context.Global.EnvironmentVariables[envName] = command.Data;
|
||||||
{
|
context.SetEnvContext(envName, command.Data);
|
||||||
context.DeferredEnvironmentVariables[envName] = command.Data;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
context.Global.EnvironmentVariables[envName] = command.Data;
|
|
||||||
context.SetEnvContext(envName, command.Data);
|
|
||||||
}
|
|
||||||
context.Debug($"{envName}='{command.Data}'");
|
context.Debug($"{envName}='{command.Data}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,15 +334,8 @@ namespace GitHub.Runner.Worker
|
|||||||
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
|
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.DeferredOutputs != null)
|
context.SetOutput(outputName, command.Data, out var reference);
|
||||||
{
|
context.Debug($"{reference}='{command.Data}'");
|
||||||
context.DeferredOutputs[outputName] = command.Data;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
context.SetOutput(outputName, command.Data, out var reference);
|
|
||||||
context.Debug($"{reference}='{command.Data}'");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class SetOutputCommandProperties
|
private static class SetOutputCommandProperties
|
||||||
@@ -479,16 +465,8 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
|
|
||||||
ArgUtil.NotNullOrEmpty(command.Data, "path");
|
ArgUtil.NotNullOrEmpty(command.Data, "path");
|
||||||
if (context.DeferredPrependPath != null)
|
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||||
{
|
context.Global.PrependPath.Add(command.Data);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GitHub.DistributedTask.WebApi;
|
|
||||||
using GitHub.Runner.Common;
|
|
||||||
using GitHub.Runner.Common.Util;
|
|
||||||
using GitHub.Runner.Sdk;
|
|
||||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker
|
|
||||||
{
|
|
||||||
[ServiceLocator(Default = typeof(BackgroundStepCoordinator))]
|
|
||||||
public interface IBackgroundStepCoordinator : IRunnerService
|
|
||||||
{
|
|
||||||
void InitializeCoordinator(int maxConcurrent);
|
|
||||||
void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken);
|
|
||||||
Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken);
|
|
||||||
Task RunControlFlowAsync(IExecutionContext stepContext, object data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Coordinates background step execution, waiting, cancellation, and deferred state.
|
|
||||||
/// Extracted from StepsRunner so the main step loop stays clean.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BackgroundStepCoordinator : RunnerService, IBackgroundStepCoordinator
|
|
||||||
{
|
|
||||||
private const int DefaultMaxBackgroundSteps = 10;
|
|
||||||
private readonly Dictionary<string, (IStep Step, Task Task, CancellationTokenSource Cts)> _backgroundSteps = new();
|
|
||||||
|
|
||||||
// IDs of background steps that have already been completed (waited on or canceled).
|
|
||||||
// Used to avoid waiting on or flushing the same step more than once.
|
|
||||||
private readonly HashSet<string> _completedStepIds = new();
|
|
||||||
|
|
||||||
// IDs of background steps that were explicitly canceled via a `cancel` control step.
|
|
||||||
// These steps are expected to be canceled, so their (Canceled) result must not be
|
|
||||||
// merged into the overall job result.
|
|
||||||
private readonly HashSet<string> _explicitlyCanceledStepIds = new();
|
|
||||||
private SemaphoreSlim _backgroundSlotSemaphore = new SemaphoreSlim(DefaultMaxBackgroundSteps);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reset per-job state. Call at the start of each job.
|
|
||||||
/// </summary>
|
|
||||||
public void InitializeCoordinator(int maxConcurrent)
|
|
||||||
{
|
|
||||||
_backgroundSteps.Clear();
|
|
||||||
_completedStepIds.Clear();
|
|
||||||
_explicitlyCanceledStepIds.Clear();
|
|
||||||
var max = maxConcurrent > 0 ? maxConcurrent : DefaultMaxBackgroundSteps;
|
|
||||||
_backgroundSlotSemaphore = new SemaphoreSlim(max);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Starting background steps
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Prepare and launch a background step. Does not block the caller.
|
|
||||||
/// </summary>
|
|
||||||
public void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken)
|
|
||||||
{
|
|
||||||
var stepId = step.ExecutionContext?.ContextName ?? step.DisplayName;
|
|
||||||
|
|
||||||
// Isolate GitHubContext so concurrent steps don't overwrite each other's GITHUB_OUTPUT paths
|
|
||||||
if (step.ExecutionContext.ExpressionValues.TryGetValue("github", out var ghCtx) && ghCtx is GitHubContext sharedGitHub)
|
|
||||||
{
|
|
||||||
step.ExecutionContext.ExpressionValues["github"] = sharedGitHub.ShallowCopy();
|
|
||||||
}
|
|
||||||
|
|
||||||
var bgCts = CancellationTokenSource.CreateLinkedTokenSource(jobCancellationToken);
|
|
||||||
|
|
||||||
// Evaluate timeout on the main thread (needs expression context)
|
|
||||||
var timeoutMinutes = 0;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator();
|
|
||||||
timeoutMinutes = templateEvaluator.EvaluateStepTimeout(step.Timeout, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Trace.Info($"Error determining timeout for background step '{stepId}': {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var task = ExecuteBackgroundStepCoreAsync(step, bgCts, stepId, timeoutMinutes);
|
|
||||||
_backgroundSteps[stepId] = (step, task, bgCts);
|
|
||||||
Trace.Info($"Background step '{stepId}' queued (slot will be acquired asynchronously).");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Safety net
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
|
|
||||||
// Drain any background steps that weren't already waited on by an explicit wait/cancel
|
|
||||||
// control step, then merge the final results of all background steps into a single result
|
|
||||||
// for the caller to fold into the job result.
|
|
||||||
public async Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var unwaitedIds = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
|
|
||||||
if (unwaitedIds.Count > 0)
|
|
||||||
{
|
|
||||||
Trace.Info($"Safety net: {unwaitedIds.Count} unwaited background step(s) at post-job boundary: {string.Join(", ", unwaitedIds)}");
|
|
||||||
await WaitForStepTasksAsync(unwaitedIds, cancellationToken);
|
|
||||||
CompleteWaitedSteps(unwaitedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = TaskResult.Succeeded;
|
|
||||||
foreach (var (stepId, (step, _, _)) in _backgroundSteps)
|
|
||||||
{
|
|
||||||
// A step that succeeded does not set a Result by default, so a missing
|
|
||||||
// value means the step succeeded and there is nothing to merge.
|
|
||||||
if (!step.ExecutionContext.Result.HasValue)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A step explicitly canceled via a `cancel` control step is expected to be canceled,
|
|
||||||
// so a Canceled result must not influence the overall job result. However, if the step
|
|
||||||
// failed (e.g. before the cancellation took effect), that failure should still count.
|
|
||||||
if (_explicitlyCanceledStepIds.Contains(stepId) &&
|
|
||||||
step.ExecutionContext.Result.Value == TaskResult.Canceled)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.Result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result != TaskResult.Succeeded)
|
|
||||||
{
|
|
||||||
Trace.Info($"Background steps reported result '{result}' to caller.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Control-flow step dispatch
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Execute a control-flow step (wait, wait-all, cancel) and propagate results.
|
|
||||||
/// </summary>
|
|
||||||
public async Task RunControlFlowAsync(IExecutionContext stepContext, object data)
|
|
||||||
{
|
|
||||||
var controlFlow = data as BackgroundStepControlFlowData;
|
|
||||||
switch (controlFlow.Type)
|
|
||||||
{
|
|
||||||
case Pipelines.BackgroundControlTypes.Wait:
|
|
||||||
{
|
|
||||||
var ids = controlFlow.StepIds ?? Array.Empty<string>();
|
|
||||||
stepContext.Output($"Waiting for background step(s) to complete: {DescribeSteps(ids)}");
|
|
||||||
await WaitForStepTasksAsync(ids, stepContext.CancellationToken);
|
|
||||||
stepContext.Result = CompleteWaitedSteps(ids);
|
|
||||||
ReportCompletedSteps(stepContext, "Finished waiting for background step(s).", ids);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case Pipelines.BackgroundControlTypes.WaitAll:
|
|
||||||
{
|
|
||||||
var remaining = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
|
|
||||||
stepContext.Output(remaining.Count > 0
|
|
||||||
? $"Waiting for all background step(s) to complete: {DescribeSteps(remaining)}"
|
|
||||||
: "No background steps remaining to wait for.");
|
|
||||||
await WaitForStepTasksAsync(remaining, stepContext.CancellationToken);
|
|
||||||
stepContext.Result = CompleteWaitedSteps(remaining);
|
|
||||||
ReportCompletedSteps(stepContext, "Finished waiting for all background step(s).", remaining);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case Pipelines.BackgroundControlTypes.Cancel:
|
|
||||||
{
|
|
||||||
var cancelIds = controlFlow.StepIds ?? Array.Empty<string>();
|
|
||||||
stepContext.Output($"Cancelling background step(s): {DescribeSteps(cancelIds)}");
|
|
||||||
await CancelStepsAsync(controlFlow.StepIds);
|
|
||||||
stepContext.Result = TaskResult.Succeeded;
|
|
||||||
ReportCompletedSteps(stepContext, "Finished cancelling background step(s).", cancelIds);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new ArgumentException($"Unknown background step control type '{controlFlow.Type}'.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Private helpers
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
|
|
||||||
// Resolve background step IDs to their display names for customer-facing output.
|
|
||||||
private string DescribeSteps(IEnumerable<string> stepIds)
|
|
||||||
{
|
|
||||||
var names = stepIds
|
|
||||||
.Select(id => _backgroundSteps.TryGetValue(id, out var entry) ? entry.Step.DisplayName : id)
|
|
||||||
.ToList();
|
|
||||||
return names.Count > 0 ? string.Join(", ", names) : "(none)";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit a completion summary plus the final result of each affected background step.
|
|
||||||
private void ReportCompletedSteps(IExecutionContext stepContext, string summary, IEnumerable<string> stepIds)
|
|
||||||
{
|
|
||||||
stepContext.Output(summary);
|
|
||||||
foreach (var id in stepIds)
|
|
||||||
{
|
|
||||||
if (_backgroundSteps.TryGetValue(id, out var entry))
|
|
||||||
{
|
|
||||||
var result = entry.Step.ExecutionContext.Result?.ToString() ?? "Unknown";
|
|
||||||
stepContext.Output($" {entry.Step.DisplayName}: {result}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExecuteBackgroundStepCoreAsync(
|
|
||||||
IStep step, CancellationTokenSource bgCts,
|
|
||||||
string stepId, int timeoutMinutes)
|
|
||||||
{
|
|
||||||
Trace.Info($"Background step '{stepId}' waiting for slot.");
|
|
||||||
await _backgroundSlotSemaphore.WaitAsync(bgCts.Token);
|
|
||||||
Trace.Info($"Background step '{stepId}' acquired slot.");
|
|
||||||
|
|
||||||
step.ExecutionContext.Start();
|
|
||||||
|
|
||||||
if (timeoutMinutes > 0)
|
|
||||||
{
|
|
||||||
step.ExecutionContext.SetTimeout(TimeSpan.FromMinutes(timeoutMinutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
using var cancelReg = bgCts.Token.Register(() =>
|
|
||||||
{
|
|
||||||
Trace.Info($"Background step '{stepId}': cancellation signalled, sending CancelToken to process.");
|
|
||||||
step.ExecutionContext.CancelToken();
|
|
||||||
});
|
|
||||||
|
|
||||||
TaskResult? result = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await step.RunAsync();
|
|
||||||
result = step.ExecutionContext.Result ?? TaskResult.Succeeded;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (bgCts.Token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
result = TaskResult.Canceled;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (step.ExecutionContext.CancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
Trace.Info($"Background step '{stepId}' timed out after {timeoutMinutes} minutes.");
|
|
||||||
step.ExecutionContext.Error($"The background step '{step.DisplayName}' has timed out after {timeoutMinutes} minutes.");
|
|
||||||
result = TaskResult.Failed;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Trace.Info($"Background step '{stepId}' failed: {ex.Message}");
|
|
||||||
step.ExecutionContext.Error(ex);
|
|
||||||
result = TaskResult.Failed;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_backgroundSlotSemaphore.Release();
|
|
||||||
|
|
||||||
if (step.ExecutionContext.CommandResult != null)
|
|
||||||
{
|
|
||||||
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.CommandResult.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
step.ExecutionContext.Result = result;
|
|
||||||
step.ExecutionContext.ApplyContinueOnError(step.ContinueOnError);
|
|
||||||
|
|
||||||
step.ExecutionContext.Complete(step.ExecutionContext.Result);
|
|
||||||
Trace.Info($"Background step '{stepId}' completed with result: {step.ExecutionContext.Result}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CancelStepsAsync(string[] cancelStepIds)
|
|
||||||
{
|
|
||||||
if (cancelStepIds == null || cancelStepIds.Length == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark these steps as expected-to-be-canceled so their result does not
|
|
||||||
// affect the overall job result.
|
|
||||||
foreach (var id in cancelStepIds)
|
|
||||||
{
|
|
||||||
_explicitlyCanceledStepIds.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var idsToCancel = cancelStepIds
|
|
||||||
.Where(id => _backgroundSteps.ContainsKey(id) && !_backgroundSteps[id].Task.IsCompleted)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (idsToCancel.Length > 0)
|
|
||||||
{
|
|
||||||
Trace.Info($"Cancelling {idsToCancel.Length} background step(s): {string.Join(", ", idsToCancel)}");
|
|
||||||
await CancelWithGracePeriodAsync(idsToCancel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush deferred state and mark canceled steps as completed.
|
|
||||||
CompleteWaitedSteps(cancelStepIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task WaitForStepTasksAsync(IEnumerable<string> stepIds, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var ids = stepIds.ToList();
|
|
||||||
var tasks = new List<Task>();
|
|
||||||
|
|
||||||
foreach (var stepId in ids)
|
|
||||||
{
|
|
||||||
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
|
|
||||||
{
|
|
||||||
tasks.Add(entry.Task);
|
|
||||||
}
|
|
||||||
else if (!_backgroundSteps.ContainsKey(stepId))
|
|
||||||
{
|
|
||||||
Trace.Info($"Wait references unknown background step: {stepId}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tasks.Count > 0)
|
|
||||||
{
|
|
||||||
Trace.Info($"Waiting for {tasks.Count} background step(s)...");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.WhenAll(tasks).WaitAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
Trace.Info("Wait interrupted by job cancellation — cancelling background steps.");
|
|
||||||
await CancelWithGracePeriodAsync(ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CancelWithGracePeriodAsync(IEnumerable<string> stepIds, double graceSeconds = 7.5)
|
|
||||||
{
|
|
||||||
var cancelledSteps = new List<(string StepId, Task Task, IStep Step)>();
|
|
||||||
foreach (var stepId in stepIds)
|
|
||||||
{
|
|
||||||
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
|
|
||||||
{
|
|
||||||
entry.Step.ExecutionContext.CancelToken();
|
|
||||||
entry.Cts.Cancel();
|
|
||||||
cancelledSteps.Add((stepId, entry.Task, entry.Step));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelledSteps.Count > 0)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.WhenAll(cancelledSteps.Select(s => s.Task)).WaitAsync(TimeSpan.FromSeconds(graceSeconds));
|
|
||||||
}
|
|
||||||
catch (TimeoutException)
|
|
||||||
{
|
|
||||||
Trace.Info($"Some background steps did not terminate within {graceSeconds}s grace period.");
|
|
||||||
|
|
||||||
// The step tasks above never completed, so their finally block never ran and
|
|
||||||
// their result was never set. Force-mark them as canceled so the abandoned
|
|
||||||
// steps still report a terminal result.
|
|
||||||
foreach (var (stepId, task, step) in cancelledSteps)
|
|
||||||
{
|
|
||||||
if (!task.IsCompleted && !step.ExecutionContext.Result.HasValue)
|
|
||||||
{
|
|
||||||
step.ExecutionContext.Result = TaskResult.Canceled;
|
|
||||||
Trace.Info($"Background step '{stepId}' did not terminate within grace period; marking as canceled.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private TaskResult CompleteWaitedSteps(IEnumerable<string> stepIds)
|
|
||||||
{
|
|
||||||
var result = TaskResult.Succeeded;
|
|
||||||
foreach (var id in stepIds)
|
|
||||||
{
|
|
||||||
_completedStepIds.Add(id);
|
|
||||||
if (_backgroundSteps.TryGetValue(id, out var entry))
|
|
||||||
{
|
|
||||||
// Flush deferred state for the completed step.
|
|
||||||
entry.Step.ExecutionContext.FlushDeferredOutputs();
|
|
||||||
entry.Step.ExecutionContext.FlushDeferredEnvironment();
|
|
||||||
entry.Step.ExecutionContext.FlushDeferredOutcomeConclusion();
|
|
||||||
Trace.Info($"Flushed deferred state for background step '{id}'.");
|
|
||||||
|
|
||||||
if (entry.Step.ExecutionContext.Result.HasValue)
|
|
||||||
{
|
|
||||||
result = TaskResultUtil.MergeTaskResults(result, entry.Step.ExecutionContext.Result.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -545,16 +545,16 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
public class SourceArguments
|
public class SourceArguments
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Source descriptor. Some clients send sourceReference only here.
|
/// Source descriptor (optional, redundant with sourceReference).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
public Source Source { get; set; }
|
public Source Source { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The reference to the source.
|
/// The reference to the source. Required by DAP spec.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)]
|
[JsonProperty("sourceReference")]
|
||||||
public int? SourceReference { get; set; }
|
public int SourceReference { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -577,6 +577,92 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region LoadedSources Request/Response
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response body for 'loadedSources' request.
|
||||||
|
/// </summary>
|
||||||
|
public class LoadedSourcesResponseBody
|
||||||
|
{
|
||||||
|
[JsonProperty("sources")]
|
||||||
|
public List<Source> Sources { get; set; } = new List<Source>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Body for 'loadedSource' event.
|
||||||
|
/// </summary>
|
||||||
|
public class LoadedSourceEventBody
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// "new" | "changed" | "removed"
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("reason")]
|
||||||
|
public string Reason { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("source")]
|
||||||
|
public Source Source { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region SetBreakpoints Request/Response
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Arguments for 'setBreakpoints' request.
|
||||||
|
/// </summary>
|
||||||
|
public class SetBreakpointsArguments
|
||||||
|
{
|
||||||
|
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public Source Source { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("breakpoints")]
|
||||||
|
public List<SourceBreakpoint> Breakpoints { get; set; } = new List<SourceBreakpoint>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Properties of a breakpoint passed to the setBreakpoints request.
|
||||||
|
/// </summary>
|
||||||
|
public class SourceBreakpoint
|
||||||
|
{
|
||||||
|
[JsonProperty("line")]
|
||||||
|
public int Line { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("condition", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string Condition { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("logMessage", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string LogMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response body for 'setBreakpoints' request.
|
||||||
|
/// </summary>
|
||||||
|
public class SetBreakpointsResponseBody
|
||||||
|
{
|
||||||
|
[JsonProperty("breakpoints")]
|
||||||
|
public List<Breakpoint> Breakpoints { get; set; } = new List<Breakpoint>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about a breakpoint created in setBreakpoints.
|
||||||
|
/// </summary>
|
||||||
|
public class Breakpoint
|
||||||
|
{
|
||||||
|
[JsonProperty("verified")]
|
||||||
|
public bool Verified { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public int? Line { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public Source Source { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Scopes Request/Response
|
#region Scopes Request/Response
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ using GitHub.DistributedTask.Pipelines.ContextData;
|
|||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
using GitHub.Runner.Worker.Container;
|
|
||||||
using GitHub.Runner.Worker.Handlers;
|
using GitHub.Runner.Worker.Handlers;
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker.Dap
|
namespace GitHub.Runner.Worker.Dap
|
||||||
@@ -44,7 +43,6 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
||||||
RunCommand command,
|
RunCommand command,
|
||||||
IExecutionContext context,
|
IExecutionContext context,
|
||||||
bool isActionStep,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (context == null)
|
if (context == null)
|
||||||
@@ -54,7 +52,7 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
|
return await ExecuteScriptAsync(command, context, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -67,17 +65,9 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
|
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
|
||||||
RunCommand command,
|
RunCommand command,
|
||||||
IExecutionContext context,
|
IExecutionContext context,
|
||||||
bool isActionStep,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. Resolve step host — container or host, same as ActionRunner.
|
// 1. Resolve shell — same logic as ScriptHandler
|
||||||
// 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 shellCommand;
|
||||||
string argFormat;
|
string argFormat;
|
||||||
|
|
||||||
@@ -97,9 +87,9 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
|
_trace.Info("Resolved REPL shell");
|
||||||
|
|
||||||
// 3. Expand ${{ }} expressions in the script body, just like
|
// 2. Expand ${{ }} expressions in the script body, just like
|
||||||
// ActionRunner evaluates step inputs before ScriptHandler sees them
|
// ActionRunner evaluates step inputs before ScriptHandler sees them
|
||||||
var contents = ExpandExpressions(command.Script, context);
|
var contents = ExpandExpressions(command.Script, context);
|
||||||
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
|
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
|
||||||
@@ -121,47 +111,25 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 4. Resolve script path — translate for container if needed
|
// 3. Format arguments with script path
|
||||||
var resolvedPath = stepHost.ResolvePathForStepHost(context, scriptFilePath).Replace("\"", "\\\"");
|
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
|
||||||
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
|
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}}'");
|
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);
|
var arguments = string.Format(argFormat, resolvedPath);
|
||||||
|
|
||||||
// 5. Resolve shell command path — for containers, use the shell
|
// 4. Resolve shell command path
|
||||||
// name directly (it will be resolved inside the container);
|
|
||||||
// for host execution, resolve the full path on the host.
|
|
||||||
string prependPath = string.Join(
|
string prependPath = string.Join(
|
||||||
Path.PathSeparator.ToString(),
|
Path.PathSeparator.ToString(),
|
||||||
Enumerable.Reverse(context.Global.PrependPath));
|
Enumerable.Reverse(context.Global.PrependPath));
|
||||||
var fileName = isContainerStepHost
|
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
|
||||||
? shellCommand
|
?? shellCommand;
|
||||||
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
|
|
||||||
|
|
||||||
// 6. Build environment — merge from execution context like a real step
|
// 5. Build environment — merge from execution context like a real step
|
||||||
var environment = BuildEnvironment(context, command.Env);
|
var environment = BuildEnvironment(context, command.Env);
|
||||||
|
|
||||||
// 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment
|
// 6. Resolve working directory
|
||||||
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;
|
var workingDirectory = command.WorkingDirectory;
|
||||||
if (string.IsNullOrEmpty(workingDirectory))
|
if (string.IsNullOrEmpty(workingDirectory))
|
||||||
{
|
{
|
||||||
@@ -173,60 +141,48 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
: null;
|
: null;
|
||||||
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
|
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
|
||||||
}
|
}
|
||||||
workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory);
|
|
||||||
|
|
||||||
_trace.Info("Executing REPL command");
|
_trace.Info("Executing REPL command");
|
||||||
|
|
||||||
// Stream execution info to debugger
|
// Stream execution info to debugger
|
||||||
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
|
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
|
||||||
|
|
||||||
// NOTE: When container hooks are enabled, ContainerStepHost routes
|
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
|
||||||
// execution through IContainerHookManager which does not raise
|
int exitCode;
|
||||||
// OutputDataReceived/ErrorDataReceived events. Output will not be
|
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
|
||||||
// streamed to the debug console in that mode.
|
|
||||||
if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables))
|
|
||||||
{
|
{
|
||||||
const string hookWarning = "Container hooks are enabled. REPL output will not be streamed to the debug console for this command.";
|
processInvoker.OutputDataReceived += (sender, args) =>
|
||||||
_trace.Warning(hookWarning);
|
{
|
||||||
SendOutput("stderr", hookWarning + "\n");
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}");
|
_trace.Info($"REPL command exited with code {exitCode}");
|
||||||
|
|
||||||
// 10. Return only the exit code summary (output was already streamed)
|
// 8. Return only the exit code summary (output was already streamed)
|
||||||
return new EvaluateResponseBody
|
return new EvaluateResponseBody
|
||||||
{
|
{
|
||||||
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
|
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
|
||||||
@@ -242,43 +198,6 @@ 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>
|
/// <summary>
|
||||||
/// Expands <c>${{ }}</c> expressions in the input string using the
|
/// Expands <c>${{ }}</c> expressions in the input string using the
|
||||||
/// runner's template evaluator — the same evaluation path that processes
|
/// 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
|
namespace GitHub.Runner.Worker.Dap
|
||||||
{
|
{
|
||||||
@@ -8,12 +8,10 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DebuggerConfig
|
public sealed class DebuggerConfig
|
||||||
{
|
{
|
||||||
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
|
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
|
||||||
{
|
{
|
||||||
Enabled = enabled;
|
Enabled = enabled;
|
||||||
Tunnel = tunnel;
|
Tunnel = tunnel;
|
||||||
OverrideWelcomeMessage = overrideWelcomeMessage;
|
|
||||||
WelcomeMessage = welcomeMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Whether the debugger is enabled for this job.</summary>
|
/// <summary>Whether the debugger is enabled for this job.</summary>
|
||||||
@@ -25,19 +23,6 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DebuggerTunnelInfo Tunnel { get; }
|
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>
|
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
|
||||||
public bool HasValidTunnel => Tunnel != null
|
public bool HasValidTunnel => Tunnel != null
|
||||||
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
|
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
|
||||||
|
|||||||
@@ -20,10 +20,25 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
{
|
{
|
||||||
Task StartAsync(IExecutionContext jobContext);
|
Task StartAsync(IExecutionContext jobContext);
|
||||||
Task WaitUntilReadyAsync();
|
Task WaitUntilReadyAsync();
|
||||||
Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps);
|
|
||||||
void OnPostStepRegistered(IStep step);
|
|
||||||
Task OnStepStartingAsync(IStep step);
|
Task OnStepStartingAsync(IStep step);
|
||||||
void OnStepCompleted(IStep step);
|
void OnStepCompleted(IStep step);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called after JobExtension.InitializeJob has returned and the initial
|
||||||
|
/// step queue + post-step stack have been populated. The debugger uses
|
||||||
|
/// these snapshots to build the synthesized job execution view served
|
||||||
|
/// via the DAP source request.
|
||||||
|
/// </summary>
|
||||||
|
Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called from ExecutionContext.RegisterPostJobStep after a post-step
|
||||||
|
/// is pushed onto the post-job stack. The debugger appends the step
|
||||||
|
/// to the running execution view so the rendered YAML reflects the
|
||||||
|
/// newly-known post-step.
|
||||||
|
/// </summary>
|
||||||
|
void OnPostStepRegistered(IStep step);
|
||||||
|
|
||||||
Task OnJobCompletedAsync();
|
Task OnJobCompletedAsync();
|
||||||
Task StopAsync();
|
Task StopAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,99 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using GitHub.Runner.Sdk;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker.Dap
|
namespace GitHub.Runner.Worker.Dap
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Stateful, append-only container that wraps <see cref="JobExecutionViewRenderer"/>
|
||||||
|
/// for runtime use. Maintains a mutable list of entries, caches the rendered YAML,
|
||||||
|
/// and provides O(1) lookup from <see cref="IStep"/> identity to the current line
|
||||||
|
/// in the rendered YAML where that step's <c>- step:</c> key appears.
|
||||||
|
///
|
||||||
|
/// Append-only growth model: post-steps are discovered lazily during execution
|
||||||
|
/// and appended. Setup/pre/main entry line numbers are stable across appends —
|
||||||
|
/// only the synthetic Cleanup boundary (which is not tracked here) shifts.
|
||||||
|
/// </summary>
|
||||||
internal sealed class JobExecutionView
|
internal sealed class JobExecutionView
|
||||||
{
|
{
|
||||||
private const string _sourceFileName = "execution.yml";
|
private readonly object _lock = new();
|
||||||
|
private readonly string _jobId;
|
||||||
|
private readonly List<JobExecutionViewEntry> _entries = new();
|
||||||
|
private readonly List<IStep> _stepIdentities = new();
|
||||||
|
private readonly Dictionary<IStep, int> _lineByStep =
|
||||||
|
new(ReferenceEqualityComparer.Instance);
|
||||||
|
// Map matchKey -> entry index for placeholders awaiting a future
|
||||||
|
// TryClaim. Removed when claimed.
|
||||||
|
private readonly Dictionary<string, int> _unclaimedByKey =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
private string _yaml;
|
||||||
|
private IReadOnlyList<int> _entryStartLines = Array.Empty<int>();
|
||||||
|
|
||||||
private readonly object _lock = new object();
|
public JobExecutionView(string jobId)
|
||||||
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;
|
if (string.IsNullOrWhiteSpace(jobId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
|
||||||
|
}
|
||||||
|
|
||||||
_preEntries.Add(new SourceEntry("Set up job"));
|
_jobId = jobId;
|
||||||
AddSteps(steps);
|
|
||||||
AddPredictedPostSteps(predictedPostSteps);
|
|
||||||
AddSteps(initialPostSteps);
|
|
||||||
_postEntries.Add(SourceEntry.CreateSyntheticCompleteJob());
|
|
||||||
Render();
|
Render();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string JobId { get; }
|
public string JobId
|
||||||
public string SourceFileName => _sourceFileName;
|
{
|
||||||
|
get { return _jobId; }
|
||||||
|
}
|
||||||
|
|
||||||
public string Content
|
/// <summary>
|
||||||
|
/// Currently rendered YAML. Always reflects all entries appended so far,
|
||||||
|
/// plus the synthetic Setup header and Cleanup footer emitted by the renderer.
|
||||||
|
/// </summary>
|
||||||
|
public string Yaml
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
return _content;
|
return _yaml;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public int CompleteJobLine
|
/// <summary>Number of entries (excludes synthetic Setup/Cleanup boundaries).</summary>
|
||||||
|
public int EntryCount
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
return _completeJobLine;
|
return _entries.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public int? TryClaimPredictedStep(string matchKey, IStep step)
|
/// <summary>
|
||||||
|
/// 1-based line where entry <paramref name="entryIndex"/>'s <c>- step:</c> key
|
||||||
|
/// currently appears in <see cref="Yaml"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int GetLine(int entryIndex)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(matchKey) || step == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var existingLine = TryGetLineForStepNoLock(step);
|
if (entryIndex < 0 || entryIndex >= _entries.Count)
|
||||||
if (existingLine.HasValue)
|
|
||||||
{
|
{
|
||||||
return existingLine;
|
throw new ArgumentOutOfRangeException(nameof(entryIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var entry in _postEntries)
|
return _entryStartLines[entryIndex];
|
||||||
{
|
|
||||||
if (!string.Equals(entry.MatchKey, matchKey, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.Step != null && !ReferenceEquals(entry.Step, step))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.Step = step;
|
|
||||||
Render();
|
|
||||||
return TryGetLineForStepNoLock(step);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 1-based line for the entry whose <see cref="IStep"/> reference identity
|
||||||
|
/// matches <paramref name="step"/>. Returns null if <paramref name="step"/>
|
||||||
|
/// is null or has not been registered.
|
||||||
|
/// </summary>
|
||||||
public int? TryGetLineForStep(IStep step)
|
public int? TryGetLineForStep(IStep step)
|
||||||
{
|
{
|
||||||
if (step == null)
|
if (step == null)
|
||||||
@@ -103,256 +103,197 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
return TryGetLineForStepNoLock(step);
|
if (_lineByStep.TryGetValue(step, out var line))
|
||||||
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int? TryGetLineForStepNoLock(IStep step)
|
/// <summary>
|
||||||
|
/// Append a new entry. If <paramref name="stepIdentity"/> is non-null,
|
||||||
|
/// registers the IStep -> line mapping for later lookup. If
|
||||||
|
/// <paramref name="matchKey"/> is non-null, the entry is registered
|
||||||
|
/// as an unclaimed placeholder that a future
|
||||||
|
/// <see cref="TryClaim(string, IStep)"/> call can bind to a real
|
||||||
|
/// IStep (used by the predictive Post-step path). Re-renders the
|
||||||
|
/// YAML and updates the start-line table.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>1-based line number of the newly-appended entry's <c>- step:</c> key.</returns>
|
||||||
|
public int Append(JobExecutionViewEntry entry, IStep stepIdentity = null, string matchKey = null)
|
||||||
{
|
{
|
||||||
foreach (var stepLine in _lineByStep)
|
if (entry == null)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(stepLine.Step, step))
|
throw new ArgumentNullException(nameof(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (stepIdentity != null && _lineByStep.ContainsKey(stepIdentity))
|
||||||
{
|
{
|
||||||
return stepLine.Line;
|
throw new InvalidOperationException("step already registered in execution view");
|
||||||
|
}
|
||||||
|
if (matchKey != null && _unclaimedByKey.ContainsKey(matchKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"matchKey already registered: {matchKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_entries.Add(entry);
|
||||||
|
_stepIdentities.Add(stepIdentity);
|
||||||
|
Render();
|
||||||
|
|
||||||
|
int index = _entries.Count - 1;
|
||||||
|
if (matchKey != null)
|
||||||
|
{
|
||||||
|
_unclaimedByKey[matchKey] = index;
|
||||||
|
}
|
||||||
|
return _entryStartLines[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bind a previously-appended placeholder entry (registered via
|
||||||
|
/// <see cref="Append(JobExecutionViewEntry, IStep, string)"/> with
|
||||||
|
/// a non-null <c>matchKey</c>) to a real <see cref="IStep"/>.
|
||||||
|
/// Returns the 1-based line of the now-claimed entry on success.
|
||||||
|
/// Returns null when no unclaimed placeholder exists for
|
||||||
|
/// <paramref name="matchKey"/>, OR when <paramref name="stepIdentity"/>
|
||||||
|
/// is already registered for a different entry (defensive).
|
||||||
|
/// Does not re-render: claim only updates the IStep -> line index.
|
||||||
|
/// </summary>
|
||||||
|
public int? TryClaim(string matchKey, IStep stepIdentity)
|
||||||
|
{
|
||||||
|
if (matchKey == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(matchKey));
|
||||||
|
}
|
||||||
|
if (stepIdentity == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(stepIdentity));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_lineByStep.ContainsKey(stepIdentity))
|
||||||
|
{
|
||||||
|
// Bail rather than double-register the step.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_unclaimedByKey.Remove(matchKey);
|
||||||
|
_stepIdentities[index] = stepIdentity;
|
||||||
|
_lineByStep[stepIdentity] = _entryStartLines[index];
|
||||||
|
return _entryStartLines[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mark a previously-appended unclaimed placeholder as skipped. Used
|
||||||
|
/// when the predicting Main step never runs (skipped by <c>if:</c>),
|
||||||
|
/// so its predicted Post-step placeholder should not appear as a
|
||||||
|
/// step that will execute. Re-renders the view (inline comment only
|
||||||
|
/// — subsequent entry line numbers stay stable).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// true if a matching unclaimed placeholder was marked; false when
|
||||||
|
/// no placeholder exists for <paramref name="matchKey"/>, or the
|
||||||
|
/// placeholder has already been claimed (claim wins).
|
||||||
|
/// </returns>
|
||||||
|
public bool TryMarkSkipped(string matchKey)
|
||||||
|
{
|
||||||
|
ArgUtil.NotNull(matchKey, nameof(matchKey));
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Defensive: only mark if it's still an unclaimed placeholder.
|
||||||
|
if (_stepIdentities[index] != null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entries[index].IsSkipped)
|
||||||
|
{
|
||||||
|
// Idempotent — already marked.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_entries[index].IsSkipped = true;
|
||||||
|
_unclaimedByKey.Remove(matchKey);
|
||||||
|
Render();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk-append for the initial population. Equivalent to calling
|
||||||
|
/// <see cref="Append"/> once per pair, but renders only once at the end.
|
||||||
|
/// State is left unchanged if any input is invalid.
|
||||||
|
/// </summary>
|
||||||
|
public void AppendRange(IEnumerable<(JobExecutionViewEntry entry, IStep stepIdentity)> items)
|
||||||
|
{
|
||||||
|
ArgUtil.NotNull(items, nameof(items));
|
||||||
|
|
||||||
|
// Materialize first so we don't enumerate twice.
|
||||||
|
var materialized = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>(items);
|
||||||
|
for (int i = 0; i < materialized.Count; i++)
|
||||||
|
{
|
||||||
|
if (materialized[i].entry == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"items[{i}].entry is null.", nameof(items));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
lock (_lock)
|
||||||
}
|
|
||||||
|
|
||||||
private void AddSteps(IEnumerable<IStep> steps)
|
|
||||||
{
|
|
||||||
if (steps == null)
|
|
||||||
{
|
{
|
||||||
return;
|
// Validate no duplicates within the input or with existing identities,
|
||||||
}
|
// before mutating state.
|
||||||
|
var seen = new HashSet<IStep>(ReferenceEqualityComparer.Instance);
|
||||||
foreach (var step in steps)
|
foreach (var (_, stepIdentity) in materialized)
|
||||||
{
|
|
||||||
if (step == null)
|
|
||||||
{
|
{
|
||||||
continue;
|
if (stepIdentity == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (_lineByStep.ContainsKey(stepIdentity) || !seen.Add(stepIdentity))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("step already registered in execution view");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GetEntries(GetSection(step)).Add(new SourceEntry(step));
|
foreach (var (entry, stepIdentity) in materialized)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddPredictedPostSteps(IEnumerable<PredictedPostStep> steps)
|
|
||||||
{
|
|
||||||
if (steps == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var step in steps)
|
|
||||||
{
|
|
||||||
if (step == null)
|
|
||||||
{
|
{
|
||||||
continue;
|
_entries.Add(entry);
|
||||||
|
_stepIdentities.Add(stepIdentity);
|
||||||
}
|
}
|
||||||
|
Render();
|
||||||
_postEntries.Add(new SourceEntry(step.DisplayName, step.MatchKey));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<SourceEntry> GetEntries(SourceSection section)
|
|
||||||
{
|
|
||||||
switch (section)
|
|
||||||
{
|
|
||||||
case SourceSection.Pre:
|
|
||||||
return _preEntries;
|
|
||||||
case SourceSection.Post:
|
|
||||||
return _postEntries;
|
|
||||||
default:
|
|
||||||
return _mainEntries;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SourceSection GetSection(IStep step)
|
|
||||||
{
|
|
||||||
if (step is IActionRunner actionRunner)
|
|
||||||
{
|
|
||||||
return GetSection(actionRunner.Stage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step.ExecutionContext != null)
|
|
||||||
{
|
|
||||||
return GetSection(step.ExecutionContext.Stage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SourceSection.Main;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SourceSection GetSection(ActionRunStage stage)
|
|
||||||
{
|
|
||||||
switch (stage)
|
|
||||||
{
|
|
||||||
case ActionRunStage.Pre:
|
|
||||||
return SourceSection.Pre;
|
|
||||||
case ActionRunStage.Post:
|
|
||||||
return SourceSection.Post;
|
|
||||||
default:
|
|
||||||
return SourceSection.Main;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caller MUST hold _lock (constructor's call is safe — no concurrent access yet).
|
||||||
private void Render()
|
private void Render()
|
||||||
{
|
{
|
||||||
|
var result = JobExecutionViewRenderer.Render(_jobId, _entries.AsReadOnly());
|
||||||
|
_yaml = result.Yaml;
|
||||||
|
_entryStartLines = result.EntryStartLines;
|
||||||
|
|
||||||
_lineByStep.Clear();
|
_lineByStep.Clear();
|
||||||
_completeJobLine = 0;
|
for (int i = 0; i < _stepIdentities.Count; i++)
|
||||||
|
|
||||||
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)
|
var step = _stepIdentities[i];
|
||||||
|
if (step != null)
|
||||||
{
|
{
|
||||||
_lineByStep.Add(new StepLine(entry.Step, line));
|
_lineByStep[step] = _entryStartLines[i];
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
391
src/Runner.Worker/Dap/JobExecutionViewRenderer.cs
Normal file
391
src/Runner.Worker/Dap/JobExecutionViewRenderer.cs
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using GitHub.Runner.Sdk;
|
||||||
|
using YamlDotNet.Core;
|
||||||
|
using YamlDotNet.Core.Events;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker.Dap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Phase a step occupies in the runner's flat execution sequence.
|
||||||
|
/// Setup and Cleanup are NOT modeled here — they are synthetic
|
||||||
|
/// boundaries hard-coded by <see cref="JobExecutionViewRenderer"/>
|
||||||
|
/// and cannot be constructed by callers.
|
||||||
|
/// </summary>
|
||||||
|
internal enum JobExecutionPhase
|
||||||
|
{
|
||||||
|
Pre,
|
||||||
|
Main,
|
||||||
|
Post,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One step in the rendered execution view. Pure data; no link to
|
||||||
|
/// any worker type. Phase 2 will translate runner step objects
|
||||||
|
/// into instances of this record.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class JobExecutionViewEntry
|
||||||
|
{
|
||||||
|
public JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase phase,
|
||||||
|
string displayName,
|
||||||
|
string uses = null,
|
||||||
|
string run = null,
|
||||||
|
string sourcePath = null,
|
||||||
|
int sourceLine = 0,
|
||||||
|
string id = null,
|
||||||
|
string @if = null,
|
||||||
|
string continueOnError = null,
|
||||||
|
string timeoutMinutes = null,
|
||||||
|
string envYaml = null,
|
||||||
|
string withYaml = null,
|
||||||
|
string shell = null,
|
||||||
|
string workingDirectory = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("displayName must not be null or whitespace.", nameof(displayName));
|
||||||
|
}
|
||||||
|
if (sourcePath != null && sourceLine < 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"sourceLine must be >= 1 when sourcePath is provided.",
|
||||||
|
nameof(sourceLine));
|
||||||
|
}
|
||||||
|
|
||||||
|
Phase = phase;
|
||||||
|
DisplayName = displayName;
|
||||||
|
Uses = uses;
|
||||||
|
Run = run;
|
||||||
|
SourcePath = sourcePath;
|
||||||
|
SourceLine = sourceLine;
|
||||||
|
Id = id;
|
||||||
|
If = @if;
|
||||||
|
ContinueOnError = continueOnError;
|
||||||
|
TimeoutMinutes = timeoutMinutes;
|
||||||
|
EnvYaml = envYaml;
|
||||||
|
WithYaml = withYaml;
|
||||||
|
Shell = shell;
|
||||||
|
WorkingDirectory = workingDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JobExecutionPhase Phase { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public string Uses { get; }
|
||||||
|
public string Run { get; }
|
||||||
|
public string SourcePath { get; }
|
||||||
|
public int SourceLine { get; }
|
||||||
|
public string Id { get; }
|
||||||
|
public string If { get; }
|
||||||
|
public string ContinueOnError { get; }
|
||||||
|
public string TimeoutMinutes { get; }
|
||||||
|
// Pre-serialized YAML fragment, already indented for embedding
|
||||||
|
// under the entry's `env:` key (6-space child indent).
|
||||||
|
public string EnvYaml { get; }
|
||||||
|
public string WithYaml { get; }
|
||||||
|
public string Shell { get; }
|
||||||
|
public string WorkingDirectory { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when the corresponding step was skipped (e.g. predicted Post
|
||||||
|
/// placeholder for a Main step that never executed because its
|
||||||
|
/// <c>if:</c> evaluated false). Rendered as an inline YAML comment
|
||||||
|
/// on the entry's <c>- step:</c> line so subsequent entry line
|
||||||
|
/// numbers stay stable.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSkipped { get; internal set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output of <see cref="JobExecutionViewRenderer.Render"/>: the YAML
|
||||||
|
/// document plus a parallel array of 1-based line numbers, one per
|
||||||
|
/// input entry, where each entry's <c>- step:</c> key appears.
|
||||||
|
/// Synthetic Setup/Cleanup boundaries are not tracked here.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly struct RenderResult
|
||||||
|
{
|
||||||
|
public RenderResult(string yaml, IReadOnlyList<int> entryStartLines)
|
||||||
|
{
|
||||||
|
Yaml = yaml;
|
||||||
|
EntryStartLines = entryStartLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Yaml { get; }
|
||||||
|
public IReadOnlyList<int> EntryStartLines { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders a job's execution-view YAML. Pure function; no I/O,
|
||||||
|
/// no logging, no static state. Output format and Setup/Cleanup
|
||||||
|
/// boundaries are fixed; callers cannot influence them.
|
||||||
|
///
|
||||||
|
/// Output is structured as phase-keyed top-level sections:
|
||||||
|
/// <c>setup:</c>, <c>pre:</c>, <c>main:</c>, <c>post:</c>, <c>cleanup:</c>.
|
||||||
|
/// <c>setup:</c> and <c>cleanup:</c> always render; <c>pre:</c>,
|
||||||
|
/// <c>main:</c>, <c>post:</c> only render when they contain at least
|
||||||
|
/// one entry.
|
||||||
|
/// </summary>
|
||||||
|
internal static class JobExecutionViewRenderer
|
||||||
|
{
|
||||||
|
public static RenderResult Render(string jobId, IReadOnlyList<JobExecutionViewEntry> entries)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(jobId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
|
||||||
|
}
|
||||||
|
ArgUtil.NotNull(entries, nameof(entries));
|
||||||
|
|
||||||
|
// Pre-validate non-null entries before any output, so partial
|
||||||
|
// state is never observed by callers.
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
if (entries[i] == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"entries[{i}] is null.", nameof(entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var startLines = new int[entries.Count];
|
||||||
|
int newlinesEmitted = 0;
|
||||||
|
|
||||||
|
// Header (3 lines).
|
||||||
|
sb.Append("# Job: ").Append(FormatScalar(jobId)).Append('\n');
|
||||||
|
sb.Append("# Runner execution plan — read-only.\n");
|
||||||
|
sb.Append('\n');
|
||||||
|
newlinesEmitted += 3;
|
||||||
|
|
||||||
|
// setup: section — always present.
|
||||||
|
sb.Append("setup:\n");
|
||||||
|
sb.Append(" - step: Setup job\n");
|
||||||
|
newlinesEmitted += 2;
|
||||||
|
|
||||||
|
// Render phase sections in fixed order. Each emits a leading
|
||||||
|
// blank line separator before its header.
|
||||||
|
EmitPhaseSection(sb, "pre", JobExecutionPhase.Pre, entries, startLines, ref newlinesEmitted);
|
||||||
|
EmitPhaseSection(sb, "main", JobExecutionPhase.Main, entries, startLines, ref newlinesEmitted);
|
||||||
|
EmitPhaseSection(sb, "post", JobExecutionPhase.Post, entries, startLines, ref newlinesEmitted);
|
||||||
|
|
||||||
|
// cleanup: section — always present, preceded by a blank line.
|
||||||
|
sb.Append('\n');
|
||||||
|
sb.Append("cleanup:\n");
|
||||||
|
sb.Append(" - step: Complete job\n");
|
||||||
|
|
||||||
|
return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EmitPhaseSection(
|
||||||
|
StringBuilder sb,
|
||||||
|
string sectionName,
|
||||||
|
JobExecutionPhase phase,
|
||||||
|
IReadOnlyList<JobExecutionViewEntry> entries,
|
||||||
|
int[] startLines,
|
||||||
|
ref int newlinesEmitted)
|
||||||
|
{
|
||||||
|
// Skip the section entirely if no entries belong to this phase.
|
||||||
|
bool any = false;
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
if (entries[i].Phase == phase) { any = true; break; }
|
||||||
|
}
|
||||||
|
if (!any)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank line separator + section header.
|
||||||
|
sb.Append('\n');
|
||||||
|
sb.Append(sectionName).Append(":\n");
|
||||||
|
newlinesEmitted += 2;
|
||||||
|
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = entries[i];
|
||||||
|
if (entry.Phase != phase)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1-based line of the `- step:` key for this entry.
|
||||||
|
startLines[i] = newlinesEmitted + 1;
|
||||||
|
|
||||||
|
sb.Append(" - step: ").Append(FormatScalar(entry.DisplayName));
|
||||||
|
if (entry.IsSkipped)
|
||||||
|
{
|
||||||
|
// Inline comment — keeps following entry line numbers stable.
|
||||||
|
sb.Append(" # (skipped — main step did not execute)");
|
||||||
|
}
|
||||||
|
sb.Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
|
||||||
|
switch (phase)
|
||||||
|
{
|
||||||
|
case JobExecutionPhase.Pre:
|
||||||
|
case JobExecutionPhase.Post:
|
||||||
|
if (!string.IsNullOrEmpty(entry.Uses))
|
||||||
|
{
|
||||||
|
sb.Append(" action: ").Append(FormatScalar(entry.Uses)).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
// No source: annotation for pre/post.
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JobExecutionPhase.Main:
|
||||||
|
if (!string.IsNullOrEmpty(entry.Id))
|
||||||
|
{
|
||||||
|
sb.Append(" id: ").Append(FormatScalar(entry.Id)).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.Uses))
|
||||||
|
{
|
||||||
|
sb.Append(" uses: ").Append(FormatScalar(entry.Uses)).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.Run))
|
||||||
|
{
|
||||||
|
if (entry.Run.IndexOf('\n') < 0)
|
||||||
|
{
|
||||||
|
sb.Append(" run: ").Append(FormatScalar(entry.Run)).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(" run: |\n");
|
||||||
|
newlinesEmitted++;
|
||||||
|
newlinesEmitted += AppendIndentedBlock(sb, entry.Run, " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.If))
|
||||||
|
{
|
||||||
|
sb.Append(" if: ").Append(FormatScalar(entry.If)).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.ContinueOnError))
|
||||||
|
{
|
||||||
|
sb.Append(" continue-on-error: ").Append(entry.ContinueOnError).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.TimeoutMinutes))
|
||||||
|
{
|
||||||
|
sb.Append(" timeout-minutes: ").Append(entry.TimeoutMinutes).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.EnvYaml))
|
||||||
|
{
|
||||||
|
sb.Append(" env:\n");
|
||||||
|
newlinesEmitted++;
|
||||||
|
sb.Append(entry.EnvYaml).Append('\n');
|
||||||
|
newlinesEmitted += CountChar(entry.EnvYaml, '\n') + 1;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.WithYaml))
|
||||||
|
{
|
||||||
|
sb.Append(" with:\n");
|
||||||
|
newlinesEmitted++;
|
||||||
|
sb.Append(entry.WithYaml).Append('\n');
|
||||||
|
newlinesEmitted += CountChar(entry.WithYaml, '\n') + 1;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.Shell))
|
||||||
|
{
|
||||||
|
sb.Append(" shell: ").Append(FormatScalar(entry.Shell)).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(entry.WorkingDirectory))
|
||||||
|
{
|
||||||
|
sb.Append(" working-directory: ").Append(FormatScalar(entry.WorkingDirectory)).Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
if (entry.SourcePath != null)
|
||||||
|
{
|
||||||
|
sb.Append(" source: ")
|
||||||
|
.Append(entry.SourcePath)
|
||||||
|
.Append(':')
|
||||||
|
.Append(entry.SourceLine.ToString(CultureInfo.InvariantCulture))
|
||||||
|
.Append('\n');
|
||||||
|
newlinesEmitted++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AppendIndentedBlock(StringBuilder sb, string text, string indent)
|
||||||
|
{
|
||||||
|
int newlines = 0;
|
||||||
|
int i = 0;
|
||||||
|
while (i < text.Length)
|
||||||
|
{
|
||||||
|
int end = text.IndexOf('\n', i);
|
||||||
|
int lineEnd = end < 0 ? text.Length : end;
|
||||||
|
int trimEnd = lineEnd;
|
||||||
|
if (trimEnd > i && text[trimEnd - 1] == '\r')
|
||||||
|
{
|
||||||
|
trimEnd--;
|
||||||
|
}
|
||||||
|
if (trimEnd > i)
|
||||||
|
{
|
||||||
|
sb.Append(indent);
|
||||||
|
sb.Append(text, i, trimEnd - i);
|
||||||
|
}
|
||||||
|
sb.Append('\n');
|
||||||
|
newlines++;
|
||||||
|
if (end < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = end + 1;
|
||||||
|
}
|
||||||
|
return newlines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountChar(string s, char c)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < s.Length; i++)
|
||||||
|
{
|
||||||
|
if (s[i] == c) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a single string as a YAML 1.x flow scalar, delegating
|
||||||
|
/// quoting/escaping decisions to YamlDotNet. This avoids maintaining
|
||||||
|
/// our own escape table for every YAML-significant character: we
|
||||||
|
/// just emit the value through the YAML library and use whichever
|
||||||
|
/// scalar style (plain, single-quoted, double-quoted) it picks.
|
||||||
|
/// A new <see cref="Emitter"/> is created per call, so the helper
|
||||||
|
/// is safe to invoke concurrently.
|
||||||
|
/// </summary>
|
||||||
|
internal static string FormatScalar(string value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||||
|
var emitter = new Emitter(sw);
|
||||||
|
emitter.Emit(new StreamStart());
|
||||||
|
emitter.Emit(new DocumentStart(null, null, true));
|
||||||
|
emitter.Emit(new Scalar(null, null, value, ScalarStyle.Any, true, true));
|
||||||
|
emitter.Emit(new DocumentEnd(true));
|
||||||
|
emitter.Emit(new StreamEnd());
|
||||||
|
|
||||||
|
string raw = sw.ToString();
|
||||||
|
if (raw.StartsWith("--- ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
raw = raw.Substring(4);
|
||||||
|
}
|
||||||
|
raw = raw.TrimEnd('\n');
|
||||||
|
const string DocEndMarker = "\n...";
|
||||||
|
if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
|
||||||
|
}
|
||||||
|
return raw.TrimEnd('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/Runner.Worker/Dap/StepEntryTranslator.cs
Normal file
238
src/Runner.Worker/Dap/StepEntryTranslator.cs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||||
|
using GitHub.DistributedTask.Pipelines;
|
||||||
|
using GitHub.Runner.Sdk;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker.Dap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Translates runner <see cref="IStep"/> instances into pure-data
|
||||||
|
/// <see cref="JobExecutionViewEntry"/> records used by the DAP debugger
|
||||||
|
/// execution view. Filters out runner-internal steps (e.g.
|
||||||
|
/// <see cref="JobExtensionRunner"/>) so the rendered view only shows
|
||||||
|
/// user-visible workflow steps.
|
||||||
|
/// </summary>
|
||||||
|
internal static class StepEntryTranslator
|
||||||
|
{
|
||||||
|
// Run-step internals carried on ActionStep.Inputs that are NOT
|
||||||
|
// user-authored `with:` entries.
|
||||||
|
private static readonly HashSet<string> RunStepInternalKeys = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"script",
|
||||||
|
"shell",
|
||||||
|
"working-directory",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translate an IStep into a JobExecutionViewEntry.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="step">The IStep to translate. Must not be null.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A JobExecutionViewEntry, or null if the step is not user-visible
|
||||||
|
/// (JobExtensionRunner and any other non-IActionRunner IStep impls).
|
||||||
|
/// </returns>
|
||||||
|
public static JobExecutionViewEntry TryTranslate(IStep step)
|
||||||
|
{
|
||||||
|
ArgUtil.NotNull(step, nameof(step));
|
||||||
|
|
||||||
|
if (step is JobExtensionRunner)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step is not IActionRunner actionRunner)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var phase = actionRunner.Stage switch
|
||||||
|
{
|
||||||
|
ActionRunStage.Pre => JobExecutionPhase.Pre,
|
||||||
|
ActionRunStage.Post => JobExecutionPhase.Post,
|
||||||
|
_ => JobExecutionPhase.Main,
|
||||||
|
};
|
||||||
|
|
||||||
|
string displayName = actionRunner.DisplayName;
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName))
|
||||||
|
{
|
||||||
|
displayName = "run";
|
||||||
|
}
|
||||||
|
|
||||||
|
string uses = null;
|
||||||
|
string run = null;
|
||||||
|
string id = null;
|
||||||
|
string ifCond = null;
|
||||||
|
string continueOnError = null;
|
||||||
|
string timeoutMinutes = null;
|
||||||
|
string envYaml = null;
|
||||||
|
string withYaml = null;
|
||||||
|
string shell = null;
|
||||||
|
string workingDirectory = null;
|
||||||
|
|
||||||
|
var action = actionRunner.Action;
|
||||||
|
var reference = action?.Reference;
|
||||||
|
bool isScript = reference?.Type == ActionSourceType.Script;
|
||||||
|
|
||||||
|
if (reference != null && !isScript)
|
||||||
|
{
|
||||||
|
uses = FormatActionReference(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the user-visible Main entry surfaces authored params.
|
||||||
|
// Pre/Post stay minimal (step + action) — they reference the
|
||||||
|
// same Action as the Main entry, and duplicating params adds
|
||||||
|
// noise without information.
|
||||||
|
if (phase == JobExecutionPhase.Main && action != null)
|
||||||
|
{
|
||||||
|
id = FilterAuthoredId(action.ContextName);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(action.Condition))
|
||||||
|
{
|
||||||
|
ifCond = action.Condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.ContinueOnError != null)
|
||||||
|
{
|
||||||
|
continueOnError = TemplateTokenYamlAdapter.Serialize(action.ContinueOnError, indentSpaces: 0);
|
||||||
|
}
|
||||||
|
if (action.TimeoutInMinutes != null)
|
||||||
|
{
|
||||||
|
timeoutMinutes = TemplateTokenYamlAdapter.Serialize(action.TimeoutInMinutes, indentSpaces: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.Environment is MappingToken envMap && envMap.Count > 0)
|
||||||
|
{
|
||||||
|
envYaml = TemplateTokenYamlAdapter.Serialize(envMap, indentSpaces: 6);
|
||||||
|
}
|
||||||
|
else if (action.Environment != null && !(action.Environment is MappingToken))
|
||||||
|
{
|
||||||
|
// Unusual but possible: env: ${{ ... }} expression form.
|
||||||
|
envYaml = TemplateTokenYamlAdapter.Serialize(action.Environment, indentSpaces: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isScript)
|
||||||
|
{
|
||||||
|
var inputs = action.Inputs as MappingToken;
|
||||||
|
if (inputs != null)
|
||||||
|
{
|
||||||
|
if (TryGetMapValue(inputs, "script", out var scriptTok) && scriptTok != null)
|
||||||
|
{
|
||||||
|
run = scriptTok.ToString();
|
||||||
|
}
|
||||||
|
if (TryGetMapValue(inputs, "shell", out var shellTok) && shellTok != null)
|
||||||
|
{
|
||||||
|
string shellText = shellTok.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(shellText))
|
||||||
|
{
|
||||||
|
shell = shellText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (TryGetMapValue(inputs, "working-directory", out var wdTok) && wdTok != null)
|
||||||
|
{
|
||||||
|
string wdText = wdTok.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(wdText))
|
||||||
|
{
|
||||||
|
workingDirectory = wdText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Action step: surface `with:` entries, filtering any
|
||||||
|
// run-step internal keys defensively.
|
||||||
|
if (action.Inputs is MappingToken withMap && withMap.Count > 0)
|
||||||
|
{
|
||||||
|
var filtered = FilterMapping(withMap, RunStepInternalKeys);
|
||||||
|
if (filtered != null && filtered.Count > 0)
|
||||||
|
{
|
||||||
|
withYaml = TemplateTokenYamlAdapter.Serialize(filtered, indentSpaces: 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source annotation (SourcePath/SourceLine) requires a public
|
||||||
|
// seam onto TemplateToken position info — not wired yet.
|
||||||
|
return new JobExecutionViewEntry(
|
||||||
|
phase: phase,
|
||||||
|
displayName: displayName,
|
||||||
|
uses: uses,
|
||||||
|
run: run,
|
||||||
|
sourcePath: null,
|
||||||
|
sourceLine: 0,
|
||||||
|
id: id,
|
||||||
|
@if: ifCond,
|
||||||
|
continueOnError: continueOnError,
|
||||||
|
timeoutMinutes: timeoutMinutes,
|
||||||
|
envYaml: envYaml,
|
||||||
|
withYaml: withYaml,
|
||||||
|
shell: shell,
|
||||||
|
workingDirectory: workingDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-generated step IDs are noise in the view: filter them out.
|
||||||
|
/// The runner's convention (see ExecutionContext) is that auto-
|
||||||
|
/// generated context names start with <c>__</c>. Only user-authored
|
||||||
|
/// IDs survive the filter.
|
||||||
|
/// </summary>
|
||||||
|
internal static string FilterAuthoredId(string contextName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(contextName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (contextName.StartsWith("__", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return contextName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetMapValue(MappingToken map, string key, out TemplateToken value)
|
||||||
|
{
|
||||||
|
foreach (var pair in map)
|
||||||
|
{
|
||||||
|
if (pair.Key is StringToken s && string.Equals(s.Value, key, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
value = pair.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MappingToken FilterMapping(MappingToken source, HashSet<string> excludeKeys)
|
||||||
|
{
|
||||||
|
var copy = new MappingToken(source.FileId, source.Line, source.Column);
|
||||||
|
foreach (var pair in source)
|
||||||
|
{
|
||||||
|
if (pair.Key is StringToken sk && excludeKeys.Contains(sk.Value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
copy.Add(pair);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatActionReference(ActionStepDefinitionReference reference)
|
||||||
|
{
|
||||||
|
switch (reference)
|
||||||
|
{
|
||||||
|
case RepositoryPathReference repo:
|
||||||
|
var path = string.IsNullOrEmpty(repo.Path) ? string.Empty : $"/{repo.Path}";
|
||||||
|
return string.IsNullOrEmpty(repo.Ref)
|
||||||
|
? $"{repo.Name}{path}"
|
||||||
|
: $"{repo.Name}{path}@{repo.Ref}";
|
||||||
|
case ContainerRegistryReference container:
|
||||||
|
return container.Image;
|
||||||
|
default:
|
||||||
|
return reference.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
Normal file
148
src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using GitHub.DistributedTask.ObjectTemplating;
|
||||||
|
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||||
|
using GitHub.Runner.Sdk;
|
||||||
|
using YamlDotNet.Core;
|
||||||
|
using YamlDotNet.Core.Events;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker.Dap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adapts a YamlDotNet <see cref="IEmitter"/> as a DT
|
||||||
|
/// <see cref="IObjectWriter"/> so a <see cref="TemplateToken"/> DOM
|
||||||
|
/// can be serialized back to YAML preserving its pre-evaluation form
|
||||||
|
/// (basic <c>${{ }}</c> expressions are written through verbatim).
|
||||||
|
///
|
||||||
|
/// Used by the DAP execution view to surface user-authored step
|
||||||
|
/// parameters (<c>env:</c>, <c>with:</c>, <c>run:</c>, ...) without
|
||||||
|
/// any expression substitution.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class TemplateTokenYamlAdapter : IObjectWriter
|
||||||
|
{
|
||||||
|
private readonly IEmitter _emitter;
|
||||||
|
|
||||||
|
public TemplateTokenYamlAdapter(IEmitter emitter)
|
||||||
|
{
|
||||||
|
ArgUtil.NotNull(emitter, nameof(emitter));
|
||||||
|
_emitter = emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteStart()
|
||||||
|
{
|
||||||
|
_emitter.Emit(new StreamStart());
|
||||||
|
_emitter.Emit(new DocumentStart(null, null, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteEnd()
|
||||||
|
{
|
||||||
|
_emitter.Emit(new DocumentEnd(true));
|
||||||
|
_emitter.Emit(new StreamEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteNull() =>
|
||||||
|
_emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false));
|
||||||
|
|
||||||
|
public void WriteBoolean(bool value) =>
|
||||||
|
_emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false));
|
||||||
|
|
||||||
|
public void WriteNumber(double value) =>
|
||||||
|
_emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false));
|
||||||
|
|
||||||
|
public void WriteString(string value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
WriteNull();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Multi-line strings render as block literal so embedded
|
||||||
|
// newlines survive the YAML round trip.
|
||||||
|
var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any;
|
||||||
|
_emitter.Emit(new Scalar(null, null, value, style, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteSequenceStart() =>
|
||||||
|
_emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any));
|
||||||
|
|
||||||
|
public void WriteSequenceEnd() =>
|
||||||
|
_emitter.Emit(new SequenceEnd());
|
||||||
|
|
||||||
|
public void WriteMappingStart() =>
|
||||||
|
_emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any));
|
||||||
|
|
||||||
|
public void WriteMappingEnd() =>
|
||||||
|
_emitter.Emit(new MappingEnd());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialize a TemplateToken to a YAML fragment ready to embed
|
||||||
|
/// under a parent key. Each non-empty line is prefixed by
|
||||||
|
/// <paramref name="indentSpaces"/> spaces. Trailing newlines and
|
||||||
|
/// the YAML stream start/document markers are stripped, so the
|
||||||
|
/// caller controls line breaks.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Empty mappings render as <c>{}</c> and empty sequences as
|
||||||
|
/// <c>[]</c> via YamlDotNet's flow style fallback for empty
|
||||||
|
/// collections.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string Serialize(TemplateToken token, int indentSpaces)
|
||||||
|
{
|
||||||
|
if (indentSpaces < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(indentSpaces));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||||
|
var emitter = new Emitter(sw);
|
||||||
|
var adapter = new TemplateTokenYamlAdapter(emitter);
|
||||||
|
TemplateWriter.Write(adapter, token);
|
||||||
|
|
||||||
|
string raw = sw.ToString();
|
||||||
|
// Strip YAML document markers ("--- " prefix and "\n..." suffix).
|
||||||
|
if (raw.StartsWith("--- ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
raw = raw.Substring(4);
|
||||||
|
}
|
||||||
|
const string DocEndMarker = "\n...";
|
||||||
|
if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1);
|
||||||
|
}
|
||||||
|
else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
|
||||||
|
}
|
||||||
|
raw = raw.TrimEnd('\n');
|
||||||
|
|
||||||
|
if (indentSpaces == 0)
|
||||||
|
{
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-indent every non-empty line. Empty lines remain empty
|
||||||
|
// so YAML block-literal blank lines stay valid.
|
||||||
|
var pad = new string(' ', indentSpaces);
|
||||||
|
var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4);
|
||||||
|
int i = 0;
|
||||||
|
while (i < raw.Length)
|
||||||
|
{
|
||||||
|
int end = raw.IndexOf('\n', i);
|
||||||
|
int lineEnd = end < 0 ? raw.Length : end;
|
||||||
|
if (lineEnd > i)
|
||||||
|
{
|
||||||
|
sb.Append(pad);
|
||||||
|
sb.Append(raw, i, lineEnd - i);
|
||||||
|
}
|
||||||
|
if (end < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sb.Append('\n');
|
||||||
|
i = end + 1;
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,23 +77,14 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
List<string> StepEnvironmentOverrides { get; }
|
List<string> StepEnvironmentOverrides { get; }
|
||||||
|
|
||||||
bool IsBackground { get; }
|
|
||||||
|
|
||||||
IExecutionContext Root { get; }
|
IExecutionContext Root { get; }
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
|
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
|
||||||
void CancelToken();
|
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, bool isBackground = false, string backgroundControlType = null, string[] backgroundControlStepIds = null, string parallelGroupId = 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);
|
||||||
IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary<string, string> intraActionState = null, string siblingScopeName = 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
|
// logging
|
||||||
long Write(string tag, string message);
|
long Write(string tag, string message);
|
||||||
void QueueAttachFile(string type, string name, string filePath);
|
void QueueAttachFile(string type, string name, string filePath);
|
||||||
@@ -109,12 +100,6 @@ namespace GitHub.Runner.Worker
|
|||||||
void SetGitHubContext(string name, string value);
|
void SetGitHubContext(string name, string value);
|
||||||
void SetOutput(string name, string value, out string reference);
|
void SetOutput(string name, string value, out string reference);
|
||||||
void SetTimeout(TimeSpan? timeout);
|
void SetTimeout(TimeSpan? timeout);
|
||||||
|
|
||||||
// Background step deferral flush methods
|
|
||||||
void FlushDeferredOutputs();
|
|
||||||
void FlushDeferredEnvironment();
|
|
||||||
void FlushDeferredOutcomeConclusion();
|
|
||||||
|
|
||||||
void AddIssue(Issue issue, ExecutionContextLogOptions logOptions);
|
void AddIssue(Issue issue, ExecutionContextLogOptions logOptions);
|
||||||
void Progress(int percentage, string currentOperation = null);
|
void Progress(int percentage, string currentOperation = null);
|
||||||
void UpdateDetailTimelineRecord(TimelineRecord record);
|
void UpdateDetailTimelineRecord(TimelineRecord record);
|
||||||
@@ -231,9 +216,6 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
public bool EchoOnActionCommand { get; set; }
|
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
|
// An embedded execution context shares the same record ID, record name, and logger
|
||||||
// as its enclosing execution context.
|
// as its enclosing execution context.
|
||||||
public bool IsEmbedded { get; private init; }
|
public bool IsEmbedded { get; private init; }
|
||||||
@@ -297,12 +279,6 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
public List<string> StepEnvironmentOverrides { get; } = new List<string>();
|
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)
|
public override void Initialize(IHostContext hostContext)
|
||||||
{
|
{
|
||||||
base.Initialize(hostContext);
|
base.Initialize(hostContext);
|
||||||
@@ -361,24 +337,14 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
|
|
||||||
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
|
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);
|
Root.PostJobSteps.Push(step);
|
||||||
|
// Only consult the DAP debugger when it was actually enabled for this job.
|
||||||
if (Root.Global.Debugger?.Enabled == true)
|
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
|
||||||
|
// instantiate the default singleton for every non-debug job, violating the
|
||||||
|
// "no debugger, no risk" containment property.
|
||||||
|
if (Global.Debugger?.Enabled == true)
|
||||||
{
|
{
|
||||||
try
|
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
|
||||||
{
|
|
||||||
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Trace.Warning("Failed to notify DAP debugger about registered post job step.");
|
|
||||||
Trace.Error(ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,11 +363,7 @@ namespace GitHub.Runner.Worker
|
|||||||
CancellationTokenSource cancellationTokenSource = null,
|
CancellationTokenSource cancellationTokenSource = null,
|
||||||
Guid embeddedId = default(Guid),
|
Guid embeddedId = default(Guid),
|
||||||
string siblingScopeName = null,
|
string siblingScopeName = null,
|
||||||
TimeSpan? timeout = null,
|
TimeSpan? timeout = null)
|
||||||
bool isBackground = false,
|
|
||||||
string backgroundControlType = null,
|
|
||||||
string[] backgroundControlStepIds = null,
|
|
||||||
string parallelGroupId = null)
|
|
||||||
{
|
{
|
||||||
Trace.Entering();
|
Trace.Entering();
|
||||||
|
|
||||||
@@ -442,24 +404,6 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
child.EchoOnActionCommand = EchoOnActionCommand;
|
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)
|
if (recordOrder != null)
|
||||||
{
|
{
|
||||||
child.InitializeTimelineRecord(_mainTimelineId, recordId, _record.Id, ExecutionContextType.Task, displayName, refName, recordOrder, embedded: isEmbedded);
|
child.InitializeTimelineRecord(_mainTimelineId, recordId, _record.Id, ExecutionContextType.Task, displayName, refName, recordOrder, embedded: isEmbedded);
|
||||||
@@ -572,11 +516,7 @@ namespace GitHub.Runner.Worker
|
|||||||
Type = StepTelemetry?.Type,
|
Type = StepTelemetry?.Type,
|
||||||
StartedAt = _record.StartTime,
|
StartedAt = _record.StartTime,
|
||||||
CompletedAt = _record.FinishTime,
|
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 =>
|
_record.Issues?.ForEach(issue =>
|
||||||
@@ -622,22 +562,11 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
_logger.End();
|
_logger.End();
|
||||||
|
|
||||||
if (!DeferOutcomeConclusion)
|
UpdateGlobalStepsContext();
|
||||||
{
|
|
||||||
UpdateGlobalStepsContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Value;
|
return Result.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void FlushDeferredOutcomeConclusion()
|
|
||||||
{
|
|
||||||
if (DeferOutcomeConclusion)
|
|
||||||
{
|
|
||||||
UpdateGlobalStepsContext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void 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.
|
// Skip if generated context name. Generated context names start with "__". After 3.2 the server will never send an empty context name.
|
||||||
@@ -713,40 +642,6 @@ namespace GitHub.Runner.Worker
|
|||||||
Global.StepsContext.SetOutput(ScopeName, ContextName, name, value, out reference);
|
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)
|
public void SetTimeout(TimeSpan? timeout)
|
||||||
{
|
{
|
||||||
if (timeout != null)
|
if (timeout != null)
|
||||||
@@ -1083,8 +978,7 @@ namespace GitHub.Runner.Worker
|
|||||||
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
|
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
|
||||||
|
|
||||||
// Debugger enabled flag (from acquire response).
|
// Debugger enabled flag (from acquire response).
|
||||||
var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false;
|
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
|
||||||
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage);
|
|
||||||
|
|
||||||
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
|
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
|
||||||
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
|
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
|
||||||
@@ -1443,10 +1337,7 @@ namespace GitHub.Runner.Worker
|
|||||||
Trace.Info($"Updated step result (continue on error)");
|
Trace.Info($"Updated step result (continue on error)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!DeferOutcomeConclusion)
|
UpdateGlobalStepsContext();
|
||||||
{
|
|
||||||
UpdateGlobalStepsContext();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
|
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||||
|
|||||||
@@ -122,16 +122,8 @@ namespace GitHub.Runner.Worker
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (context.DeferredPrependPath != null)
|
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||||
{
|
context.Global.PrependPath.Add(line);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,15 +172,8 @@ namespace GitHub.Runner.Worker
|
|||||||
string name,
|
string name,
|
||||||
string value)
|
string value)
|
||||||
{
|
{
|
||||||
if (context.DeferredEnvironmentVariables != null)
|
context.Global.EnvironmentVariables[name] = value;
|
||||||
{
|
context.SetEnvContext(name, value);
|
||||||
context.DeferredEnvironmentVariables[name] = value;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
context.Global.EnvironmentVariables[name] = value;
|
|
||||||
context.SetEnvContext(name, value);
|
|
||||||
}
|
|
||||||
context.Debug($"{name}='{value}'");
|
context.Debug($"{name}='{value}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,14 +302,7 @@ namespace GitHub.Runner.Worker
|
|||||||
var pairs = new EnvFileKeyValuePairs(context, filePath);
|
var pairs = new EnvFileKeyValuePairs(context, filePath);
|
||||||
foreach (var pair in pairs)
|
foreach (var pair in pairs)
|
||||||
{
|
{
|
||||||
if (context.DeferredOutputs != null)
|
context.SetOutput(pair.Key, pair.Value, out var reference);
|
||||||
{
|
|
||||||
context.DeferredOutputs[pair.Key] = pair.Value;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
context.SetOutput(pair.Key, pair.Value, out var reference);
|
|
||||||
}
|
|
||||||
context.Debug($"Set output {pair.Key} = {pair.Value}");
|
context.Debug($"Set output {pair.Key} = {pair.Value}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ using GitHub.Runner.Common.Util;
|
|||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
using GitHub.Runner.Worker.Container;
|
using GitHub.Runner.Worker.Container;
|
||||||
using GitHub.Runner.Worker.Container.ContainerHooks;
|
using GitHub.Runner.Worker.Container.ContainerHooks;
|
||||||
using GitHub.Services.Common;
|
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker.Handlers
|
namespace GitHub.Runner.Worker.Handlers
|
||||||
{
|
{
|
||||||
@@ -129,15 +128,6 @@ namespace GitHub.Runner.Worker.Handlers
|
|||||||
// file name character on Linux.
|
// file name character on Linux.
|
||||||
string arguments = StepHost.ResolvePathForStepHost(ExecutionContext, StringUtil.Format(@"""{0}""", target.Replace(@"""", @"\""")));
|
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
|
#if OS_WINDOWS
|
||||||
// It appears that node.exe outputs UTF8 when not in TTY mode.
|
// It appears that node.exe outputs UTF8 when not in TTY mode.
|
||||||
Encoding outputEncoding = Encoding.UTF8;
|
Encoding outputEncoding = Encoding.UTF8;
|
||||||
|
|||||||
@@ -345,38 +345,6 @@ namespace GitHub.Runner.Worker
|
|||||||
preJobSteps.Add(preStep);
|
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))
|
if (message.Variables.TryGetValue("system.workflowFileFullPath", out VariableValue workflowFileFullPath))
|
||||||
@@ -432,107 +400,13 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create execution context for job steps
|
// 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)
|
foreach (var step in jobSteps)
|
||||||
{
|
{
|
||||||
if (step is IActionRunner actionStep)
|
if (step is IActionRunner actionStep)
|
||||||
{
|
{
|
||||||
ArgUtil.NotNull(actionStep, step.DisplayName);
|
ArgUtil.NotNull(actionStep, step.DisplayName);
|
||||||
intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState);
|
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,7 +13,6 @@ using GitHub.DistributedTask.WebApi;
|
|||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
using GitHub.Runner.Worker.Dap;
|
|
||||||
using GitHub.Services.Common;
|
using GitHub.Services.Common;
|
||||||
using GitHub.Services.WebApi;
|
using GitHub.Services.WebApi;
|
||||||
using Sdk.RSWebApi.Contracts;
|
using Sdk.RSWebApi.Contracts;
|
||||||
@@ -233,8 +232,20 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
if (jobContext.Global.Debugger?.Enabled == true)
|
if (jobContext.Global.Debugger?.Enabled == true)
|
||||||
{
|
{
|
||||||
var dapDebugger = HostContext.GetService<IDapDebugger>();
|
// Only consult the DAP debugger when it was actually enabled for this job.
|
||||||
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
|
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
|
||||||
|
// instantiate the default singleton for every non-debug job, violating the
|
||||||
|
// "no debugger, no risk" containment property.
|
||||||
|
var dapDebugger = HostContext.GetService<Dap.IDapDebugger>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning("DAP OnJobStepsInitialized error; continuing without DAP view.");
|
||||||
|
Trace.Error(ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await stepsRunner.RunAsync(jobContext);
|
await stepsRunner.RunAsync(jobContext);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
||||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.48" />
|
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ namespace GitHub.Runner.Worker
|
|||||||
{
|
{
|
||||||
private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
|
private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
|
||||||
private readonly DictionaryContextData _contextData = new();
|
private readonly DictionaryContextData _contextData = new();
|
||||||
private readonly object _lock = new();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears memory for a composite action's isolated "steps" context, after the action
|
/// Clears memory for a composite action's isolated "steps" context, after the action
|
||||||
@@ -26,12 +25,9 @@ namespace GitHub.Runner.Worker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void ClearScope(string scopeName)
|
public void ClearScope(string scopeName)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
if (_contextData.TryGetValue(scopeName, out _))
|
||||||
{
|
{
|
||||||
if (_contextData.TryGetValue(scopeName, out _))
|
_contextData[scopeName] = new DictionaryContextData();
|
||||||
{
|
|
||||||
_contextData[scopeName] = new DictionaryContextData();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,26 +41,23 @@ namespace GitHub.Runner.Worker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DictionaryContextData GetScope(string scopeName)
|
public DictionaryContextData GetScope(string scopeName)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
if (scopeName == null)
|
||||||
{
|
{
|
||||||
if (scopeName == null)
|
scopeName = string.Empty;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetOutput(
|
public void SetOutput(
|
||||||
@@ -74,19 +67,16 @@ namespace GitHub.Runner.Worker
|
|||||||
string value,
|
string value,
|
||||||
out string reference)
|
out string reference)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
var step = GetStep(scopeName, stepName);
|
||||||
|
var outputs = step["outputs"].AssertDictionary("outputs");
|
||||||
|
outputs[outputName] = new StringContextData(value);
|
||||||
|
if (_propertyRegex.IsMatch(outputName))
|
||||||
{
|
{
|
||||||
var step = GetStep(scopeName, stepName);
|
reference = $"steps.{stepName}.outputs.{outputName}";
|
||||||
var outputs = step["outputs"].AssertDictionary("outputs");
|
}
|
||||||
outputs[outputName] = new StringContextData(value);
|
else
|
||||||
if (_propertyRegex.IsMatch(outputName))
|
{
|
||||||
{
|
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
||||||
reference = $"steps.{stepName}.outputs.{outputName}";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +85,8 @@ namespace GitHub.Runner.Worker
|
|||||||
string stepName,
|
string stepName,
|
||||||
ActionResult conclusion)
|
ActionResult conclusion)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
var step = GetStep(scopeName, stepName);
|
||||||
{
|
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
||||||
var step = GetStep(scopeName, stepName);
|
|
||||||
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetOutcome(
|
public void SetOutcome(
|
||||||
@@ -107,11 +94,8 @@ namespace GitHub.Runner.Worker
|
|||||||
string stepName,
|
string stepName,
|
||||||
ActionResult outcome)
|
ActionResult outcome)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
var step = GetStep(scopeName, stepName);
|
||||||
{
|
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
||||||
var step = GetStep(scopeName, stepName);
|
|
||||||
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private DictionaryContextData GetStep(string scopeName, string stepName)
|
private DictionaryContextData GetStep(string scopeName, string stepName)
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ namespace GitHub.Runner.Worker
|
|||||||
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
||||||
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));
|
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));
|
||||||
|
|
||||||
var _bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
|
|
||||||
|
|
||||||
// TaskResult:
|
// TaskResult:
|
||||||
// Abandoned (Server set this.)
|
// Abandoned (Server set this.)
|
||||||
// Canceled
|
// Canceled
|
||||||
@@ -59,15 +57,6 @@ namespace GitHub.Runner.Worker
|
|||||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||||
{
|
{
|
||||||
checkPostJobActions = true;
|
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))
|
while (jobContext.PostJobSteps.TryPop(out var postStep))
|
||||||
{
|
{
|
||||||
jobContext.JobSteps.Enqueue(postStep);
|
jobContext.JobSteps.Enqueue(postStep);
|
||||||
@@ -83,11 +72,8 @@ namespace GitHub.Runner.Worker
|
|||||||
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
||||||
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));
|
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));
|
||||||
|
|
||||||
// Start — defer for background steps until the slot is acquired
|
// Start
|
||||||
if (!step.ExecutionContext.IsBackground)
|
step.ExecutionContext.Start();
|
||||||
{
|
|
||||||
step.ExecutionContext.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expression functions
|
// Expression functions
|
||||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||||
@@ -233,31 +219,29 @@ namespace GitHub.Runner.Worker
|
|||||||
// Condition is false
|
// Condition is false
|
||||||
Trace.Info("Skipping step due to condition evaluation.");
|
Trace.Info("Skipping step due to condition evaluation.");
|
||||||
CompleteStep(step, TaskResult.Skipped, resultCode: conditionTraceWriter.Trace);
|
CompleteStep(step, TaskResult.Skipped, resultCode: conditionTraceWriter.Trace);
|
||||||
|
// Notify the DAP debugger so any predicted Post-step
|
||||||
|
// placeholder for this Main step can be marked as
|
||||||
|
// skipped — otherwise the rendered view leaves a
|
||||||
|
// stale "Post X" entry for a step that never ran.
|
||||||
|
dapDebugger?.OnStepCompleted(step);
|
||||||
}
|
}
|
||||||
else if (conditionEvaluateError != null)
|
else if (conditionEvaluateError != null)
|
||||||
{
|
{
|
||||||
// Condition error
|
// Condition error
|
||||||
step.ExecutionContext.Error(conditionEvaluateError);
|
step.ExecutionContext.Error(conditionEvaluateError);
|
||||||
CompleteStep(step, TaskResult.Failed);
|
CompleteStep(step, TaskResult.Failed);
|
||||||
|
dapDebugger?.OnStepCompleted(step);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (step.ExecutionContext.IsBackground)
|
// Pause for DAP debugger before step execution
|
||||||
{
|
await dapDebugger?.OnStepStartingAsync(step);
|
||||||
// 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 synchronously (normal behavior)
|
// Run the step
|
||||||
await RunStepAsync(step, jobContext.CancellationToken);
|
await RunStepAsync(step, jobContext.CancellationToken);
|
||||||
CompleteStep(step);
|
CompleteStep(step);
|
||||||
|
|
||||||
dapDebugger?.OnStepCompleted(step);
|
dapDebugger?.OnStepCompleted(step);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
Inputs = actionToClone.Inputs?.Clone();
|
Inputs = actionToClone.Inputs?.Clone();
|
||||||
ContextName = actionToClone?.ContextName;
|
ContextName = actionToClone?.ContextName;
|
||||||
DisplayNameToken = actionToClone.DisplayNameToken?.Clone();
|
DisplayNameToken = actionToClone.DisplayNameToken?.Clone();
|
||||||
Background = actionToClone.Background;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override StepType Type => StepType.Action;
|
public override StepType Type => StepType.Action;
|
||||||
@@ -50,9 +49,6 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
[DataMember(EmitDefaultValue = false)]
|
[DataMember(EmitDefaultValue = false)]
|
||||||
public TemplateToken Inputs { get; set; }
|
public TemplateToken Inputs { get; set; }
|
||||||
|
|
||||||
[DataMember(EmitDefaultValue = false)]
|
|
||||||
public bool Background { get; set; }
|
|
||||||
|
|
||||||
public override Step Clone()
|
public override Step Clone()
|
||||||
{
|
{
|
||||||
return new ActionStep(this);
|
return new ActionStep(this);
|
||||||
|
|||||||
@@ -267,21 +267,6 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
set;
|
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>
|
/// <summary>
|
||||||
/// Gets the workflow-level action dependencies (lockfile entries)
|
/// Gets the workflow-level action dependencies (lockfile entries)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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,7 +22,6 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
this.Condition = stepToClone.Condition;
|
this.Condition = stepToClone.Condition;
|
||||||
this.ContinueOnError = stepToClone.ContinueOnError?.Clone();
|
this.ContinueOnError = stepToClone.ContinueOnError?.Clone();
|
||||||
this.TimeoutInMinutes = stepToClone.TimeoutInMinutes?.Clone();
|
this.TimeoutInMinutes = stepToClone.TimeoutInMinutes?.Clone();
|
||||||
this.ParallelGroupId = stepToClone.ParallelGroupId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[DataMember(EmitDefaultValue = false)]
|
[DataMember(EmitDefaultValue = false)]
|
||||||
@@ -45,8 +44,5 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
get;
|
get;
|
||||||
set;
|
set;
|
||||||
}
|
}
|
||||||
|
|
||||||
[DataMember(EmitDefaultValue = false)]
|
|
||||||
public string ParallelGroupId { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
{
|
{
|
||||||
[DataContract]
|
[DataContract]
|
||||||
[KnownType(typeof(ActionStep))]
|
[KnownType(typeof(ActionStep))]
|
||||||
[KnownType(typeof(BackgroundStepControl))]
|
|
||||||
[JsonConverter(typeof(StepConverter))]
|
[JsonConverter(typeof(StepConverter))]
|
||||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
public abstract class Step
|
public abstract class Step
|
||||||
@@ -69,7 +68,5 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
{
|
{
|
||||||
[DataMember]
|
[DataMember]
|
||||||
Action = 4,
|
Action = 4,
|
||||||
[DataMember]
|
|
||||||
BackgroundStepControl = 5,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
case StepType.Action:
|
case StepType.Action:
|
||||||
stepObject = new ActionStep();
|
stepObject = new ActionStep();
|
||||||
break;
|
break;
|
||||||
case StepType.BackgroundStepControl:
|
|
||||||
stepObject = new BackgroundStepControl();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var objectReader = value.CreateReader())
|
using (var objectReader = value.CreateReader())
|
||||||
|
|||||||
@@ -186,16 +186,7 @@
|
|||||||
"vars",
|
"vars",
|
||||||
"needs",
|
"needs",
|
||||||
"strategy",
|
"strategy",
|
||||||
"matrix",
|
"matrix"
|
||||||
"steps",
|
|
||||||
"job",
|
|
||||||
"runner",
|
|
||||||
"env",
|
|
||||||
"always(0,0)",
|
|
||||||
"failure(0,0)",
|
|
||||||
"cancelled(0,0)",
|
|
||||||
"success(0,0)",
|
|
||||||
"hashFiles(1,255)"
|
|
||||||
],
|
],
|
||||||
"string": {}
|
"string": {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ namespace GitHub.DistributedTask.WebApi
|
|||||||
this.WarningCount = recordToBeCloned.WarningCount;
|
this.WarningCount = recordToBeCloned.WarningCount;
|
||||||
this.NoticeCount = recordToBeCloned.NoticeCount;
|
this.NoticeCount = recordToBeCloned.NoticeCount;
|
||||||
this.AgentPlatform = recordToBeCloned.AgentPlatform;
|
this.AgentPlatform = recordToBeCloned.AgentPlatform;
|
||||||
this.IsBackground = recordToBeCloned.IsBackground;
|
|
||||||
this.BackgroundControlType = recordToBeCloned.BackgroundControlType;
|
|
||||||
this.BackgroundControlStepIds = recordToBeCloned.BackgroundControlStepIds;
|
|
||||||
this.ParallelGroupId = recordToBeCloned.ParallelGroupId;
|
|
||||||
|
|
||||||
if (recordToBeCloned.Log != null)
|
if (recordToBeCloned.Log != null)
|
||||||
{
|
{
|
||||||
@@ -293,34 +289,6 @@ namespace GitHub.DistributedTask.WebApi
|
|||||||
set;
|
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
|
public IList<TimelineAttempt> PreviousAttempts
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|||||||
@@ -50,14 +50,5 @@ namespace GitHub.Actions.RunService.WebApi
|
|||||||
|
|
||||||
[DataMember(Name = "annotations", EmitDefaultValue = false)]
|
[DataMember(Name = "annotations", EmitDefaultValue = false)]
|
||||||
public List<Annotation> Annotations { get; set; }
|
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="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
|
<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.Cng" Version="5.0.0" />
|
||||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.7" />
|
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.6" />
|
||||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||||
<PackageReference Include="Minimatch" Version="2.0.0" />
|
<PackageReference Include="Minimatch" Version="2.0.0" />
|
||||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||||
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
|
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
|
||||||
<PackageReference Include="System.Formats.Asn1" Version="10.0.7" />
|
<PackageReference Include="System.Formats.Asn1" Version="10.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -179,14 +179,6 @@ namespace GitHub.Services.Results.Contracts
|
|||||||
public string CompletedAt;
|
public string CompletedAt;
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public Conclusion Conclusion;
|
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
|
public enum Status
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ namespace GitHub.Services.Results.Client
|
|||||||
|
|
||||||
private Step ConvertTimelineRecordToStep(TimelineRecord r)
|
private Step ConvertTimelineRecordToStep(TimelineRecord r)
|
||||||
{
|
{
|
||||||
var step = new Step()
|
return new Step()
|
||||||
{
|
{
|
||||||
ExternalId = r.Id.ToString(),
|
ExternalId = r.Id.ToString(),
|
||||||
Number = r.Order.GetValueOrDefault(),
|
Number = r.Order.GetValueOrDefault(),
|
||||||
@@ -522,25 +522,8 @@ namespace GitHub.Services.Results.Client
|
|||||||
Status = ConvertStateToStatus(r.State.GetValueOrDefault()),
|
Status = ConvertStateToStatus(r.State.GetValueOrDefault()),
|
||||||
StartedAt = r.StartTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
StartedAt = r.StartTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
||||||
CompletedAt = r.FinishTime?.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)
|
private Status ConvertStateToStatus(TimelineRecordState s)
|
||||||
|
|||||||
@@ -2291,10 +2291,6 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
|||||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Needs),
|
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Needs),
|
||||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Strategy),
|
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Strategy),
|
||||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Matrix),
|
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[]
|
private static readonly IFunctionInfo[] s_jobConditionFunctions = new IFunctionInfo[]
|
||||||
{
|
{
|
||||||
@@ -2311,13 +2307,6 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
|||||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
|
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
|
||||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
|
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
|
||||||
};
|
};
|
||||||
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = new IFunctionInfo[]
|
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = null;
|
||||||
{
|
|
||||||
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,16 +2196,7 @@
|
|||||||
"vars",
|
"vars",
|
||||||
"needs",
|
"needs",
|
||||||
"strategy",
|
"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.",
|
"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": {
|
"string": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.Serialization.Json;
|
using System.Runtime.Serialization.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -17,13 +17,13 @@ public sealed class AgentJobRequestMessageL0
|
|||||||
// Arrange
|
// Arrange
|
||||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||||
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
|
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
|
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(recoveredMessage);
|
Assert.NotNull(recoveredMessage);
|
||||||
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
|
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
|
||||||
@@ -37,13 +37,13 @@ public sealed class AgentJobRequestMessageL0
|
|||||||
// Arrange
|
// Arrange
|
||||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||||
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
|
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(recoveredMessage);
|
Assert.NotNull(recoveredMessage);
|
||||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
|
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
|
||||||
@@ -57,13 +57,13 @@ public sealed class AgentJobRequestMessageL0
|
|||||||
// Arrange
|
// Arrange
|
||||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||||
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
|
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
|
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(recoveredMessage);
|
Assert.NotNull(recoveredMessage);
|
||||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
|
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
|
||||||
@@ -161,26 +161,6 @@ public sealed class AgentJobRequestMessageL0
|
|||||||
Assert.Empty(recoveredMessage.ActionsDependencies);
|
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)
|
private static string DoubleQuotify(string text)
|
||||||
{
|
{
|
||||||
return text.Replace('\'', '"');
|
return text.Replace('\'', '"');
|
||||||
|
|||||||
@@ -1,702 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Moq;
|
|
||||||
using Xunit;
|
|
||||||
using GitHub.DistributedTask.Expressions2;
|
|
||||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
|
||||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
|
||||||
using GitHub.DistributedTask.WebApi;
|
|
||||||
using GitHub.Runner.Common.Util;
|
|
||||||
using GitHub.Runner.Worker;
|
|
||||||
using GitHub.Runner.Worker.Dap;
|
|
||||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
|
||||||
|
|
||||||
namespace GitHub.Runner.Common.Tests.Worker
|
|
||||||
{
|
|
||||||
public sealed class BackgroundStepsL0
|
|
||||||
{
|
|
||||||
private Mock<IExecutionContext> _ec;
|
|
||||||
private StepsRunner _stepsRunner;
|
|
||||||
private Variables _variables;
|
|
||||||
private Dictionary<string, string> _env;
|
|
||||||
private DictionaryContextData _contexts;
|
|
||||||
private JobContext _jobContext;
|
|
||||||
private StepsContext _stepContext;
|
|
||||||
|
|
||||||
private TestHostContext CreateTestContext([CallerMemberName] String testName = "")
|
|
||||||
{
|
|
||||||
var hc = new TestHostContext(this, testName);
|
|
||||||
Dictionary<string, VariableValue> variablesToCopy = new();
|
|
||||||
_variables = new Variables(
|
|
||||||
hostContext: hc,
|
|
||||||
copy: variablesToCopy);
|
|
||||||
_env = new Dictionary<string, string>()
|
|
||||||
{
|
|
||||||
{"env1", "1"},
|
|
||||||
{"test", "github_actions"}
|
|
||||||
};
|
|
||||||
_ec = new Mock<IExecutionContext>();
|
|
||||||
_ec.SetupAllProperties();
|
|
||||||
_ec.Setup(x => x.Global).Returns(new GlobalContext { WriteDebug = true });
|
|
||||||
_ec.Object.Global.Variables = _variables;
|
|
||||||
_ec.Object.Global.EnvironmentVariables = _env;
|
|
||||||
_ec.Object.Global.FileTable = new List<string>();
|
|
||||||
|
|
||||||
_contexts = new DictionaryContextData();
|
|
||||||
_jobContext = new JobContext();
|
|
||||||
_contexts["github"] = new GitHubContext();
|
|
||||||
_contexts["runner"] = new DictionaryContextData();
|
|
||||||
_contexts["job"] = _jobContext;
|
|
||||||
_ec.Setup(x => x.ExpressionValues).Returns(_contexts);
|
|
||||||
_ec.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
|
||||||
_ec.Setup(x => x.JobContext).Returns(_jobContext);
|
|
||||||
_ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
|
||||||
|
|
||||||
_stepContext = new StepsContext();
|
|
||||||
_ec.Object.Global.StepsContext = _stepContext;
|
|
||||||
|
|
||||||
_ec.Setup(x => x.PostJobSteps).Returns(new Stack<IStep>());
|
|
||||||
|
|
||||||
var trace = hc.GetTrace();
|
|
||||||
|
|
||||||
// Mock CreateChild for implicit wait-all step injection
|
|
||||||
_ec.Setup(x => x.CreateChild(
|
|
||||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ActionRunStage>(),
|
|
||||||
It.IsAny<Dictionary<string, string>>(), It.IsAny<int?>(), It.IsAny<IPagingLogger>(),
|
|
||||||
It.IsAny<bool>(), It.IsAny<List<Issue>>(), It.IsAny<CancellationTokenSource>(),
|
|
||||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(),
|
|
||||||
It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string[]>(), It.IsAny<string>()))
|
|
||||||
.Returns((Guid recordId, string displayName, string refName, string scopeName, string contextName,
|
|
||||||
ActionRunStage stage, Dictionary<string, string> intraActionState, int? recordOrder, IPagingLogger logger,
|
|
||||||
bool isEmbedded, List<Issue> issues, CancellationTokenSource cts, Guid embeddedId, string siblingScopeName, TimeSpan? timeout,
|
|
||||||
bool isBackground, string backgroundControlType, string[] backgroundControlStepIds, string parallelGroupId) =>
|
|
||||||
{
|
|
||||||
var childEc = new Mock<IExecutionContext>();
|
|
||||||
childEc.SetupAllProperties();
|
|
||||||
childEc.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
|
||||||
childEc.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
|
|
||||||
childEc.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
|
||||||
childEc.Setup(x => x.ContextName).Returns(contextName);
|
|
||||||
childEc.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
|
||||||
childEc.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
|
||||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
|
||||||
{
|
|
||||||
if (r != null) childEc.Object.Result = r;
|
|
||||||
});
|
|
||||||
childEc.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
|
||||||
return childEc.Object;
|
|
||||||
});
|
|
||||||
|
|
||||||
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
|
||||||
|
|
||||||
_stepsRunner = new StepsRunner();
|
|
||||||
_stepsRunner.Initialize(hc);
|
|
||||||
|
|
||||||
var bgCoordinator = new BackgroundStepCoordinator();
|
|
||||||
bgCoordinator.Initialize(hc);
|
|
||||||
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
|
|
||||||
|
|
||||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
|
||||||
hc.SetSingleton(mockDapDebugger.Object);
|
|
||||||
|
|
||||||
return hc;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task BackgroundStepRunsConcurrentlyWithForeground()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
// Arrange: background step that takes time, followed by a foreground step
|
|
||||||
var executionOrder = new List<string>();
|
|
||||||
|
|
||||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg-step", contextName: "bg", isBackground: true);
|
|
||||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
|
||||||
{
|
|
||||||
executionOrder.Add("bg-start");
|
|
||||||
await Task.Delay(2000);
|
|
||||||
executionOrder.Add("bg-end");
|
|
||||||
});
|
|
||||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "bg-step",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "bg",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var fgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "fg-step", contextName: "fg");
|
|
||||||
fgStep.Setup(x => x.RunAsync()).Returns(() =>
|
|
||||||
{
|
|
||||||
executionOrder.Add("fg-run");
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
|
|
||||||
var waitAllStep = CreateWaitAllStep(hc);
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep.Object, fgStep.Object, waitAllStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert: foreground step should start before background step finishes
|
|
||||||
Assert.Contains("bg-start", executionOrder);
|
|
||||||
Assert.Contains("fg-run", executionOrder);
|
|
||||||
Assert.Contains("bg-end", executionOrder);
|
|
||||||
var fgIndex = executionOrder.IndexOf("fg-run");
|
|
||||||
var bgEndIndex = executionOrder.IndexOf("bg-end");
|
|
||||||
Assert.True(fgIndex < bgEndIndex, "Foreground step should run before background step completes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task WaitStepBlocksUntilBackgroundCompletes()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var bgCompleted = false;
|
|
||||||
|
|
||||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "db", contextName: "db", isBackground: true);
|
|
||||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(100);
|
|
||||||
bgCompleted = true;
|
|
||||||
});
|
|
||||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "db",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "db",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var waitStep = CreateWaitStep(hc, new[] { "db" });
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep.Object, waitStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert: background step must have completed after wait
|
|
||||||
Assert.True(bgCompleted, "Background step should have completed after wait");
|
|
||||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task BackgroundStepFailurePropagatesAtWait()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
// Arrange: background step that fails
|
|
||||||
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "flaky", contextName: "flaky", isBackground: true);
|
|
||||||
bgStep.Setup(x => x.RunAsync()).Returns(() =>
|
|
||||||
{
|
|
||||||
throw new Exception("Service crashed");
|
|
||||||
});
|
|
||||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "flaky",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "flaky",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var waitStep = CreateWaitStep(hc, new[] { "flaky" });
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep.Object, waitStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert: job should fail because background step failed
|
|
||||||
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task CancelStepTerminatesBackgroundStep()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
// Arrange: background step that runs until cancelled via ExecutionContext.CancellationToken
|
|
||||||
var stepCts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server");
|
|
||||||
// Wire CancellationToken to our CTS so the cancel path can trigger it
|
|
||||||
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
|
|
||||||
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
|
|
||||||
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
|
|
||||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(5), stepCts.Token);
|
|
||||||
});
|
|
||||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "server",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "server",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var cancelStep = CreateCancelStep(hc, "server");
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep.Object, cancelStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert: background step should have been cancelled
|
|
||||||
// Note: the cancel mechanism uses the BackgroundStepContext.Cts, not bgCts
|
|
||||||
// so wasCancelled may not be true in this mock, but the step should complete
|
|
||||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task WaitAllWaitsForAllBackgroundSteps()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
// Arrange: two background steps
|
|
||||||
var step1Done = false;
|
|
||||||
var step2Done = false;
|
|
||||||
|
|
||||||
var bgStep1 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc1", contextName: "svc1", isBackground: true);
|
|
||||||
bgStep1.Setup(x => x.RunAsync()).Returns(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(50);
|
|
||||||
step1Done = true;
|
|
||||||
});
|
|
||||||
bgStep1.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "svc1",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "svc1",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var bgStep2 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc2", contextName: "svc2", isBackground: true);
|
|
||||||
bgStep2.Setup(x => x.RunAsync()).Returns(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(100);
|
|
||||||
step2Done = true;
|
|
||||||
});
|
|
||||||
bgStep2.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "svc2",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "svc2",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var waitAllStep = CreateWaitAllStep(hc);
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep1.Object, bgStep2.Object, waitAllStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(step1Done, "Background step 1 should have completed");
|
|
||||||
Assert.True(step2Done, "Background step 2 should have completed");
|
|
||||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task CancelStepPublishesCanceledBackgroundExternalId()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
|
|
||||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
|
||||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "server",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "server",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var cancelStep = CreateCancelStep(hc, "server");
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep.Object, cancelStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert: cancel step completed without error
|
|
||||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task CanceledBackgroundStepDoesNotAffectJobResult()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
// Arrange: a background step that runs until explicitly canceled. When canceled it
|
|
||||||
// reports TaskResult.Canceled, but since the cancellation is expected (driven by a
|
|
||||||
// cancel control step), it must not impact the overall job result.
|
|
||||||
using var stepCts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
|
|
||||||
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
|
|
||||||
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
|
|
||||||
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
|
|
||||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2), stepCts.Token);
|
|
||||||
});
|
|
||||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "server",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "server",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var cancelStep = CreateCancelStep(hc, "server");
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep.Object, cancelStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert: the canceled background step reported Canceled, but the job result is unaffected.
|
|
||||||
Assert.Equal(TaskResult.Canceled, bgStep.Object.ExecutionContext.Result);
|
|
||||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task FailedBackgroundStepTargetedByCancelStillAffectsJobResult()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
// Arrange: a background step that fails (e.g. before the cancel takes effect). Even
|
|
||||||
// though a cancel control step targets it, its Failed result must still propagate to
|
|
||||||
// the overall job result.
|
|
||||||
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "server", contextName: "server", isBackground: true);
|
|
||||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
|
||||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "server",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "server",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var cancelStep = CreateCancelStep(hc, "server");
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep.Object, cancelStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert: the background step failed, so the job result reflects that failure.
|
|
||||||
Assert.Equal(TaskResult.Failed, bgStep.Object.ExecutionContext.Result);
|
|
||||||
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task StepsContextThreadSafety()
|
|
||||||
{
|
|
||||||
// Test that concurrent SetOutput/SetConclusion doesn't throw
|
|
||||||
var stepsContext = new StepsContext();
|
|
||||||
var tasks = new List<Task>();
|
|
||||||
|
|
||||||
for (int i = 0; i < 100; i++)
|
|
||||||
{
|
|
||||||
var index = i;
|
|
||||||
tasks.Add(Task.Run(() =>
|
|
||||||
{
|
|
||||||
stepsContext.SetOutput("", $"step{index}", "out", $"value{index}", out _);
|
|
||||||
stepsContext.SetConclusion("", $"step{index}", ActionResult.Success);
|
|
||||||
stepsContext.SetOutcome("", $"step{index}", ActionResult.Success);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.WhenAll(tasks);
|
|
||||||
|
|
||||||
// Assert: all 100 steps should have their data set
|
|
||||||
var scope = stepsContext.GetScope("");
|
|
||||||
Assert.Equal(100, scope.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public async Task ControlFlowStepsRunEvenAfterFailure()
|
|
||||||
{
|
|
||||||
using (TestHostContext hc = CreateTestContext())
|
|
||||||
{
|
|
||||||
// Arrange: a background step, a foreground step that fails, then a wait step
|
|
||||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg", contextName: "bg", isBackground: true);
|
|
||||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
|
||||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = "bg",
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ContextName = "bg",
|
|
||||||
Background = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
var failStep = CreateStep(hc, TaskResult.Failed, "success()", name: "fail", contextName: "fail");
|
|
||||||
|
|
||||||
// Wait step uses always() condition — should run even after failure
|
|
||||||
var waitStep = CreateWaitStep(hc, new[] { "bg" });
|
|
||||||
waitStep.Condition = $"{GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants.Always}()";
|
|
||||||
|
|
||||||
_ec.Object.Result = null;
|
|
||||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
|
||||||
{
|
|
||||||
bgStep.Object, failStep.Object, waitStep
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
|
||||||
|
|
||||||
// Assert: wait step should have run (not skipped) because it has always() condition
|
|
||||||
Assert.NotNull(waitStep.ExecutionContext.Result);
|
|
||||||
Assert.NotEqual(TaskResult.Skipped, waitStep.ExecutionContext.Result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Helpers
|
|
||||||
|
|
||||||
private Mock<IActionRunner> CreateStep(TestHostContext hc, TaskResult result, string condition, string name = "Test", string contextName = null, Guid? recordId = null, bool isBackground = false)
|
|
||||||
{
|
|
||||||
var stepRecordId = recordId ?? Guid.NewGuid();
|
|
||||||
var step = new Mock<IActionRunner>();
|
|
||||||
step.Setup(x => x.Condition).Returns(condition);
|
|
||||||
step.Setup(x => x.ContinueOnError).Returns(new BooleanToken(null, null, null, false));
|
|
||||||
step.Setup(x => x.Stage).Returns(ActionRunStage.Main);
|
|
||||||
step.Setup(x => x.Action)
|
|
||||||
.Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
|
||||||
{
|
|
||||||
Name = name,
|
|
||||||
Id = stepRecordId,
|
|
||||||
ContextName = contextName ?? name,
|
|
||||||
});
|
|
||||||
|
|
||||||
var stepContext = new Mock<IExecutionContext>();
|
|
||||||
stepContext.SetupAllProperties();
|
|
||||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
|
||||||
stepContext.Setup(x => x.IsBackground).Returns(isBackground);
|
|
||||||
var expressionValues = new DictionaryContextData();
|
|
||||||
foreach (var pair in _ec.Object.ExpressionValues)
|
|
||||||
{
|
|
||||||
expressionValues[pair.Key] = pair.Value;
|
|
||||||
}
|
|
||||||
stepContext.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
|
||||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
|
||||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
|
||||||
stepContext.Setup(x => x.Id).Returns(stepRecordId);
|
|
||||||
stepContext.Setup(x => x.ContextName).Returns(step.Object.Action.ContextName);
|
|
||||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
|
||||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
|
||||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
|
||||||
{
|
|
||||||
if (r != null)
|
|
||||||
{
|
|
||||||
stepContext.Object.Result = r;
|
|
||||||
}
|
|
||||||
_stepContext.SetOutcome("", stepContext.Object.ContextName, (stepContext.Object.Outcome ?? stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
|
|
||||||
_stepContext.SetConclusion("", stepContext.Object.ContextName, (stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
|
|
||||||
});
|
|
||||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
|
||||||
stepContext.Setup(x => x.ApplyContinueOnError(It.IsAny<TemplateToken>()));
|
|
||||||
stepContext.Setup(x => x.FlushDeferredOutputs()).Callback(() =>
|
|
||||||
{
|
|
||||||
if (stepContext.Object.DeferredOutputs != null)
|
|
||||||
{
|
|
||||||
foreach (var kvp in stepContext.Object.DeferredOutputs)
|
|
||||||
{
|
|
||||||
_stepContext.SetOutput("", stepContext.Object.ContextName, kvp.Key, kvp.Value, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var trace = hc.GetTrace();
|
|
||||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
|
||||||
stepContext.Object.Result = result;
|
|
||||||
step.Setup(x => x.ExecutionContext).Returns(stepContext.Object);
|
|
||||||
step.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
return step;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JobExtensionRunner CreateWaitStep(TestHostContext hc, string[] stepIds, Dictionary<string, string> timelineVariables = null)
|
|
||||||
{
|
|
||||||
var waitData = new BackgroundStepControlFlowData
|
|
||||||
{
|
|
||||||
Type = Pipelines.BackgroundControlTypes.Wait,
|
|
||||||
StepIds = stepIds,
|
|
||||||
};
|
|
||||||
var bgCoordinator = hc.GetService<IBackgroundStepCoordinator>();
|
|
||||||
var waitRunner = new JobExtensionRunner(
|
|
||||||
runAsync: bgCoordinator.RunControlFlowAsync,
|
|
||||||
condition: "success()",
|
|
||||||
displayName: "Wait",
|
|
||||||
data: waitData);
|
|
||||||
|
|
||||||
var stepContext = new Mock<IExecutionContext>();
|
|
||||||
stepContext.SetupAllProperties();
|
|
||||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
|
||||||
var waitExprValues = new DictionaryContextData();
|
|
||||||
foreach (var pair in _ec.Object.ExpressionValues) { waitExprValues[pair.Key] = pair.Value; }
|
|
||||||
stepContext.Setup(x => x.ExpressionValues).Returns(waitExprValues);
|
|
||||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
|
||||||
stepContext.Setup(x => x.ContextName).Returns("__wait");
|
|
||||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
|
||||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
|
||||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
|
||||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
|
||||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
|
||||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
|
||||||
{
|
|
||||||
if (r != null) stepContext.Object.Result = r;
|
|
||||||
});
|
|
||||||
var trace = hc.GetTrace();
|
|
||||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
|
||||||
|
|
||||||
waitRunner.ExecutionContext = stepContext.Object;
|
|
||||||
return waitRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JobExtensionRunner CreateWaitAllStep(TestHostContext hc, Dictionary<string, string> timelineVariables = null)
|
|
||||||
{
|
|
||||||
var waitAllData = new BackgroundStepControlFlowData
|
|
||||||
{
|
|
||||||
Type = Pipelines.BackgroundControlTypes.WaitAll,
|
|
||||||
};
|
|
||||||
var bgCoordinator2 = hc.GetService<IBackgroundStepCoordinator>();
|
|
||||||
var waitAllRunner = new JobExtensionRunner(
|
|
||||||
runAsync: bgCoordinator2.RunControlFlowAsync,
|
|
||||||
condition: "success()",
|
|
||||||
displayName: "Wait All",
|
|
||||||
data: waitAllData);
|
|
||||||
|
|
||||||
var stepContext = new Mock<IExecutionContext>();
|
|
||||||
stepContext.SetupAllProperties();
|
|
||||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
|
||||||
var waitAllExprValues = new DictionaryContextData();
|
|
||||||
foreach (var pair in _ec.Object.ExpressionValues) { waitAllExprValues[pair.Key] = pair.Value; }
|
|
||||||
stepContext.Setup(x => x.ExpressionValues).Returns(waitAllExprValues);
|
|
||||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
|
||||||
stepContext.Setup(x => x.ContextName).Returns("__wait-all");
|
|
||||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
|
||||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
|
||||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
|
||||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
|
||||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
|
||||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
|
||||||
{
|
|
||||||
if (r != null) stepContext.Object.Result = r;
|
|
||||||
});
|
|
||||||
var trace = hc.GetTrace();
|
|
||||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
|
||||||
|
|
||||||
waitAllRunner.ExecutionContext = stepContext.Object;
|
|
||||||
return waitAllRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JobExtensionRunner CreateCancelStep(TestHostContext hc, string cancelStepId, Dictionary<string, string> timelineVariables = null)
|
|
||||||
{
|
|
||||||
var cancelData = new BackgroundStepControlFlowData
|
|
||||||
{
|
|
||||||
Type = Pipelines.BackgroundControlTypes.Cancel,
|
|
||||||
StepIds = new[] { cancelStepId },
|
|
||||||
};
|
|
||||||
var bgCoordinator3 = hc.GetService<IBackgroundStepCoordinator>();
|
|
||||||
var cancelRunner = new JobExtensionRunner(
|
|
||||||
runAsync: bgCoordinator3.RunControlFlowAsync,
|
|
||||||
condition: "success()",
|
|
||||||
displayName: "Cancel",
|
|
||||||
data: cancelData);
|
|
||||||
|
|
||||||
var stepContext = new Mock<IExecutionContext>();
|
|
||||||
stepContext.SetupAllProperties();
|
|
||||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
|
||||||
var cancelExprValues = new DictionaryContextData();
|
|
||||||
foreach (var pair in _ec.Object.ExpressionValues) { cancelExprValues[pair.Key] = pair.Value; }
|
|
||||||
stepContext.Setup(x => x.ExpressionValues).Returns(cancelExprValues);
|
|
||||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
|
||||||
stepContext.Setup(x => x.ContextName).Returns("__cancel");
|
|
||||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
|
||||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
|
||||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
|
||||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
|
||||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
|
||||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
|
||||||
{
|
|
||||||
if (r != null) stepContext.Object.Result = r;
|
|
||||||
});
|
|
||||||
var trace = hc.GetTrace();
|
|
||||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
|
||||||
|
|
||||||
cancelRunner.ExecutionContext = stepContext.Object;
|
|
||||||
return cancelRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -171,36 +171,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
Assert.Equal("normal", deserialized.PresentationHint);
|
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]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GitHub.DistributedTask.Expressions2;
|
using GitHub.DistributedTask.Expressions2;
|
||||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||||
using GitHub.DistributedTask.WebApi;
|
|
||||||
using GitHub.Runner.Common.Tests;
|
using GitHub.Runner.Common.Tests;
|
||||||
using GitHub.Runner.Worker;
|
using GitHub.Runner.Worker;
|
||||||
using GitHub.Runner.Worker.Container;
|
|
||||||
using GitHub.Runner.Worker.Dap;
|
using GitHub.Runner.Worker.Dap;
|
||||||
using GitHub.Runner.Worker.Handlers;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -43,8 +40,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
|
|
||||||
private Mock<IExecutionContext> CreateMockContext(
|
private Mock<IExecutionContext> CreateMockContext(
|
||||||
DictionaryContextData exprValues = null,
|
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>();
|
var mock = new Mock<IExecutionContext>();
|
||||||
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
|
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
|
||||||
@@ -55,7 +51,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
PrependPath = new List<string>(),
|
PrependPath = new List<string>(),
|
||||||
JobDefaults = jobDefaults
|
JobDefaults = jobDefaults
|
||||||
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
|
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
|
||||||
Container = container,
|
|
||||||
};
|
};
|
||||||
mock.Setup(x => x.Global).Returns(global);
|
mock.Setup(x => x.Global).Returns(global);
|
||||||
|
|
||||||
@@ -70,7 +65,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
using (CreateTestContext())
|
using (CreateTestContext())
|
||||||
{
|
{
|
||||||
var command = new RunCommand { Script = "echo hello" };
|
var command = new RunCommand { Script = "echo hello" };
|
||||||
var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None);
|
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("error", result.Type);
|
Assert.Equal("error", result.Type);
|
||||||
Assert.Contains("No execution context available", result.Result);
|
Assert.Contains("No execution context available", result.Result);
|
||||||
@@ -238,101 +233,5 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
Assert.False(result.ContainsKey("BAZ"));
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using GitHub.DistributedTask.Pipelines.ContextData;
|
|||||||
using GitHub.DistributedTask.WebApi;
|
using GitHub.DistributedTask.WebApi;
|
||||||
using GitHub.Runner.Worker;
|
using GitHub.Runner.Worker;
|
||||||
using GitHub.Runner.Worker.Container;
|
using GitHub.Runner.Worker.Container;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
using GitHub.Runner.Worker.Handlers;
|
using GitHub.Runner.Worker.Handlers;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -361,119 +362,6 @@ 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]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
@@ -518,6 +406,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
hc.EnqueueInstance(pagingLogger5.Object);
|
hc.EnqueueInstance(pagingLogger5.Object);
|
||||||
hc.EnqueueInstance(actionRunner1 as IActionRunner);
|
hc.EnqueueInstance(actionRunner1 as IActionRunner);
|
||||||
hc.EnqueueInstance(actionRunner2 as IActionRunner);
|
hc.EnqueueInstance(actionRunner2 as IActionRunner);
|
||||||
|
hc.SetSingleton(new Mock<IDapDebugger>().Object);
|
||||||
hc.SetSingleton(jobServerQueue.Object);
|
hc.SetSingleton(jobServerQueue.Object);
|
||||||
|
|
||||||
var jobContext = new Runner.Worker.ExecutionContext();
|
var jobContext = new Runner.Worker.ExecutionContext();
|
||||||
@@ -616,6 +505,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
hc.EnqueueInstance(pagingLogger5.Object);
|
hc.EnqueueInstance(pagingLogger5.Object);
|
||||||
hc.EnqueueInstance(actionRunner1 as IActionRunner);
|
hc.EnqueueInstance(actionRunner1 as IActionRunner);
|
||||||
hc.EnqueueInstance(actionRunner2 as IActionRunner);
|
hc.EnqueueInstance(actionRunner2 as IActionRunner);
|
||||||
|
hc.SetSingleton(new Mock<IDapDebugger>().Object);
|
||||||
hc.SetSingleton(jobServerQueue.Object);
|
hc.SetSingleton(jobServerQueue.Object);
|
||||||
|
|
||||||
var jobContext = new Runner.Worker.ExecutionContext();
|
var jobContext = new Runner.Worker.ExecutionContext();
|
||||||
@@ -657,6 +547,75 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void RegisterPostJobAction_DebuggerDisabled_DoesNotInvokeDapDebugger()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: Create a job request message with EnableDebugger left at the default (false).
|
||||||
|
TaskOrchestrationPlanReference plan = new();
|
||||||
|
TimelineReference timeline = new();
|
||||||
|
Guid jobId = Guid.NewGuid();
|
||||||
|
string jobName = "some job name";
|
||||||
|
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
|
||||||
|
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
|
||||||
|
{
|
||||||
|
Alias = Pipelines.PipelineConstants.SelfAlias,
|
||||||
|
Id = "github",
|
||||||
|
Version = "sha1"
|
||||||
|
});
|
||||||
|
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
|
||||||
|
|
||||||
|
var pagingLogger = new Mock<IPagingLogger>();
|
||||||
|
var jobServerQueue = new Mock<IJobServerQueue>();
|
||||||
|
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
|
||||||
|
jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()));
|
||||||
|
|
||||||
|
var actionRunner = new ActionRunner();
|
||||||
|
actionRunner.Initialize(hc);
|
||||||
|
|
||||||
|
hc.EnqueueInstance(pagingLogger.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger.Object);
|
||||||
|
hc.EnqueueInstance(actionRunner as IActionRunner);
|
||||||
|
|
||||||
|
// Register a strict mock IDapDebugger. If the production code calls
|
||||||
|
// ANY method on it, the test fails — proving the containment guard
|
||||||
|
// short-circuited before HostContext.GetService<IDapDebugger>().
|
||||||
|
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
|
||||||
|
hc.SetSingleton(dapMock.Object);
|
||||||
|
hc.SetSingleton(jobServerQueue.Object);
|
||||||
|
|
||||||
|
var jobContext = new Runner.Worker.ExecutionContext();
|
||||||
|
jobContext.Initialize(hc);
|
||||||
|
jobContext.InitializeJob(jobRequest, CancellationToken.None);
|
||||||
|
|
||||||
|
var action = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null, 0);
|
||||||
|
|
||||||
|
var postRunner = hc.CreateService<IActionRunner>();
|
||||||
|
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
|
||||||
|
postRunner.Stage = ActionRunStage.Post;
|
||||||
|
postRunner.Condition = "always()";
|
||||||
|
postRunner.DisplayName = "post";
|
||||||
|
|
||||||
|
// Sanity: ensure the production code path actually believes the debugger is disabled.
|
||||||
|
Assert.True(jobContext.Global.Debugger == null || jobContext.Global.Debugger.Enabled == false);
|
||||||
|
|
||||||
|
// Act.
|
||||||
|
action.RegisterPostJobStep(postRunner);
|
||||||
|
|
||||||
|
// Assert: the debugger was never consulted on the non-debug path.
|
||||||
|
dapMock.VerifyNoOtherCalls();
|
||||||
|
Assert.Equal(1, jobContext.PostJobSteps.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using GitHub.DistributedTask.Pipelines;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using GitHub.Runner.Worker;
|
using GitHub.Runner.Worker;
|
||||||
using GitHub.Runner.Worker.Dap;
|
using GitHub.Runner.Worker.Dap;
|
||||||
using Moq;
|
using Moq;
|
||||||
@@ -9,122 +12,456 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
{
|
{
|
||||||
public sealed class JobExecutionViewL0
|
public sealed class JobExecutionViewL0
|
||||||
{
|
{
|
||||||
[Fact]
|
private static JobExecutionViewEntry MainEntry(string name)
|
||||||
[Trait("Level", "L0")]
|
|
||||||
[Trait("Category", "Worker")]
|
|
||||||
public void RendersPreMainAndPostSections()
|
|
||||||
{
|
{
|
||||||
var pre = CreateStep("Pre cache", ActionRunStage.Pre);
|
return new JobExecutionViewEntry(JobExecutionPhase.Main, name, run: name);
|
||||||
var checkout = CreateStep("Checkout");
|
}
|
||||||
var post = CreateStep("Post cache", ActionRunStage.Post);
|
|
||||||
|
|
||||||
var view = new JobExecutionView(
|
private static IStep NewStep(string displayName = "step")
|
||||||
"job",
|
{
|
||||||
new[] { pre.Object, checkout.Object },
|
var mock = new Mock<IStep>();
|
||||||
new[] { post.Object });
|
mock.Setup(s => s.DisplayName).Returns(displayName);
|
||||||
|
return mock.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]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
public void ClaimsPredictedPostStepWithoutChangingLine()
|
public void Constructor_RendersEmptyView()
|
||||||
{
|
{
|
||||||
var action = CreateRepositoryActionStep("actions/cache");
|
var view = new JobExecutionView("my-job");
|
||||||
var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action);
|
|
||||||
var predicted = new JobExecutionView.PredictedPostStep(
|
|
||||||
"Post Checkout",
|
|
||||||
MatchKeyFor(action.Id));
|
|
||||||
|
|
||||||
var view = new JobExecutionView(
|
Assert.Equal(0, view.EntryCount);
|
||||||
"job",
|
Assert.Contains("# Job: my-job", view.Yaml);
|
||||||
new[] { checkout.Object },
|
Assert.Contains("- step: Setup job", view.Yaml);
|
||||||
Array.Empty<IStep>(),
|
Assert.Contains("- step: Complete job", view.Yaml);
|
||||||
new[] { predicted });
|
|
||||||
|
|
||||||
var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action);
|
// Only the two synthetic boundaries appear.
|
||||||
var line = view.TryClaimPredictedStep(MatchKeyFor(action.Id), post.Object);
|
int stepCount = view.Yaml.Split("- step: ").Length - 1;
|
||||||
|
Assert.Equal(2, stepCount);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(8, line);
|
[Theory]
|
||||||
Assert.Equal(8, view.TryGetLineForStep(post.Object));
|
[Trait("Level", "L0")]
|
||||||
Assert.Equal(
|
[Trait("Category", "Worker")]
|
||||||
"pre:\n - step: \"Set up job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n",
|
[InlineData(null)]
|
||||||
view.Content);
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Constructor_ThrowsOnInvalidJobId(string jobId)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => new JobExecutionView(jobId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
public void UsesSyntheticCompleteJobLineWhenPostStepSharesName()
|
public void Append_IncrementsEntryCount()
|
||||||
{
|
{
|
||||||
var checkout = CreateStep("Checkout");
|
var view = new JobExecutionView("j");
|
||||||
var realPost = CreateStep("Complete job", ActionRunStage.Post);
|
|
||||||
|
|
||||||
var view = new JobExecutionView(
|
int line0 = view.Append(MainEntry("a"));
|
||||||
"job",
|
int line1 = view.Append(MainEntry("b"));
|
||||||
new[] { checkout.Object },
|
int line2 = view.Append(MainEntry("c"));
|
||||||
new[] { realPost.Object });
|
|
||||||
|
|
||||||
Assert.Equal(8, view.TryGetLineForStep(realPost.Object));
|
Assert.Equal(3, view.EntryCount);
|
||||||
Assert.Equal(9, view.CompleteJobLine);
|
Assert.True(line0 < line1);
|
||||||
|
Assert.True(line1 < line2);
|
||||||
|
Assert.Equal(line0, view.GetLine(0));
|
||||||
|
Assert.Equal(line1, view.GetLine(1));
|
||||||
|
Assert.Equal(line2, view.GetLine(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Mock<IStep> CreateStep(string displayName, ActionRunStage? stage = null)
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Append_PreservesPriorEntryLines()
|
||||||
{
|
{
|
||||||
var step = new Mock<IStep>();
|
var view = new JobExecutionView("j");
|
||||||
step.Setup(s => s.DisplayName).Returns(displayName);
|
|
||||||
if (stage.HasValue)
|
int l0 = view.Append(MainEntry("a"));
|
||||||
|
int l1 = view.Append(MainEntry("b"));
|
||||||
|
int l2 = view.Append(MainEntry("c"));
|
||||||
|
|
||||||
|
view.Append(MainEntry("d"));
|
||||||
|
Assert.Equal(l0, view.GetLine(0));
|
||||||
|
Assert.Equal(l1, view.GetLine(1));
|
||||||
|
Assert.Equal(l2, view.GetLine(2));
|
||||||
|
|
||||||
|
view.Append(MainEntry("e"));
|
||||||
|
Assert.Equal(l0, view.GetLine(0));
|
||||||
|
Assert.Equal(l1, view.GetLine(1));
|
||||||
|
Assert.Equal(l2, view.GetLine(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Append_RegistersStepIdentity()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
var step = NewStep();
|
||||||
|
|
||||||
|
int line = view.Append(MainEntry("a"), step);
|
||||||
|
|
||||||
|
Assert.Equal(line, view.GetLine(0));
|
||||||
|
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Append_NullStepIdentity_StillAppends()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
|
||||||
|
view.Append(MainEntry("a"), stepIdentity: null);
|
||||||
|
|
||||||
|
Assert.Equal(1, view.EntryCount);
|
||||||
|
Assert.Null(view.TryGetLineForStep(null));
|
||||||
|
Assert.Contains("- step: a", view.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Append_DuplicateStepIdentity_Throws()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
var step = NewStep();
|
||||||
|
|
||||||
|
view.Append(MainEntry("a"), step);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => view.Append(MainEntry("b"), step));
|
||||||
|
|
||||||
|
// State preserved: only the first entry is present.
|
||||||
|
Assert.Equal(1, view.EntryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Append_NullEntry_Throws()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
Assert.Throws<ArgumentNullException>(() => view.Append(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void AppendRange_AppendsAllAndRendersOnce()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
var steps = Enumerable.Range(0, 5).Select(i => NewStep("s" + i)).ToList();
|
||||||
|
var items = steps
|
||||||
|
.Select((s, i) => (entry: MainEntry("e" + i), stepIdentity: s))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
view.AppendRange(items);
|
||||||
|
|
||||||
|
Assert.Equal(5, view.EntryCount);
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
{
|
{
|
||||||
var executionContext = new Mock<IExecutionContext>();
|
int line = view.GetLine(i);
|
||||||
executionContext.Setup(x => x.Stage).Returns(stage.Value);
|
Assert.Equal(line, view.TryGetLineForStep(steps[i]));
|
||||||
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)
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void AppendRange_RejectsDuplicateInInput()
|
||||||
{
|
{
|
||||||
var executionContext = new Mock<IExecutionContext>();
|
var view = new JobExecutionView("j");
|
||||||
executionContext.Setup(x => x.Stage).Returns(stage);
|
var dup = NewStep();
|
||||||
|
var items = new List<(JobExecutionViewEntry, IStep)>
|
||||||
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(),
|
(MainEntry("a"), dup),
|
||||||
Name = name,
|
(MainEntry("b"), dup),
|
||||||
Reference = new RepositoryPathReference
|
|
||||||
{
|
|
||||||
Name = name,
|
|
||||||
Ref = "v1",
|
|
||||||
RepositoryType = RepositoryTypes.GitHub
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
|
||||||
|
Assert.Equal(0, view.EntryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string MatchKeyFor(Guid actionId)
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void AppendRange_RejectsOverlapWithExisting()
|
||||||
{
|
{
|
||||||
return $"post:{actionId:N}";
|
var view = new JobExecutionView("j");
|
||||||
|
var step = NewStep();
|
||||||
|
view.Append(MainEntry("a"), step);
|
||||||
|
|
||||||
|
var items = new List<(JobExecutionViewEntry, IStep)>
|
||||||
|
{
|
||||||
|
(MainEntry("b"), step),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
|
||||||
|
Assert.Equal(1, view.EntryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void AppendRange_NullItems_Throws()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
Assert.Throws<ArgumentNullException>(() => view.AppendRange(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryGetLineForStep_NullStep_ReturnsNull()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
Assert.Null(view.TryGetLineForStep(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryGetLineForStep_UnknownStep_ReturnsNull()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
var step = NewStep();
|
||||||
|
Assert.Null(view.TryGetLineForStep(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
public void GetLine_OutOfRange_Throws(int index)
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
view.Append(MainEntry("a"));
|
||||||
|
view.Append(MainEntry("b"));
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => view.GetLine(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Yaml_UpdatesAfterAppend()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
view.Append(MainEntry("first"));
|
||||||
|
string before = view.Yaml;
|
||||||
|
Assert.Contains("- step: first", before);
|
||||||
|
|
||||||
|
view.Append(MainEntry("second"));
|
||||||
|
string after = view.Yaml;
|
||||||
|
|
||||||
|
Assert.Contains("- step: first", after);
|
||||||
|
Assert.Contains("- step: second", after);
|
||||||
|
Assert.NotEqual(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Yaml_AlwaysEndsWithCleanupBoundary()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||||
|
|
||||||
|
view.Append(MainEntry("a"));
|
||||||
|
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||||
|
|
||||||
|
view.Append(MainEntry("b"));
|
||||||
|
view.Append(MainEntry("c"));
|
||||||
|
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Append_WithMatchKey_TracksUnclaimed()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
|
||||||
|
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
|
||||||
|
|
||||||
|
var step = NewStep("real");
|
||||||
|
int? claimed = view.TryClaim("k1", step);
|
||||||
|
Assert.Equal(line, claimed);
|
||||||
|
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryClaim_UnknownKey_ReturnsNull()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||||
|
|
||||||
|
Assert.Null(view.TryClaim("nope", NewStep()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryClaim_AlreadyClaimed_ReturnsNull()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||||
|
|
||||||
|
var first = NewStep("first");
|
||||||
|
Assert.NotNull(view.TryClaim("k1", first));
|
||||||
|
|
||||||
|
var second = NewStep("second");
|
||||||
|
Assert.Null(view.TryClaim("k1", second));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryClaim_StepAlreadyRegistered_ReturnsNull()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
var step = NewStep();
|
||||||
|
// Step is registered for the first entry.
|
||||||
|
view.Append(MainEntry("a"), step);
|
||||||
|
// A placeholder is registered for the second entry.
|
||||||
|
view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1");
|
||||||
|
|
||||||
|
// Trying to claim the placeholder with the already-registered
|
||||||
|
// step must return null (defensive — would otherwise double-bind).
|
||||||
|
Assert.Null(view.TryClaim("k1", step));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Append_DuplicateMatchKey_Throws()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(
|
||||||
|
() => view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Append_MatchKeyNull_BehavesLikeOldOverload()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
var step = NewStep();
|
||||||
|
|
||||||
|
int line = view.Append(MainEntry("a"), step);
|
||||||
|
|
||||||
|
Assert.Equal(line, view.GetLine(0));
|
||||||
|
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||||
|
// TryClaim with any key must return null since no matchKey was registered.
|
||||||
|
Assert.Null(view.TryClaim("anything", NewStep()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryClaim_AfterClaim_TryGetLineForStepResolves()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
|
||||||
|
|
||||||
|
var step = NewStep();
|
||||||
|
Assert.Equal(line, view.TryClaim("k1", step));
|
||||||
|
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||||
|
|
||||||
|
// And a later Append doesn't lose the claim (Render rebuilds
|
||||||
|
// the IStep -> line map from the persisted identities).
|
||||||
|
view.Append(MainEntry("b"));
|
||||||
|
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryClaim_NullArgs_Throws()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
Assert.Throws<ArgumentNullException>(() => view.TryClaim(null, NewStep()));
|
||||||
|
Assert.Throws<ArgumentNullException>(() => view.TryClaim("k", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task ConcurrentAppends_DontCorruptState()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
const int N = 50;
|
||||||
|
var steps = Enumerable.Range(0, N).Select(i => NewStep("s" + i)).ToList();
|
||||||
|
var returnedLines = new ConcurrentBag<int>();
|
||||||
|
|
||||||
|
var tasks = Enumerable.Range(0, N).Select(i => Task.Run(() =>
|
||||||
|
{
|
||||||
|
int line = view.Append(MainEntry("e" + i), steps[i]);
|
||||||
|
returnedLines.Add(line);
|
||||||
|
})).ToArray();
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
Assert.Equal(N, view.EntryCount);
|
||||||
|
Assert.Equal(N, returnedLines.Distinct().Count());
|
||||||
|
|
||||||
|
// Every step identity resolves to some line in [0, N).
|
||||||
|
var entryLines = Enumerable.Range(0, N).Select(view.GetLine).ToHashSet();
|
||||||
|
Assert.Equal(N, entryLines.Count);
|
||||||
|
foreach (var step in steps)
|
||||||
|
{
|
||||||
|
int? line = view.TryGetLineForStep(step);
|
||||||
|
Assert.NotNull(line);
|
||||||
|
Assert.Contains(line.Value, entryLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryMarkSkipped_MarksUnclaimedPlaceholder()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
var postEntry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
|
||||||
|
view.Append(postEntry, stepIdentity: null, matchKey: "k1");
|
||||||
|
|
||||||
|
Assert.True(view.TryMarkSkipped("k1"));
|
||||||
|
Assert.True(postEntry.IsSkipped);
|
||||||
|
Assert.Contains("(skipped — main step did not execute)", view.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryMarkSkipped_ReturnsFalseForUnknownKey()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
Assert.False(view.TryMarkSkipped("nope"));
|
||||||
|
Assert.DoesNotContain("(skipped", view.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void TryMarkSkipped_ReturnsFalseForClaimedPlaceholder()
|
||||||
|
{
|
||||||
|
var view = new JobExecutionView("j");
|
||||||
|
var postEntry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
|
||||||
|
view.Append(postEntry, stepIdentity: null, matchKey: "k1");
|
||||||
|
|
||||||
|
var step = NewStep("real-post");
|
||||||
|
Assert.NotNull(view.TryClaim("k1", step));
|
||||||
|
|
||||||
|
// Already claimed — must not mark as skipped.
|
||||||
|
Assert.False(view.TryMarkSkipped("k1"));
|
||||||
|
Assert.False(postEntry.IsSkipped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
703
src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs
Normal file
703
src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using GitHub.DistributedTask.Pipelines;
|
||||||
|
using GitHub.DistributedTask.WebApi;
|
||||||
|
using GitHub.Runner.Worker;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
|
using Moq;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Common.Tests.Worker
|
||||||
|
{
|
||||||
|
public sealed class JobExecutionViewLifecycleL0
|
||||||
|
{
|
||||||
|
private DapDebugger _debugger;
|
||||||
|
|
||||||
|
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||||
|
{
|
||||||
|
var hc = new TestHostContext(this, testName);
|
||||||
|
_debugger = new DapDebugger();
|
||||||
|
_debugger.Initialize(hc);
|
||||||
|
_debugger.SkipTunnelRelay = true;
|
||||||
|
_debugger.SkipWebSocketBridge = true;
|
||||||
|
return hc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort GetFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
return (ushort)((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = "ci-job")
|
||||||
|
{
|
||||||
|
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
|
||||||
|
{
|
||||||
|
TunnelId = "test-tunnel",
|
||||||
|
ClusterId = "test-cluster",
|
||||||
|
HostToken = "test-token",
|
||||||
|
Port = port
|
||||||
|
};
|
||||||
|
var debuggerConfig = new DebuggerConfig(true, tunnel);
|
||||||
|
var jobContext = new Mock<IExecutionContext>();
|
||||||
|
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
|
||||||
|
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
|
||||||
|
jobContext
|
||||||
|
.Setup(x => x.GetGitHubContext(It.IsAny<string>()))
|
||||||
|
.Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null);
|
||||||
|
return jobContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DriveToReadyAsync(DapDebugger debugger, int port)
|
||||||
|
{
|
||||||
|
var waitTask = debugger.WaitUntilReadyAsync();
|
||||||
|
var client = new TcpClient();
|
||||||
|
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
var request = new Request { Seq = 1, Type = "request", Command = "configurationDone" };
|
||||||
|
var json = JsonConvert.SerializeObject(request);
|
||||||
|
var body = Encoding.UTF8.GetBytes(json);
|
||||||
|
var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n");
|
||||||
|
await stream.WriteAsync(header, 0, header.Length);
|
||||||
|
await stream.WriteAsync(body, 0, body.Length);
|
||||||
|
await stream.FlushAsync();
|
||||||
|
await waitTask;
|
||||||
|
// Keep client alive by holding a reference via GC root in caller scope.
|
||||||
|
// We deliberately don't dispose here; tests dispose the context.
|
||||||
|
_ = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IActionRunner> NewActionRunner(ActionRunStage stage, string displayName, string actionName = "actions/checkout", string actionRef = "v4", Guid actionId = default)
|
||||||
|
{
|
||||||
|
var mock = new Mock<IActionRunner>();
|
||||||
|
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||||
|
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||||
|
mock.SetupGet(x => x.Action).Returns(new ActionStep
|
||||||
|
{
|
||||||
|
Id = actionId,
|
||||||
|
Reference = new RepositoryPathReference { Name = actionName, Ref = actionRef },
|
||||||
|
});
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IActionRunner> NewSelfActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
|
||||||
|
{
|
||||||
|
// RepositoryType = "self" — the predictor must skip these.
|
||||||
|
var mock = new Mock<IActionRunner>();
|
||||||
|
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||||
|
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||||
|
mock.SetupGet(x => x.Action).Returns(new ActionStep
|
||||||
|
{
|
||||||
|
Id = actionId,
|
||||||
|
Reference = new RepositoryPathReference
|
||||||
|
{
|
||||||
|
RepositoryType = GitHub.DistributedTask.Pipelines.PipelineConstants.SelfAlias,
|
||||||
|
Path = "./.github/actions/local",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IActionRunner> NewScriptActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
|
||||||
|
{
|
||||||
|
// ScriptReference — a `run:` step. Not a RepositoryPathReference,
|
||||||
|
// so the predictor's pattern match falls through.
|
||||||
|
var mock = new Mock<IActionRunner>();
|
||||||
|
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||||
|
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||||
|
mock.SetupGet(x => x.Action).Returns(new ActionStep
|
||||||
|
{
|
||||||
|
Id = actionId,
|
||||||
|
Reference = new ScriptReference(),
|
||||||
|
});
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IActionManager mock that returns specific Definitions per action by
|
||||||
|
// matching on the action's reference Name. Actions whose name is not
|
||||||
|
// in the map get a Definition with HasPost = false.
|
||||||
|
private static Mock<IActionManager> NewActionManagerWithPost(params string[] actionNamesWithPost)
|
||||||
|
{
|
||||||
|
var withPost = new HashSet<string>(actionNamesWithPost, StringComparer.Ordinal);
|
||||||
|
var mock = new Mock<IActionManager>();
|
||||||
|
mock.Setup(x => x.LoadAction(It.IsAny<IExecutionContext>(), It.IsAny<ActionStep>()))
|
||||||
|
.Returns((IExecutionContext _, ActionStep step) =>
|
||||||
|
{
|
||||||
|
var name = (step.Reference as RepositoryPathReference)?.Name ?? "";
|
||||||
|
return new Definition
|
||||||
|
{
|
||||||
|
Data = new ActionDefinitionData
|
||||||
|
{
|
||||||
|
Execution = withPost.Contains(name)
|
||||||
|
? new NodeJSActionExecutionData { Post = "post.js" }
|
||||||
|
: new NodeJSActionExecutionData(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IStep NewJobExtensionRunner(string displayName)
|
||||||
|
{
|
||||||
|
return new JobExtensionRunner(
|
||||||
|
runAsync: (_, __) => Task.CompletedTask,
|
||||||
|
condition: null,
|
||||||
|
displayName: displayName,
|
||||||
|
data: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnJobStepsInitialized_NotActive_NoOps()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var step = NewActionRunner(ActionRunStage.Main, "Run").Object;
|
||||||
|
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
Assert.Null(_debugger.ExecutionView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnPostStepRegistered_NotActive_NoOps()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var step = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
|
||||||
|
_debugger.OnPostStepRegistered(step); // must not throw
|
||||||
|
Assert.Null(_debugger.ExecutionView);
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnJobStepsInitialized_Active_BuildsView()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object;
|
||||||
|
var main2 = NewActionRunner(ActionRunStage.Main, "Run actions/setup-node@v3", "actions/setup-node", "v3").Object;
|
||||||
|
var jobExt = NewJobExtensionRunner("Set up job");
|
||||||
|
var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/checkout@v4").Object;
|
||||||
|
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(
|
||||||
|
new IStep[] { main1, jobExt, main2 },
|
||||||
|
new IStep[] { post1 });
|
||||||
|
|
||||||
|
var view = _debugger.ExecutionView;
|
||||||
|
Assert.NotNull(view);
|
||||||
|
Assert.Equal(3, view.EntryCount); // jobExt filtered out
|
||||||
|
Assert.Contains("Run actions/checkout@v4", view.Yaml);
|
||||||
|
Assert.Contains("Run actions/setup-node@v3", view.Yaml);
|
||||||
|
Assert.Contains("Post Run actions/checkout@v4", view.Yaml);
|
||||||
|
Assert.NotNull(view.TryGetLineForStep(main1));
|
||||||
|
Assert.NotNull(view.TryGetLineForStep(main2));
|
||||||
|
Assert.NotNull(view.TryGetLineForStep(post1));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnJobStepsInitialized_PreservesQueueOrder()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var s1 = NewActionRunner(ActionRunStage.Main, "Step 1", "a/b", "v1").Object;
|
||||||
|
var s2 = NewActionRunner(ActionRunStage.Main, "Step 2", "c/d", "v2").Object;
|
||||||
|
var s3 = NewActionRunner(ActionRunStage.Main, "Step 3", "e/f", "v3").Object;
|
||||||
|
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { s1, s2, s3 }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var view = _debugger.ExecutionView;
|
||||||
|
Assert.Equal(3, view.EntryCount);
|
||||||
|
var l1 = view.TryGetLineForStep(s1);
|
||||||
|
var l2 = view.TryGetLineForStep(s2);
|
||||||
|
var l3 = view.TryGetLineForStep(s3);
|
||||||
|
Assert.NotNull(l1);
|
||||||
|
Assert.NotNull(l2);
|
||||||
|
Assert.NotNull(l3);
|
||||||
|
Assert.True(l1 < l2);
|
||||||
|
Assert.True(l2 < l3);
|
||||||
|
Assert.Equal(view.GetLine(0), l1);
|
||||||
|
Assert.Equal(view.GetLine(1), l2);
|
||||||
|
Assert.Equal(view.GetLine(2), l3);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnPostStepRegistered_AppendsToView()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object;
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { main1 }, Array.Empty<IStep>());
|
||||||
|
Assert.Equal(1, _debugger.ExecutionView.EntryCount);
|
||||||
|
|
||||||
|
var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/cache@v3", "actions/cache", "v3").Object;
|
||||||
|
_debugger.OnPostStepRegistered(post1);
|
||||||
|
|
||||||
|
var view = _debugger.ExecutionView;
|
||||||
|
Assert.Equal(2, view.EntryCount);
|
||||||
|
Assert.Contains("Post Run actions/cache@v3", view.Yaml);
|
||||||
|
Assert.NotNull(view.TryGetLineForStep(post1));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnPostStepRegistered_BeforeViewBuilt_NoOps()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
|
||||||
|
_debugger.OnPostStepRegistered(post); // must not throw
|
||||||
|
|
||||||
|
Assert.Null(_debugger.ExecutionView);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnPostStepRegistered_DuplicateStep_DoesNotThrow()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(Array.Empty<IStep>(), Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
|
||||||
|
_debugger.OnPostStepRegistered(post);
|
||||||
|
_debugger.OnPostStepRegistered(post); // duplicate, must be silently ignored
|
||||||
|
|
||||||
|
Assert.Equal(1, _debugger.ExecutionView.EntryCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnPostStepRegistered_FilteredStep_NoOps()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(Array.Empty<IStep>(), Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var before = _debugger.ExecutionView.EntryCount;
|
||||||
|
_debugger.OnPostStepRegistered(NewJobExtensionRunner("Cleanup"));
|
||||||
|
Assert.Equal(before, _debugger.ExecutionView.EntryCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Predictive Post-step synthesis ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnJobStepsInitialized_PredictsPostForActionsWithHasPost()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var withPost = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: Guid.NewGuid()).Object;
|
||||||
|
var noPost = NewActionRunner(ActionRunStage.Main, "Run actions/no-post@v1", "actions/no-post", "v1", actionId: Guid.NewGuid()).Object;
|
||||||
|
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { withPost, noPost }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var view = _debugger.ExecutionView;
|
||||||
|
Assert.NotNull(view);
|
||||||
|
// 2 main entries + 1 predicted post placeholder.
|
||||||
|
Assert.Equal(3, view.EntryCount);
|
||||||
|
Assert.Contains("post:\n", view.Yaml);
|
||||||
|
Assert.Contains("Post Run actions/has-post@v1", view.Yaml);
|
||||||
|
Assert.DoesNotContain("Post Run actions/no-post@v1", view.Yaml);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnJobStepsInitialized_PostPredictionsInReverseOrder()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Both actions have post — predictions must render in
|
||||||
|
// reverse declaration order to mirror the runner's LIFO
|
||||||
|
// post-execution order.
|
||||||
|
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/a", "actions/b").Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var aMain = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object;
|
||||||
|
var bMain = NewActionRunner(ActionRunStage.Main, "Run actions/b@v1", "actions/b", "v1", actionId: Guid.NewGuid()).Object;
|
||||||
|
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { aMain, bMain }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
string yaml = _debugger.ExecutionView.Yaml;
|
||||||
|
int idxPostB = yaml.IndexOf("Post Run actions/b@v1", StringComparison.Ordinal);
|
||||||
|
int idxPostA = yaml.IndexOf("Post Run actions/a@v1", StringComparison.Ordinal);
|
||||||
|
Assert.True(idxPostB > 0 && idxPostA > 0, "both post placeholders expected");
|
||||||
|
// Reverse declaration order: Post B appears BEFORE Post A.
|
||||||
|
Assert.True(idxPostB < idxPostA, $"expected Post B before Post A (b={idxPostB} a={idxPostA})");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnJobStepsInitialized_SkipsScriptSteps()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Even if the action manager would say HasPost, the predictor
|
||||||
|
// must skip script run-steps because their reference is not
|
||||||
|
// a RepositoryPathReference.
|
||||||
|
hc.SetSingleton<IActionManager>(NewActionManagerWithPost(/* nothing */).Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var script = NewScriptActionRunner(ActionRunStage.Main, "Run script", Guid.NewGuid()).Object;
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { script }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var view = _debugger.ExecutionView;
|
||||||
|
Assert.NotNull(view);
|
||||||
|
Assert.DoesNotContain("post:\n", view.Yaml);
|
||||||
|
Assert.DoesNotContain("Post ", view.Yaml);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnJobStepsInitialized_SkipsSelfActions()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Self-action: ActionRunner.cs:106 guards against creating a
|
||||||
|
// Post for self-repository references. The predictor mirrors
|
||||||
|
// that, regardless of what the manifest reports.
|
||||||
|
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("anything").Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var selfRunner = NewSelfActionRunner(ActionRunStage.Main, "Run ./local-action", Guid.NewGuid()).Object;
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { selfRunner }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
Assert.DoesNotContain("post:\n", _debugger.ExecutionView.Yaml);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnPostStepRegistered_ClaimsExistingPlaceholder()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var actionId = Guid.NewGuid();
|
||||||
|
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var view = _debugger.ExecutionView;
|
||||||
|
int before = view.EntryCount;
|
||||||
|
Assert.Equal(2, before); // main + predicted post placeholder
|
||||||
|
|
||||||
|
// The real Post IActionRunner shares the same Action.Id
|
||||||
|
// as the Main runner (ActionRunner.cs:131).
|
||||||
|
var postRunner = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
|
||||||
|
_debugger.OnPostStepRegistered(postRunner);
|
||||||
|
|
||||||
|
// No new entry: the placeholder was claimed.
|
||||||
|
Assert.Equal(before, view.EntryCount);
|
||||||
|
Assert.NotNull(view.TryGetLineForStep(postRunner));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnPostStepRegistered_UnpredictedFallsBackToAppend()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Manager returns no HasPost — no predictions made.
|
||||||
|
hc.SetSingleton<IActionManager>(NewActionManagerWithPost(/* nothing */).Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object;
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var view = _debugger.ExecutionView;
|
||||||
|
int before = view.EntryCount;
|
||||||
|
Assert.Equal(1, before); // just main, no predicted post
|
||||||
|
|
||||||
|
var unpredictedPost = NewActionRunner(ActionRunStage.Post, "Post Surprise", "actions/surprise", "v1", actionId: Guid.NewGuid()).Object;
|
||||||
|
_debugger.OnPostStepRegistered(unpredictedPost);
|
||||||
|
|
||||||
|
// Falls back to Append.
|
||||||
|
Assert.Equal(before + 1, view.EntryCount);
|
||||||
|
Assert.NotNull(view.TryGetLineForStep(unpredictedPost));
|
||||||
|
Assert.Contains("Post Surprise", view.Yaml);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnPostStepRegistered_DuplicateClaim_NoDoubleEntry()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var actionId = Guid.NewGuid();
|
||||||
|
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
|
||||||
|
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
|
||||||
|
|
||||||
|
// First registration claims the placeholder.
|
||||||
|
var post1 = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
|
||||||
|
_debugger.OnPostStepRegistered(post1);
|
||||||
|
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
|
||||||
|
|
||||||
|
// Second registration with the same Action.Id but a
|
||||||
|
// different IStep: TryClaim returns null (already
|
||||||
|
// claimed). Falls through to Append. But the entry
|
||||||
|
// it builds matches no existing step, so a new entry
|
||||||
|
// would be added — UNLESS we constructed the second
|
||||||
|
// post as a duplicate IStep registration of the same
|
||||||
|
// step. Here we intentionally pass the same `post1`
|
||||||
|
// step a second time — Append will reject the
|
||||||
|
// already-registered step, the handler swallows it.
|
||||||
|
_debugger.OnPostStepRegistered(post1);
|
||||||
|
|
||||||
|
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnStepCompleted_SkippedMainStep_MarksPostPlaceholder()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriveToReadyAsync(_debugger, port);
|
||||||
|
|
||||||
|
var actionId = Guid.NewGuid();
|
||||||
|
var mainMock = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId);
|
||||||
|
var execCtx = new Mock<IExecutionContext>();
|
||||||
|
execCtx.SetupGet(x => x.Result).Returns(TaskResult.Skipped);
|
||||||
|
mainMock.SetupGet(x => x.ExecutionContext).Returns(execCtx.Object);
|
||||||
|
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(new[] { mainMock.Object }, Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var view = _debugger.ExecutionView;
|
||||||
|
Assert.Equal(2, view.EntryCount); // main + predicted post placeholder
|
||||||
|
Assert.DoesNotContain("(skipped", view.Yaml);
|
||||||
|
|
||||||
|
_debugger.OnStepCompleted(mainMock.Object);
|
||||||
|
|
||||||
|
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
|
||||||
|
Assert.Contains("(skipped — main step did not execute)", _debugger.ExecutionView.Yaml);
|
||||||
|
// Inline annotation must not have introduced a new line.
|
||||||
|
Assert.Equal(view.Yaml.Split('\n').Length, _debugger.ExecutionView.Yaml.Split('\n').Length);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
617
src/Test/L0/Worker/JobExecutionViewRendererL0.cs
Normal file
617
src/Test/L0/Worker/JobExecutionViewRendererL0.cs
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Common.Tests.Worker
|
||||||
|
{
|
||||||
|
public sealed class JobExecutionViewRendererL0
|
||||||
|
{
|
||||||
|
// Verbatim expected YAML for the design doc's "Worked example".
|
||||||
|
// The render output is structured as phase-keyed top-level sections;
|
||||||
|
// there is no per-entry `phase:` field. The setup: and cleanup:
|
||||||
|
// sections always render; pre:/main:/post: render only when
|
||||||
|
// they contain at least one entry. The Main entries surface
|
||||||
|
// user-authored step parameters pre-evaluation (no expression
|
||||||
|
// substitution); Pre/Post entries stay minimal.
|
||||||
|
private const string ExpectedWorkedExampleYaml =
|
||||||
|
"# Job: build\n" +
|
||||||
|
"# Runner execution plan — read-only.\n" +
|
||||||
|
"\n" +
|
||||||
|
"setup:\n" +
|
||||||
|
" - step: Setup job\n" +
|
||||||
|
"\n" +
|
||||||
|
"pre:\n" +
|
||||||
|
" - step: Pre actions/checkout@v4\n" +
|
||||||
|
" action: actions/checkout@v4\n" +
|
||||||
|
" - step: Pre actions/cache@v5\n" +
|
||||||
|
" action: actions/cache@v5\n" +
|
||||||
|
"\n" +
|
||||||
|
"main:\n" +
|
||||||
|
" - step: actions/checkout@v4\n" +
|
||||||
|
" uses: actions/checkout@v4\n" +
|
||||||
|
" source: .github/workflows/ci.yml:10\n" +
|
||||||
|
" - step: Cache Primes\n" +
|
||||||
|
" id: cache-primes\n" +
|
||||||
|
" uses: actions/cache@v5\n" +
|
||||||
|
" with:\n" +
|
||||||
|
" path: prime-numbers\n" +
|
||||||
|
" key: ${{ runner.os }}-primes\n" +
|
||||||
|
" source: .github/workflows/ci.yml:12\n" +
|
||||||
|
" - step: Run tests\n" +
|
||||||
|
" id: test\n" +
|
||||||
|
" run: |\n" +
|
||||||
|
" echo starting\n" +
|
||||||
|
" npm test\n" +
|
||||||
|
" if: ${{ github.event_name == 'push' }}\n" +
|
||||||
|
" env:\n" +
|
||||||
|
" NODE_ENV: production\n" +
|
||||||
|
" shell: bash\n" +
|
||||||
|
" working-directory: ./api\n" +
|
||||||
|
" source: .github/workflows/ci.yml:18\n" +
|
||||||
|
" - step: npm ci\n" +
|
||||||
|
" run: npm ci\n" +
|
||||||
|
" source: .github/workflows/ci.yml:28\n" +
|
||||||
|
"\n" +
|
||||||
|
"post:\n" +
|
||||||
|
" - step: Post actions/cache@v5\n" +
|
||||||
|
" action: actions/cache@v5\n" +
|
||||||
|
" - step: Post actions/checkout@v4\n" +
|
||||||
|
" action: actions/checkout@v4\n" +
|
||||||
|
"\n" +
|
||||||
|
"cleanup:\n" +
|
||||||
|
" - step: Complete job\n";
|
||||||
|
|
||||||
|
private static List<JobExecutionViewEntry> WorkedExampleEntries()
|
||||||
|
{
|
||||||
|
return new List<JobExecutionViewEntry>
|
||||||
|
{
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/checkout@v4", uses: "actions/checkout@v4"),
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/cache@v5", uses: "actions/cache@v5"),
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Main, "actions/checkout@v4", uses: "actions/checkout@v4", sourcePath: ".github/workflows/ci.yml", sourceLine: 10),
|
||||||
|
new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"Cache Primes",
|
||||||
|
uses: "actions/cache@v5",
|
||||||
|
id: "cache-primes",
|
||||||
|
withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes",
|
||||||
|
sourcePath: ".github/workflows/ci.yml",
|
||||||
|
sourceLine: 12),
|
||||||
|
new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"Run tests",
|
||||||
|
run: "echo starting\nnpm test",
|
||||||
|
id: "test",
|
||||||
|
@if: "${{ github.event_name == 'push' }}",
|
||||||
|
envYaml: " NODE_ENV: production",
|
||||||
|
shell: "bash",
|
||||||
|
workingDirectory: "./api",
|
||||||
|
sourcePath: ".github/workflows/ci.yml",
|
||||||
|
sourceLine: 18),
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Main, "npm ci", run: "npm ci", sourcePath: ".github/workflows/ci.yml", sourceLine: 28),
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/cache@v5", uses: "actions/cache@v5"),
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/checkout@v4", uses: "actions/checkout@v4"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_MatchesDesignDocWorkedExample()
|
||||||
|
{
|
||||||
|
var entries = WorkedExampleEntries();
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("build", entries);
|
||||||
|
|
||||||
|
Assert.Equal(ExpectedWorkedExampleYaml, result.Yaml);
|
||||||
|
Assert.Equal(8, result.EntryStartLines.Count);
|
||||||
|
var lines = result.Yaml.Split('\n');
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
Assert.StartsWith(" - step: ", lines[result.EntryStartLines[i] - 1]);
|
||||||
|
Assert.Contains(entries[i].DisplayName, lines[result.EntryStartLines[i] - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_AlwaysEmitsSetupAndCleanup()
|
||||||
|
{
|
||||||
|
var result = JobExecutionViewRenderer.Render("job-1", new List<JobExecutionViewEntry>());
|
||||||
|
|
||||||
|
const string expected =
|
||||||
|
"# Job: job-1\n" +
|
||||||
|
"# Runner execution plan — read-only.\n" +
|
||||||
|
"\n" +
|
||||||
|
"setup:\n" +
|
||||||
|
" - step: Setup job\n" +
|
||||||
|
"\n" +
|
||||||
|
"cleanup:\n" +
|
||||||
|
" - step: Complete job\n";
|
||||||
|
Assert.Equal(expected, result.Yaml);
|
||||||
|
Assert.Empty(result.EntryStartLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_OmitsEmptyOptionalSections()
|
||||||
|
{
|
||||||
|
// Only a Main entry — pre:/post: must not appear.
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[]
|
||||||
|
{
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Main, "echo", run: "echo hello"),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("setup:\n", result.Yaml);
|
||||||
|
Assert.Contains("main:\n", result.Yaml);
|
||||||
|
Assert.Contains("cleanup:\n", result.Yaml);
|
||||||
|
Assert.DoesNotContain("\npre:\n", result.Yaml);
|
||||||
|
Assert.DoesNotContain("\npost:\n", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_EmitsPhaseSectionsInFixedOrder()
|
||||||
|
{
|
||||||
|
// Input order [Post, Pre, Main] should still render as setup → pre → main → post → cleanup.
|
||||||
|
var entries = new[]
|
||||||
|
{
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Post, "post-a", uses: "a/b@v1"),
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-a", uses: "a/b@v1"),
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a/b@v1"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", entries);
|
||||||
|
string yaml = result.Yaml;
|
||||||
|
|
||||||
|
int setupIdx = yaml.IndexOf("setup:\n", StringComparison.Ordinal);
|
||||||
|
int preIdx = yaml.IndexOf("\npre:\n", StringComparison.Ordinal);
|
||||||
|
int mainIdx = yaml.IndexOf("\nmain:\n", StringComparison.Ordinal);
|
||||||
|
int postIdx = yaml.IndexOf("\npost:\n", StringComparison.Ordinal);
|
||||||
|
int cleanupIdx = yaml.IndexOf("\ncleanup:\n", StringComparison.Ordinal);
|
||||||
|
Assert.True(setupIdx >= 0 && preIdx > setupIdx && mainIdx > preIdx && postIdx > mainIdx && cleanupIdx > postIdx,
|
||||||
|
$"section ordering wrong: setup={setupIdx} pre={preIdx} main={mainIdx} post={postIdx} cleanup={cleanupIdx}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_StartLinesAlignWithInputOrder()
|
||||||
|
{
|
||||||
|
// Input order is [Pre, Main, Post]; output order is also pre/main/post,
|
||||||
|
// but startLines must be indexed by INPUT position, not by section.
|
||||||
|
var entries = new[]
|
||||||
|
{
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-x", uses: "x/y@v1"), // index 0
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-x", uses: "x/y@v1"), // index 1
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Post, "post-x", uses: "x/y@v1"), // index 2
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", entries);
|
||||||
|
var lines = result.Yaml.Split('\n');
|
||||||
|
|
||||||
|
Assert.StartsWith(" - step: pre-x", lines[result.EntryStartLines[0] - 1]);
|
||||||
|
Assert.StartsWith(" - step: main-x", lines[result.EntryStartLines[1] - 1]);
|
||||||
|
Assert.StartsWith(" - step: post-x", lines[result.EntryStartLines[2] - 1]);
|
||||||
|
// And input-order ordering of start lines is strictly increasing
|
||||||
|
// when phases are in declaration order matching the section order.
|
||||||
|
Assert.True(result.EntryStartLines[0] < result.EntryStartLines[1]);
|
||||||
|
Assert.True(result.EntryStartLines[1] < result.EntryStartLines[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_StartLinesFollowInputOrderEvenWhenPhasesAreInterleaved()
|
||||||
|
{
|
||||||
|
// Input order is [Main A, Pre B, Main C]: pre section will render
|
||||||
|
// first (Pre B) and main second (Main A then Main C). startLines
|
||||||
|
// must still be indexed by input order.
|
||||||
|
var entries = new[]
|
||||||
|
{
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a@v1"), // index 0 — renders in main section
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-b", uses: "b@v1"), // index 1 — renders in pre section
|
||||||
|
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-c", uses: "c@v1"), // index 2 — renders in main section
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", entries);
|
||||||
|
var lines = result.Yaml.Split('\n');
|
||||||
|
|
||||||
|
Assert.StartsWith(" - step: main-a", lines[result.EntryStartLines[0] - 1]);
|
||||||
|
Assert.StartsWith(" - step: pre-b", lines[result.EntryStartLines[1] - 1]);
|
||||||
|
Assert.StartsWith(" - step: main-c", lines[result.EntryStartLines[2] - 1]);
|
||||||
|
// The pre section comes before main: input-index-1 entry's line is
|
||||||
|
// before input-index-0 entry's line.
|
||||||
|
Assert.True(result.EntryStartLines[1] < result.EntryStartLines[0]);
|
||||||
|
Assert.True(result.EntryStartLines[0] < result.EntryStartLines[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_EntryStartLinesPointAtStepKeys()
|
||||||
|
{
|
||||||
|
var entries = WorkedExampleEntries();
|
||||||
|
var result = JobExecutionViewRenderer.Render("build", entries);
|
||||||
|
var lines = result.Yaml.Split('\n');
|
||||||
|
|
||||||
|
for (int i = 0; i < result.EntryStartLines.Count; i++)
|
||||||
|
{
|
||||||
|
int oneBased = result.EntryStartLines[i];
|
||||||
|
Assert.True(oneBased >= 1 && oneBased <= lines.Length, $"start line {oneBased} out of range");
|
||||||
|
Assert.StartsWith(" - step: ", lines[oneBased - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_EntryStartLinesExcludeSetupAndCleanup()
|
||||||
|
{
|
||||||
|
var entries = WorkedExampleEntries();
|
||||||
|
var result = JobExecutionViewRenderer.Render("build", entries);
|
||||||
|
var lines = result.Yaml.Split('\n');
|
||||||
|
|
||||||
|
int setupLine = -1, cleanupLine = -1;
|
||||||
|
for (int i = 0; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
if (lines[i] == " - step: Setup job") setupLine = i + 1;
|
||||||
|
if (lines[i] == " - step: Complete job") cleanupLine = i + 1;
|
||||||
|
}
|
||||||
|
Assert.True(setupLine > 0 && cleanupLine > 0, "Setup/Cleanup lines must exist");
|
||||||
|
Assert.DoesNotContain(setupLine, result.EntryStartLines);
|
||||||
|
Assert.DoesNotContain(cleanupLine, result.EntryStartLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
[InlineData("hello")]
|
||||||
|
[InlineData("with: colon")]
|
||||||
|
[InlineData("with#hash")]
|
||||||
|
[InlineData(" leading")]
|
||||||
|
[InlineData("trailing ")]
|
||||||
|
[InlineData("a\"b")]
|
||||||
|
[InlineData("a\\b")]
|
||||||
|
[InlineData("@at")]
|
||||||
|
[InlineData("*star")]
|
||||||
|
public void Render_QuotesSpecialChars(string displayName)
|
||||||
|
{
|
||||||
|
// Round-trip the rendered YAML through YamlDotNet's deserializer
|
||||||
|
// and assert the parsed step's display name matches the input.
|
||||||
|
// This decouples the test from any specific quoting style.
|
||||||
|
var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, displayName);
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
|
||||||
|
var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build();
|
||||||
|
var doc = deserializer.Deserialize<Dictionary<string, List<Dictionary<string, object>>>>(result.Yaml);
|
||||||
|
Assert.NotNull(doc);
|
||||||
|
Assert.True(doc.ContainsKey("main"), "rendered YAML missing top-level 'main' key");
|
||||||
|
var mainSteps = doc["main"];
|
||||||
|
Assert.Single(mainSteps);
|
||||||
|
Assert.Equal(displayName, mainSteps[0]["step"] as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_EmitsSourceAnnotationForMainStep()
|
||||||
|
{
|
||||||
|
var entry = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"npm ci",
|
||||||
|
run: "npm ci",
|
||||||
|
sourcePath: ".github/workflows/ci.yml",
|
||||||
|
sourceLine: 42);
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
|
||||||
|
Assert.Contains(" source: .github/workflows/ci.yml:42\n", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_OmitsSourceAnnotationForPreAndPost()
|
||||||
|
{
|
||||||
|
var pre = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Pre,
|
||||||
|
"Pre actions/checkout@v4",
|
||||||
|
uses: "actions/checkout@v4",
|
||||||
|
sourcePath: ".github/workflows/ci.yml",
|
||||||
|
sourceLine: 9);
|
||||||
|
var post = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Post,
|
||||||
|
"Post actions/checkout@v4",
|
||||||
|
uses: "actions/checkout@v4",
|
||||||
|
sourcePath: ".github/workflows/ci.yml",
|
||||||
|
sourceLine: 9);
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { pre, post });
|
||||||
|
|
||||||
|
Assert.DoesNotContain("source:", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_EmitsMultilineRunAsBlockScalar()
|
||||||
|
{
|
||||||
|
var entry = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"multi",
|
||||||
|
run: "echo a\necho b\necho c");
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
|
||||||
|
Assert.Contains(" run: |\n", result.Yaml);
|
||||||
|
Assert.Contains(" echo a\n", result.Yaml);
|
||||||
|
Assert.Contains(" echo b\n", result.Yaml);
|
||||||
|
Assert.Contains(" echo c\n", result.Yaml);
|
||||||
|
Assert.DoesNotContain("truncated", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_EmitsAllUserAuthoredParamsForActionStep()
|
||||||
|
{
|
||||||
|
var entry = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"Run action",
|
||||||
|
uses: "actions/cache@v5",
|
||||||
|
id: "cache-primes",
|
||||||
|
@if: "${{ github.event_name == 'push' }}",
|
||||||
|
continueOnError: "true",
|
||||||
|
timeoutMinutes: "10",
|
||||||
|
envYaml: " NODE_ENV: production",
|
||||||
|
withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes",
|
||||||
|
sourcePath: "ci.yml",
|
||||||
|
sourceLine: 5);
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
|
||||||
|
Assert.Contains(" id: cache-primes\n", result.Yaml);
|
||||||
|
Assert.Contains(" uses: actions/cache@v5\n", result.Yaml);
|
||||||
|
Assert.Contains(" continue-on-error: true\n", result.Yaml);
|
||||||
|
Assert.Contains(" timeout-minutes: 10\n", result.Yaml);
|
||||||
|
Assert.Contains(" env:\n NODE_ENV: production\n", result.Yaml);
|
||||||
|
Assert.Contains(" with:\n path: prime-numbers\n key: ${{ runner.os }}-primes\n", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_EmitsRunStepWithShellAndWorkingDirectory()
|
||||||
|
{
|
||||||
|
var entry = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"Run tests",
|
||||||
|
run: "echo starting\nnpm test",
|
||||||
|
id: "test",
|
||||||
|
shell: "bash",
|
||||||
|
workingDirectory: "./api");
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
|
||||||
|
Assert.Contains(" run: |\n echo starting\n npm test\n", result.Yaml);
|
||||||
|
Assert.Contains(" shell: bash\n", result.Yaml);
|
||||||
|
Assert.Contains(" working-directory: ./api\n", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_PreservesExpressionsInRenderedYaml()
|
||||||
|
{
|
||||||
|
var entry = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"Cache",
|
||||||
|
uses: "actions/cache@v5",
|
||||||
|
withYaml: " key: ${{ runner.os }}-primes");
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
|
||||||
|
// Expressions render exactly as authored — no evaluation.
|
||||||
|
Assert.Contains("${{ runner.os }}-primes", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_PrePostStepsRemainMinimal()
|
||||||
|
{
|
||||||
|
// Even if a pre/post entry carries user-param fields (it shouldn't
|
||||||
|
// in production, but the renderer must defensively drop them),
|
||||||
|
// only step: + action: render for these phases.
|
||||||
|
var pre = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Pre,
|
||||||
|
"Pre actions/cache@v5",
|
||||||
|
uses: "actions/cache@v5",
|
||||||
|
id: "should-not-appear",
|
||||||
|
envYaml: " X: y",
|
||||||
|
withYaml: " key: nope");
|
||||||
|
var post = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Post,
|
||||||
|
"Post actions/cache@v5",
|
||||||
|
uses: "actions/cache@v5",
|
||||||
|
id: "should-not-appear",
|
||||||
|
envYaml: " X: y");
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { pre, post });
|
||||||
|
|
||||||
|
Assert.DoesNotContain("id:", result.Yaml);
|
||||||
|
Assert.DoesNotContain("env:", result.Yaml);
|
||||||
|
Assert.DoesNotContain("with:", result.Yaml);
|
||||||
|
Assert.DoesNotContain("should-not-appear", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_FieldOrderIsStable()
|
||||||
|
{
|
||||||
|
var entry = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"Everything",
|
||||||
|
uses: "actions/cache@v5",
|
||||||
|
id: "x",
|
||||||
|
@if: "always()",
|
||||||
|
continueOnError: "false",
|
||||||
|
timeoutMinutes: "5",
|
||||||
|
envYaml: " A: 1",
|
||||||
|
withYaml: " key: k",
|
||||||
|
sourcePath: "ci.yml",
|
||||||
|
sourceLine: 1);
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
var y = result.Yaml;
|
||||||
|
int iStep = y.IndexOf(" - step: ", StringComparison.Ordinal) >= 0
|
||||||
|
? y.IndexOf("- step:", StringComparison.Ordinal) : y.IndexOf("- step:", StringComparison.Ordinal);
|
||||||
|
int iId = y.IndexOf(" id:", StringComparison.Ordinal);
|
||||||
|
int iUses = y.IndexOf(" uses:", StringComparison.Ordinal);
|
||||||
|
int iIf = y.IndexOf(" if:", StringComparison.Ordinal);
|
||||||
|
int iCoe = y.IndexOf(" continue-on-error:", StringComparison.Ordinal);
|
||||||
|
int iTm = y.IndexOf(" timeout-minutes:", StringComparison.Ordinal);
|
||||||
|
int iEnv = y.IndexOf(" env:", StringComparison.Ordinal);
|
||||||
|
int iWith = y.IndexOf(" with:", StringComparison.Ordinal);
|
||||||
|
int iSrc = y.IndexOf(" source:", StringComparison.Ordinal);
|
||||||
|
Assert.True(iId < iUses && iUses < iIf && iIf < iCoe && iCoe < iTm && iTm < iEnv && iEnv < iWith && iWith < iSrc,
|
||||||
|
$"order wrong: id={iId} uses={iUses} if={iIf} coe={iCoe} tm={iTm} env={iEnv} with={iWith} src={iSrc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_OmitsEmptyOptionalFields()
|
||||||
|
{
|
||||||
|
var entry = new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"bare",
|
||||||
|
uses: "a/b@v1");
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
Assert.DoesNotContain(" id:", result.Yaml);
|
||||||
|
Assert.DoesNotContain(" if:", result.Yaml);
|
||||||
|
Assert.DoesNotContain(" continue-on-error:", result.Yaml);
|
||||||
|
Assert.DoesNotContain(" timeout-minutes:", result.Yaml);
|
||||||
|
Assert.DoesNotContain(" env:", result.Yaml);
|
||||||
|
Assert.DoesNotContain(" with:", result.Yaml);
|
||||||
|
Assert.DoesNotContain(" shell:", result.Yaml);
|
||||||
|
Assert.DoesNotContain(" working-directory:", result.Yaml);
|
||||||
|
Assert.DoesNotContain(" source:", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_HandlesEmptyEntries()
|
||||||
|
{
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new List<JobExecutionViewEntry>());
|
||||||
|
|
||||||
|
Assert.Empty(result.EntryStartLines);
|
||||||
|
Assert.Contains(" - step: Setup job\n", result.Yaml);
|
||||||
|
Assert.Contains(" - step: Complete job\n", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_NoPerEntryPhaseField()
|
||||||
|
{
|
||||||
|
// The phase: <value> per-entry field is gone — the section
|
||||||
|
// header is the phase indicator. Guard against accidental
|
||||||
|
// regressions.
|
||||||
|
var result = JobExecutionViewRenderer.Render("build", WorkedExampleEntries());
|
||||||
|
Assert.DoesNotContain("phase:", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_ThrowsOnNullJobId()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(
|
||||||
|
() => JobExecutionViewRenderer.Render(null, new List<JobExecutionViewEntry>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_ThrowsOnWhitespaceJobId()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(
|
||||||
|
() => JobExecutionViewRenderer.Render(" ", new List<JobExecutionViewEntry>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_ThrowsOnNullEntries()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(
|
||||||
|
() => JobExecutionViewRenderer.Render("j", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
[InlineData(null, 1)]
|
||||||
|
[InlineData("", 1)]
|
||||||
|
[InlineData(" ", 1)]
|
||||||
|
public void Entry_Constructor_RejectsBadDisplayName(string displayName, int sourceLine)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(
|
||||||
|
() => new JobExecutionViewEntry(JobExecutionPhase.Main, displayName, sourceLine: sourceLine));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Entry_Constructor_RejectsZeroLineWhenSourcePathSet()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(
|
||||||
|
() => new JobExecutionViewEntry(
|
||||||
|
JobExecutionPhase.Main,
|
||||||
|
"ok",
|
||||||
|
sourcePath: "ci.yml",
|
||||||
|
sourceLine: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_EmitsSkippedAnnotationForMarkedEntry()
|
||||||
|
{
|
||||||
|
var entry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
|
||||||
|
entry.IsSkipped = true;
|
||||||
|
|
||||||
|
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||||
|
|
||||||
|
// Annotation is inline on the `- step:` line so subsequent
|
||||||
|
// entry line numbers stay stable.
|
||||||
|
Assert.Contains("- step: Post X # (skipped — main step did not execute)\n", result.Yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Render_SkippedAnnotation_DoesNotShiftSubsequentLines()
|
||||||
|
{
|
||||||
|
var skipped = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post A", uses: "actions/a@v1");
|
||||||
|
var following = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post B", uses: "actions/b@v1");
|
||||||
|
|
||||||
|
var unmarked = JobExecutionViewRenderer.Render("j", new[] { skipped, following });
|
||||||
|
skipped.IsSkipped = true;
|
||||||
|
var marked = JobExecutionViewRenderer.Render("j", new[] { skipped, following });
|
||||||
|
|
||||||
|
// Following entry's start line must not move when the prior
|
||||||
|
// entry gets an inline skipped annotation.
|
||||||
|
Assert.Equal(unmarked.EntryStartLines[1], marked.EntryStartLines[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
hc.SetSingleton(_diagnosticLogManager.Object);
|
hc.SetSingleton(_diagnosticLogManager.Object);
|
||||||
hc.SetSingleton(_jobHookProvider.Object);
|
hc.SetSingleton(_jobHookProvider.Object);
|
||||||
hc.SetSingleton(_snapshotOperationProvider.Object);
|
hc.SetSingleton(_snapshotOperationProvider.Object);
|
||||||
|
hc.SetSingleton(new Mock<IDapDebugger>().Object);
|
||||||
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // JobExecutionContext
|
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // JobExecutionContext
|
||||||
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // job start hook
|
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // job start hook
|
||||||
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // Initial Job
|
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // Initial Job
|
||||||
@@ -549,10 +550,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
var _stepsRunner = new StepsRunner();
|
var _stepsRunner = new StepsRunner();
|
||||||
_stepsRunner.Initialize(hc);
|
_stepsRunner.Initialize(hc);
|
||||||
|
|
||||||
var bgCoordinator = new BackgroundStepCoordinator();
|
|
||||||
bgCoordinator.Initialize(hc);
|
|
||||||
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
|
|
||||||
|
|
||||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||||
hc.SetSingleton(mockDapDebugger.Object);
|
hc.SetSingleton(mockDapDebugger.Object);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using GitHub.DistributedTask.WebApi;
|
using GitHub.DistributedTask.WebApi;
|
||||||
using GitHub.Runner.Worker;
|
using GitHub.Runner.Worker;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
using Moq;
|
using Moq;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -83,6 +84,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
hc.SetSingleton(_extensions.Object);
|
hc.SetSingleton(_extensions.Object);
|
||||||
hc.SetSingleton(_temp.Object);
|
hc.SetSingleton(_temp.Object);
|
||||||
hc.SetSingleton(_diagnosticLogManager.Object);
|
hc.SetSingleton(_diagnosticLogManager.Object);
|
||||||
|
hc.SetSingleton(new Mock<IDapDebugger>().Object);
|
||||||
hc.EnqueueInstance<IExecutionContext>(_jobEc);
|
hc.EnqueueInstance<IExecutionContext>(_jobEc);
|
||||||
hc.EnqueueInstance<IPagingLogger>(_logger.Object);
|
hc.EnqueueInstance<IPagingLogger>(_logger.Object);
|
||||||
hc.EnqueueInstance<IJobExtension>(_jobExtension.Object);
|
hc.EnqueueInstance<IJobExtension>(_jobExtension.Object);
|
||||||
@@ -175,5 +177,29 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
|
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task DebuggerDisabled_DoesNotInvokeDapDebugger()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Override the lenient IDapDebugger singleton from CreateTestContext
|
||||||
|
// with a strict mock. If the containment guard fails, the production
|
||||||
|
// code will call OnJobStepsInitializedAsync and the strict mock will throw.
|
||||||
|
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
|
||||||
|
hc.SetSingleton(dapMock.Object);
|
||||||
|
|
||||||
|
var message = GetMessage();
|
||||||
|
// EnableDebugger defaults to false on AgentJobRequestMessage.
|
||||||
|
Assert.False(message.EnableDebugger);
|
||||||
|
|
||||||
|
await _jobRunner.RunAsync(message, _tokenSource.Token);
|
||||||
|
|
||||||
|
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
|
||||||
|
dapMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
425
src/Test/L0/Worker/StepEntryTranslatorL0.cs
Normal file
425
src/Test/L0/Worker/StepEntryTranslatorL0.cs
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||||
|
using GitHub.DistributedTask.Pipelines;
|
||||||
|
using GitHub.Runner.Worker;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Common.Tests.Worker
|
||||||
|
{
|
||||||
|
public sealed class StepEntryTranslatorL0
|
||||||
|
{
|
||||||
|
private static StringToken Str(string s) => new(null, null, null, s);
|
||||||
|
|
||||||
|
private static MappingToken Map(params (string Key, TemplateToken Value)[] pairs)
|
||||||
|
{
|
||||||
|
var m = new MappingToken(null, null, null);
|
||||||
|
foreach (var (k, v) in pairs)
|
||||||
|
{
|
||||||
|
m.Add(Str(k), v);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IActionRunner> NewActionRunnerMock(
|
||||||
|
ActionRunStage stage,
|
||||||
|
string displayName,
|
||||||
|
ActionStepDefinitionReference reference,
|
||||||
|
ActionStep actionOverride = null)
|
||||||
|
{
|
||||||
|
var mock = new Mock<IActionRunner>();
|
||||||
|
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||||
|
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||||
|
mock.SetupGet(x => x.Action).Returns(actionOverride ?? new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
});
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_NullStep_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() =>
|
||||||
|
StepEntryTranslator.TryTranslate(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_JobExtensionRunner_ReturnsNull()
|
||||||
|
{
|
||||||
|
var step = new JobExtensionRunner(
|
||||||
|
runAsync: (_, __) => System.Threading.Tasks.Task.CompletedTask,
|
||||||
|
condition: null,
|
||||||
|
displayName: "Set up job",
|
||||||
|
data: null);
|
||||||
|
|
||||||
|
Assert.Null(StepEntryTranslator.TryTranslate(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_OtherIStepType_ReturnsNull()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IStep>();
|
||||||
|
mock.SetupGet(x => x.DisplayName).Returns("custom");
|
||||||
|
|
||||||
|
Assert.Null(StepEntryTranslator.TryTranslate(mock.Object));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionRunnerPre_ReturnsPreEntry()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference
|
||||||
|
{
|
||||||
|
Name = "actions/checkout",
|
||||||
|
Ref = "v4",
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre Run actions/checkout@v4", reference);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
|
||||||
|
Assert.Equal("Pre Run actions/checkout@v4", entry.DisplayName);
|
||||||
|
Assert.Equal("actions/checkout@v4", entry.Uses);
|
||||||
|
Assert.Null(entry.Run);
|
||||||
|
Assert.Null(entry.SourcePath);
|
||||||
|
Assert.Equal(0, entry.SourceLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionRunnerMain_ReturnsMainEntryWithUses()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference
|
||||||
|
{
|
||||||
|
Name = "actions/setup-node",
|
||||||
|
Path = "subdir",
|
||||||
|
Ref = "v3",
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run actions/setup-node@v3", reference);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
|
||||||
|
Assert.Equal("actions/setup-node/subdir@v3", entry.Uses);
|
||||||
|
Assert.Null(entry.Run);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionRunnerMain_ScriptReference_LeavesUsesNull()
|
||||||
|
{
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo hi", new ScriptReference());
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
|
||||||
|
Assert.Null(entry.Uses);
|
||||||
|
Assert.Null(entry.Run);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionRunnerMain_ContainerReference_UsesImage()
|
||||||
|
{
|
||||||
|
var reference = new ContainerRegistryReference { Image = "alpine:3.18" };
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run alpine", reference);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal("alpine:3.18", entry.Uses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionRunnerPost_ReturnsPostEntry()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v3" };
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Post, "Post Run actions/cache@v3", reference);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal(JobExecutionPhase.Post, entry.Phase);
|
||||||
|
Assert.Equal("Post Run actions/cache@v3", entry.DisplayName);
|
||||||
|
Assert.Equal("actions/cache@v3", entry.Uses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionRunner_NullAction_LeavesUsesNull()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IActionRunner>();
|
||||||
|
mock.SetupGet(x => x.Stage).Returns(ActionRunStage.Main);
|
||||||
|
mock.SetupGet(x => x.DisplayName).Returns("anonymous");
|
||||||
|
mock.SetupGet(x => x.Action).Returns((ActionStep)null);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal("anonymous", entry.DisplayName);
|
||||||
|
Assert.Null(entry.Uses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionStep_ExtractsWith()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
Inputs = Map(("path", Str("prime-numbers")), ("key", Str("k"))),
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.NotNull(entry.WithYaml);
|
||||||
|
Assert.Contains("path: prime-numbers", entry.WithYaml);
|
||||||
|
Assert.Contains("key: k", entry.WithYaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionStep_PreservesExpressionInWith()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
Inputs = Map(("key", Str("${{ runner.os }}-primes"))),
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Contains("${{ runner.os }}-primes", entry.WithYaml);
|
||||||
|
Assert.DoesNotContain("Linux", entry.WithYaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_RunStep_ExtractsScript()
|
||||||
|
{
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = new ScriptReference(),
|
||||||
|
Inputs = Map(("script", Str("echo hi"))),
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo", new ScriptReference(), action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Null(entry.Uses);
|
||||||
|
Assert.Equal("echo hi", entry.Run);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_RunStep_ExtractsShellAndWorkingDirectory()
|
||||||
|
{
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = new ScriptReference(),
|
||||||
|
Inputs = Map(
|
||||||
|
("script", Str("npm test")),
|
||||||
|
("shell", Str("bash")),
|
||||||
|
("working-directory", Str("./api"))),
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", new ScriptReference(), action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal("npm test", entry.Run);
|
||||||
|
Assert.Equal("bash", entry.Shell);
|
||||||
|
Assert.Equal("./api", entry.WorkingDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionStep_FiltersRunStepKeysFromWith()
|
||||||
|
{
|
||||||
|
// Defensive: an action step's Inputs should not contain
|
||||||
|
// run-step internal keys, but if it did, they must not
|
||||||
|
// surface in the with: rendering.
|
||||||
|
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
Inputs = Map(
|
||||||
|
("mode", Str("ci")),
|
||||||
|
("script", Str("leak")),
|
||||||
|
("shell", Str("leak")),
|
||||||
|
("working-directory", Str("leak"))),
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.NotNull(entry.WithYaml);
|
||||||
|
Assert.Contains("mode: ci", entry.WithYaml);
|
||||||
|
Assert.DoesNotContain("leak", entry.WithYaml);
|
||||||
|
Assert.DoesNotContain("script", entry.WithYaml);
|
||||||
|
Assert.DoesNotContain("shell", entry.WithYaml);
|
||||||
|
Assert.DoesNotContain("working-directory", entry.WithYaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionStep_OmitsEmptyEnv()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
Environment = new MappingToken(null, null, null),
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Null(entry.EnvYaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionStep_ExtractsEnv()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
Environment = Map(("NODE_ENV", Str("production"))),
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.NotNull(entry.EnvYaml);
|
||||||
|
Assert.Contains("NODE_ENV: production", entry.EnvYaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("__1")]
|
||||||
|
[InlineData("__123")]
|
||||||
|
public void Translate_FiltersAutoGeneratedId(string contextName)
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
ContextName = contextName,
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Null(entry.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_PreservesUserId()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
ContextName = "cache-primes",
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal("cache-primes", entry.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_ActionStep_ExtractsCondition()
|
||||||
|
{
|
||||||
|
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
Condition = "always()",
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal("always()", entry.If);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Translate_PreEntry_OmitsUserParams()
|
||||||
|
{
|
||||||
|
// Pre entries stay minimal — they reference the same Action as
|
||||||
|
// Main, and duplicating params adds noise.
|
||||||
|
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||||
|
var action = new ActionStep
|
||||||
|
{
|
||||||
|
Reference = reference,
|
||||||
|
ContextName = "user-id",
|
||||||
|
Condition = "always()",
|
||||||
|
Environment = Map(("X", Str("y"))),
|
||||||
|
Inputs = Map(("k", Str("v"))),
|
||||||
|
};
|
||||||
|
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre a/b@v1", reference, action);
|
||||||
|
|
||||||
|
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
|
||||||
|
Assert.Null(entry.Id);
|
||||||
|
Assert.Null(entry.If);
|
||||||
|
Assert.Null(entry.EnvYaml);
|
||||||
|
Assert.Null(entry.WithYaml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,10 +63,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
_stepsRunner = new StepsRunner();
|
_stepsRunner = new StepsRunner();
|
||||||
_stepsRunner.Initialize(hc);
|
_stepsRunner.Initialize(hc);
|
||||||
|
|
||||||
var bgCoordinator = new BackgroundStepCoordinator();
|
|
||||||
bgCoordinator.Initialize(hc);
|
|
||||||
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
|
|
||||||
|
|
||||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||||
hc.SetSingleton(mockDapDebugger.Object);
|
hc.SetSingleton(mockDapDebugger.Object);
|
||||||
|
|
||||||
|
|||||||
155
src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
Normal file
155
src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Common.Tests.Worker
|
||||||
|
{
|
||||||
|
public sealed class TemplateTokenYamlAdapterL0
|
||||||
|
{
|
||||||
|
private static StringToken Str(string s) => new(null, null, null, s);
|
||||||
|
private static BooleanToken Bool(bool b) => new(null, null, null, b);
|
||||||
|
private static NumberToken Num(double n) => new(null, null, null, n);
|
||||||
|
private static BasicExpressionToken Expr(string s) => new(null, null, null, s);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_StringScalar()
|
||||||
|
{
|
||||||
|
Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_BooleanScalar()
|
||||||
|
{
|
||||||
|
Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0));
|
||||||
|
Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_NumberScalar()
|
||||||
|
{
|
||||||
|
Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_NullToken_RendersAsNull()
|
||||||
|
{
|
||||||
|
Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_PreservesBasicExpression()
|
||||||
|
{
|
||||||
|
var token = Expr("runner.os");
|
||||||
|
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||||
|
Assert.Contains("${{ runner.os }}", yaml);
|
||||||
|
Assert.DoesNotContain("Linux", yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_PreservesCompositeExpressionInStringToken()
|
||||||
|
{
|
||||||
|
// Composite strings like `${{ runner.os }}-primes` are parsed
|
||||||
|
// as a StringToken whose value is exactly that literal. The
|
||||||
|
// adapter must round-trip the literal unchanged.
|
||||||
|
var token = Str("${{ runner.os }}-primes");
|
||||||
|
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||||
|
Assert.Contains("${{ runner.os }}-primes", yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_NestedMapping()
|
||||||
|
{
|
||||||
|
var inner = new MappingToken(null, null, null);
|
||||||
|
inner.Add(Str("b"), Num(1));
|
||||||
|
inner.Add(Str("c"), Expr("x"));
|
||||||
|
var outer = new MappingToken(null, null, null);
|
||||||
|
outer.Add(Str("a"), inner);
|
||||||
|
|
||||||
|
string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0);
|
||||||
|
|
||||||
|
Assert.Contains("a:", yaml);
|
||||||
|
Assert.Contains("b: 1", yaml);
|
||||||
|
Assert.Contains("c: ${{ x }}", yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_EmptyMapping()
|
||||||
|
{
|
||||||
|
var token = new MappingToken(null, null, null);
|
||||||
|
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||||
|
Assert.Equal("{}", yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_EmptySequence()
|
||||||
|
{
|
||||||
|
var token = new SequenceToken(null, null, null);
|
||||||
|
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||||
|
Assert.Equal("[]", yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_MultilineString_UsesBlockScalar()
|
||||||
|
{
|
||||||
|
var token = Str("line1\nline2\nline3");
|
||||||
|
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||||
|
// Block-literal indicator `|` appears for multi-line scalars.
|
||||||
|
Assert.Contains("|", yaml);
|
||||||
|
Assert.Contains("line1", yaml);
|
||||||
|
Assert.Contains("line2", yaml);
|
||||||
|
Assert.Contains("line3", yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_IndentLevel_PrefixesNonEmptyLines()
|
||||||
|
{
|
||||||
|
var map = new MappingToken(null, null, null);
|
||||||
|
map.Add(Str("k1"), Str("v1"));
|
||||||
|
map.Add(Str("k2"), Str("v2"));
|
||||||
|
|
||||||
|
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4);
|
||||||
|
|
||||||
|
foreach (var line in yaml.Split('\n'))
|
||||||
|
{
|
||||||
|
if (line.Length > 0)
|
||||||
|
{
|
||||||
|
Assert.StartsWith(" ", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.Contains("k1: v1", yaml);
|
||||||
|
Assert.Contains("k2: v2", yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void Serialize_NoTrailingNewline()
|
||||||
|
{
|
||||||
|
var token = Str("hello");
|
||||||
|
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||||
|
Assert.False(yaml.EndsWith("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
|
|||||||
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
|
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
|
||||||
PACKAGE_DIR="$SCRIPT_DIR/../_package"
|
PACKAGE_DIR="$SCRIPT_DIR/../_package"
|
||||||
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
|
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
|
||||||
DOTNETSDK_VERSION="8.0.421"
|
DOTNETSDK_VERSION="8.0.420"
|
||||||
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
||||||
RUNNER_VERSION=$(cat runnerversion)
|
RUNNER_VERSION=$(cat runnerversion)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"sdk": {
|
"sdk": {
|
||||||
"version": "8.0.421"
|
"version": "8.0.420"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.335.0
|
2.334.0
|
||||||
|
|||||||
Reference in New Issue
Block a user