Compare commits

..

1 Commits

Author SHA1 Message Date
Allan Guigou
39f6c643d3 Update release version to 2.331.0 2026-01-09 12:22:08 -05:00
29 changed files with 70 additions and 1518 deletions

View File

@@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.417"
"version": "8.0.416"
},
"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
- 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

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.2.0
ARG BUILDX_VERSION=0.31.1
ARG DOCKER_VERSION=29.0.2
ARG BUILDX_VERSION=0.30.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.1/actions-runner-hooks-k8s-0.8.1.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 \
&& unzip ./runner-container-hooks.zip -d ./k8s-novolume \
&& rm runner-container-hooks.zip

View File

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

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.20.0"
NODE24_VERSION="24.13.0"
NODE20_VERSION="20.19.6"
NODE24_VERSION="24.12.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 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

@@ -173,7 +173,6 @@ 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<TaskResult> RunOnceJobCompleted { get; }
TaskCompletionSource<bool> 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<TaskResult> _runOnceJobCompleted = new();
private TaskCompletionSource<bool> _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<TaskResult> RunOnceJobCompleted => _runOnceJobCompleted;
public TaskCompletionSource<bool> RunOnceJobCompleted => _runOnceJobCompleted;
public bool Busy { get; private set; }
@@ -340,19 +340,18 @@ namespace GitHub.Runner.Listener
private async Task RunOnceAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
{
var jobResult = TaskResult.Succeeded;
try
{
jobResult = await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
}
finally
{
Trace.Info("Fire signal for one time used runner.");
_runOnceJobCompleted.TrySetResult(jobResult);
_runOnceJobCompleted.TrySetResult(true);
}
}
private async Task<TaskResult> RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
private async Task RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
{
Busy = true;
try
@@ -400,7 +399,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 TaskResult.Abandoned;
return;
}
if (jobRequestCancellationToken.IsCancellationRequested)
@@ -413,7 +412,7 @@ namespace GitHub.Runner.Listener
// complete job request with result Cancelled
await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, TaskResult.Canceled);
return TaskResult.Canceled;
return;
}
HostContext.WritePerfCounter($"JobRequestRenewed_{requestId.ToString()}");
@@ -524,7 +523,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 TaskResult.Failed;
return;
}
// we get first jobrequest renew succeed and start the worker process with the job message.
@@ -605,7 +604,7 @@ namespace GitHub.Runner.Listener
Trace.Error(detailInfo);
}
return TaskResultUtil.TranslateFromReturnCode(returnCode);
return;
}
else if (completedTask == renewJobRequest)
{
@@ -707,8 +706,6 @@ 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.Claims;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -324,11 +324,8 @@ 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 || returnJobResultForHosted, returnJobResultForHosted);
return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral);
}
else
{
@@ -404,32 +401,17 @@ 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, bool returnRunOnceJobResult = false)
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = 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");
@@ -440,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);
@@ -468,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)
{
@@ -521,7 +503,7 @@ namespace GitHub.Runner.Listener
restartSession = true;
break;
}
TaskAgentMessage message = null;
bool skipMessageDeletion = false;
try
@@ -583,21 +565,6 @@ 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;
}
}
@@ -884,15 +851,15 @@ namespace GitHub.Runner.Listener
return Constants.Runner.ReturnCode.Success;
}
private async Task<int> ExecuteRunnerAsync(RunnerSettings settings, bool runOnce, bool returnRunOnceJobResult)
private async Task<int> ExecuteRunnerAsync(RunnerSettings settings, bool runOnce)
{
int returnCode = Constants.Runner.ReturnCode.Success;
bool restart = false;
do
{
restart = false;
returnCode = await RunAsync(settings, runOnce, returnRunOnceJobResult);
returnCode = await RunAsync(settings, runOnce);
if (returnCode == Constants.Runner.ReturnCode.RunnerConfigurationRefreshed)
{
Trace.Info("Runner configuration was refreshed, restarting session...");

View File

@@ -17,7 +17,7 @@
<ItemGroup>
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">

View File

@@ -318,17 +318,6 @@ 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.");
@@ -364,17 +353,6 @@ 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,8 +84,7 @@ 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");
});
@@ -166,150 +165,9 @@ namespace GitHub.Runner.Worker
return null;
}
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
};
// 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);
}
private T ConvertToLegacyToken<T>(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newToken) where T : TemplateToken
@@ -775,14 +633,6 @@ 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);
@@ -847,6 +697,5 @@ namespace GitHub.Runner.Worker
return messages;
}
}
}

View File

@@ -379,14 +379,7 @@ namespace GitHub.Runner.Worker
{
prefix = PipelineTemplateConstants.RunDisplayPrefix;
var repositoryReference = action.Reference as RepositoryPathReference;
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 pathString = string.IsNullOrEmpty(repositoryReference.Path) ? string.Empty : $"/{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 == ExecutionContextType.Task)
if (_record.RecordType == "Task")
{
var stepResult = new StepResult
{
@@ -532,25 +532,6 @@ 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,7 +31,5 @@ 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

@@ -1,3 +0,0 @@
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,15 +216,12 @@ namespace GitHub.Runner.Worker
}
}
internal TLegacy EvaluateAndCompare<TLegacy, TNew>(
private 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);
@@ -256,18 +253,14 @@ 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");
hasMismatch = true;
RecordMismatch($"{methodName}");
}
}
else
@@ -276,20 +269,6 @@ 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}");
}
}
@@ -633,13 +612,6 @@ 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);
@@ -662,67 +634,6 @@ 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": "string",
"options": "non-empty-string",
"env": "container-env",
"ports": "sequence-of-non-empty-string",
"volumes": "sequence-of-non-empty-string",

View File

@@ -23,14 +23,14 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.2" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="8.0.0" />
<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="10.0.2" />
<PackageReference Include="System.Formats.Asn1" Version="8.0.1" />
</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,12 +1154,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
if (String.IsNullOrEmpty(result.Image))
{
// 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");
}
context.Error(value, "Container image cannot be empty");
return null;
}
@@ -1843,9 +1838,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": "string",
"type": "non-empty-string",
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
},
"env": "container-env",

View File

@@ -739,8 +739,7 @@ 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)
{
var result = await jobDispatcher.RunOnceJobCompleted.Task;
Assert.Equal(TaskResult.Succeeded, result);
Assert.True(await jobDispatcher.RunOnceJobCompleted.Task, "JobDispatcher should set task complete token to 'TRUE' for one time agent.");
}
}
}

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<TaskResult>();
var runOnceJobCompleted = new TaskCompletionSource<bool>();
_jobDispatcher.Setup(x => x.RunOnceJobCompleted)
.Returns(runOnceJobCompleted);
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()))
.Callback(() =>
{
runOnceJobCompleted.TrySetResult(TaskResult.Succeeded);
runOnceJobCompleted.TrySetResult(true);
});
_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<TaskResult>();
var runOnceJobCompleted = new TaskCompletionSource<bool>();
_jobDispatcher.Setup(x => x.RunOnceJobCompleted)
.Returns(runOnceJobCompleted);
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()))
.Callback(() =>
{
runOnceJobCompleted.TrySetResult(TaskResult.Succeeded);
runOnceJobCompleted.TrySetResult(true);
});
_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<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
_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<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
_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<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
//Act

View File

@@ -457,8 +457,6 @@ namespace GitHub.Runner.Common.Tests.Worker
new SetEnvCommandExtension(),
new WarningCommandExtension(),
new AddMaskCommandExtension(),
new SetOutputCommandExtension(),
new SaveStateCommandExtension(),
};
foreach (var command in commands)
{
@@ -501,53 +499,5 @@ 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

@@ -1,424 +0,0 @@
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,94 +316,6 @@ 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")]
@@ -547,7 +459,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

@@ -1,550 +0,0 @@
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.417"
DOTNETSDK_VERSION="8.0.416"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)

View File

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