Compare commits

..

1 Commits

Author SHA1 Message Date
Salman Chishti
a153d802ff Update release version to 2.332.0 2026-02-25 13:46:12 +00:00
46 changed files with 72 additions and 6687 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,369 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Handlers;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Executes <see cref="RunCommand"/> objects in the job's runtime context.
///
/// Mirrors the behavior of a normal workflow <c>run:</c> step as closely
/// as possible by reusing the runner's existing shell-resolution logic,
/// script fixup helpers, and process execution infrastructure.
///
/// Output is streamed to the debugger via DAP <c>output</c> events with
/// secrets masked before emission.
/// </summary>
internal sealed class DapReplExecutor
{
private readonly IHostContext _hostContext;
private readonly Action<string, string> _sendOutput;
private readonly Tracing _trace;
public DapReplExecutor(IHostContext hostContext, Action<string, string> sendOutput)
{
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
_sendOutput = sendOutput ?? throw new ArgumentNullException(nameof(sendOutput));
_trace = hostContext.GetTrace(nameof(DapReplExecutor));
}
/// <summary>
/// Executes a <see cref="RunCommand"/> and returns the exit code as a
/// formatted <see cref="EvaluateResponseBody"/>.
/// </summary>
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
RunCommand command,
IExecutionContext context,
CancellationToken cancellationToken)
{
if (context == null)
{
return ErrorResult("No execution context available. The debugger must be paused at a step to run commands.");
}
try
{
return await ExecuteScriptAsync(command, context, cancellationToken);
}
catch (Exception ex)
{
_trace.Error($"REPL run command failed ({ex.GetType().Name})");
var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message);
return ErrorResult($"Command failed: {maskedError}");
}
}
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
RunCommand command,
IExecutionContext context,
CancellationToken cancellationToken)
{
// 1. Resolve shell — same logic as ScriptHandler
string shellCommand;
string argFormat;
if (!string.IsNullOrEmpty(command.Shell))
{
// Explicit shell from the DSL
var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell);
shellCommand = parsed.shellCommand;
argFormat = string.IsNullOrEmpty(parsed.shellArgs)
? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand)
: parsed.shellArgs;
}
else
{
// Default shell — mirrors ScriptHandler platform defaults
shellCommand = ResolveDefaultShell(context);
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
_trace.Info("Resolved REPL shell");
// 2. Expand ${{ }} expressions in the script body, just like
// ActionRunner evaluates step inputs before ScriptHandler sees them
var contents = ExpandExpressions(command.Script, context);
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
// Write to a temp file (same pattern as ScriptHandler)
var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand);
var scriptFilePath = Path.Combine(
_hostContext.GetDirectory(WellKnownDirectory.Temp),
$"dap_repl_{Guid.NewGuid()}{extension}");
Encoding encoding = new UTF8Encoding(false);
#if OS_WINDOWS
contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n");
encoding = Console.InputEncoding.CodePage != 65001
? Console.InputEncoding
: encoding;
#endif
File.WriteAllText(scriptFilePath, contents, encoding);
try
{
// 3. Format arguments with script path
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
{
return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'");
}
var arguments = string.Format(argFormat, resolvedPath);
// 4. Resolve shell command path
string prependPath = string.Join(
Path.PathSeparator.ToString(),
Enumerable.Reverse(context.Global.PrependPath));
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
?? shellCommand;
// 5. Build environment — merge from execution context like a real step
var environment = BuildEnvironment(context, command.Env);
// 6. Resolve working directory
var workingDirectory = command.WorkingDirectory;
if (string.IsNullOrEmpty(workingDirectory))
{
var githubContext = context.ExpressionValues.TryGetValue("github", out var gh)
? gh as DictionaryContextData
: null;
var workspace = githubContext?.TryGetValue("workspace", out var ws) == true
? (ws as StringContextData)?.Value
: null;
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
}
_trace.Info("Executing REPL command");
// Stream execution info to debugger
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
int exitCode;
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
{
processInvoker.OutputDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stdout", masked + "\n");
}
};
processInvoker.ErrorDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stderr", masked + "\n");
}
};
exitCode = await processInvoker.ExecuteAsync(
workingDirectory: workingDirectory,
fileName: commandPath,
arguments: arguments,
environment: environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: true,
cancellationToken: cancellationToken);
}
_trace.Info($"REPL command exited with code {exitCode}");
// 8. Return only the exit code summary (output was already streamed)
return new EvaluateResponseBody
{
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
Type = exitCode == 0 ? "string" : "error",
VariablesReference = 0
};
}
finally
{
// Clean up temp script file
try { File.Delete(scriptFilePath); }
catch { /* best effort */ }
}
}
/// <summary>
/// Expands <c>${{ }}</c> expressions in the input string using the
/// runner's template evaluator — the same evaluation path that processes
/// step inputs before <see cref="ScriptHandler"/> runs them.
///
/// Each <c>${{ expr }}</c> occurrence is individually evaluated and
/// replaced with its masked string result, mirroring the semantics of
/// expression interpolation in a workflow <c>run:</c> step body.
/// </summary>
internal string ExpandExpressions(string input, IExecutionContext context)
{
if (string.IsNullOrEmpty(input) || !input.Contains("${{"))
{
return input ?? string.Empty;
}
var result = new StringBuilder();
int pos = 0;
while (pos < input.Length)
{
var start = input.IndexOf("${{", pos, StringComparison.Ordinal);
if (start < 0)
{
result.Append(input, pos, input.Length - pos);
break;
}
// Append the literal text before the expression
result.Append(input, pos, start - pos);
var end = input.IndexOf("}}", start + 3, StringComparison.Ordinal);
if (end < 0)
{
// Unterminated expression — keep literal
result.Append(input, start, input.Length - start);
break;
}
var expr = input.Substring(start + 3, end - start - 3).Trim();
end += 2; // skip past "}}"
// Evaluate the expression
try
{
var templateEvaluator = context.ToPipelineTemplateEvaluator();
var token = new GitHub.DistributedTask.ObjectTemplating.Tokens.BasicExpressionToken(
null, null, null, expr);
var evaluated = templateEvaluator.EvaluateStepDisplayName(
token,
context.ExpressionValues,
context.ExpressionFunctions);
result.Append(_hostContext.SecretMasker.MaskSecrets(evaluated ?? string.Empty));
}
catch (Exception ex)
{
_trace.Warning($"Expression expansion failed ({ex.GetType().Name})");
// Keep the original expression literal on failure
result.Append(input, start, end - start);
}
pos = end;
}
return result.ToString();
}
/// <summary>
/// Resolves the default shell the same way <see cref="ScriptHandler"/>
/// does: check job defaults, then fall back to platform default.
/// </summary>
internal string ResolveDefaultShell(IExecutionContext context)
{
// Check job defaults
if (context.Global?.JobDefaults != null &&
context.Global.JobDefaults.TryGetValue("run", out var runDefaults) &&
runDefaults.TryGetValue("shell", out var defaultShell) &&
!string.IsNullOrEmpty(defaultShell))
{
_trace.Info("Using job default shell");
return defaultShell;
}
#if OS_WINDOWS
string prependPath = string.Join(
Path.PathSeparator.ToString(),
context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty<string>());
var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath);
return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell";
#else
return "sh";
#endif
}
/// <summary>
/// Merges the job context environment with any REPL-specific overrides.
/// </summary>
internal Dictionary<string, string> BuildEnvironment(
IExecutionContext context,
Dictionary<string, string> replEnv)
{
var env = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer);
// Pull environment from the execution context (same as ActionRunner)
if (context.ExpressionValues.TryGetValue("env", out var envData))
{
if (envData is DictionaryContextData dictEnv)
{
foreach (var pair in dictEnv)
{
if (pair.Value is StringContextData str)
{
env[pair.Key] = str.Value;
}
}
}
else if (envData is CaseSensitiveDictionaryContextData csEnv)
{
foreach (var pair in csEnv)
{
if (pair.Value is StringContextData str)
{
env[pair.Key] = str.Value;
}
}
}
}
// Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.)
foreach (var ctxPair in context.ExpressionValues)
{
if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
{
foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables())
{
env[rtEnv.Key] = rtEnv.Value;
}
}
}
// Apply REPL-specific overrides last (so they win),
// expanding any ${{ }} expressions in the values
if (replEnv != null)
{
foreach (var pair in replEnv)
{
env[pair.Key] = ExpandExpressions(pair.Value, context);
}
}
return env;
}
private void SendOutput(string category, string text)
{
_sendOutput(category, text);
}
private static EvaluateResponseBody ErrorResult(string message)
{
return new EvaluateResponseBody
{
Result = message,
Type = "error",
VariablesReference = 0
};
}
}
}

View File

@@ -1,411 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Base type for all REPL DSL commands.
/// </summary>
internal abstract class DapReplCommand
{
}
/// <summary>
/// <c>help</c> or <c>help("run")</c>
/// </summary>
internal sealed class HelpCommand : DapReplCommand
{
public string Topic { get; set; }
}
/// <summary>
/// <c>run("echo hello")</c> or
/// <c>run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp")</c>
/// </summary>
internal sealed class RunCommand : DapReplCommand
{
public string Script { get; set; }
public string Shell { get; set; }
public Dictionary<string, string> Env { get; set; }
public string WorkingDirectory { get; set; }
}
/// <summary>
/// Parses REPL input into typed <see cref="DapReplCommand"/> objects.
///
/// Grammar (intentionally minimal — extend as the DSL grows):
/// <code>
/// help → HelpCommand { Topic = null }
/// help("run") → HelpCommand { Topic = "run" }
/// run("script body") → RunCommand { Script = "script body" }
/// run("script", shell: "bash") → RunCommand { Shell = "bash" }
/// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } }
/// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" }
/// </code>
///
/// Parsing is intentionally hand-rolled rather than regex-based so it can
/// handle nested braces, quoted strings with escapes, and grow to support
/// future commands without accumulating regex complexity.
/// </summary>
internal static class DapReplParser
{
/// <summary>
/// Attempts to parse REPL input into a command. Returns null if the
/// input does not match any known DSL command (i.e. it should be
/// treated as an expression instead).
/// </summary>
internal static DapReplCommand TryParse(string input, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(input))
{
return null;
}
var trimmed = input.Trim();
// help / help("topic")
if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase))
{
return ParseHelp(trimmed, out error);
}
// run("...")
if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase))
{
return ParseRun(trimmed, out error);
}
// Not a DSL command
return null;
}
internal static string GetGeneralHelp()
{
return """
Actions Debug Console
Commands:
help Show this help
help("run") Show help for the run command
run("script") Execute a script (like a workflow run step)
Anything else is evaluated as a GitHub Actions expression.
Example: github.repository
Example: ${{ github.event_name }}
""";
}
internal static string GetRunHelp()
{
return """
run command execute a script in the job context
Usage:
run("echo hello")
run("echo $FOO", shell: "bash")
run("echo $FOO", env: { FOO: "bar" })
run("ls", working_directory: "/tmp")
run("echo $X", shell: "bash", env: { X: "1" }, working_directory: "/tmp")
Options:
shell: Shell to use (default: job default, e.g. bash)
env: Extra environment variables as { KEY: "value" }
working_directory: Working directory for the command
Behavior:
- Equivalent to a workflow `run:` step
- Expressions in the script body are expanded (${{ ... }})
- Output is streamed in real time and secrets are masked
""";
}
#region Parsers
private static HelpCommand ParseHelp(string input, out string error)
{
error = null;
if (input.Equals("help", StringComparison.OrdinalIgnoreCase))
{
return new HelpCommand();
}
// help("topic")
var inner = ExtractParenthesizedArgs(input, "help", out error);
if (error != null) return null;
var topic = ExtractQuotedString(inner.Trim(), out error);
if (error != null) return null;
return new HelpCommand { Topic = topic };
}
private static RunCommand ParseRun(string input, out string error)
{
error = null;
var inner = ExtractParenthesizedArgs(input, "run", out error);
if (error != null) return null;
// Split into argument list respecting quotes and braces
var args = SplitArguments(inner, out error);
if (error != null) return null;
if (args.Count == 0)
{
error = "run() requires a script argument. Example: run(\"echo hello\")";
return null;
}
// First arg must be the script body (a quoted string)
var script = ExtractQuotedString(args[0].Trim(), out error);
if (error != null)
{
error = $"First argument to run() must be a quoted string. {error}";
return null;
}
var cmd = new RunCommand { Script = script };
// Parse remaining keyword arguments
for (int i = 1; i < args.Count; i++)
{
var kv = args[i].Trim();
var colonIdx = kv.IndexOf(':');
if (colonIdx <= 0)
{
error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}";
return null;
}
var key = kv.Substring(0, colonIdx).Trim();
var value = kv.Substring(colonIdx + 1).Trim();
switch (key.ToLowerInvariant())
{
case "shell":
cmd.Shell = ExtractQuotedString(value, out error);
if (error != null) { error = $"shell: {error}"; return null; }
break;
case "working_directory":
cmd.WorkingDirectory = ExtractQuotedString(value, out error);
if (error != null) { error = $"working_directory: {error}"; return null; }
break;
case "env":
cmd.Env = ParseEnvBlock(value, out error);
if (error != null) { error = $"env: {error}"; return null; }
break;
default:
error = $"Unknown option: {key}. Valid options: shell, env, working_directory";
return null;
}
}
return cmd;
}
#endregion
#region Low-level parsing helpers
/// <summary>
/// Given "cmd(...)" returns the inner content between the outer parens.
/// </summary>
private static string ExtractParenthesizedArgs(string input, string prefix, out string error)
{
error = null;
var start = prefix.Length; // skip "cmd"
if (start >= input.Length || input[start] != '(')
{
error = $"Expected '(' after {prefix}";
return null;
}
if (input[input.Length - 1] != ')')
{
error = $"Expected ')' at end of {prefix}(...)";
return null;
}
return input.Substring(start + 1, input.Length - start - 2);
}
/// <summary>
/// Extracts a double-quoted string value, handling escaped quotes.
/// </summary>
internal static string ExtractQuotedString(string input, out string error)
{
error = null;
if (string.IsNullOrEmpty(input))
{
error = "Expected a quoted string, got empty input";
return null;
}
if (input[0] != '"')
{
error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}";
return null;
}
var sb = new StringBuilder();
for (int i = 1; i < input.Length; i++)
{
if (input[i] == '\\' && i + 1 < input.Length)
{
sb.Append(input[i + 1]);
i++;
}
else if (input[i] == '"')
{
// Check nothing meaningful follows the closing quote
var rest = input.Substring(i + 1).Trim();
if (rest.Length > 0)
{
error = $"Unexpected content after closing quote: {Truncate(rest, 40)}";
return null;
}
return sb.ToString();
}
else
{
sb.Append(input[i]);
}
}
error = "Unterminated string (missing closing \")";
return null;
}
/// <summary>
/// Splits a comma-separated argument list, respecting quoted strings
/// and nested braces so that <c>"a, b", env: { K: "V, W" }</c> is
/// correctly split into two arguments.
/// </summary>
internal static List<string> SplitArguments(string input, out string error)
{
error = null;
var result = new List<string>();
var current = new StringBuilder();
int depth = 0;
bool inQuote = false;
for (int i = 0; i < input.Length; i++)
{
var ch = input[i];
if (ch == '\\' && inQuote && i + 1 < input.Length)
{
current.Append(ch);
current.Append(input[++i]);
continue;
}
if (ch == '"')
{
inQuote = !inQuote;
current.Append(ch);
continue;
}
if (!inQuote)
{
if (ch == '{')
{
depth++;
current.Append(ch);
continue;
}
if (ch == '}')
{
depth--;
current.Append(ch);
continue;
}
if (ch == ',' && depth == 0)
{
result.Add(current.ToString());
current.Clear();
continue;
}
}
current.Append(ch);
}
if (inQuote)
{
error = "Unterminated string in arguments";
return null;
}
if (depth != 0)
{
error = "Unmatched braces in arguments";
return null;
}
if (current.Length > 0)
{
result.Add(current.ToString());
}
return result;
}
/// <summary>
/// Parses <c>{ KEY: "value", KEY2: "value2" }</c> into a dictionary.
/// </summary>
internal static Dictionary<string, string> ParseEnvBlock(string input, out string error)
{
error = null;
var trimmed = input.Trim();
if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}"))
{
error = "Expected env block in the form { KEY: \"value\" }";
return null;
}
var inner = trimmed.Substring(1, trimmed.Length - 2).Trim();
if (string.IsNullOrEmpty(inner))
{
return new Dictionary<string, string>();
}
var pairs = SplitArguments(inner, out error);
if (error != null) return null;
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in pairs)
{
var colonIdx = pair.IndexOf(':');
if (colonIdx <= 0)
{
error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}";
return null;
}
var key = pair.Substring(0, colonIdx).Trim();
var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error);
if (error != null) return null;
result[key] = val;
}
return result;
}
private static string Truncate(string value, int maxLength)
{
if (value == null) return "(null)";
return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "...";
}
#endregion
}
}

View File

@@ -1,387 +0,0 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Maps runner execution context data to DAP scopes and variables.
///
/// This is the single point where runner context values are materialized
/// for the debugger. All values pass through the runner's existing
/// <see cref="GitHub.DistributedTask.Logging.ISecretMasker"/> so the DAP
/// surface never exposes anything beyond what a normal CI log would show.
///
/// The secrets scope is intentionally opaque: keys are visible but every
/// value is replaced with a constant redaction marker.
///
/// Designed to be reusable by future DAP features (evaluate, hover, REPL)
/// so that masking policy is never duplicated.
/// </summary>
internal sealed class DapVariableProvider
{
// Well-known scope names that map to top-level expression contexts.
// Order matters: the index determines the stable variablesReference ID.
internal static readonly string[] ScopeNames =
{
"github", "env", "runner", "job", "steps",
"secrets", "inputs", "vars", "matrix", "needs"
};
// Scope references occupy the range [1, ScopeReferenceMax].
private const int ScopeReferenceBase = 1;
private const int ScopeReferenceMax = 100;
// Dynamic (nested) variable references start above the scope range.
private const int DynamicReferenceBase = 101;
internal const string RedactedValue = "***";
private readonly IHostContext _hostContext;
// Maps dynamic variable reference IDs to the backing data and its
// dot-separated path (e.g. "github.event.pull_request").
private readonly Dictionary<int, (PipelineContextData Data, string Path)> _variableReferences = new();
private int _nextVariableReference = DynamicReferenceBase;
public DapVariableProvider(IHostContext hostContext)
{
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
}
/// <summary>
/// Clears all dynamic variable references.
/// Call this whenever the paused execution context changes (e.g. new step)
/// so that stale nested references are not served to the client.
/// </summary>
public void Reset()
{
_variableReferences.Clear();
_nextVariableReference = DynamicReferenceBase;
}
/// <summary>
/// Returns the list of DAP scopes for the given execution context.
/// Each scope corresponds to a well-known runner expression context
/// (github, env, secrets, …) and carries a stable variablesReference
/// that the client can use to drill into variables.
/// </summary>
public List<Scope> GetScopes(IExecutionContext context)
{
var scopes = new List<Scope>();
if (context?.ExpressionValues == null)
{
return scopes;
}
for (int i = 0; i < ScopeNames.Length; i++)
{
var scopeName = ScopeNames[i];
if (!context.ExpressionValues.TryGetValue(scopeName, out var value) || value == null)
{
continue;
}
var scope = new Scope
{
Name = scopeName,
VariablesReference = ScopeReferenceBase + i,
Expensive = false,
PresentationHint = scopeName == "secrets" ? "registers" : null
};
if (value is DictionaryContextData dict)
{
scope.NamedVariables = dict.Count;
}
else if (value is CaseSensitiveDictionaryContextData csDict)
{
scope.NamedVariables = csDict.Count;
}
scopes.Add(scope);
}
return scopes;
}
/// <summary>
/// Returns the child variables for a given variablesReference.
/// The reference may point at a top-level scope (1100) or a
/// dynamically registered nested container (101+).
/// </summary>
public List<Variable> GetVariables(IExecutionContext context, int variablesReference)
{
var variables = new List<Variable>();
if (context?.ExpressionValues == null)
{
return variables;
}
PipelineContextData data = null;
string basePath = null;
bool isSecretsScope = false;
if (variablesReference >= ScopeReferenceBase && variablesReference <= ScopeReferenceMax)
{
var scopeIndex = variablesReference - ScopeReferenceBase;
if (scopeIndex < ScopeNames.Length)
{
var scopeName = ScopeNames[scopeIndex];
isSecretsScope = scopeName == "secrets";
if (context.ExpressionValues.TryGetValue(scopeName, out data))
{
basePath = scopeName;
}
}
}
else if (_variableReferences.TryGetValue(variablesReference, out var refData))
{
data = refData.Data;
basePath = refData.Path;
isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true;
}
if (data == null)
{
return variables;
}
ConvertToVariables(data, basePath, isSecretsScope, variables);
return variables;
}
/// <summary>
/// Applies the runner's secret masker to the given value.
/// This is the single masking entry-point for all DAP-visible strings
/// and is intentionally public so future DAP features (evaluate, REPL)
/// can reuse it without duplicating policy.
/// </summary>
public string MaskSecrets(string value)
{
if (string.IsNullOrEmpty(value))
{
return value ?? string.Empty;
}
return _hostContext.SecretMasker.MaskSecrets(value);
}
/// <summary>
/// Evaluates a GitHub Actions expression (e.g. "github.repository",
/// "${{ github.event_name }}") in the context of the current step and
/// returns a masked result suitable for the DAP evaluate response.
///
/// Uses the runner's standard <see cref="GitHub.DistributedTask.Pipelines.ObjectTemplating.IPipelineTemplateEvaluator"/>
/// so the full expression language is available (functions, operators,
/// context access).
/// </summary>
public EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context)
{
if (context?.ExpressionValues == null)
{
return new EvaluateResponseBody
{
Result = "(no execution context available)",
Type = "string",
VariablesReference = 0
};
}
// Strip ${{ }} wrapper if present
var expr = expression?.Trim() ?? string.Empty;
if (expr.StartsWith("${{") && expr.EndsWith("}}"))
{
expr = expr.Substring(3, expr.Length - 5).Trim();
}
if (string.IsNullOrEmpty(expr))
{
return new EvaluateResponseBody
{
Result = string.Empty,
Type = "string",
VariablesReference = 0
};
}
try
{
var templateEvaluator = context.ToPipelineTemplateEvaluator();
var token = new BasicExpressionToken(null, null, null, expr);
var result = templateEvaluator.EvaluateStepDisplayName(
token,
context.ExpressionValues,
context.ExpressionFunctions);
result = MaskSecrets(result ?? "null");
return new EvaluateResponseBody
{
Result = result,
Type = InferResultType(result),
VariablesReference = 0
};
}
catch (Exception ex)
{
var errorMessage = MaskSecrets($"Evaluation error: {ex.Message}");
return new EvaluateResponseBody
{
Result = errorMessage,
Type = "string",
VariablesReference = 0
};
}
}
/// <summary>
/// Infers a simple DAP type hint from the string representation of a result.
/// </summary>
internal static string InferResultType(string value)
{
if (value == null || value == "null")
return "null";
if (value == "true" || value == "false")
return "boolean";
if (double.TryParse(value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out _))
return "number";
if (value.StartsWith("{") || value.StartsWith("["))
return "object";
return "string";
}
#region Private helpers
private void ConvertToVariables(
PipelineContextData data,
string basePath,
bool isSecretsScope,
List<Variable> variables)
{
switch (data)
{
case DictionaryContextData dict:
foreach (var pair in dict)
{
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
}
break;
case CaseSensitiveDictionaryContextData csDict:
foreach (var pair in csDict)
{
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
}
break;
case ArrayContextData array:
for (int i = 0; i < array.Count; i++)
{
var variable = CreateVariable($"[{i}]", array[i], basePath, isSecretsScope);
variables.Add(variable);
}
break;
}
}
private Variable CreateVariable(
string name,
PipelineContextData value,
string basePath,
bool isSecretsScope)
{
var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}";
var variable = new Variable
{
Name = name,
EvaluateName = $"${{{{ {childPath} }}}}"
};
if (value == null)
{
variable.Value = isSecretsScope ? RedactedValue : "null";
variable.Type = "null";
variable.VariablesReference = 0;
return variable;
}
// Secrets scope: redact ALL values regardless of underlying type.
// Keys are visible but values are always replaced with the
// redaction marker, and nested containers are not drillable.
if (isSecretsScope)
{
variable.Value = RedactedValue;
variable.Type = "string";
variable.VariablesReference = 0;
return variable;
}
switch (value)
{
case StringContextData str:
variable.Value = MaskSecrets(str.Value);
variable.Type = "string";
variable.VariablesReference = 0;
break;
case NumberContextData num:
variable.Value = MaskSecrets(num.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture));
variable.Type = "number";
variable.VariablesReference = 0;
break;
case BooleanContextData boolVal:
variable.Value = MaskSecrets(boolVal.Value ? "true" : "false");
variable.Type = "boolean";
variable.VariablesReference = 0;
break;
case DictionaryContextData dict:
variable.Value = $"Object ({dict.Count} properties)";
variable.Type = "object";
variable.VariablesReference = RegisterVariableReference(dict, childPath);
variable.NamedVariables = dict.Count;
break;
case CaseSensitiveDictionaryContextData csDict:
variable.Value = $"Object ({csDict.Count} properties)";
variable.Type = "object";
variable.VariablesReference = RegisterVariableReference(csDict, childPath);
variable.NamedVariables = csDict.Count;
break;
case ArrayContextData array:
variable.Value = $"Array ({array.Count} items)";
variable.Type = "array";
variable.VariablesReference = RegisterVariableReference(array, childPath);
variable.IndexedVariables = array.Count;
break;
default:
var rawValue = value.ToJToken()?.ToString() ?? "unknown";
variable.Value = MaskSecrets(rawValue);
variable.Type = value.GetType().Name;
variable.VariablesReference = 0;
break;
}
return variable;
}
private int RegisterVariableReference(PipelineContextData data, string path)
{
var reference = _nextVariableReference++;
_variableReferences[reference] = (data, path);
return reference;
}
#endregion
}
}

View File

@@ -1,26 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
public enum DapSessionState
{
WaitingForConnection,
Initializing,
Ready,
Paused,
Running,
Terminated
}
[ServiceLocator(Default = typeof(DapDebugger))]
public interface IDapDebugger : IRunnerService
{
Task StartAsync(CancellationToken cancellationToken);
Task WaitUntilReadyAsync(CancellationToken cancellationToken);
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken);
void OnStepCompleted(IStep step);
Task OnJobCompletedAsync();
}
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Test")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]

View File

@@ -13,7 +13,6 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
using Sdk.RSWebApi.Contracts;
@@ -29,7 +28,6 @@ namespace GitHub.Runner.Worker
public sealed class JobRunner : RunnerService, IJobRunner
{
private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult";
private IJobServerQueue _jobServerQueue;
private RunnerSettings _runnerSettings;
private ITempDirectoryManager _tempDirectoryManager;
@@ -114,7 +112,6 @@ namespace GitHub.Runner.Worker
IExecutionContext jobContext = null;
CancellationTokenRegistration? runnerShutdownRegistration = null;
IDapDebugger dapDebugger = null;
try
{
// Create the job execution context.
@@ -181,26 +178,6 @@ namespace GitHub.Runner.Worker
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
_tempDirectoryManager.InitializeTempDirectory(jobContext);
// Setup the debugger
if (jobContext.Global.EnableDebugger)
{
Trace.Info("Debugger enabled for this job run");
try
{
dapDebugger = HostContext.GetService<IDapDebugger>();
await dapDebugger.StartAsync(jobRequestCancellationToken);
}
catch (Exception ex)
{
Trace.Error($"Failed to start DAP debugger: {ex.Message}");
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
jobContext.Error("Failed to start debugger.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
}
}
// Get the job extension.
Trace.Info("Getting job extension.");
IJobExtension jobExtension = HostContext.CreateService<IJobExtension>();
@@ -242,33 +219,6 @@ namespace GitHub.Runner.Worker
await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000));
}
// Wait for DAP debugger client connection and handshake after "Set up job"
// so the job page shows the setup step before we block on the debugger
if (dapDebugger != null)
{
try
{
await dapDebugger.WaitUntilReadyAsync(jobRequestCancellationToken);
AddDebuggerConnectionTelemetry(jobContext, "Connected");
}
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
{
Trace.Info("Job was cancelled before debugger client connected.");
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
jobContext.Error("Job was cancelled before debugger client connected.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled);
}
catch (Exception ex)
{
Trace.Error($"DAP debugger failed to become ready: {ex.Message}");
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
// If debugging was requested but the debugger is not available, fail the job
jobContext.Error("The debugger failed to start or no debugger client connected in time.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
}
}
// Run all job steps
Trace.Info("Run all job steps.");
var stepsRunner = HostContext.GetService<IStepsRunner>();
@@ -309,11 +259,6 @@ namespace GitHub.Runner.Worker
runnerShutdownRegistration = null;
}
if (dapDebugger != null)
{
await dapDebugger.OnJobCompletedAsync();
}
await ShutdownQueue(throwOnFailure: false);
}
}
@@ -495,15 +440,6 @@ namespace GitHub.Runner.Worker
throw new AggregateException(exceptions);
}
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
{
jobContext.Global.JobTelemetry.Add(new JobTelemetry
{
Type = JobTelemetryType.General,
Message = $"{DebuggerConnectionTelemetryPrefix}: {result}"
});
}
private void MaskTelemetrySecrets(List<JobTelemetry> jobTelemetry)
{
foreach (var telemetryItem in jobTelemetry)

View File

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

View File

@@ -10,7 +10,6 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Expressions;
namespace GitHub.Runner.Worker
@@ -51,7 +50,6 @@ namespace GitHub.Runner.Worker
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
bool checkPostJobActions = false;
var dapDebugger = HostContext.GetService<IDapDebugger>();
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
{
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
@@ -228,14 +226,9 @@ namespace GitHub.Runner.Worker
}
else
{
// Pause for DAP debugger before step execution
await dapDebugger?.OnStepStartingAsync(step, jobContext, jobContext.CancellationToken);
// Run the step
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);
dapDebugger?.OnStepCompleted(step);
}
}
finally
@@ -262,7 +255,6 @@ namespace GitHub.Runner.Worker
Trace.Info($"Current state: job state = '{jobContext.Result}'");
}
}
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
using GitHub.Runner.Listener.Check;
using GitHub.Runner.Listener.Configuration;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Container.ContainerHooks;
using GitHub.Runner.Worker.Handlers;
using System;

View File

@@ -1,452 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Newtonsoft.Json;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapDebuggerL0
{
private const string PortEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT";
private const string TimeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT";
private DapDebugger _debugger;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_debugger = new DapDebugger();
_debugger.Initialize(hc);
return hc;
}
private static async Task WithEnvironmentVariableAsync(string name, string value, Func<Task> action)
{
var originalValue = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
try
{
await action();
}
finally
{
Environment.SetEnvironmentVariable(name, originalValue);
}
}
private static void WithEnvironmentVariable(string name, string value, Action action)
{
var originalValue = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
try
{
action();
}
finally
{
Environment.SetEnvironmentVariable(name, originalValue);
}
}
private static int GetFreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
private static async Task<TcpClient> ConnectClientAsync(int port)
{
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port);
return client;
}
private static async Task SendRequestAsync(NetworkStream stream, Request request)
{
var json = JsonConvert.SerializeObject(request);
var body = Encoding.UTF8.GetBytes(json);
var header = $"Content-Length: {body.Length}\r\n\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, 0, headerBytes.Length);
await stream.WriteAsync(body, 0, body.Length);
await stream.FlushAsync();
}
/// <summary>
/// Reads a single DAP-framed message from a stream with a timeout.
/// Parses the Content-Length header, reads exactly that many bytes,
/// and returns the JSON body. Fails with a clear error on timeout.
/// </summary>
private static async Task<string> ReadDapMessageAsync(NetworkStream stream, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
var token = cts.Token;
var headerBuilder = new StringBuilder();
var buffer = new byte[1];
var contentLength = -1;
while (true)
{
var readTask = stream.ReadAsync(buffer, 0, 1, token);
var bytesRead = await readTask;
if (bytesRead == 0)
{
throw new EndOfStreamException("Connection closed while reading DAP headers");
}
headerBuilder.Append((char)buffer[0]);
var headers = headerBuilder.ToString();
if (headers.EndsWith("\r\n\r\n", StringComparison.Ordinal))
{
foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase))
{
contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim());
}
}
break;
}
}
if (contentLength < 0)
{
throw new InvalidOperationException("No Content-Length header found in DAP message");
}
var body = new byte[contentLength];
var totalRead = 0;
while (totalRead < contentLength)
{
var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead, token);
if (bytesRead == 0)
{
throw new EndOfStreamException("Connection closed while reading DAP body");
}
totalRead += bytesRead;
}
return Encoding.UTF8.GetString(body);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeSucceeds()
{
using (CreateTestContext())
{
Assert.NotNull(_debugger);
Assert.False(_debugger.IsActive);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortUsesCustomPortFromEnvironment()
{
using (CreateTestContext())
{
WithEnvironmentVariable(PortEnvironmentVariable, "9999", () =>
{
Assert.Equal(9999, _debugger.ResolvePort());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortIgnoresInvalidPortFromEnvironment()
{
using (CreateTestContext())
{
WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () =>
{
Assert.Equal(4711, _debugger.ResolvePort());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortIgnoresOutOfRangePortFromEnvironment()
{
using (CreateTestContext())
{
WithEnvironmentVariable(PortEnvironmentVariable, "99999", () =>
{
Assert.Equal(4711, _debugger.ResolvePort());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveTimeoutUsesCustomTimeoutFromEnvironment()
{
using (CreateTestContext())
{
WithEnvironmentVariable(TimeoutEnvironmentVariable, "30", () =>
{
Assert.Equal(30, _debugger.ResolveTimeout());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveTimeoutIgnoresInvalidTimeoutFromEnvironment()
{
using (CreateTestContext())
{
WithEnvironmentVariable(TimeoutEnvironmentVariable, "not-a-number", () =>
{
Assert.Equal(15, _debugger.ResolveTimeout());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveTimeoutIgnoresZeroTimeoutFromEnvironment()
{
using (CreateTestContext())
{
WithEnvironmentVariable(TimeoutEnvironmentVariable, "0", () =>
{
Assert.Equal(15, _debugger.ResolveTimeout());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAndStopLifecycle()
{
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _debugger.StartAsync(cts.Token);
using var client = await ConnectClientAsync(port);
Assert.True(client.Connected);
await _debugger.StopAsync();
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAndStopMultipleTimesDoesNotThrow()
{
using (CreateTestContext())
{
foreach (var port in new[] { GetFreePort(), GetFreePort() })
{
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _debugger.StartAsync(cts.Token);
await _debugger.StopAsync();
});
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitUntilReadyCompletesAfterClientConnectionAndConfigurationDone()
{
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _debugger.StartAsync(cts.Token);
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
Assert.Equal(DapSessionState.Ready, _debugger.State);
await _debugger.StopAsync();
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitUntilReadyRegistersCancellation()
{
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _debugger.StartAsync(cts.Token);
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
cts.Cancel();
Assert.True(SpinWait.SpinUntil(() => _debugger.State == DapSessionState.Terminated, TimeSpan.FromSeconds(5)));
await _debugger.StopAsync();
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StopWithoutStartDoesNotThrow()
{
using (CreateTestContext())
{
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobCompletedTerminatesSession()
{
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _debugger.StartAsync(cts.Token);
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitUntilReadyBeforeStartIsNoOp()
{
using (CreateTestContext())
{
await _debugger.WaitUntilReadyAsync(CancellationToken.None);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledException()
{
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _debugger.StartAsync(cts.Token);
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
await Task.Delay(50);
cts.Cancel();
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
Assert.IsNotType<TimeoutException>(ex);
await _debugger.StopAsync();
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task InitializeRequestOverSocketPreservesProtocolMetadataWhenSecretsCollide()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue("response");
hc.SecretMasker.AddValue("initialize");
hc.SecretMasker.AddValue("event");
hc.SecretMasker.AddValue("initialized");
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _debugger.StartAsync(cts.Token);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"response\"", response);
Assert.Contains("\"command\":\"initialize\"", response);
Assert.Contains("\"success\":true", response);
var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"event\"", initializedEvent);
Assert.Contains("\"event\":\"initialized\"", initializedEvent);
await _debugger.StopAsync();
});
}
}
}
}

View File

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

View File

@@ -1,237 +0,0 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapReplExecutorL0
{
private TestHostContext _hc;
private DapReplExecutor _executor;
private List<Event> _sentEvents;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
_hc = new TestHostContext(this, testName);
_sentEvents = new List<Event>();
_executor = new DapReplExecutor(_hc, (category, text) =>
{
_sentEvents.Add(new Event
{
EventType = "output",
Body = new OutputEventBody
{
Category = category,
Output = text
}
});
});
return _hc;
}
private Mock<IExecutionContext> CreateMockContext(
DictionaryContextData exprValues = null,
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
{
var mock = new Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
mock.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
var global = new GlobalContext
{
PrependPath = new List<string>(),
JobDefaults = jobDefaults
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
};
mock.Setup(x => x.Global).Returns(global);
return mock;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ExecuteRunCommand_NullContext_ReturnsError()
{
using (CreateTestContext())
{
var command = new RunCommand { Script = "echo hello" };
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
Assert.Equal("error", result.Type);
Assert.Contains("No execution context available", result.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_NoExpressions_ReturnsInput()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("echo hello", context.Object);
Assert.Equal("echo hello", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_NullInput_ReturnsEmpty()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions(null, context.Object);
Assert.Equal(string.Empty, result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_EmptyInput_ReturnsEmpty()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("", context.Object);
Assert.Equal(string.Empty, result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_UnterminatedExpression_KeepsLiteral()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object);
Assert.Equal("echo ${{ github.repo", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveDefaultShell_NoJobDefaults_ReturnsPlatformDefault()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ResolveDefaultShell(context.Object);
#if OS_WINDOWS
Assert.True(result == "pwsh" || result == "powershell");
#else
Assert.Equal("sh", result);
#endif
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault()
{
using (CreateTestContext())
{
var jobDefaults = new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase)
{
["run"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["shell"] = "bash"
}
};
var context = CreateMockContext(jobDefaults: jobDefaults);
var result = _executor.ResolveDefaultShell(context.Object);
Assert.Equal("bash", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_MergesEnvContextAndReplOverrides()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("bar"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var replEnv = new Dictionary<string, string> { { "BAZ", "qux" } };
var result = _executor.BuildEnvironment(context.Object, replEnv);
Assert.Equal("bar", result["FOO"]);
Assert.Equal("qux", result["BAZ"]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_ReplOverridesWin()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("original"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var replEnv = new Dictionary<string, string> { { "FOO", "override" } };
var result = _executor.BuildEnvironment(context.Object, replEnv);
Assert.Equal("override", result["FOO"]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("bar"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var result = _executor.BuildEnvironment(context.Object, null);
Assert.Equal("bar", result["FOO"]);
Assert.False(result.ContainsKey("BAZ"));
}
}
}
}

View File

@@ -1,314 +0,0 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapReplParserL0
{
#region help command
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_HelpReturnsHelpCommand()
{
var cmd = DapReplParser.TryParse("help", out var error);
Assert.Null(error);
var help = Assert.IsType<HelpCommand>(cmd);
Assert.Null(help.Topic);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_HelpCaseInsensitive()
{
var cmd = DapReplParser.TryParse("Help", out var error);
Assert.Null(error);
Assert.IsType<HelpCommand>(cmd);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_HelpWithTopic()
{
var cmd = DapReplParser.TryParse("help(\"run\")", out var error);
Assert.Null(error);
var help = Assert.IsType<HelpCommand>(cmd);
Assert.Equal("run", help.Topic);
}
#endregion
#region run command basic
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunSimpleScript()
{
var cmd = DapReplParser.TryParse("run(\"echo hello\")", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo hello", run.Script);
Assert.Null(run.Shell);
Assert.Null(run.Env);
Assert.Null(run.WorkingDirectory);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithShell()
{
var cmd = DapReplParser.TryParse("run(\"echo hello\", shell: \"bash\")", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo hello", run.Script);
Assert.Equal("bash", run.Shell);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithWorkingDirectory()
{
var cmd = DapReplParser.TryParse("run(\"ls\", working_directory: \"/tmp\")", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("ls", run.Script);
Assert.Equal("/tmp", run.WorkingDirectory);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithEnv()
{
var cmd = DapReplParser.TryParse("run(\"echo $FOO\", env: { FOO: \"bar\" })", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo $FOO", run.Script);
Assert.NotNull(run.Env);
Assert.Equal("bar", run.Env["FOO"]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithMultipleEnvVars()
{
var cmd = DapReplParser.TryParse("run(\"echo\", env: { A: \"1\", B: \"2\" })", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal(2, run.Env.Count);
Assert.Equal("1", run.Env["A"]);
Assert.Equal("2", run.Env["B"]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithAllOptions()
{
var input = "run(\"echo $X\", shell: \"zsh\", env: { X: \"1\" }, working_directory: \"/tmp\")";
var cmd = DapReplParser.TryParse(input, out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo $X", run.Script);
Assert.Equal("zsh", run.Shell);
Assert.Equal("1", run.Env["X"]);
Assert.Equal("/tmp", run.WorkingDirectory);
}
#endregion
#region run command edge cases
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithEscapedQuotes()
{
var cmd = DapReplParser.TryParse("run(\"echo \\\"hello\\\"\")", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo \"hello\"", run.Script);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithCommaInEnvValue()
{
var cmd = DapReplParser.TryParse("run(\"echo\", env: { CSV: \"a,b,c\" })", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("a,b,c", run.Env["CSV"]);
}
#endregion
#region error cases
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunEmptyArgsReturnsError()
{
var cmd = DapReplParser.TryParse("run()", out var error);
Assert.NotNull(error);
Assert.Null(cmd);
Assert.Contains("requires a script argument", error);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunUnquotedArgReturnsError()
{
var cmd = DapReplParser.TryParse("run(echo hello)", out var error);
Assert.NotNull(error);
Assert.Null(cmd);
Assert.Contains("quoted string", error);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunUnknownOptionReturnsError()
{
var cmd = DapReplParser.TryParse("run(\"echo\", timeout: \"10\")", out var error);
Assert.NotNull(error);
Assert.Null(cmd);
Assert.Contains("Unknown option", error);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunMissingClosingParenReturnsError()
{
var cmd = DapReplParser.TryParse("run(\"echo\"", out var error);
Assert.NotNull(error);
Assert.Null(cmd);
}
#endregion
#region non-DSL input falls through
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_ExpressionReturnsNull()
{
var cmd = DapReplParser.TryParse("github.repository", out var error);
Assert.Null(error);
Assert.Null(cmd);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_WrappedExpressionReturnsNull()
{
var cmd = DapReplParser.TryParse("${{ github.event_name }}", out var error);
Assert.Null(error);
Assert.Null(cmd);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_EmptyInputReturnsNull()
{
var cmd = DapReplParser.TryParse("", out var error);
Assert.Null(error);
Assert.Null(cmd);
cmd = DapReplParser.TryParse(null, out error);
Assert.Null(error);
Assert.Null(cmd);
}
#endregion
#region help text
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetGeneralHelp_ContainsCommands()
{
var help = DapReplParser.GetGeneralHelp();
Assert.Contains("help", help);
Assert.Contains("run", help);
Assert.Contains("expression", help, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetRunHelp_ContainsOptions()
{
var help = DapReplParser.GetRunHelp();
Assert.Contains("shell", help);
Assert.Contains("env", help);
Assert.Contains("working_directory", help);
}
#endregion
#region internal parser helpers
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SplitArguments_HandlesNestedBraces()
{
var args = DapReplParser.SplitArguments("\"hello\", env: { A: \"1\", B: \"2\" }", out var error);
Assert.Null(error);
Assert.Equal(2, args.Count);
Assert.Equal("\"hello\"", args[0].Trim());
Assert.Contains("A:", args[1]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ParseEnvBlock_HandlesEmptyBlock()
{
var result = DapReplParser.ParseEnvBlock("{ }", out var error);
Assert.Null(error);
Assert.NotNull(result);
Assert.Empty(result);
}
#endregion
}
}

View File

@@ -1,772 +0,0 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapVariableProviderL0
{
private TestHostContext _hc;
private DapVariableProvider _provider;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
_hc = new TestHostContext(this, testName);
_provider = new DapVariableProvider(_hc);
return _hc;
}
private Moq.Mock<GitHub.Runner.Worker.IExecutionContext> CreateMockContext(DictionaryContextData expressionValues)
{
var mock = new Moq.Mock<GitHub.Runner.Worker.IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
return mock;
}
#region GetScopes tests
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_ReturnsEmptyWhenContextIsNull()
{
using (CreateTestContext())
{
var scopes = _provider.GetScopes(null);
Assert.Empty(scopes);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_ReturnsOnlyPopulatedScopes()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "repository", new StringContextData("owner/repo") }
};
exprValues["env"] = new DictionaryContextData
{
{ "CI", new StringContextData("true") },
{ "HOME", new StringContextData("/home/runner") }
};
// "runner" is not set — should not appear in scopes
var ctx = CreateMockContext(exprValues);
var scopes = _provider.GetScopes(ctx.Object);
Assert.Equal(2, scopes.Count);
Assert.Equal("github", scopes[0].Name);
Assert.Equal("env", scopes[1].Name);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_ReportsNamedVariableCount()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "A", new StringContextData("1") },
{ "B", new StringContextData("2") },
{ "C", new StringContextData("3") }
};
var ctx = CreateMockContext(exprValues);
var scopes = _provider.GetScopes(ctx.Object);
Assert.Single(scopes);
Assert.Equal(3, scopes[0].NamedVariables);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_SecretsGetSpecialPresentationHint()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "MY_SECRET", new StringContextData("super-secret") }
};
exprValues["env"] = new DictionaryContextData
{
{ "CI", new StringContextData("true") }
};
var ctx = CreateMockContext(exprValues);
var scopes = _provider.GetScopes(ctx.Object);
var envScope = scopes.Find(s => s.Name == "env");
var secretsScope = scopes.Find(s => s.Name == "secrets");
Assert.NotNull(envScope);
Assert.Null(envScope.PresentationHint);
Assert.NotNull(secretsScope);
Assert.Equal("registers", secretsScope.PresentationHint);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_StableVariablesReferenceIds()
{
using (CreateTestContext())
{
// Populate all 10 scopes and verify their reference IDs
// are stable and based on array position
var exprValues = new DictionaryContextData();
foreach (var name in DapVariableProvider.ScopeNames)
{
exprValues[name] = new DictionaryContextData();
}
var ctx = CreateMockContext(exprValues);
var scopes = _provider.GetScopes(ctx.Object);
Assert.Equal(DapVariableProvider.ScopeNames.Length, scopes.Count);
for (int i = 0; i < scopes.Count; i++)
{
Assert.Equal(DapVariableProvider.ScopeNames[i], scopes[i].Name);
// Reference IDs are 1-based: index 0 -> ref 1, index 1 -> ref 2, etc.
Assert.Equal(i + 1, scopes[i].VariablesReference);
}
}
}
#endregion
#region GetVariables basic types
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_ReturnsEmptyWhenContextIsNull()
{
using (CreateTestContext())
{
var variables = _provider.GetVariables(null, 1);
Assert.Empty(variables);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_ReturnsStringVariables()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "CI", new StringContextData("true") },
{ "HOME", new StringContextData("/home/runner") }
};
var ctx = CreateMockContext(exprValues);
// "env" is at ScopeNames index 1 → variablesReference = 2
var variables = _provider.GetVariables(ctx.Object, 2);
Assert.Equal(2, variables.Count);
var ciVar = variables.Find(v => v.Name == "CI");
Assert.NotNull(ciVar);
Assert.Equal("true", ciVar.Value);
Assert.Equal("string", ciVar.Type);
Assert.Equal(0, ciVar.VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_ReturnsBooleanVariables()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "event_name", new StringContextData("push") },
};
// Use a nested dict with boolean to test
var jobDict = new DictionaryContextData();
// BooleanContextData is a valid PipelineContextData type
// but job context typically has strings. Use env scope instead.
exprValues["env"] = new DictionaryContextData
{
{ "flag", new BooleanContextData(true) }
};
var ctx = CreateMockContext(exprValues);
// "env" is at index 1 → ref 2
var variables = _provider.GetVariables(ctx.Object, 2);
var flagVar = variables.Find(v => v.Name == "flag");
Assert.NotNull(flagVar);
Assert.Equal("true", flagVar.Value);
Assert.Equal("boolean", flagVar.Type);
Assert.Equal(0, flagVar.VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_ReturnsNumberVariables()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "count", new NumberContextData(42) }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 2);
var countVar = variables.Find(v => v.Name == "count");
Assert.NotNull(countVar);
Assert.Equal("42", countVar.Value);
Assert.Equal("number", countVar.Type);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_HandlesNullValues()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var dict = new DictionaryContextData();
dict["present"] = new StringContextData("yes");
dict["missing"] = null;
exprValues["env"] = dict;
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 2);
var nullVar = variables.Find(v => v.Name == "missing");
Assert.NotNull(nullVar);
Assert.Equal("null", nullVar.Value);
Assert.Equal("null", nullVar.Type);
Assert.Equal(0, nullVar.VariablesReference);
}
}
#endregion
#region GetVariables nested expansion
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_NestedDictionaryIsExpandable()
{
using (CreateTestContext())
{
var innerDict = new DictionaryContextData
{
{ "name", new StringContextData("push") },
{ "ref", new StringContextData("refs/heads/main") }
};
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "event", innerDict }
};
var ctx = CreateMockContext(exprValues);
// "github" is at index 0 → ref 1
var variables = _provider.GetVariables(ctx.Object, 1);
var eventVar = variables.Find(v => v.Name == "event");
Assert.NotNull(eventVar);
Assert.Equal("object", eventVar.Type);
Assert.True(eventVar.VariablesReference > 0, "Nested dict should have a non-zero variablesReference");
Assert.Equal(2, eventVar.NamedVariables);
// Now expand it
var children = _provider.GetVariables(ctx.Object, eventVar.VariablesReference);
Assert.Equal(2, children.Count);
var nameVar = children.Find(v => v.Name == "name");
Assert.NotNull(nameVar);
Assert.Equal("push", nameVar.Value);
Assert.Equal("${{ github.event.name }}", nameVar.EvaluateName);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_NestedArrayIsExpandable()
{
using (CreateTestContext())
{
var array = new ArrayContextData();
array.Add(new StringContextData("item0"));
array.Add(new StringContextData("item1"));
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "list", array }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 2);
var listVar = variables.Find(v => v.Name == "list");
Assert.NotNull(listVar);
Assert.Equal("array", listVar.Type);
Assert.True(listVar.VariablesReference > 0);
Assert.Equal(2, listVar.IndexedVariables);
// Expand the array
var items = _provider.GetVariables(ctx.Object, listVar.VariablesReference);
Assert.Equal(2, items.Count);
Assert.Equal("[0]", items[0].Name);
Assert.Equal("item0", items[0].Value);
Assert.Equal("[1]", items[1].Name);
Assert.Equal("item1", items[1].Value);
}
}
#endregion
#region Secret masking
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeValuesAreRedacted()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "MY_TOKEN", new StringContextData("ghp_abc123secret") },
{ "DB_PASSWORD", new StringContextData("p@ssword!") }
};
var ctx = CreateMockContext(exprValues);
// "secrets" is at index 5 → ref 6
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Equal(2, variables.Count);
foreach (var v in variables)
{
Assert.Equal(DapVariableProvider.RedactedValue, v.Value);
Assert.Equal("string", v.Type);
}
// Keys should still be visible
Assert.Contains(variables, v => v.Name == "MY_TOKEN");
Assert.Contains(variables, v => v.Name == "DB_PASSWORD");
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_NonSecretScopeValuesMaskedBySecretMasker()
{
using (var hc = CreateTestContext())
{
// Register a known secret value with the masker
hc.SecretMasker.AddValue("super-secret-token");
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "SAFE", new StringContextData("hello world") },
{ "LEAKED", new StringContextData("prefix-super-secret-token-suffix") }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 2);
var safeVar = variables.Find(v => v.Name == "SAFE");
Assert.NotNull(safeVar);
Assert.Equal("hello world", safeVar.Value);
var leakedVar = variables.Find(v => v.Name == "LEAKED");
Assert.NotNull(leakedVar);
Assert.DoesNotContain("super-secret-token", leakedVar.Value);
Assert.Contains("***", leakedVar.Value);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MaskSecrets_DelegatesToHostContextSecretMasker()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue("my-secret");
Assert.Equal("before-***-after", _provider.MaskSecrets("before-my-secret-after"));
Assert.Equal("no secrets here", _provider.MaskSecrets("no secrets here"));
Assert.Equal(string.Empty, _provider.MaskSecrets(null));
Assert.Equal(string.Empty, _provider.MaskSecrets(string.Empty));
}
}
#endregion
#region Reset
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Reset_InvalidatesNestedReferences()
{
using (CreateTestContext())
{
var innerDict = new DictionaryContextData
{
{ "name", new StringContextData("push") }
};
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "event", innerDict }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 1);
var eventVar = variables.Find(v => v.Name == "event");
Assert.True(eventVar.VariablesReference > 0);
var savedRef = eventVar.VariablesReference;
// Reset should clear all dynamic references
_provider.Reset();
var children = _provider.GetVariables(ctx.Object, savedRef);
Assert.Empty(children);
}
}
#endregion
#region EvaluateName
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SetsEvaluateNameWithDotPath()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "repository", new StringContextData("owner/repo") }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 1);
var repoVar = variables.Find(v => v.Name == "repository");
Assert.NotNull(repoVar);
Assert.Equal("${{ github.repository }}", repoVar.EvaluateName);
}
}
#endregion
#region EvaluateExpression
/// <summary>
/// Creates a mock execution context with Global set up so that
/// ToPipelineTemplateEvaluator() works for real expression evaluation.
/// </summary>
private Moq.Mock<IExecutionContext> CreateEvaluatableContext(
TestHostContext hc,
DictionaryContextData expressionValues)
{
var mock = new Moq.Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
mock.Setup(x => x.ExpressionFunctions)
.Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
mock.Setup(x => x.Global).Returns(new GlobalContext
{
FileTable = new List<string>(),
Variables = new Variables(hc, new Dictionary<string, VariableValue>()),
});
// ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls
// context.Write — provide a no-op so it doesn't NRE.
mock.Setup(x => x.Write(Moq.It.IsAny<string>(), Moq.It.IsAny<string>()));
return mock;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_ReturnsValueForSimpleExpression()
{
using (var hc = CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "repository", new StringContextData("owner/repo") }
};
var ctx = CreateEvaluatableContext(hc, exprValues);
var result = _provider.EvaluateExpression("github.repository", ctx.Object);
Assert.Equal("owner/repo", result.Result);
Assert.Equal("string", result.Type);
Assert.Equal(0, result.VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_StripsWrapperSyntax()
{
using (var hc = CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "event_name", new StringContextData("push") }
};
var ctx = CreateEvaluatableContext(hc, exprValues);
var result = _provider.EvaluateExpression("${{ github.event_name }}", ctx.Object);
Assert.Equal("push", result.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_MasksSecretInResult()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue("super-secret");
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "TOKEN", new StringContextData("super-secret") }
};
var ctx = CreateEvaluatableContext(hc, exprValues);
var result = _provider.EvaluateExpression("env.TOKEN", ctx.Object);
Assert.DoesNotContain("super-secret", result.Result);
Assert.Contains("***", result.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_ReturnsErrorForInvalidExpression()
{
using (var hc = CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData();
var ctx = CreateEvaluatableContext(hc, exprValues);
// An invalid expression syntax should not throw — it should
// return an error result.
var result = _provider.EvaluateExpression("!!!invalid[[", ctx.Object);
Assert.Contains("error", result.Result, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_ReturnsMessageWhenNoContext()
{
using (CreateTestContext())
{
var result = _provider.EvaluateExpression("github.repository", null);
Assert.Contains("no execution context", result.Result, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_ReturnsEmptyForEmptyExpression()
{
using (var hc = CreateTestContext())
{
var exprValues = new DictionaryContextData();
var ctx = CreateEvaluatableContext(hc, exprValues);
var result = _provider.EvaluateExpression("", ctx.Object);
Assert.Equal(string.Empty, result.Result);
}
}
#endregion
#region InferResultType
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InferResultType_ClassifiesCorrectly()
{
using (CreateTestContext())
{
Assert.Equal("null", DapVariableProvider.InferResultType(null));
Assert.Equal("null", DapVariableProvider.InferResultType("null"));
Assert.Equal("boolean", DapVariableProvider.InferResultType("true"));
Assert.Equal("boolean", DapVariableProvider.InferResultType("false"));
Assert.Equal("number", DapVariableProvider.InferResultType("42"));
Assert.Equal("number", DapVariableProvider.InferResultType("3.14"));
Assert.Equal("object", DapVariableProvider.InferResultType("{\"key\":\"val\"}"));
Assert.Equal("object", DapVariableProvider.InferResultType("[1,2,3]"));
Assert.Equal("string", DapVariableProvider.InferResultType("hello world"));
Assert.Equal("string", DapVariableProvider.InferResultType("owner/repo"));
}
}
#endregion
#region Non-string secret type redaction
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNumberContextData()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "NUMERIC_SECRET", new NumberContextData(12345) }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NUMERIC_SECRET", variables[0].Name);
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
Assert.Equal("string", variables[0].Type);
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsBooleanContextData()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "BOOL_SECRET", new BooleanContextData(true) }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("BOOL_SECRET", variables[0].Name);
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
Assert.Equal("string", variables[0].Type);
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNestedDictionary()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "NESTED_SECRET", new DictionaryContextData
{
{ "inner_key", new StringContextData("inner_value") }
}
}
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NESTED_SECRET", variables[0].Name);
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
Assert.Equal("string", variables[0].Type);
// Nested container should NOT be drillable under secrets
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNullValue()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var secrets = new DictionaryContextData();
secrets["NULL_SECRET"] = null;
exprValues["secrets"] = secrets;
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NULL_SECRET", variables[0].Name);
Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
Assert.Equal(0, variables[0].VariablesReference);
}
}
#endregion
}
}

View File

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

View File

@@ -8,7 +8,6 @@ using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
using Pipelines = GitHub.DistributedTask.Pipelines;
@@ -548,10 +547,6 @@ namespace GitHub.Runner.Common.Tests.Worker
var _stepsRunner = new StepsRunner();
_stepsRunner.Initialize(hc);
var mockDapDebugger = new Mock<IDapDebugger>();
hc.SetSingleton(mockDapDebugger.Object);
await _stepsRunner.RunAsync(_jobEc);
Assert.Equal("Create custom image", snapshotStep.DisplayName);

View File

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

View File

@@ -12,7 +12,6 @@ using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
namespace GitHub.Runner.Common.Tests.Worker
{
@@ -62,10 +61,6 @@ namespace GitHub.Runner.Common.Tests.Worker
_stepsRunner = new StepsRunner();
_stepsRunner.Initialize(hc);
var mockDapDebugger = new Mock<IDapDebugger>();
hc.SetSingleton(mockDapDebugger.Object);
return hc;
}