Compare commits

..

1 Commits

Author SHA1 Message Date
eric sciple
90da9fbb8c Add CutoverWorkflowParser feature flag for workflow parser cutover
Add a new feature flag (actions_runner_cutover_workflow_parser) that enables
the wrapper classes to use only the new workflow parser/evaluator implementation
while converting results back to legacy types for callers.

Flag precedence: cutover > compare > legacy-only.
Rename EvaluateAndCompare to EvaluateWrapper in both wrapper classes.
2026-02-06 16:17:00 +00:00
14 changed files with 4256 additions and 31426 deletions

View File

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

View File

@@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@actions/glob": "^0.6.1"
"@actions/glob": "^0.4.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^3.1.0",
@@ -36,45 +36,31 @@
}
},
"node_modules/@actions/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
"integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
"dependencies": {
"@actions/exec": "^3.0.0",
"@actions/http-client": "^4.0.0"
}
},
"node_modules/@actions/exec": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"dependencies": {
"@actions/io": "^3.0.2"
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
}
},
"node_modules/@actions/glob": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.6.1.tgz",
"integrity": "sha512-K4+2Ac5ILcf2ySdJCha+Pop9NcKjxqCL4xL4zI50dgB2PbXgC0+AcP011xfH4Of6b4QEJJg8dyZYv7zl4byTsw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz",
"integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==",
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/core": "^1.9.1",
"minimatch": "^3.0.4"
}
},
"node_modules/@actions/http-client": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
"integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz",
"integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==",
"dependencies": {
"tunnel": "^0.0.6",
"undici": "^6.23.0"
"tunnel": "^0.0.6"
}
},
"node_modules/@actions/io": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="
},
"node_modules/@babel/runtime": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
@@ -4480,14 +4466,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -4543,6 +4521,14 @@
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4693,45 +4679,31 @@
"dev": true
},
"@actions/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
"integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
"requires": {
"@actions/exec": "^3.0.0",
"@actions/http-client": "^4.0.0"
}
},
"@actions/exec": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"requires": {
"@actions/io": "^3.0.2"
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
}
},
"@actions/glob": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.6.1.tgz",
"integrity": "sha512-K4+2Ac5ILcf2ySdJCha+Pop9NcKjxqCL4xL4zI50dgB2PbXgC0+AcP011xfH4Of6b4QEJJg8dyZYv7zl4byTsw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz",
"integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==",
"requires": {
"@actions/core": "^3.0.0",
"@actions/core": "^1.9.1",
"minimatch": "^3.0.4"
}
},
"@actions/http-client": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
"integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz",
"integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==",
"requires": {
"tunnel": "^0.0.6",
"undici": "^6.23.0"
"tunnel": "^0.0.6"
}
},
"@actions/io": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="
},
"@babel/runtime": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
@@ -7688,11 +7660,6 @@
"which-boxed-primitive": "^1.0.2"
}
},
"undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="
},
"undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -7724,6 +7691,11 @@
"punycode": "^2.1.0"
}
},
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -2,14 +2,13 @@
"name": "hashFiles",
"version": "1.0.0",
"description": "GitHub Actions HashFiles() expression function",
"type": "module",
"main": "lib/hashFiles.js",
"scripts": {
"build": "tsc",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts",
"pack": "ncc build -o ../../layoutbin/hashFiles --target es2020",
"pack": "ncc build -o ../../layoutbin/hashFiles",
"all": "npm run format && npm run lint && npm run build && npm run pack",
"prepare": "cd ../../../../ && husky"
},
@@ -33,7 +32,7 @@
"author": "GitHub Actions",
"license": "MIT",
"dependencies": {
"@actions/glob": "^0.6.1"
"@actions/glob": "^0.4.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^3.1.0",

View File

@@ -1,8 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
"strict": true, /* Enable all strict type-checking options. */

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

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

View File

@@ -172,6 +172,7 @@ 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 CutoverWorkflowParser = "actions_runner_cutover_workflow_parser";
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
}

View File

@@ -5,8 +5,8 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -406,27 +406,12 @@ namespace GitHub.Runner.Listener
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");
@@ -437,15 +422,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);
@@ -465,7 +450,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)
{
@@ -518,7 +503,7 @@ namespace GitHub.Runner.Listener
restartSession = true;
break;
}
TaskAgentMessage message = null;
bool skipMessageDeletion = false;
try
@@ -874,7 +859,7 @@ namespace GitHub.Runner.Listener
{
restart = false;
returnCode = await RunAsync(settings, runOnce);
if (returnCode == Constants.Runner.ReturnCode.RunnerConfigurationRefreshed)
{
Trace.Info("Runner configuration was refreshed, restarting session...");

View File

@@ -40,7 +40,7 @@ namespace GitHub.Runner.Worker
public ActionDefinitionData Load(IExecutionContext executionContext, string manifestFile)
{
return EvaluateAndCompare(
return EvaluateWrapper(
executionContext,
"Load",
() => _legacyManager.Load(executionContext, manifestFile),
@@ -53,7 +53,7 @@ namespace GitHub.Runner.Worker
TemplateToken token,
IDictionary<string, PipelineContextData> extraExpressionValues)
{
return EvaluateAndCompare(
return EvaluateWrapper(
executionContext,
"EvaluateCompositeOutputs",
() => _legacyManager.EvaluateCompositeOutputs(executionContext, token, extraExpressionValues),
@@ -66,7 +66,7 @@ namespace GitHub.Runner.Worker
SequenceToken token,
IDictionary<string, PipelineContextData> extraExpressionValues)
{
return EvaluateAndCompare(
return EvaluateWrapper(
executionContext,
"EvaluateContainerArguments",
() => _legacyManager.EvaluateContainerArguments(executionContext, token, extraExpressionValues),
@@ -79,12 +79,13 @@ namespace GitHub.Runner.Worker
MappingToken token,
IDictionary<string, PipelineContextData> extraExpressionValues)
{
return EvaluateAndCompare(
return EvaluateWrapper(
executionContext,
"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");
});
@@ -95,7 +96,7 @@ namespace GitHub.Runner.Worker
string inputName,
TemplateToken token)
{
return EvaluateAndCompare(
return EvaluateWrapper(
executionContext,
"EvaluateDefaultInput",
() => _legacyManager.EvaluateDefaultInput(executionContext, inputName, token),
@@ -216,13 +217,27 @@ namespace GitHub.Runner.Worker
}
// Comparison helper methods
private TLegacy EvaluateAndCompare<TLegacy, TNew>(
private TLegacy EvaluateWrapper<TLegacy, TNew>(
IExecutionContext context,
string methodName,
Func<TLegacy> legacyEvaluator,
Func<TNew> newEvaluator,
Func<TLegacy, TNew, bool> resultComparer)
{
// Cutover: use only the new evaluator, convert result to legacy type
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CutoverWorkflowParser) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_CUTOVER_WORKFLOW_PARSER")))
{
var newResult = newEvaluator();
if (typeof(TLegacy) == typeof(TNew))
{
return (TLegacy)(object)newResult;
}
var json = StringUtil.ConvertToJson(newResult, Newtonsoft.Json.Formatting.None);
return StringUtil.ConvertFromJson<TLegacy>(json);
}
// Legacy only?
if (!((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER"))))

View File

@@ -1416,7 +1416,8 @@ namespace GitHub.Runner.Worker
public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
{
// Create wrapper?
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")))
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER"))
|| (context.Global.Variables.GetBoolean(Constants.Runner.Features.CutoverWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_CUTOVER_WORKFLOW_PARSER")))
{
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter);
}

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;
@@ -19,6 +19,7 @@ namespace GitHub.Runner.Worker
private WorkflowTemplateEvaluator _newEvaluator;
private IExecutionContext _context;
private Tracing _trace;
private bool _cutover;
public PipelineTemplateEvaluatorWrapper(
IHostContext hostContext,
@@ -29,6 +30,8 @@ namespace GitHub.Runner.Worker
ArgUtil.NotNull(context, nameof(context));
_context = context;
_trace = hostContext.GetTrace(nameof(PipelineTemplateEvaluatorWrapper));
_cutover = (context.Global.Variables.GetBoolean(Constants.Runner.Features.CutoverWorkflowParser) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_CUTOVER_WORKFLOW_PARSER"));
if (traceWriter == null)
{
@@ -55,7 +58,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateStepContinueOnError",
() => _legacyEvaluator.EvaluateStepContinueOnError(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateStepContinueOnError(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -67,7 +70,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateStepDisplayName",
() => _legacyEvaluator.EvaluateStepDisplayName(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateStepName(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -80,7 +83,7 @@ namespace GitHub.Runner.Worker
IList<IFunctionInfo> expressionFunctions,
StringComparer keyComparer)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateStepEnvironment",
() => _legacyEvaluator.EvaluateStepEnvironment(token, contextData, expressionFunctions, keyComparer),
() => _newEvaluator.EvaluateStepEnvironment(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), keyComparer),
@@ -93,7 +96,7 @@ namespace GitHub.Runner.Worker
IList<IFunctionInfo> expressionFunctions,
IEnumerable<KeyValuePair<string, object>> expressionState)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateStepIf",
() => _legacyEvaluator.EvaluateStepIf(token, contextData, expressionFunctions, expressionState),
() => _newEvaluator.EvaluateStepIf(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), expressionState),
@@ -105,7 +108,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateStepInputs",
() => _legacyEvaluator.EvaluateStepInputs(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateStepInputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -117,7 +120,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateStepTimeout",
() => _legacyEvaluator.EvaluateStepTimeout(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateStepTimeout(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -129,7 +132,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateJobContainer",
() => _legacyEvaluator.EvaluateJobContainer(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateJobContainer(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -141,7 +144,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateJobOutput",
() => _legacyEvaluator.EvaluateJobOutput(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateJobOutputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -153,7 +156,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateEnvironmentUrl",
() => _legacyEvaluator.EvaluateEnvironmentUrl(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateJobEnvironmentUrl(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -165,7 +168,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateJobDefaultsRun",
() => _legacyEvaluator.EvaluateJobDefaultsRun(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateJobDefaultsRun(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -177,7 +180,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateJobServiceContainers",
() => _legacyEvaluator.EvaluateJobServiceContainers(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateJobServiceContainers(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -189,7 +192,7 @@ namespace GitHub.Runner.Worker
DictionaryContextData contextData,
IList<IFunctionInfo> expressionFunctions)
{
return EvaluateAndCompare(
return EvaluateWrapper(
"EvaluateJobSnapshotRequest",
() => _legacyEvaluator.EvaluateJobSnapshotRequest(token, contextData, expressionFunctions),
() => _newEvaluator.EvaluateSnapshot(string.Empty, ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
@@ -216,12 +219,25 @@ namespace GitHub.Runner.Worker
}
}
private TLegacy EvaluateAndCompare<TLegacy, TNew>(
private TLegacy EvaluateWrapper<TLegacy, TNew>(
string methodName,
Func<TLegacy> legacyEvaluator,
Func<TNew> newEvaluator,
Func<TLegacy, TNew, bool> resultComparer)
{
// Cutover: use only the new evaluator, convert result to legacy type
if (_cutover)
{
var newResult = newEvaluator();
if (typeof(TLegacy) == typeof(TNew))
{
return (TLegacy)(object)newResult;
}
var json = StringUtil.ConvertToJson(newResult, Newtonsoft.Json.Formatting.None);
return StringUtil.ConvertFromJson<TLegacy>(json);
}
// Legacy evaluator
var legacyException = default(Exception);
var legacyResult = default(TLegacy);

View File

@@ -0,0 +1,456 @@
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 EvaluateWrapper_SkipsMismatchRecording_WhenCancellationOccursDuringEvaluation()
{
try
{
// Arrange - Test that mismatches are not recorded when cancellation state changes during evaluation
Setup();
// Enable comparison feature
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
// Create the wrapper
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
// Create a simple token for evaluation
var token = new StringToken(null, null, null, "test-value");
var contextData = new DictionaryContextData();
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
// First evaluation without cancellation - should work normally
var result1 = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
Assert.Equal("test-value", result1);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
// Now simulate a scenario where cancellation occurs during evaluation
// Cancel the token before next evaluation
_ecTokenSource.Cancel();
// Evaluate again - even if there were a mismatch, it should be skipped due to cancellation
var result2 = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
Assert.Equal("test-value", result2);
// Verify no mismatch was recorded (cancellation race detection should have prevented it)
// Note: In this test, both parsers return the same result, so there's no actual mismatch.
// The cancellation race detection is a safeguard for when results differ due to timing.
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateWrapper_DoesNotRecordMismatch_WhenResultsMatch()
{
try
{
// Arrange - Test that no mismatch is recorded when both parsers return matching results
Setup();
// Enable comparison feature
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
// Create the wrapper
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
// Create a simple token for evaluation
var token = new StringToken(null, null, null, "test-value");
var contextData = new DictionaryContextData();
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
// Evaluation without cancellation - should work normally and not record mismatch for matching results
var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
Assert.Equal("test-value", result);
// Since both parsers return the same result, no mismatch should be recorded
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CutoverFlag_UsesNewEvaluator_ForPipelineTemplateEvaluator()
{
try
{
// Arrange - Test that cutover flag causes the wrapper to use only the new evaluator
Setup();
// Enable cutover feature (not comparison)
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CutoverWorkflowParser, "true");
// Create the wrapper
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
// Create a simple token for evaluation
var token = new StringToken(null, null, null, "test-value");
var contextData = new DictionaryContextData();
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
// Act - Evaluate in cutover mode
var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
// Assert - Should get the correct result from the new evaluator
Assert.Equal("test-value", result);
// No mismatch should be recorded (comparison is skipped entirely in cutover mode)
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CutoverFlag_UsesNewManager_ForActionManifestLoad()
{
try
{
// Arrange - Test that cutover flag causes the manifest wrapper to use only the new manager
Setup();
// Enable cutover feature (not comparison)
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CutoverWorkflowParser, "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 in cutover mode
var result = wrapper.Load(_ec.Object, manifestPath);
// Assert - Should get the correct result from the new manager
Assert.NotNull(result);
Assert.Equal(ActionExecutionType.Composite, result.Execution.ExecutionType);
// No mismatch should be recorded (comparison is skipped in cutover mode)
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CutoverFlag_TakesPrecedence_OverCompareFlag()
{
try
{
// Arrange - Test that cutover flag takes precedence over compare flag
Setup();
// Enable both flags
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CutoverWorkflowParser, "true");
// Create the wrapper
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>();
// Act - Evaluate (cutover should take precedence, skipping comparison entirely)
var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
// Assert - Should get correct result, no comparison mismatch recorded
Assert.Equal("test-value", result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
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();
}
}
}