Compare commits

...

16 Commits

Author SHA1 Message Date
eric sciple
15cb558d8f Fix parser comparison mismatches (#4220) 2026-02-11 09:44:01 -06:00
eric sciple
d5a8a936c1 Add telemetry tracking for deprecated set-output and save-state commands (#4221) 2026-02-10 12:28:42 -06:00
Tingluo Huang
cdb77c6804 Support return job result as exitcode in hosted runner. (#4233) 2026-02-10 09:31:10 -05:00
Nikola Jokic
a4a19b152e Bump hook to 0.8.1 (#4222) 2026-02-10 01:07:20 +00:00
Tingluo Huang
1b5486aa8f Validate work dir during runner start up. (#4227) 2026-02-09 08:42:07 -05:00
Takuma Ishikawa
4214709d1b Add support for libssl3 and libssl3t64 for newer Debian/Ubuntu versions (#4213) 2026-02-08 16:03:41 -05:00
github-actions[bot]
3ffedabea3 Update Docker to v29.2.0 and Buildx to v0.31.1 (#4219)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-02 02:15:37 +00:00
eric sciple
3a80a78cae Fix local action display name showing Run /./ instead of Run ./ (#4218) 2026-01-30 09:24:06 -06:00
Tingluo Huang
6822f4aba2 Report job level annotations (#4216) 2026-01-27 16:52:25 -05:00
github-actions[bot]
ad43c639cf Update Docker to v29.1.5 and Buildx to v0.31.0 (#4212)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-25 21:10:56 -05:00
eric sciple
5d4fb30d5b Allow empty container options (#4208) 2026-01-22 15:17:18 -06:00
dependabot[bot]
1df72a54ca Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs (#4202)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 14:41:15 +00:00
github-actions[bot]
02013cf967 Update dotnet sdk to latest version @8.0.417 (#4201)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-19 23:08:47 -05:00
github-actions[bot]
7d5c17a190 chore: update Node versions (#4200)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-20 02:18:53 +00:00
Allan Guigou
3f43560cb9 Prepare runner release 2.331.0 (#4190) 2026-01-09 12:15:39 -05:00
dependabot[bot]
73f7dbb681 Bump Azure.Storage.Blobs from 12.26.0 to 12.27.0 (#4189)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-09 14:54:40 +00:00
29 changed files with 1538 additions and 93 deletions

View File

@@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.416"
"version": "8.0.417"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"

View File

@@ -28,8 +28,8 @@ Debian based OS (Debian, Ubuntu, Linux Mint)
- liblttng-ust1 or liblttng-ust0
- libkrb5-3
- zlib1g
- libssl1.1, libssl1.0.2 or libssl1.0.0
- libicu63, libicu60, libicu57 or libicu55
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)

View File

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.0.2
ARG BUILDX_VERSION=0.30.1
ARG DOCKER_VERSION=29.2.0
ARG BUILDX_VERSION=0.31.1
RUN apt update -y && apt install curl unzip -y
@@ -21,7 +21,7 @@ RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-c
&& unzip ./runner-container-hooks.zip -d ./k8s \
&& rm runner-container-hooks.zip
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.0/actions-runner-hooks-k8s-0.8.0.zip \
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.1/actions-runner-hooks-k8s-0.8.1.zip \
&& unzip ./runner-container-hooks.zip -d ./k8s-novolume \
&& rm runner-container-hooks.zip

View File

@@ -1,30 +1,27 @@
## What's Changed
* Custom Image: Preflight checks by @lawrencegripper in https://github.com/actions/runner/pull/4081
* Update dotnet sdk to latest version @8.0.415 by @github-actions[bot] in https://github.com/actions/runner/pull/4080
* Link to an extant discussion category by @jsoref in https://github.com/actions/runner/pull/4084
* Improve logic around decide IsHostedServer. by @TingluoHuang in https://github.com/actions/runner/pull/4086
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4093
* Compare updated template evaluator by @ericsciple in https://github.com/actions/runner/pull/4092
* fix(dockerfile): set more lenient permissions on /home/runner by @caxu-rh in https://github.com/actions/runner/pull/4083
* Add support for libicu73-76 for newer Debian/Ubuntu versions by @lets-build-an-ocean in https://github.com/actions/runner/pull/4098
* Bump actions/download-artifact from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4089
* Bump actions/upload-artifact from 4 to 5 by @dependabot[bot] in https://github.com/actions/runner/pull/4088
* Bump Azure.Storage.Blobs from 12.25.1 to 12.26.0 by @dependabot[bot] in https://github.com/actions/runner/pull/4077
* Only start runner after network is online by @dupondje in https://github.com/actions/runner/pull/4094
* Retry http error related to DNS resolution failure. by @TingluoHuang in https://github.com/actions/runner/pull/4110
* Update Docker to v29.0.1 and Buildx to v0.30.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4114
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4115
* Update dotnet sdk to latest version @8.0.416 by @github-actions[bot] in https://github.com/actions/runner/pull/4116
* Compare updated workflow parser for ActionManifestManager by @ericsciple in https://github.com/actions/runner/pull/4111
* Bump npm pkg version for hashFiles. by @TingluoHuang in https://github.com/actions/runner/pull/4122
* Fix owner of /home/runner directory by @nikola-jokic in https://github.com/actions/runner/pull/4132
* Update Docker to v29.0.2 and Buildx to v0.30.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4135
* Update workflow around runner docker image. by @TingluoHuang in https://github.com/actions/runner/pull/4133
* Fix regex for validating runner version format by @TingluoHuang in https://github.com/actions/runner/pull/4136
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4144
* Ensure safe_sleep tries alternative approaches by @TingluoHuang in https://github.com/actions/runner/pull/4146
* Bump actions/github-script from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4137
* Bump actions/checkout from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4130
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4149
* Bump docker image to use ubuntu 24.04 by @TingluoHuang in https://github.com/actions/runner/pull/4018
* Add support for case function by @AllanGuigou in https://github.com/actions/runner/pull/4147
* Cleanup feature flag actions_container_action_runner_temp by @ericsciple in https://github.com/actions/runner/pull/4163
* Bump actions/download-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4155
* Bump actions/upload-artifact from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4157
* Set ACTIONS_ORCHESTRATION_ID as env to actions. by @TingluoHuang in https://github.com/actions/runner/pull/4178
* Allow hosted VM report job telemetry via .setup_info file. by @TingluoHuang in https://github.com/actions/runner/pull/4186
* Bump typescript from 5.9.2 to 5.9.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4184
* Bump Azure.Storage.Blobs from 12.26.0 to 12.27.0 by @dependabot[bot] in https://github.com/actions/runner/pull/4189
## New Contributors
* @lawrencegripper made their first contribution in https://github.com/actions/runner/pull/4081
* @caxu-rh made their first contribution in https://github.com/actions/runner/pull/4083
* @lets-build-an-ocean made their first contribution in https://github.com/actions/runner/pull/4098
* @dupondje made their first contribution in https://github.com/actions/runner/pull/4094
* @AllanGuigou made their first contribution in https://github.com/actions/runner/pull/4147
**Full Changelog**: https://github.com/actions/runner/compare/v2.329.0...v2.330.0
**Full Changelog**: https://github.com/actions/runner/compare/v2.330.0...v2.331.0
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.

View File

@@ -6,8 +6,8 @@ NODE_URL=https://nodejs.org/dist
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.19.6"
NODE24_VERSION="24.12.0"
NODE20_VERSION="20.20.0"
NODE24_VERSION="24.13.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args

View File

@@ -102,7 +102,7 @@ then
exit 1
fi
apt_get_with_fallbacks libssl1.1$ libssl1.0.2$ libssl1.0.0$
apt_get_with_fallbacks libssl3t64$ libssl3$ libssl1.1$ libssl1.0.2$ libssl1.0.0$
if [ $? -ne 0 ]
then
echo "'$apt_get' failed with exit code '$?'"

View File

@@ -173,6 +173,7 @@ namespace GitHub.Runner.Common
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 SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
}
// Node version migration related constants

View File

@@ -24,7 +24,7 @@ namespace GitHub.Runner.Listener
public interface IJobDispatcher : IRunnerService
{
bool Busy { get; }
TaskCompletionSource<bool> RunOnceJobCompleted { get; }
TaskCompletionSource<TaskResult> RunOnceJobCompleted { get; }
void Run(Pipelines.AgentJobRequestMessage message, bool runOnce = false);
bool Cancel(JobCancelMessage message);
Task WaitAsync(CancellationToken token);
@@ -56,7 +56,7 @@ namespace GitHub.Runner.Listener
// timeout limit can be overwritten by environment GITHUB_ACTIONS_RUNNER_CHANNEL_TIMEOUT
private TimeSpan _channelTimeout;
private TaskCompletionSource<bool> _runOnceJobCompleted = new();
private TaskCompletionSource<TaskResult> _runOnceJobCompleted = new();
public event EventHandler<JobStatusEventArgs> JobStatus;
@@ -82,7 +82,7 @@ namespace GitHub.Runner.Listener
Trace.Info($"Set runner/worker IPC timeout to {_channelTimeout.TotalSeconds} seconds.");
}
public TaskCompletionSource<bool> RunOnceJobCompleted => _runOnceJobCompleted;
public TaskCompletionSource<TaskResult> RunOnceJobCompleted => _runOnceJobCompleted;
public bool Busy { get; private set; }
@@ -340,18 +340,19 @@ namespace GitHub.Runner.Listener
private async Task RunOnceAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
{
var jobResult = TaskResult.Succeeded;
try
{
await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
jobResult = await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
}
finally
{
Trace.Info("Fire signal for one time used runner.");
_runOnceJobCompleted.TrySetResult(true);
_runOnceJobCompleted.TrySetResult(jobResult);
}
}
private async Task RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
private async Task<TaskResult> RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
{
Busy = true;
try
@@ -399,7 +400,7 @@ namespace GitHub.Runner.Listener
{
// renew job request task complete means we run out of retry for the first job request renew.
Trace.Info($"Unable to renew job request for job {message.JobId} for the first time, stop dispatching job to worker.");
return;
return TaskResult.Abandoned;
}
if (jobRequestCancellationToken.IsCancellationRequested)
@@ -412,7 +413,7 @@ namespace GitHub.Runner.Listener
// complete job request with result Cancelled
await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, TaskResult.Canceled);
return;
return TaskResult.Canceled;
}
HostContext.WritePerfCounter($"JobRequestRenewed_{requestId.ToString()}");
@@ -523,7 +524,7 @@ namespace GitHub.Runner.Listener
await renewJobRequest;
// not finish the job request since the job haven't run on worker at all, we will not going to set a result to server.
return;
return TaskResult.Failed;
}
// we get first jobrequest renew succeed and start the worker process with the job message.
@@ -604,7 +605,7 @@ namespace GitHub.Runner.Listener
Trace.Error(detailInfo);
}
return;
return TaskResultUtil.TranslateFromReturnCode(returnCode);
}
else if (completedTask == renewJobRequest)
{
@@ -706,6 +707,8 @@ namespace GitHub.Runner.Listener
// complete job request
await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, resultOnAbandonOrCancel);
return resultOnAbandonOrCancel;
}
finally
{

View File

@@ -5,8 +5,8 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -324,8 +324,11 @@ namespace GitHub.Runner.Listener
HostContext.EnableAuthMigration("EnableAuthMigrationByDefault");
}
// hosted runner only run one job and would like to know the result of the job for telemetry and alerting on failure spike.
var returnJobResultForHosted = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED"));
// Run the runner interactively or as service
return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral);
return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral || returnJobResultForHosted, returnJobResultForHosted);
}
else
{
@@ -401,17 +404,32 @@ namespace GitHub.Runner.Listener
}
//create worker manager, create message listener and start listening to the queue
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = false)
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = false, bool returnRunOnceJobResult = false)
{
try
{
Trace.Info(nameof(RunAsync));
// Validate directory permissions.
string workDirectory = HostContext.GetDirectory(WellKnownDirectory.Work);
Trace.Info($"Validating directory permissions for: '{workDirectory}'");
try
{
Directory.CreateDirectory(workDirectory);
IOUtil.ValidateExecutePermission(workDirectory);
}
catch (Exception ex)
{
Trace.Error(ex);
_term.WriteError($"Fail to create and validate runner's work directory '{workDirectory}'.");
return Constants.Runner.ReturnCode.TerminatedError;
}
// First try using migrated settings if available
var configManager = HostContext.GetService<IConfigurationManager>();
RunnerSettings migratedSettings = null;
try
try
{
migratedSettings = configManager.LoadMigratedSettings();
Trace.Info("Loaded migrated settings from .runner_migrated file");
@@ -422,15 +440,15 @@ namespace GitHub.Runner.Listener
// If migrated settings file doesn't exist or can't be loaded, we'll use the provided settings
Trace.Info($"Failed to load migrated settings: {ex.Message}");
}
bool usedMigratedSettings = false;
if (migratedSettings != null)
{
// Try to create session with migrated settings first
Trace.Info("Attempting to create session using migrated settings");
_listener = GetMessageListener(migratedSettings, isMigratedSettings: true);
try
{
CreateSessionResult createSessionResult = await _listener.CreateSessionAsync(HostContext.RunnerShutdownToken);
@@ -450,7 +468,7 @@ namespace GitHub.Runner.Listener
Trace.Error($"Exception when creating session with migrated settings: {ex}");
}
}
// If migrated settings weren't used or session creation failed, use original settings
if (!usedMigratedSettings)
{
@@ -503,7 +521,7 @@ namespace GitHub.Runner.Listener
restartSession = true;
break;
}
TaskAgentMessage message = null;
bool skipMessageDeletion = false;
try
@@ -565,6 +583,21 @@ namespace GitHub.Runner.Listener
Trace.Info($"Ignore any exception after cancel message loop. {ex}");
}
if (returnRunOnceJobResult)
{
try
{
var jobResult = await jobDispatcher.RunOnceJobCompleted.Task;
return TaskResultUtil.TranslateToReturnCode(jobResult);
}
catch (Exception ex)
{
Trace.Error("run once job finished with error.");
Trace.Error(ex);
return Constants.Runner.ReturnCode.TerminatedError;
}
}
return Constants.Runner.ReturnCode.Success;
}
}
@@ -851,15 +884,15 @@ namespace GitHub.Runner.Listener
return Constants.Runner.ReturnCode.Success;
}
private async Task<int> ExecuteRunnerAsync(RunnerSettings settings, bool runOnce)
private async Task<int> ExecuteRunnerAsync(RunnerSettings settings, bool runOnce, bool returnRunOnceJobResult)
{
int returnCode = Constants.Runner.ReturnCode.Success;
bool restart = false;
do
{
restart = false;
returnCode = await RunAsync(settings, runOnce);
returnCode = await RunAsync(settings, runOnce, returnRunOnceJobResult);
if (returnCode == Constants.Runner.ReturnCode.RunnerConfigurationRefreshed)
{
Trace.Info("Runner configuration was refreshed, restarting session...");

View File

@@ -318,6 +318,17 @@ namespace GitHub.Runner.Worker
context.AddIssue(issue, ExecutionContextLogOptions.Default);
}
if (!context.Global.HasDeprecatedSetOutput)
{
context.Global.HasDeprecatedSetOutput = true;
var telemetry = new JobTelemetry
{
Type = JobTelemetryType.ActionCommand,
Message = "DeprecatedCommand: set-output"
};
context.Global.JobTelemetry.Add(telemetry);
}
if (!command.Properties.TryGetValue(SetOutputCommandProperties.Name, out string outputName) || string.IsNullOrEmpty(outputName))
{
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
@@ -353,6 +364,17 @@ namespace GitHub.Runner.Worker
context.AddIssue(issue, ExecutionContextLogOptions.Default);
}
if (!context.Global.HasDeprecatedSaveState)
{
context.Global.HasDeprecatedSaveState = true;
var telemetry = new JobTelemetry
{
Type = JobTelemetryType.ActionCommand,
Message = "DeprecatedCommand: save-state"
};
context.Global.JobTelemetry.Add(telemetry);
}
if (!command.Properties.TryGetValue(SaveStateCommandProperties.Name, out string stateName) || string.IsNullOrEmpty(stateName))
{
throw new Exception("Required field 'name' is missing in ##[save-state] command.");

View File

@@ -84,7 +84,8 @@ namespace GitHub.Runner.Worker
"EvaluateContainerEnvironment",
() => _legacyManager.EvaluateContainerEnvironment(executionContext, token, extraExpressionValues),
() => _newManager.EvaluateContainerEnvironment(executionContext, ConvertToNewToken(token) as GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.MappingToken, ConvertToNewExpressionValues(extraExpressionValues)),
(legacyResult, newResult) => {
(legacyResult, newResult) =>
{
var trace = HostContext.GetTrace(nameof(ActionManifestManagerWrapper));
return CompareDictionaries(trace, legacyResult, newResult, "ContainerEnvironment");
});
@@ -165,9 +166,150 @@ namespace GitHub.Runner.Worker
return null;
}
// Serialize new steps and deserialize to old steps
var json = StringUtil.ConvertToJson(newSteps, Newtonsoft.Json.Formatting.None);
return StringUtil.ConvertFromJson<List<GitHub.DistributedTask.Pipelines.ActionStep>>(json);
var result = new List<GitHub.DistributedTask.Pipelines.ActionStep>();
foreach (var step in newSteps)
{
var actionStep = new GitHub.DistributedTask.Pipelines.ActionStep
{
ContextName = step.Id,
};
if (step is GitHub.Actions.WorkflowParser.RunStep runStep)
{
actionStep.Condition = ExtractConditionString(runStep.If);
actionStep.DisplayNameToken = ConvertToLegacyToken<TemplateToken>(runStep.Name);
actionStep.ContinueOnError = ConvertToLegacyToken<TemplateToken>(runStep.ContinueOnError);
actionStep.TimeoutInMinutes = ConvertToLegacyToken<TemplateToken>(runStep.TimeoutMinutes);
actionStep.Environment = ConvertToLegacyToken<TemplateToken>(runStep.Env);
actionStep.Reference = new GitHub.DistributedTask.Pipelines.ScriptReference();
actionStep.Inputs = BuildRunStepInputs(runStep);
}
else if (step is GitHub.Actions.WorkflowParser.ActionStep usesStep)
{
actionStep.Condition = ExtractConditionString(usesStep.If);
actionStep.DisplayNameToken = ConvertToLegacyToken<TemplateToken>(usesStep.Name);
actionStep.ContinueOnError = ConvertToLegacyToken<TemplateToken>(usesStep.ContinueOnError);
actionStep.TimeoutInMinutes = ConvertToLegacyToken<TemplateToken>(usesStep.TimeoutMinutes);
actionStep.Environment = ConvertToLegacyToken<TemplateToken>(usesStep.Env);
actionStep.Reference = ParseActionReference(usesStep.Uses?.Value);
actionStep.Inputs = ConvertToLegacyToken<MappingToken>(usesStep.With);
}
result.Add(actionStep);
}
return result;
}
private string ExtractConditionString(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.BasicExpressionToken ifToken)
{
if (ifToken == null)
{
return null;
}
// The Expression property is internal, so we use ToString() which formats as "${{ expr }}"
// Then strip the delimiters to get just the expression
var str = ifToken.ToString();
if (str.StartsWith("${{") && str.EndsWith("}}"))
{
return str.Substring(3, str.Length - 5).Trim();
}
return str;
}
private MappingToken BuildRunStepInputs(GitHub.Actions.WorkflowParser.RunStep runStep)
{
var inputs = new MappingToken(null, null, null);
// script (from run)
if (runStep.Run != null)
{
inputs.Add(
new StringToken(null, null, null, "script"),
ConvertToLegacyToken<TemplateToken>(runStep.Run));
}
// shell
if (runStep.Shell != null)
{
inputs.Add(
new StringToken(null, null, null, "shell"),
ConvertToLegacyToken<TemplateToken>(runStep.Shell));
}
// working-directory
if (runStep.WorkingDirectory != null)
{
inputs.Add(
new StringToken(null, null, null, "workingDirectory"),
ConvertToLegacyToken<TemplateToken>(runStep.WorkingDirectory));
}
return inputs.Count > 0 ? inputs : null;
}
private GitHub.DistributedTask.Pipelines.ActionStepDefinitionReference ParseActionReference(string uses)
{
if (string.IsNullOrEmpty(uses))
{
return null;
}
// Docker reference: docker://image:tag
if (uses.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
{
return new GitHub.DistributedTask.Pipelines.ContainerRegistryReference
{
Image = uses.Substring("docker://".Length)
};
}
// Local path reference: ./path/to/action
if (uses.StartsWith("./") || uses.StartsWith(".\\"))
{
return new GitHub.DistributedTask.Pipelines.RepositoryPathReference
{
RepositoryType = "self",
Path = uses
};
}
// Repository reference: owner/repo@ref or owner/repo/path@ref
var atIndex = uses.LastIndexOf('@');
string refPart = null;
string repoPart = uses;
if (atIndex > 0)
{
refPart = uses.Substring(atIndex + 1);
repoPart = uses.Substring(0, atIndex);
}
// Split by / to get owner/repo and optional path
var parts = repoPart.Split('/');
string name;
string path = null;
if (parts.Length >= 2)
{
name = $"{parts[0]}/{parts[1]}";
if (parts.Length > 2)
{
path = string.Join("/", parts, 2, parts.Length - 2);
}
}
else
{
name = repoPart;
}
return new GitHub.DistributedTask.Pipelines.RepositoryPathReference
{
RepositoryType = "GitHub",
Name = name,
Ref = refPart,
Path = path
};
}
private T ConvertToLegacyToken<T>(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newToken) where T : TemplateToken
@@ -633,6 +775,14 @@ namespace GitHub.Runner.Worker
return false;
}
// Check for known equivalent error patterns (e.g., JSON parse errors)
// where both parsers correctly reject invalid input but with different wording
if (PipelineTemplateEvaluatorWrapper.HasJsonExceptionType(legacyException) && PipelineTemplateEvaluatorWrapper.HasJsonExceptionType(newException))
{
trace.Info("CompareExceptions - both exceptions are JSON parse errors, treating as matched");
return true;
}
// Compare exception messages recursively (including inner exceptions)
var legacyMessages = GetExceptionMessages(legacyException);
var newMessages = GetExceptionMessages(newException);
@@ -697,5 +847,6 @@ namespace GitHub.Runner.Worker
return messages;
}
}
}

View File

@@ -379,7 +379,14 @@ namespace GitHub.Runner.Worker
{
prefix = PipelineTemplateConstants.RunDisplayPrefix;
var repositoryReference = action.Reference as RepositoryPathReference;
var pathString = string.IsNullOrEmpty(repositoryReference.Path) ? string.Empty : $"/{repositoryReference.Path}";
var pathString = string.Empty;
if (!string.IsNullOrEmpty(repositoryReference.Path))
{
// For local actions (Name is empty), don't prepend "/" to avoid "/./"
pathString = string.IsNullOrEmpty(repositoryReference.Name)
? repositoryReference.Path
: $"/{repositoryReference.Path}";
}
var repoString = string.IsNullOrEmpty(repositoryReference.Ref) ? $"{repositoryReference.Name}{pathString}" :
$"{repositoryReference.Name}{pathString}@{repositoryReference.Ref}";
tokenToParse = new StringToken(null, null, null, repoString);

View File

@@ -499,7 +499,7 @@ namespace GitHub.Runner.Worker
PublishStepTelemetry();
if (_record.RecordType == "Task")
if (_record.RecordType == ExecutionContextType.Task)
{
var stepResult = new StepResult
{
@@ -532,6 +532,25 @@ namespace GitHub.Runner.Worker
Global.StepsResult.Add(stepResult);
}
if (Global.Variables.GetBoolean(Constants.Runner.Features.SendJobLevelAnnotations) ?? false)
{
if (_record.RecordType == ExecutionContextType.Job)
{
_record.Issues?.ForEach(issue =>
{
var annotation = issue.ToAnnotation();
if (annotation != null)
{
Global.JobAnnotations.Add(annotation.Value);
if (annotation.Value.IsInfrastructureIssue && string.IsNullOrEmpty(Global.InfrastructureFailureCategory))
{
Global.InfrastructureFailureCategory = issue.Category;
}
}
});
}
}
if (Root != this)
{
// only dispose TokenSource for step level ExecutionContext

View File

@@ -31,5 +31,7 @@ namespace GitHub.Runner.Worker
public JObject ContainerHookState { get; set; }
public bool HasTemplateEvaluatorMismatch { get; set; }
public bool HasActionManifestMismatch { get; set; }
public bool HasDeprecatedSetOutput { get; set; }
public bool HasDeprecatedSaveState { get; set; }
}
}

View File

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

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using GitHub.Actions.WorkflowParser;
using GitHub.DistributedTask.Expressions2;
@@ -216,12 +216,15 @@ namespace GitHub.Runner.Worker
}
}
private TLegacy EvaluateAndCompare<TLegacy, TNew>(
internal TLegacy EvaluateAndCompare<TLegacy, TNew>(
string methodName,
Func<TLegacy> legacyEvaluator,
Func<TNew> newEvaluator,
Func<TLegacy, TNew, bool> resultComparer)
{
// Capture cancellation state before evaluation
var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;
// Legacy evaluator
var legacyException = default(Exception);
var legacyResult = default(TLegacy);
@@ -253,14 +256,18 @@ namespace GitHub.Runner.Worker
newException = ex;
}
// Capture cancellation state after evaluation
var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested;
// Compare results or exceptions
bool hasMismatch = false;
if (legacyException != null || newException != null)
{
// Either one or both threw exceptions - compare them
if (!CompareExceptions(legacyException, newException))
{
_trace.Info($"{methodName} exception mismatch");
RecordMismatch($"{methodName}");
hasMismatch = true;
}
}
else
@@ -269,6 +276,20 @@ namespace GitHub.Runner.Worker
if (!resultComparer(legacyResult, newResult))
{
_trace.Info($"{methodName} mismatch");
hasMismatch = true;
}
}
// Only record mismatch if it wasn't caused by a cancellation race condition
if (hasMismatch)
{
if (!cancellationRequestedBefore && cancellationRequestedAfter)
{
// Cancellation state changed during evaluation window - skip recording
_trace.Info($"{methodName} mismatch skipped due to cancellation race condition");
}
else
{
RecordMismatch($"{methodName}");
}
}
@@ -612,6 +633,13 @@ namespace GitHub.Runner.Worker
return false;
}
// Check for known equivalent error patterns (e.g., JSON parse errors)
// where both parsers correctly reject invalid input but with different wording
if (IsKnownEquivalentErrorPattern(legacyException, newException))
{
return true;
}
// Compare exception messages recursively (including inner exceptions)
var legacyMessages = GetExceptionMessages(legacyException);
var newMessages = GetExceptionMessages(newException);
@@ -634,6 +662,67 @@ namespace GitHub.Runner.Worker
return true;
}
/// <summary>
/// Checks if two exceptions match a known pattern where both parsers correctly reject
/// invalid input but with different error messages (e.g., JSON parse errors from fromJSON).
/// </summary>
private bool IsKnownEquivalentErrorPattern(Exception legacyException, Exception newException)
{
// fromJSON('') - both parsers fail when parsing empty string as JSON
// The error messages differ but both indicate JSON parsing failure.
// Legacy throws raw JsonReaderException: "Error reading JToken from JsonReader..."
// New wraps it: "Error parsing fromJson" with inner JsonReaderException
// Both may be wrapped in TemplateValidationException: "The template is not valid..."
if (HasJsonExceptionType(legacyException) && HasJsonExceptionType(newException))
{
_trace.Info("CompareExceptions - both exceptions are JSON parse errors, treating as matched");
return true;
}
return false;
}
/// <summary>
/// Checks if the exception chain contains a JSON-related exception type.
/// </summary>
internal static bool HasJsonExceptionType(Exception ex)
{
var toProcess = new Queue<Exception>();
toProcess.Enqueue(ex);
int count = 0;
while (toProcess.Count > 0 && count < 50)
{
var current = toProcess.Dequeue();
if (current == null) continue;
count++;
if (current is Newtonsoft.Json.JsonReaderException ||
current is System.Text.Json.JsonException)
{
return true;
}
if (current is AggregateException aggregateEx)
{
foreach (var innerEx in aggregateEx.InnerExceptions)
{
if (innerEx != null && count < 50)
{
toProcess.Enqueue(innerEx);
}
}
}
else if (current.InnerException != null)
{
toProcess.Enqueue(current.InnerException);
}
}
return false;
}
private IList<string> GetExceptionMessages(Exception ex)
{
var messages = new List<string>();

View File

@@ -421,7 +421,7 @@
"mapping": {
"properties": {
"image": "string",
"options": "non-empty-string",
"options": "string",
"env": "container-env",
"ports": "sequence-of-non-empty-string",
"volumes": "sequence-of-non-empty-string",

View File

@@ -18,19 +18,19 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.2" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="Minimatch" Version="2.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
<PackageReference Include="System.Formats.Asn1" Version="8.0.1" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -43,7 +43,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
{
case WorkflowTemplateConstants.On:
var inputTypes = ConvertToOnWorkflowDispatchInputTypes(workflowPair.Value);
foreach(var item in inputTypes)
foreach (var item in inputTypes)
{
result.InputTypes.TryAdd(item.Key, item.Value);
}
@@ -432,7 +432,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
context.Error(snapshotToken, $"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName} is required.");
return null;
}
return new Snapshot
{
ImageName = imageName,
@@ -445,7 +445,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
{
var versionSegments = versionString.Split(".");
if (versionSegments.Length != 2 ||
if (versionSegments.Length != 2 ||
!versionSegments[1].Equals("*") ||
!Int32.TryParse(versionSegments[0], NumberStyles.None, CultureInfo.InvariantCulture, result: out int parsedMajor) ||
parsedMajor < 0)
@@ -1154,7 +1154,12 @@ namespace GitHub.Actions.WorkflowParser.Conversion
if (String.IsNullOrEmpty(result.Image))
{
context.Error(value, "Container image cannot be empty");
// 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;
}
@@ -1838,9 +1843,9 @@ namespace GitHub.Actions.WorkflowParser.Conversion
case "actions":
permissions.Actions = permissionLevel;
break;
case "artifact-metadata":
permissions.ArtifactMetadata = permissionLevel;
break;
case "artifact-metadata":
permissions.ArtifactMetadata = permissionLevel;
break;
case "attestations":
permissions.Attestations = permissionLevel;
break;

View File

@@ -2593,7 +2593,7 @@
"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": "non-empty-string",
"type": "string",
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
},
"env": "container-env",

View File

@@ -739,7 +739,8 @@ namespace GitHub.Runner.Common.Tests.Listener
Assert.True(jobDispatcher.RunOnceJobCompleted.Task.IsCompleted, "JobDispatcher should set task complete token for one time agent.");
if (jobDispatcher.RunOnceJobCompleted.Task.IsCompleted)
{
Assert.True(await jobDispatcher.RunOnceJobCompleted.Task, "JobDispatcher should set task complete token to 'TRUE' for one time agent.");
var result = await jobDispatcher.RunOnceJobCompleted.Task;
Assert.Equal(TaskResult.Succeeded, result);
}
}
}

View File

@@ -295,13 +295,13 @@ namespace GitHub.Runner.Common.Tests.Listener
_messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()))
.Returns(Task.CompletedTask);
var runOnceJobCompleted = new TaskCompletionSource<bool>();
var runOnceJobCompleted = new TaskCompletionSource<TaskResult>();
_jobDispatcher.Setup(x => x.RunOnceJobCompleted)
.Returns(runOnceJobCompleted);
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()))
.Callback(() =>
{
runOnceJobCompleted.TrySetResult(true);
runOnceJobCompleted.TrySetResult(TaskResult.Succeeded);
});
_jobNotification.Setup(x => x.StartClient(It.IsAny<String>()))
.Callback(() =>
@@ -399,13 +399,13 @@ namespace GitHub.Runner.Common.Tests.Listener
_messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()))
.Returns(Task.CompletedTask);
var runOnceJobCompleted = new TaskCompletionSource<bool>();
var runOnceJobCompleted = new TaskCompletionSource<TaskResult>();
_jobDispatcher.Setup(x => x.RunOnceJobCompleted)
.Returns(runOnceJobCompleted);
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()))
.Callback(() =>
{
runOnceJobCompleted.TrySetResult(true);
runOnceJobCompleted.TrySetResult(TaskResult.Succeeded);
});
_jobNotification.Setup(x => x.StartClient(It.IsAny<String>()))
.Callback(() =>
@@ -733,8 +733,8 @@ namespace GitHub.Runner.Common.Tests.Listener
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
var completedTask = new TaskCompletionSource<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
//Act
@@ -834,8 +834,8 @@ namespace GitHub.Runner.Common.Tests.Listener
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
var completedTask = new TaskCompletionSource<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
//Act
@@ -954,8 +954,8 @@ namespace GitHub.Runner.Common.Tests.Listener
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
var completedTask = new TaskCompletionSource<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
//Act

View File

@@ -457,6 +457,8 @@ namespace GitHub.Runner.Common.Tests.Worker
new SetEnvCommandExtension(),
new WarningCommandExtension(),
new AddMaskCommandExtension(),
new SetOutputCommandExtension(),
new SaveStateCommandExtension(),
};
foreach (var command in commands)
{
@@ -499,5 +501,53 @@ namespace GitHub.Runner.Common.Tests.Worker
};
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SetOutputCommand_EmitsTelemetryOnce()
{
using (TestHostContext hc = CreateTestContext())
{
_ec.Object.Global.JobTelemetry = new List<JobTelemetry>();
var reference = string.Empty;
_ec.Setup(x => x.SetOutput(It.IsAny<string>(), It.IsAny<string>(), out reference));
// First set-output should add telemetry
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::set-output name=foo::bar", null));
Assert.Single(_ec.Object.Global.JobTelemetry);
Assert.Equal(JobTelemetryType.ActionCommand, _ec.Object.Global.JobTelemetry[0].Type);
Assert.Equal("DeprecatedCommand: set-output", _ec.Object.Global.JobTelemetry[0].Message);
Assert.True(_ec.Object.Global.HasDeprecatedSetOutput);
// Second set-output should not add another telemetry entry
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::set-output name=foo2::bar2", null));
Assert.Single(_ec.Object.Global.JobTelemetry);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SaveStateCommand_EmitsTelemetryOnce()
{
using (TestHostContext hc = CreateTestContext())
{
_ec.Object.Global.JobTelemetry = new List<JobTelemetry>();
_ec.Setup(x => x.IsEmbedded).Returns(false);
_ec.Setup(x => x.IntraActionState).Returns(new Dictionary<string, string>());
// First save-state should add telemetry
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::save-state name=foo::bar", null));
Assert.Single(_ec.Object.Global.JobTelemetry);
Assert.Equal(JobTelemetryType.ActionCommand, _ec.Object.Global.JobTelemetry[0].Type);
Assert.Equal("DeprecatedCommand: save-state", _ec.Object.Global.JobTelemetry[0].Message);
Assert.True(_ec.Object.Global.HasDeprecatedSaveState);
// Second save-state should not add another telemetry entry
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::save-state name=foo2::bar2", null));
Assert.Single(_ec.Object.Global.JobTelemetry);
}
}
}
}

View File

@@ -0,0 +1,424 @@
using GitHub.Actions.WorkflowParser;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using LegacyContextData = GitHub.DistributedTask.Pipelines.ContextData;
using LegacyExpressions = GitHub.DistributedTask.Expressions2;
using Moq;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
/// <summary>
/// Tests for parser comparison wrapper classes.
/// </summary>
public sealed class ActionManifestParserComparisonL0
{
private CancellationTokenSource _ecTokenSource;
private Mock<IExecutionContext> _ec;
private TestHostContext _hc;
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ConvertToLegacySteps_ProducesCorrectSteps_WithExplicitPropertyMapping()
{
try
{
// Arrange - Test that ActionManifestManagerWrapper properly converts new steps to legacy format
Setup();
// Enable comparison feature
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
// Register required services
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
var manifestPath = Path.Combine(TestUtil.GetTestDataPath(), "conditional_composite_action.yml");
// Act - Load through the wrapper (which internally converts)
var result = wrapper.Load(_ec.Object, manifestPath);
// Assert
Assert.NotNull(result);
Assert.Equal(ActionExecutionType.Composite, result.Execution.ExecutionType);
var compositeExecution = result.Execution as CompositeActionExecutionData;
Assert.NotNull(compositeExecution);
Assert.NotNull(compositeExecution.Steps);
Assert.Equal(6, compositeExecution.Steps.Count);
// Verify steps are NOT null (this was the bug - JSON round-trip produced nulls)
foreach (var step in compositeExecution.Steps)
{
Assert.NotNull(step);
Assert.NotNull(step.Reference);
Assert.IsType<GitHub.DistributedTask.Pipelines.ScriptReference>(step.Reference);
}
// Verify step with condition
var successStep = compositeExecution.Steps[2];
Assert.Equal("success-conditional", successStep.ContextName);
Assert.Equal("success()", successStep.Condition);
// Verify step with complex condition
var lastStep = compositeExecution.Steps[5];
Assert.Contains("inputs.exit-code == 1", lastStep.Condition);
Assert.Contains("failure()", lastStep.Condition);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobContainer_EmptyImage_BothParsersReturnNull()
{
try
{
// Arrange - Test that both parsers return null for empty container image at runtime
Setup();
var fileTable = new List<string>();
// Create legacy evaluator
var legacyTraceWriter = new GitHub.DistributedTask.ObjectTemplating.EmptyTraceWriter();
var schema = PipelineTemplateSchemaFactory.GetSchema();
var legacyEvaluator = new PipelineTemplateEvaluator(legacyTraceWriter, schema, fileTable);
// Create new evaluator
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
var newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, fileTable, features: null);
// Create a token representing an empty container image (simulates expression evaluated to empty string)
var emptyImageToken = new StringToken(null, null, null, "");
var contextData = new DictionaryContextData();
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
// Act - Call both evaluators
var legacyResult = legacyEvaluator.EvaluateJobContainer(emptyImageToken, contextData, expressionFunctions);
// Convert token for new evaluator
var newToken = new GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.StringToken(null, null, null, "");
var newContextData = new GitHub.Actions.Expressions.Data.DictionaryExpressionData();
var newExpressionFunctions = new List<GitHub.Actions.Expressions.IFunctionInfo>();
var newResult = newEvaluator.EvaluateJobContainer(newToken, newContextData, newExpressionFunctions);
// Assert - Both should return null for empty image (no container)
Assert.Null(legacyResult);
Assert.Null(newResult);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FromJsonEmptyString_BothParsersFail_WithDifferentMessages()
{
// This test verifies that both parsers fail with different error messages when parsing fromJSON('')
// The comparison layer should treat these as semantically equivalent (both are JSON parse errors)
try
{
Setup();
var fileTable = new List<string>();
// Create legacy evaluator
var legacyTraceWriter = new GitHub.DistributedTask.ObjectTemplating.EmptyTraceWriter();
var schema = PipelineTemplateSchemaFactory.GetSchema();
var legacyEvaluator = new PipelineTemplateEvaluator(legacyTraceWriter, schema, fileTable);
// Create new evaluator
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
var newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, fileTable, features: null);
// Create expression token for fromJSON('')
var legacyToken = new BasicExpressionToken(null, null, null, "fromJson('')");
var newToken = new GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.BasicExpressionToken(null, null, null, "fromJson('')");
var contextData = new DictionaryContextData();
var newContextData = new GitHub.Actions.Expressions.Data.DictionaryExpressionData();
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
var newExpressionFunctions = new List<GitHub.Actions.Expressions.IFunctionInfo>();
// Act - Both should throw
Exception legacyException = null;
Exception newException = null;
try
{
legacyEvaluator.EvaluateStepDisplayName(legacyToken, contextData, expressionFunctions);
}
catch (Exception ex)
{
legacyException = ex;
}
try
{
newEvaluator.EvaluateStepName(newToken, newContextData, newExpressionFunctions);
}
catch (Exception ex)
{
newException = ex;
}
// Assert - Both threw exceptions
Assert.NotNull(legacyException);
Assert.NotNull(newException);
// Verify the error messages are different (which is why we need semantic comparison)
Assert.NotEqual(legacyException.Message, newException.Message);
// Verify both are JSON parse errors (contain JSON-related error indicators)
var legacyFullMsg = GetFullExceptionMessage(legacyException);
var newFullMsg = GetFullExceptionMessage(newException);
// At least one should contain indicators of JSON parsing failure
var legacyIsJsonError = legacyFullMsg.Contains("JToken") ||
legacyFullMsg.Contains("JsonReader") ||
legacyFullMsg.Contains("fromJson");
var newIsJsonError = newFullMsg.Contains("JToken") ||
newFullMsg.Contains("JsonReader") ||
newFullMsg.Contains("fromJson");
Assert.True(legacyIsJsonError, $"Legacy exception should be JSON error: {legacyFullMsg}");
Assert.True(newIsJsonError, $"New exception should be JSON error: {newFullMsg}");
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateDefaultInput_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
_ec.Object.ExpressionValues["github"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["strategy"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["matrix"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["steps"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["job"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["runner"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["env"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionFunctions.Add(new LegacyExpressions.FunctionInfo<GitHub.Runner.Worker.Expressions.HashFilesFunction>("hashFiles", 1, 255));
var result = wrapper.EvaluateDefaultInput(_ec.Object, "testInput", new StringToken(null, null, null, "defaultValue"));
Assert.Equal("defaultValue", result);
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateContainerArguments_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
var arguments = new SequenceToken(null, null, null);
arguments.Add(new StringToken(null, null, null, "arg1"));
arguments.Add(new StringToken(null, null, null, "arg2"));
var evaluateContext = new Dictionary<string, LegacyContextData.PipelineContextData>(StringComparer.OrdinalIgnoreCase);
var result = wrapper.EvaluateContainerArguments(_ec.Object, arguments, evaluateContext);
Assert.Equal(2, result.Count);
Assert.Equal("arg1", result[0]);
Assert.Equal("arg2", result[1]);
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateContainerEnvironment_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
var environment = new MappingToken(null, null, null);
environment.Add(new StringToken(null, null, null, "hello"), new StringToken(null, null, null, "world"));
var evaluateContext = new Dictionary<string, LegacyContextData.PipelineContextData>(StringComparer.OrdinalIgnoreCase);
var result = wrapper.EvaluateContainerEnvironment(_ec.Object, environment, evaluateContext);
Assert.Equal(1, result.Count);
Assert.Equal("world", result["hello"]);
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateCompositeOutputs_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
var outputDef = new MappingToken(null, null, null);
outputDef.Add(new StringToken(null, null, null, "description"), new StringToken(null, null, null, "test output"));
outputDef.Add(new StringToken(null, null, null, "value"), new StringToken(null, null, null, "value1"));
var token = new MappingToken(null, null, null);
token.Add(new StringToken(null, null, null, "output1"), outputDef);
var evaluateContext = new Dictionary<string, LegacyContextData.PipelineContextData>(StringComparer.OrdinalIgnoreCase);
var result = wrapper.EvaluateCompositeOutputs(_ec.Object, token, evaluateContext);
Assert.NotNull(result);
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
}
finally
{
Teardown();
}
}
private string GetFullExceptionMessage(Exception ex)
{
var messages = new List<string>();
var current = ex;
while (current != null)
{
messages.Add(current.Message);
current = current.InnerException;
}
return string.Join(" -> ", messages);
}
private void Setup([CallerMemberName] string name = "")
{
_ecTokenSource?.Dispose();
_ecTokenSource = new CancellationTokenSource();
_hc = new TestHostContext(this, name);
var expressionValues = new LegacyContextData.DictionaryContextData();
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
_ec = new Mock<IExecutionContext>();
_ec.Setup(x => x.Global)
.Returns(new GlobalContext
{
FileTable = new List<String>(),
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
WriteDebug = true,
});
_ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token);
_ec.Setup(x => x.ExpressionValues).Returns(expressionValues);
_ec.Setup(x => x.ExpressionFunctions).Returns(expressionFunctions);
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); });
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
}
private void Teardown()
{
_hc?.Dispose();
}
}
}

View File

@@ -316,6 +316,94 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Equal("${{ matrix.node }}", _actionRunner.DisplayName);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateDisplayNameForLocalAction()
{
// Arrange
Setup();
var actionId = Guid.NewGuid();
var action = new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
RepositoryType = Pipelines.PipelineConstants.SelfAlias,
Path = "./"
}
};
_actionRunner.Action = action;
// Act
var validDisplayName = _actionRunner.EvaluateDisplayName(_context, _actionRunner.ExecutionContext, out bool updated);
// Assert
Assert.True(validDisplayName);
Assert.True(updated);
Assert.Equal("Run ./", _actionRunner.DisplayName); // NOT "Run /./"
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateDisplayNameForLocalActionWithPath()
{
// Arrange
Setup();
var actionId = Guid.NewGuid();
var action = new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
RepositoryType = Pipelines.PipelineConstants.SelfAlias,
Path = "./.github/actions/my-action"
}
};
_actionRunner.Action = action;
// Act
var validDisplayName = _actionRunner.EvaluateDisplayName(_context, _actionRunner.ExecutionContext, out bool updated);
// Assert
Assert.True(validDisplayName);
Assert.True(updated);
Assert.Equal("Run ./.github/actions/my-action", _actionRunner.DisplayName);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateDisplayNameForRemoteActionWithPath()
{
// Arrange
Setup();
var actionId = Guid.NewGuid();
var action = new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "owner/repo",
Path = "subdir",
Ref = "v1"
}
};
_actionRunner.Action = action;
// Act
var validDisplayName = _actionRunner.EvaluateDisplayName(_context, _actionRunner.ExecutionContext, out bool updated);
// Assert
Assert.True(validDisplayName);
Assert.True(updated);
Assert.Equal("Run owner/repo/subdir@v1", _actionRunner.DisplayName);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -459,7 +547,7 @@ namespace GitHub.Runner.Common.Tests.Worker
_handlerFactory = new Mock<IHandlerFactory>();
_defaultStepHost = new Mock<IDefaultStepHost>();
var actionManifestLegacy = new ActionManifestManagerLegacy();
actionManifestLegacy.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(actionManifestLegacy);

View File

@@ -0,0 +1,550 @@
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using LegacyContextData = GitHub.DistributedTask.Pipelines.ContextData;
using LegacyExpressions = GitHub.DistributedTask.Expressions2;
using Moq;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class PipelineTemplateEvaluatorWrapperL0
{
private CancellationTokenSource _ecTokenSource;
private Mock<IExecutionContext> _ec;
private TestHostContext _hc;
// -------------------------------------------------------------------
// EvaluateAndCompare core behavior
// -------------------------------------------------------------------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateAndCompare_DoesNotRecordMismatch_WhenResultsMatch()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var token = new StringToken(null, null, null, "test-value");
var contextData = new DictionaryContextData();
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
Assert.Equal("test-value", result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuringEvaluation()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
// Call EvaluateAndCompare directly: the new evaluator cancels the token
// and returns a different value, forcing hasMismatch = true.
// Because cancellation flipped during the evaluation window, the
// mismatch should be skipped.
var result = wrapper.EvaluateAndCompare<string, string>(
"TestCancellationSkip",
() => "legacy-value",
() =>
{
_ecTokenSource.Cancel();
return "different-value";
},
(legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal));
Assert.Equal("legacy-value", result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateAndCompare_RecordsMismatch_WhenResultsDifferWithoutCancellation()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
// Different results without cancellation — mismatch SHOULD be recorded.
var result = wrapper.EvaluateAndCompare<string, string>(
"TestMismatchRecorded",
() => "legacy-value",
() => "different-value",
(legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal));
Assert.Equal("legacy-value", result);
Assert.True(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
// -------------------------------------------------------------------
// Smoke tests — both parsers agree, no mismatch recorded
// -------------------------------------------------------------------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateStepContinueOnError_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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>();
var result = wrapper.EvaluateStepContinueOnError(token, contextData, functions);
Assert.True(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateStepEnvironment_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateStepEnvironment(token, contextData, functions, StringComparer.OrdinalIgnoreCase);
Assert.NotNull(result);
Assert.Equal("bar", result["FOO"]);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateStepIf_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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>();
var expressionState = new List<KeyValuePair<string, object>>();
var result = wrapper.EvaluateStepIf(token, contextData, functions, expressionState);
Assert.True(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateStepInputs_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateStepInputs(token, contextData, functions);
Assert.NotNull(result);
Assert.Equal("val1", result["input1"]);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateStepTimeout_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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>();
var result = wrapper.EvaluateStepTimeout(token, contextData, functions);
Assert.Equal(10, result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobContainer_EmptyImage_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var token = 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 EvaluateJobOutput_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobOutput(token, contextData, functions);
Assert.NotNull(result);
Assert.Equal("val1", result["out1"]);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateEnvironmentUrl_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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>();
var result = wrapper.EvaluateEnvironmentUrl(token, contextData, functions);
Assert.NotNull(result);
var stringResult = result as StringToken;
Assert.NotNull(stringResult);
Assert.Equal("https://example.com", stringResult.Value);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobDefaultsRun_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobDefaultsRun(token, contextData, functions);
Assert.NotNull(result);
Assert.Equal("bash", result["shell"]);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobServiceContainers_Null_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobServiceContainers(null, contextData, functions);
Assert.Null(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateJobSnapshotRequest_Null_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobSnapshotRequest(null, contextData, functions);
Assert.Null(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
// -------------------------------------------------------------------
// JSON parse error equivalence via EvaluateAndCompare
// -------------------------------------------------------------------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateAndCompare_JsonReaderExceptions_TreatedAsEquivalent()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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.");
var newEx = new Newtonsoft.Json.JsonReaderException("Error parsing fromJson", new Newtonsoft.Json.JsonReaderException("Unexpected end"));
Assert.Throws<Newtonsoft.Json.JsonReaderException>(() =>
wrapper.EvaluateAndCompare<string, string>(
"TestJsonEquivalence",
() => throw legacyEx,
() => throw newEx,
(a, b) => string.Equals(a, b, StringComparison.Ordinal)));
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateAndCompare_MixedJsonExceptionTypes_TreatedAsEquivalent()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
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");
var newEx = new System.Text.Json.JsonException("Error parsing fromJson");
Assert.Throws<Newtonsoft.Json.JsonReaderException>(() =>
wrapper.EvaluateAndCompare<string, string>(
"TestMixedJsonTypes",
() => throw legacyEx,
() => throw newEx,
(a, b) => string.Equals(a, b, StringComparison.Ordinal)));
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateAndCompare_NonJsonExceptions_RecordsMismatch()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
// Both throw non-JSON exceptions with different messages — should record mismatch
var legacyEx = new InvalidOperationException("some error");
var newEx = new InvalidOperationException("different error");
Assert.Throws<InvalidOperationException>(() =>
wrapper.EvaluateAndCompare<string, string>(
"TestNonJsonMismatch",
() => throw legacyEx,
() => throw newEx,
(a, b) => string.Equals(a, b, StringComparison.Ordinal)));
Assert.True(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
private void Setup([CallerMemberName] string name = "")
{
_ecTokenSource?.Dispose();
_ecTokenSource = new CancellationTokenSource();
_hc = new TestHostContext(this, name);
var expressionValues = new LegacyContextData.DictionaryContextData();
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
_ec = new Mock<IExecutionContext>();
_ec.Setup(x => x.Global)
.Returns(new GlobalContext
{
FileTable = new List<String>(),
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
WriteDebug = true,
});
_ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token);
_ec.Setup(x => x.ExpressionValues).Returns(expressionValues);
_ec.Setup(x => x.ExpressionFunctions).Returns(expressionFunctions);
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); });
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
}
private void Teardown()
{
_hc?.Dispose();
}
}
}

View File

@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
PACKAGE_DIR="$SCRIPT_DIR/../_package"
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
DOTNETSDK_VERSION="8.0.416"
DOTNETSDK_VERSION="8.0.417"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)

View File

@@ -1,5 +1,5 @@
{
"sdk": {
"version": "8.0.416"
"version": "8.0.417"
}
}

View File

@@ -1 +1 @@
2.330.0
2.331.0