From cdb77c680459f251d304cb1e7673787072b96085 Mon Sep 17 00:00:00 2001 From: Tingluo Huang Date: Tue, 10 Feb 2026 09:31:10 -0500 Subject: [PATCH 01/25] Support return job result as exitcode in hosted runner. (#4233) --- src/Runner.Listener/JobDispatcher.cs | 23 ++++++++++++---------- src/Runner.Listener/Runner.cs | 26 +++++++++++++++++++++---- src/Test/L0/Listener/JobDispatcherL0.cs | 3 ++- src/Test/L0/Listener/RunnerL0.cs | 20 +++++++++---------- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/Runner.Listener/JobDispatcher.cs b/src/Runner.Listener/JobDispatcher.cs index bbc09593c..10076441e 100644 --- a/src/Runner.Listener/JobDispatcher.cs +++ b/src/Runner.Listener/JobDispatcher.cs @@ -24,7 +24,7 @@ namespace GitHub.Runner.Listener public interface IJobDispatcher : IRunnerService { bool Busy { get; } - TaskCompletionSource RunOnceJobCompleted { get; } + TaskCompletionSource 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 _runOnceJobCompleted = new(); + private TaskCompletionSource _runOnceJobCompleted = new(); public event EventHandler JobStatus; @@ -82,7 +82,7 @@ namespace GitHub.Runner.Listener Trace.Info($"Set runner/worker IPC timeout to {_channelTimeout.TotalSeconds} seconds."); } - public TaskCompletionSource RunOnceJobCompleted => _runOnceJobCompleted; + public TaskCompletionSource RunOnceJobCompleted => _runOnceJobCompleted; public bool Busy { get; private set; } @@ -340,18 +340,19 @@ namespace GitHub.Runner.Listener private async Task RunOnceAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken) { + var jobResult = TaskResult.Succeeded; try { - await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken); + jobResult = await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken); } finally { Trace.Info("Fire signal for one time used runner."); - _runOnceJobCompleted.TrySetResult(true); + _runOnceJobCompleted.TrySetResult(jobResult); } } - private async Task RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken) + private async Task RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken) { Busy = true; try @@ -399,7 +400,7 @@ namespace GitHub.Runner.Listener { // renew job request task complete means we run out of retry for the first job request renew. Trace.Info($"Unable to renew job request for job {message.JobId} for the first time, stop dispatching job to worker."); - return; + return TaskResult.Abandoned; } if (jobRequestCancellationToken.IsCancellationRequested) @@ -412,7 +413,7 @@ namespace GitHub.Runner.Listener // complete job request with result Cancelled await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, TaskResult.Canceled); - return; + return TaskResult.Canceled; } HostContext.WritePerfCounter($"JobRequestRenewed_{requestId.ToString()}"); @@ -523,7 +524,7 @@ namespace GitHub.Runner.Listener await renewJobRequest; // not finish the job request since the job haven't run on worker at all, we will not going to set a result to server. - return; + return TaskResult.Failed; } // we get first jobrequest renew succeed and start the worker process with the job message. @@ -604,7 +605,7 @@ namespace GitHub.Runner.Listener Trace.Error(detailInfo); } - return; + return TaskResultUtil.TranslateFromReturnCode(returnCode); } else if (completedTask == renewJobRequest) { @@ -706,6 +707,8 @@ namespace GitHub.Runner.Listener // complete job request await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, resultOnAbandonOrCancel); + + return resultOnAbandonOrCancel; } finally { diff --git a/src/Runner.Listener/Runner.cs b/src/Runner.Listener/Runner.cs index a7577bca8..e20eb6256 100644 --- a/src/Runner.Listener/Runner.cs +++ b/src/Runner.Listener/Runner.cs @@ -324,8 +324,11 @@ namespace GitHub.Runner.Listener HostContext.EnableAuthMigration("EnableAuthMigrationByDefault"); } + // hosted runner only run one job and would like to know the result of the job for telemetry and alerting on failure spike. + var returnJobResultForHosted = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED")); + // Run the runner interactively or as service - return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral); + return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral || returnJobResultForHosted, returnJobResultForHosted); } else { @@ -401,7 +404,7 @@ namespace GitHub.Runner.Listener } //create worker manager, create message listener and start listening to the queue - private async Task RunAsync(RunnerSettings settings, bool runOnce = false) + private async Task RunAsync(RunnerSettings settings, bool runOnce = false, bool returnRunOnceJobResult = false) { try { @@ -580,6 +583,21 @@ namespace GitHub.Runner.Listener Trace.Info($"Ignore any exception after cancel message loop. {ex}"); } + if (returnRunOnceJobResult) + { + try + { + var jobResult = await jobDispatcher.RunOnceJobCompleted.Task; + return TaskResultUtil.TranslateToReturnCode(jobResult); + } + catch (Exception ex) + { + Trace.Error("run once job finished with error."); + Trace.Error(ex); + return Constants.Runner.ReturnCode.TerminatedError; + } + } + return Constants.Runner.ReturnCode.Success; } } @@ -866,14 +884,14 @@ namespace GitHub.Runner.Listener return Constants.Runner.ReturnCode.Success; } - private async Task ExecuteRunnerAsync(RunnerSettings settings, bool runOnce) + private async Task ExecuteRunnerAsync(RunnerSettings settings, bool runOnce, bool returnRunOnceJobResult) { int returnCode = Constants.Runner.ReturnCode.Success; bool restart = false; do { restart = false; - returnCode = await RunAsync(settings, runOnce); + returnCode = await RunAsync(settings, runOnce, returnRunOnceJobResult); if (returnCode == Constants.Runner.ReturnCode.RunnerConfigurationRefreshed) { diff --git a/src/Test/L0/Listener/JobDispatcherL0.cs b/src/Test/L0/Listener/JobDispatcherL0.cs index 3b26233a4..a160ffba2 100644 --- a/src/Test/L0/Listener/JobDispatcherL0.cs +++ b/src/Test/L0/Listener/JobDispatcherL0.cs @@ -739,7 +739,8 @@ namespace GitHub.Runner.Common.Tests.Listener Assert.True(jobDispatcher.RunOnceJobCompleted.Task.IsCompleted, "JobDispatcher should set task complete token for one time agent."); if (jobDispatcher.RunOnceJobCompleted.Task.IsCompleted) { - Assert.True(await jobDispatcher.RunOnceJobCompleted.Task, "JobDispatcher should set task complete token to 'TRUE' for one time agent."); + var result = await jobDispatcher.RunOnceJobCompleted.Task; + Assert.Equal(TaskResult.Succeeded, result); } } } diff --git a/src/Test/L0/Listener/RunnerL0.cs b/src/Test/L0/Listener/RunnerL0.cs index 456f51cc9..c88bc8274 100644 --- a/src/Test/L0/Listener/RunnerL0.cs +++ b/src/Test/L0/Listener/RunnerL0.cs @@ -295,13 +295,13 @@ namespace GitHub.Runner.Common.Tests.Listener _messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny())) .Returns(Task.CompletedTask); - var runOnceJobCompleted = new TaskCompletionSource(); + var runOnceJobCompleted = new TaskCompletionSource(); _jobDispatcher.Setup(x => x.RunOnceJobCompleted) .Returns(runOnceJobCompleted); _jobDispatcher.Setup(x => x.Run(It.IsAny(), It.IsAny())) .Callback(() => { - runOnceJobCompleted.TrySetResult(true); + runOnceJobCompleted.TrySetResult(TaskResult.Succeeded); }); _jobNotification.Setup(x => x.StartClient(It.IsAny())) .Callback(() => @@ -399,13 +399,13 @@ namespace GitHub.Runner.Common.Tests.Listener _messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny())) .Returns(Task.CompletedTask); - var runOnceJobCompleted = new TaskCompletionSource(); + var runOnceJobCompleted = new TaskCompletionSource(); _jobDispatcher.Setup(x => x.RunOnceJobCompleted) .Returns(runOnceJobCompleted); _jobDispatcher.Setup(x => x.Run(It.IsAny(), It.IsAny())) .Callback(() => { - runOnceJobCompleted.TrySetResult(true); + runOnceJobCompleted.TrySetResult(TaskResult.Succeeded); }); _jobNotification.Setup(x => x.StartClient(It.IsAny())) .Callback(() => @@ -733,8 +733,8 @@ namespace GitHub.Runner.Common.Tests.Listener _configStore.Setup(x => x.IsServiceConfigured()).Returns(false); - var completedTask = new TaskCompletionSource(); - completedTask.SetResult(true); + var completedTask = new TaskCompletionSource(); + completedTask.SetResult(TaskResult.Succeeded); _jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask); //Act @@ -834,8 +834,8 @@ namespace GitHub.Runner.Common.Tests.Listener _configStore.Setup(x => x.IsServiceConfigured()).Returns(false); - var completedTask = new TaskCompletionSource(); - completedTask.SetResult(true); + var completedTask = new TaskCompletionSource(); + completedTask.SetResult(TaskResult.Succeeded); _jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask); //Act @@ -954,8 +954,8 @@ namespace GitHub.Runner.Common.Tests.Listener _configStore.Setup(x => x.IsServiceConfigured()).Returns(false); - var completedTask = new TaskCompletionSource(); - completedTask.SetResult(true); + var completedTask = new TaskCompletionSource(); + completedTask.SetResult(TaskResult.Succeeded); _jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask); //Act From d5a8a936c199712b0a8bc663873ee205271f9d57 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Tue, 10 Feb 2026 12:28:42 -0600 Subject: [PATCH 02/25] Add telemetry tracking for deprecated set-output and save-state commands (#4221) --- src/Runner.Worker/ActionCommandManager.cs | 22 +++++++++ src/Runner.Worker/GlobalContext.cs | 2 + src/Test/L0/Worker/ActionCommandManagerL0.cs | 50 ++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/src/Runner.Worker/ActionCommandManager.cs b/src/Runner.Worker/ActionCommandManager.cs index 2a487df3d..77d6a4c5f 100644 --- a/src/Runner.Worker/ActionCommandManager.cs +++ b/src/Runner.Worker/ActionCommandManager.cs @@ -318,6 +318,17 @@ namespace GitHub.Runner.Worker context.AddIssue(issue, ExecutionContextLogOptions.Default); } + if (!context.Global.HasDeprecatedSetOutput) + { + context.Global.HasDeprecatedSetOutput = true; + var telemetry = new JobTelemetry + { + Type = JobTelemetryType.ActionCommand, + Message = "DeprecatedCommand: set-output" + }; + context.Global.JobTelemetry.Add(telemetry); + } + if (!command.Properties.TryGetValue(SetOutputCommandProperties.Name, out string outputName) || string.IsNullOrEmpty(outputName)) { throw new Exception("Required field 'name' is missing in ##[set-output] command."); @@ -353,6 +364,17 @@ namespace GitHub.Runner.Worker context.AddIssue(issue, ExecutionContextLogOptions.Default); } + if (!context.Global.HasDeprecatedSaveState) + { + context.Global.HasDeprecatedSaveState = true; + var telemetry = new JobTelemetry + { + Type = JobTelemetryType.ActionCommand, + Message = "DeprecatedCommand: save-state" + }; + context.Global.JobTelemetry.Add(telemetry); + } + if (!command.Properties.TryGetValue(SaveStateCommandProperties.Name, out string stateName) || string.IsNullOrEmpty(stateName)) { throw new Exception("Required field 'name' is missing in ##[save-state] command."); diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index da45c010d..6d4494843 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -31,5 +31,7 @@ namespace GitHub.Runner.Worker public JObject ContainerHookState { get; set; } public bool HasTemplateEvaluatorMismatch { get; set; } public bool HasActionManifestMismatch { get; set; } + public bool HasDeprecatedSetOutput { get; set; } + public bool HasDeprecatedSaveState { get; set; } } } diff --git a/src/Test/L0/Worker/ActionCommandManagerL0.cs b/src/Test/L0/Worker/ActionCommandManagerL0.cs index 3a1f8f70f..906f446fc 100644 --- a/src/Test/L0/Worker/ActionCommandManagerL0.cs +++ b/src/Test/L0/Worker/ActionCommandManagerL0.cs @@ -457,6 +457,8 @@ namespace GitHub.Runner.Common.Tests.Worker new SetEnvCommandExtension(), new WarningCommandExtension(), new AddMaskCommandExtension(), + new SetOutputCommandExtension(), + new SaveStateCommandExtension(), }; foreach (var command in commands) { @@ -499,5 +501,53 @@ namespace GitHub.Runner.Common.Tests.Worker }; } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetOutputCommand_EmitsTelemetryOnce() + { + using (TestHostContext hc = CreateTestContext()) + { + _ec.Object.Global.JobTelemetry = new List(); + var reference = string.Empty; + _ec.Setup(x => x.SetOutput(It.IsAny(), It.IsAny(), 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(); + _ec.Setup(x => x.IsEmbedded).Returns(false); + _ec.Setup(x => x.IntraActionState).Returns(new Dictionary()); + + // 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); + } + } + } } From 15cb558d8f557ef114ab6976e76b2ca561df9422 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Wed, 11 Feb 2026 09:44:01 -0600 Subject: [PATCH 03/25] Fix parser comparison mismatches (#4220) --- .../ActionManifestManagerWrapper.cs | 159 ++++- src/Runner.Worker/InternalsVisibleTo.cs | 3 + .../PipelineTemplateEvaluatorWrapper.cs | 95 ++- .../Conversion/WorkflowTemplateConverter.cs | 21 +- .../ActionManifestParserComparisonL0.cs | 424 ++++++++++++++ .../PipelineTemplateEvaluatorWrapperL0.cs | 550 ++++++++++++++++++ 6 files changed, 1237 insertions(+), 15 deletions(-) create mode 100644 src/Runner.Worker/InternalsVisibleTo.cs create mode 100644 src/Test/L0/Worker/ActionManifestParserComparisonL0.cs create mode 100644 src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs diff --git a/src/Runner.Worker/ActionManifestManagerWrapper.cs b/src/Runner.Worker/ActionManifestManagerWrapper.cs index aa265dbf4..6d893fd82 100644 --- a/src/Runner.Worker/ActionManifestManagerWrapper.cs +++ b/src/Runner.Worker/ActionManifestManagerWrapper.cs @@ -84,7 +84,8 @@ namespace GitHub.Runner.Worker "EvaluateContainerEnvironment", () => _legacyManager.EvaluateContainerEnvironment(executionContext, token, extraExpressionValues), () => _newManager.EvaluateContainerEnvironment(executionContext, ConvertToNewToken(token) as GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.MappingToken, ConvertToNewExpressionValues(extraExpressionValues)), - (legacyResult, newResult) => { + (legacyResult, newResult) => + { var trace = HostContext.GetTrace(nameof(ActionManifestManagerWrapper)); return CompareDictionaries(trace, legacyResult, newResult, "ContainerEnvironment"); }); @@ -165,9 +166,150 @@ namespace GitHub.Runner.Worker return null; } - // Serialize new steps and deserialize to old steps - var json = StringUtil.ConvertToJson(newSteps, Newtonsoft.Json.Formatting.None); - return StringUtil.ConvertFromJson>(json); + var result = new List(); + 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(runStep.Name); + actionStep.ContinueOnError = ConvertToLegacyToken(runStep.ContinueOnError); + actionStep.TimeoutInMinutes = ConvertToLegacyToken(runStep.TimeoutMinutes); + actionStep.Environment = ConvertToLegacyToken(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(usesStep.Name); + actionStep.ContinueOnError = ConvertToLegacyToken(usesStep.ContinueOnError); + actionStep.TimeoutInMinutes = ConvertToLegacyToken(usesStep.TimeoutMinutes); + actionStep.Environment = ConvertToLegacyToken(usesStep.Env); + actionStep.Reference = ParseActionReference(usesStep.Uses?.Value); + actionStep.Inputs = ConvertToLegacyToken(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(runStep.Run)); + } + + // shell + if (runStep.Shell != null) + { + inputs.Add( + new StringToken(null, null, null, "shell"), + ConvertToLegacyToken(runStep.Shell)); + } + + // working-directory + if (runStep.WorkingDirectory != null) + { + inputs.Add( + new StringToken(null, null, null, "workingDirectory"), + ConvertToLegacyToken(runStep.WorkingDirectory)); + } + + return inputs.Count > 0 ? inputs : null; + } + + private GitHub.DistributedTask.Pipelines.ActionStepDefinitionReference ParseActionReference(string uses) + { + if (string.IsNullOrEmpty(uses)) + { + return null; + } + + // Docker reference: docker://image:tag + if (uses.StartsWith("docker://", StringComparison.OrdinalIgnoreCase)) + { + return new GitHub.DistributedTask.Pipelines.ContainerRegistryReference + { + Image = uses.Substring("docker://".Length) + }; + } + + // Local path reference: ./path/to/action + if (uses.StartsWith("./") || uses.StartsWith(".\\")) + { + return new GitHub.DistributedTask.Pipelines.RepositoryPathReference + { + RepositoryType = "self", + Path = uses + }; + } + + // Repository reference: owner/repo@ref or owner/repo/path@ref + var atIndex = uses.LastIndexOf('@'); + string refPart = null; + string repoPart = uses; + + if (atIndex > 0) + { + refPart = uses.Substring(atIndex + 1); + repoPart = uses.Substring(0, atIndex); + } + + // Split by / to get owner/repo and optional path + var parts = repoPart.Split('/'); + string name; + string path = null; + + if (parts.Length >= 2) + { + name = $"{parts[0]}/{parts[1]}"; + if (parts.Length > 2) + { + path = string.Join("/", parts, 2, parts.Length - 2); + } + } + else + { + name = repoPart; + } + + return new GitHub.DistributedTask.Pipelines.RepositoryPathReference + { + RepositoryType = "GitHub", + Name = name, + Ref = refPart, + Path = path + }; } private T ConvertToLegacyToken(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newToken) where T : TemplateToken @@ -633,6 +775,14 @@ namespace GitHub.Runner.Worker return false; } + // Check for known equivalent error patterns (e.g., JSON parse errors) + // where both parsers correctly reject invalid input but with different wording + if (PipelineTemplateEvaluatorWrapper.HasJsonExceptionType(legacyException) && PipelineTemplateEvaluatorWrapper.HasJsonExceptionType(newException)) + { + trace.Info("CompareExceptions - both exceptions are JSON parse errors, treating as matched"); + return true; + } + // Compare exception messages recursively (including inner exceptions) var legacyMessages = GetExceptionMessages(legacyException); var newMessages = GetExceptionMessages(newException); @@ -697,5 +847,6 @@ namespace GitHub.Runner.Worker return messages; } + } } diff --git a/src/Runner.Worker/InternalsVisibleTo.cs b/src/Runner.Worker/InternalsVisibleTo.cs new file mode 100644 index 000000000..a825116a6 --- /dev/null +++ b/src/Runner.Worker/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Test")] diff --git a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs index 53742469b..61dfdacce 100644 --- a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs +++ b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using GitHub.Actions.WorkflowParser; using GitHub.DistributedTask.Expressions2; @@ -216,12 +216,15 @@ namespace GitHub.Runner.Worker } } - private TLegacy EvaluateAndCompare( + internal TLegacy EvaluateAndCompare( string methodName, Func legacyEvaluator, Func newEvaluator, Func resultComparer) { + // Capture cancellation state before evaluation + var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested; + // Legacy evaluator var legacyException = default(Exception); var legacyResult = default(TLegacy); @@ -253,14 +256,18 @@ namespace GitHub.Runner.Worker newException = ex; } + // Capture cancellation state after evaluation + var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested; + // Compare results or exceptions + bool hasMismatch = false; if (legacyException != null || newException != null) { // Either one or both threw exceptions - compare them if (!CompareExceptions(legacyException, newException)) { _trace.Info($"{methodName} exception mismatch"); - RecordMismatch($"{methodName}"); + hasMismatch = true; } } else @@ -269,6 +276,20 @@ namespace GitHub.Runner.Worker if (!resultComparer(legacyResult, newResult)) { _trace.Info($"{methodName} mismatch"); + hasMismatch = true; + } + } + + // Only record mismatch if it wasn't caused by a cancellation race condition + if (hasMismatch) + { + if (!cancellationRequestedBefore && cancellationRequestedAfter) + { + // Cancellation state changed during evaluation window - skip recording + _trace.Info($"{methodName} mismatch skipped due to cancellation race condition"); + } + else + { RecordMismatch($"{methodName}"); } } @@ -612,6 +633,13 @@ namespace GitHub.Runner.Worker return false; } + // Check for known equivalent error patterns (e.g., JSON parse errors) + // where both parsers correctly reject invalid input but with different wording + if (IsKnownEquivalentErrorPattern(legacyException, newException)) + { + return true; + } + // Compare exception messages recursively (including inner exceptions) var legacyMessages = GetExceptionMessages(legacyException); var newMessages = GetExceptionMessages(newException); @@ -634,6 +662,67 @@ namespace GitHub.Runner.Worker return true; } + /// + /// 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). + /// + 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; + } + + /// + /// Checks if the exception chain contains a JSON-related exception type. + /// + internal static bool HasJsonExceptionType(Exception ex) + { + var toProcess = new Queue(); + 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 GetExceptionMessages(Exception ex) { var messages = new List(); diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index 0c7a74564..7c5764cb3 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1,4 +1,4 @@ -#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references +#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references using System; using System.Collections.Generic; @@ -43,7 +43,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion { case WorkflowTemplateConstants.On: var inputTypes = ConvertToOnWorkflowDispatchInputTypes(workflowPair.Value); - foreach(var item in inputTypes) + foreach (var item in inputTypes) { result.InputTypes.TryAdd(item.Key, item.Value); } @@ -432,7 +432,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion context.Error(snapshotToken, $"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName} is required."); return null; } - + return new Snapshot { ImageName = imageName, @@ -445,7 +445,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion { var versionSegments = versionString.Split("."); - if (versionSegments.Length != 2 || + if (versionSegments.Length != 2 || !versionSegments[1].Equals("*") || !Int32.TryParse(versionSegments[0], NumberStyles.None, CultureInfo.InvariantCulture, result: out int parsedMajor) || parsedMajor < 0) @@ -1154,7 +1154,12 @@ namespace GitHub.Actions.WorkflowParser.Conversion if (String.IsNullOrEmpty(result.Image)) { - context.Error(value, "Container image cannot be empty"); + // Only error during early validation (parse time) + // At runtime (expression evaluation), empty image = no container + if (isEarlyValidation) + { + context.Error(value, "Container image cannot be empty"); + } return null; } @@ -1838,9 +1843,9 @@ namespace GitHub.Actions.WorkflowParser.Conversion case "actions": permissions.Actions = permissionLevel; break; - case "artifact-metadata": - permissions.ArtifactMetadata = permissionLevel; - break; + case "artifact-metadata": + permissions.ArtifactMetadata = permissionLevel; + break; case "attestations": permissions.Attestations = permissionLevel; break; diff --git a/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs b/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs new file mode 100644 index 000000000..d7039767c --- /dev/null +++ b/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs @@ -0,0 +1,424 @@ +using GitHub.Actions.WorkflowParser; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.Pipelines.ObjectTemplating; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using LegacyContextData = GitHub.DistributedTask.Pipelines.ContextData; +using LegacyExpressions = GitHub.DistributedTask.Expressions2; +using Moq; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + /// + /// Tests for parser comparison wrapper classes. + /// + public sealed class ActionManifestParserComparisonL0 + { + private CancellationTokenSource _ecTokenSource; + private Mock _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(legacyManager); + + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(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(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(); + + // 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(); + + // 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(); + + 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(); + + // 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(); + var newExpressionFunctions = new List(); + + // 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(legacyManager); + + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(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("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(legacyManager); + + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(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(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(legacyManager); + + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(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(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(legacyManager); + + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(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(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(); + 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(); + + _ec = new Mock(); + _ec.Setup(x => x.Global) + .Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(_hc, new Dictionary()), + 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(), It.IsAny())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); }); + _ec.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); }); + } + + private void Teardown() + { + _hc?.Dispose(); + } + } +} diff --git a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs new file mode 100644 index 000000000..2caf5dba0 --- /dev/null +++ b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs @@ -0,0 +1,550 @@ +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.Pipelines.ObjectTemplating; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using LegacyContextData = GitHub.DistributedTask.Pipelines.ContextData; +using LegacyExpressions = GitHub.DistributedTask.Expressions2; +using Moq; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class PipelineTemplateEvaluatorWrapperL0 + { + private CancellationTokenSource _ecTokenSource; + private Mock _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(); + + 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( + "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( + "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(); + + 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(); + + 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(); + var expressionState = new List>(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(() => + wrapper.EvaluateAndCompare( + "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(() => + wrapper.EvaluateAndCompare( + "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(() => + wrapper.EvaluateAndCompare( + "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(); + + _ec = new Mock(); + _ec.Setup(x => x.Global) + .Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(_hc, new Dictionary()), + 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(), It.IsAny())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); }); + _ec.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); }); + } + + private void Teardown() + { + _hc?.Dispose(); + } + } +} From 66800900843747f37591b077091dd2c8cf2c1796 Mon Sep 17 00:00:00 2001 From: Zach Renner <13670625+zarenner@users.noreply.github.com> Date: Thu, 12 Feb 2026 05:46:48 -0800 Subject: [PATCH 04/25] Remove unnecessary connection test during some registration flows (#4244) --- src/Runner.Listener/Configuration/ConfigurationManager.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Runner.Listener/Configuration/ConfigurationManager.cs b/src/Runner.Listener/Configuration/ConfigurationManager.cs index 2c18d8d60..360728a1c 100644 --- a/src/Runner.Listener/Configuration/ConfigurationManager.cs +++ b/src/Runner.Listener/Configuration/ConfigurationManager.cs @@ -178,8 +178,12 @@ namespace GitHub.Runner.Listener.Configuration } } - // Validate can connect. - await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), creds); + // Validate can connect using the obtained vss credentials. + // In Runner Admin flow there's nothing new to test connection to at this point as registerToken is already validated via GetTenantCredential. + if (!runnerSettings.UseRunnerAdminFlow) + { + await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), creds); + } _term.WriteLine(); _term.WriteSuccessMessage("Connected to GitHub"); From 9efea31a8996d94707f566e84c61407e453f6d4c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:29:25 +0000 Subject: [PATCH 05/25] chore: update Node versions (#4249) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/Misc/externals.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh index 3094710a4..5cbb6f64d 100755 --- a/src/Misc/externals.sh +++ b/src/Misc/externals.sh @@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download # When you update Node versions you must also create a new release of alpine_nodejs at that updated version. # Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started NODE20_VERSION="20.20.0" -NODE24_VERSION="24.13.0" +NODE24_VERSION="24.13.1" get_abs_path() { # exploits the fact that pwd will print abs path when no args From a798a45826f347c980f431de81a25c5fbda3eba5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:34:26 +0000 Subject: [PATCH 06/25] Update dotnet sdk to latest version @8.0.418 (#4250) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Salman Chishti --- .devcontainer/devcontainer.json | 2 +- src/dev.sh | 2 +- src/global.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ba8f9153c..2fd60937e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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.418" }, "ghcr.io/devcontainers/features/node:1": { "version": "20" diff --git a/src/dev.sh b/src/dev.sh index 798cec0f2..716fa08e6 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -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.418" DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION" RUNNER_VERSION=$(cat runnerversion) diff --git a/src/global.json b/src/global.json index 3b20d9c45..519b109fa 100644 --- a/src/global.json +++ b/src/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.417" + "version": "8.0.418" } } From e012ab630b9778524163d938f8875a346471635f Mon Sep 17 00:00:00 2001 From: Tingluo Huang Date: Tue, 17 Feb 2026 14:09:05 -0500 Subject: [PATCH 07/25] Fix link to SECURITY.md in README (#4253) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a2e3bcc97..0279d839e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ We are taking the following steps to better direct requests related to GitHub Ac 2. High Priority bugs can be reported through Community Discussions or you can report these to our support team https://support.github.com/contact/bug-report. -3. Security Issues should be handled as per our [security.md](security.md) +3. Security Issues should be handled as per our [SECURITY.md](https://github.com/actions/runner?tab=security-ov-file) We will still provide security updates for this project and fix major breaking changes during this time. From 72189aabf8cb4ae533bf6e9c968d197dcecc22ac Mon Sep 17 00:00:00 2001 From: Tingluo Huang Date: Wed, 18 Feb 2026 12:00:37 -0500 Subject: [PATCH 08/25] Try to infer runner is on hosted/ghes when githuburl is empty. (#4254) --- src/Runner.Common/ConfigurationStore.cs | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Runner.Common/ConfigurationStore.cs b/src/Runner.Common/ConfigurationStore.cs index 9b47f85bc..21276e296 100644 --- a/src/Runner.Common/ConfigurationStore.cs +++ b/src/Runner.Common/ConfigurationStore.cs @@ -75,6 +75,41 @@ namespace GitHub.Runner.Common { return UrlUtil.IsHostedServer(new UriBuilder(GitHubUrl)); } + else + { + // feature flag env in case the new logic is wrong. + if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_FORCE_EMPTY_GITHUB_URL_IS_HOSTED"))) + { + return true; + } + + // GitHubUrl will be empty for jit configured runner + // We will try to infer it from the ServerUrl/ServerUrlV2 + if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_FORCE_GHES"))) + { + // Allow env to override and force GHES in case the inference logic is wrong. + return false; + } + + if (!string.IsNullOrEmpty(ServerUrl)) + { + // pipelines services + var serverUrl = new UriBuilder(ServerUrl); + return serverUrl.Host.EndsWith(".actions.githubusercontent.com", StringComparison.OrdinalIgnoreCase) + || serverUrl.Host.EndsWith(".codedev.ms", StringComparison.OrdinalIgnoreCase); + } + + if (!string.IsNullOrEmpty(ServerUrlV2)) + { + // broker-listener + var serverUrlV2 = new UriBuilder(ServerUrlV2); + return serverUrlV2.Host.EndsWith(".actions.githubusercontent.com", StringComparison.OrdinalIgnoreCase) + || serverUrlV2.Host.EndsWith(".githubapp.com", StringComparison.OrdinalIgnoreCase) + || serverUrlV2.Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase) + || serverUrlV2.Host.EndsWith(".actions.localhost", StringComparison.OrdinalIgnoreCase) + || serverUrlV2.Host.EndsWith(".ghe.localhost", StringComparison.OrdinalIgnoreCase); + } + } // Default to true since Hosted runners likely don't have this property set. return true; From 9426c35fdaf2b2e00c3ef751a15c04fa8e2a9582 Mon Sep 17 00:00:00 2001 From: Salman Chishti Date: Thu, 19 Feb 2026 17:05:32 +0000 Subject: [PATCH 09/25] Add Node.js 20 deprecation warning annotation (Phase 1) (#4242) --- src/Runner.Common/Constants.cs | 4 + src/Runner.Worker/ExecutionContext.cs | 3 + src/Runner.Worker/GlobalContext.cs | 1 + src/Runner.Worker/Handlers/HandlerFactory.cs | 37 ++- src/Runner.Worker/JobExtension.cs | 9 + src/Test/L0/Worker/HandlerFactoryL0.cs | 256 ++++++++++++++++++- 6 files changed, 308 insertions(+), 2 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index f2d5dd26e..3cc9d28b4 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -190,6 +190,10 @@ namespace GitHub.Runner.Common // Feature flags for controlling the migration phases public static readonly string UseNode24ByDefaultFlag = "actions.runner.usenode24bydefault"; public static readonly string RequireNode24Flag = "actions.runner.requirenode24"; + public static readonly string WarnOnNode20Flag = "actions.runner.warnonnode20"; + + // Blog post URL for Node 20 deprecation + public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"; } public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry"; diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 2a7cd11fb..53484e6b6 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -856,6 +856,9 @@ namespace GitHub.Runner.Worker // Job level annotations Global.JobAnnotations = new List(); + // Track Node.js 20 actions for deprecation warning + Global.DeprecatedNode20Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + // Job Outputs JobOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 6d4494843..27c326d68 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -33,5 +33,6 @@ namespace GitHub.Runner.Worker public bool HasActionManifestMismatch { get; set; } public bool HasDeprecatedSetOutput { get; set; } public bool HasDeprecatedSaveState { get; set; } + public HashSet DeprecatedNode20Actions { get; set; } } } diff --git a/src/Runner.Worker/Handlers/HandlerFactory.cs b/src/Runner.Worker/Handlers/HandlerFactory.cs index ee022ec9d..e9e2a5a60 100644 --- a/src/Runner.Worker/Handlers/HandlerFactory.cs +++ b/src/Runner.Worker/Handlers/HandlerFactory.cs @@ -65,6 +65,20 @@ namespace GitHub.Runner.Worker.Handlers nodeData.NodeVersion = Common.Constants.Runner.NodeMigration.Node20; } + // Track Node.js 20 actions for deprecation annotation + if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase)) + { + bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false; + if (warnOnNode20) + { + string actionName = GetActionName(action); + if (!string.IsNullOrEmpty(actionName)) + { + executionContext.Global.DeprecatedNode20Actions?.Add(actionName); + } + } + } + // Check if node20 was explicitly specified in the action // We don't modify if node24 was explicitly specified if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase)) @@ -90,7 +104,8 @@ namespace GitHub.Runner.Worker.Handlers if (useNode24ByDefault && !requireNode24 && string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase)) { string infoMessage = "Node 20 is being deprecated. This workflow is running with Node 24 by default. " + - "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable."; + "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. " + + $"For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; executionContext.Output(infoMessage); } } @@ -129,5 +144,25 @@ namespace GitHub.Runner.Worker.Handlers handler.LocalActionContainerSetupSteps = localActionContainerSetupSteps; return handler; } + + private static string GetActionName(Pipelines.ActionStepDefinitionReference action) + { + if (action is Pipelines.RepositoryPathReference repoRef) + { + var pathString = string.Empty; + if (!string.IsNullOrEmpty(repoRef.Path)) + { + pathString = string.IsNullOrEmpty(repoRef.Name) + ? repoRef.Path + : $"/{repoRef.Path}"; + } + var repoString = string.IsNullOrEmpty(repoRef.Ref) + ? $"{repoRef.Name}{pathString}" + : $"{repoRef.Name}{pathString}@{repoRef.Ref}"; + return string.IsNullOrEmpty(repoString) ? null : repoString; + } + + return null; + } } } diff --git a/src/Runner.Worker/JobExtension.cs b/src/Runner.Worker/JobExtension.cs index ea36034ec..bd4644766 100644 --- a/src/Runner.Worker/JobExtension.cs +++ b/src/Runner.Worker/JobExtension.cs @@ -735,6 +735,15 @@ namespace GitHub.Runner.Worker context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"Fail to check service connectivity. {ex.Message}" }); } } + + // Add deprecation warning annotation for Node.js 20 actions + if (context.Global.DeprecatedNode20Actions?.Count > 0) + { + var sortedActions = context.Global.DeprecatedNode20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase); + var actionsList = string.Join(", ", sortedActions); + var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2025. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; + context.Warning(deprecationMessage); + } } catch (Exception ex) { diff --git a/src/Test/L0/Worker/HandlerFactoryL0.cs b/src/Test/L0/Worker/HandlerFactoryL0.cs index 37981e46a..85a70ff52 100644 --- a/src/Test/L0/Worker/HandlerFactoryL0.cs +++ b/src/Test/L0/Worker/HandlerFactoryL0.cs @@ -74,7 +74,7 @@ namespace GitHub.Runner.Common.Tests.Worker } } - + [Fact] [Trait("Level", "L0")] @@ -116,5 +116,259 @@ namespace GitHub.Runner.Common.Tests.Worker Assert.Equal("node24", handler.Data.NodeVersion); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_TrackedWhenWarnFlagEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v4" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert. + Assert.Contains("actions/checkout@v4", deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_NotTrackedWhenWarnFlagDisabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary(); + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v4" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - should not track when flag is disabled + Assert.Empty(deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node24Action_NotTrackedEvenWhenWarnFlagEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v5" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node24"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - node24 actions should not be tracked + Assert.Empty(deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node12Action_TrackedAsDeprecatedWhenWarnFlagEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "some-org/old-action", + Ref = "v1" + }; + + // Act - node12 gets migrated to node20, then should be tracked + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node12"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - node12 gets migrated to node20 and should be tracked + Assert.Contains("some-org/old-action@v1", deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void LocalNode20Action_TrackedWhenWarnFlagEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + // Local action: Name is empty, Path is the local path + var actionRef = new RepositoryPathReference + { + Name = "", + Path = "./.github/actions/my-action", + RepositoryType = "self" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - local action should be tracked with its path + Assert.Contains("./.github/actions/my-action", deprecatedActions); + } + } } } From a2b220990b9f65238cdb8027d6532a46b907bea1 Mon Sep 17 00:00:00 2001 From: Salman Chishti Date: Sat, 21 Feb 2026 19:19:46 +0000 Subject: [PATCH 10/25] Update Node.js 20 deprecation date to June 2nd, 2026 (#4258) Co-authored-by: Salman --- src/Runner.Worker/JobExtension.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Runner.Worker/JobExtension.cs b/src/Runner.Worker/JobExtension.cs index bd4644766..c210ebeb8 100644 --- a/src/Runner.Worker/JobExtension.cs +++ b/src/Runner.Worker/JobExtension.cs @@ -741,7 +741,7 @@ namespace GitHub.Runner.Worker { var sortedActions = context.Global.DeprecatedNode20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase); var actionsList = string.Join(", ", sortedActions); - var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2025. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; + var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; context.Warning(deprecationMessage); } } From ecb5f298fad8b116679389f6df064c1a0229d148 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 23 Feb 2026 09:00:12 -0600 Subject: [PATCH 11/25] Composite Action Step Markers (#4243) --- src/Runner.Common/ActionCommand.cs | 20 ++ src/Runner.Common/Constants.cs | 2 + .../Handlers/CompositeActionHandler.cs | 95 +++++- src/Runner.Worker/Handlers/OutputManager.cs | 8 + .../Handlers/CompositeActionHandlerL0.cs | 271 ++++++++++++++++++ src/Test/L0/Worker/OutputManagerL0.cs | 60 ++++ 6 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs diff --git a/src/Runner.Common/ActionCommand.cs b/src/Runner.Common/ActionCommand.cs index 080f613cc..c51fa5a34 100644 --- a/src/Runner.Common/ActionCommand.cs +++ b/src/Runner.Common/ActionCommand.cs @@ -204,6 +204,26 @@ namespace GitHub.Runner.Common return unescaped; } + /// + /// Escapes special characters in a value using the standard action command escape mappings. + /// Iterates in reverse so that '%' is escaped first to avoid double-encoding. + /// + public static string EscapeValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + string escaped = value; + for (int i = _escapeMappings.Length - 1; i >= 0; i--) + { + escaped = escaped.Replace(_escapeMappings[i].Token, _escapeMappings[i].Replacement); + } + + return escaped; + } + private static string UnescapeProperty(string escaped) { if (string.IsNullOrEmpty(escaped)) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 3cc9d28b4..266c7ae5e 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -174,6 +174,7 @@ namespace GitHub.Runner.Common 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"; + public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers"; } // Node version migration related constants @@ -288,6 +289,7 @@ namespace GitHub.Runner.Common public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION"; public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT"; public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE"; + public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS"; } public static class System diff --git a/src/Runner.Worker/Handlers/CompositeActionHandler.cs b/src/Runner.Worker/Handlers/CompositeActionHandler.cs index b0fcf8a1e..c0bc6f688 100644 --- a/src/Runner.Worker/Handlers/CompositeActionHandler.cs +++ b/src/Runner.Worker/Handlers/CompositeActionHandler.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -226,6 +227,11 @@ namespace GitHub.Runner.Worker.Handlers { ArgUtil.NotNull(embeddedSteps, nameof(embeddedSteps)); + bool emitCompositeMarkers = + (ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.EmitCompositeMarkers) ?? false) + || StringUtil.ConvertToBoolean( + System.Environment.GetEnvironmentVariable(Constants.Variables.Agent.EmitCompositeMarkers)); + foreach (IStep step in embeddedSteps) { Trace.Info($"Processing embedded step: DisplayName='{step.DisplayName}'"); @@ -297,6 +303,20 @@ namespace GitHub.Runner.Worker.Handlers SetStepConclusion(step, TaskResult.Failed); } + // Marker ID uses the step's fully qualified context name (ScopeName.ContextName), + // which encodes the full composite nesting chain at any depth. + var markerId = emitCompositeMarkers ? step.ExecutionContext.GetFullyQualifiedContextName() : null; + var stepStopwatch = default(Stopwatch); + var endMarkerEmitted = false; + + // Emit start marker after full context setup so display name expressions resolve correctly + if (emitCompositeMarkers) + { + step.TryUpdateDisplayName(out _); + ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]"); + stepStopwatch = Stopwatch.StartNew(); + } + // Register Callback CancellationTokenRegistration? jobCancelRegister = null; try @@ -381,6 +401,14 @@ namespace GitHub.Runner.Worker.Handlers // Condition is false Trace.Info("Skipping step due to condition evaluation."); SetStepConclusion(step, TaskResult.Skipped); + + if (emitCompositeMarkers) + { + stepStopwatch.Stop(); + ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome=skipped;conclusion=skipped;duration_ms=0]"); + endMarkerEmitted = true; + } + continue; } else if (conditionEvaluateError != null) @@ -389,13 +417,31 @@ namespace GitHub.Runner.Worker.Handlers step.ExecutionContext.Error(conditionEvaluateError); SetStepConclusion(step, TaskResult.Failed); ExecutionContext.Result = TaskResult.Failed; + + if (emitCompositeMarkers) + { + stepStopwatch.Stop(); + ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome=failure;conclusion=failure;duration_ms={stepStopwatch.ElapsedMilliseconds}]"); + endMarkerEmitted = true; + } + break; } else { await RunStepAsync(step); - } + if (emitCompositeMarkers) + { + stepStopwatch.Stop(); + // Outcome = raw result before continue-on-error (null when continue-on-error didn't fire) + // Result = final result after continue-on-error + var outcome = (step.ExecutionContext.Outcome ?? step.ExecutionContext.Result ?? TaskResult.Succeeded).ToActionResult().ToString().ToLowerInvariant(); + var conclusion = (step.ExecutionContext.Result ?? TaskResult.Succeeded).ToActionResult().ToString().ToLowerInvariant(); + ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]"); + endMarkerEmitted = true; + } + } } finally { @@ -404,6 +450,14 @@ namespace GitHub.Runner.Worker.Handlers jobCancelRegister?.Dispose(); jobCancelRegister = null; } + + if (emitCompositeMarkers && !endMarkerEmitted) + { + stepStopwatch.Stop(); + var outcome = (step.ExecutionContext.Outcome ?? step.ExecutionContext.Result ?? TaskResult.Failed).ToActionResult().ToString().ToLowerInvariant(); + var conclusion = (step.ExecutionContext.Result ?? TaskResult.Failed).ToActionResult().ToString().ToLowerInvariant(); + ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]"); + } } // Check failed or cancelled if (step.ExecutionContext.Result == TaskResult.Failed || step.ExecutionContext.Result == TaskResult.Canceled) @@ -470,5 +524,44 @@ namespace GitHub.Runner.Worker.Handlers step.ExecutionContext.Result = result; step.ExecutionContext.UpdateGlobalStepsContext(); } + + /// + /// Escapes marker property values so they cannot break the ##[command key=value] format. + /// Delegates to ActionCommand.EscapeValue which escapes `;`, `]`, `\r`, `\n`, and `%`. + /// + internal static string EscapeProperty(string value) + { + return ActionCommand.EscapeValue(value); + } + + /// Maximum character length for display names in markers to prevent log bloat. + internal const int MaxDisplayNameLength = 1000; + + /// + /// Normalizes a step display name for safe embedding in a marker property. + /// Trims leading whitespace, drops everything after the first newline, and + /// truncates to characters. + /// + internal static string SanitizeDisplayName(string displayName) + { + if (string.IsNullOrEmpty(displayName)) return displayName; + + // Take first line only (FormatStepName in ActionRunner.cs already does this + // for most cases, but be defensive for any code path that skips it) + var result = displayName.TrimStart(' ', '\t', '\r', '\n'); + var firstNewLine = result.IndexOfAny(new[] { '\r', '\n' }); + if (firstNewLine >= 0) + { + result = result.Substring(0, firstNewLine); + } + + // Truncate excessively long names + if (result.Length > MaxDisplayNameLength) + { + result = result.Substring(0, MaxDisplayNameLength); + } + + return result; + } } } diff --git a/src/Runner.Worker/Handlers/OutputManager.cs b/src/Runner.Worker/Handlers/OutputManager.cs index f424f33f7..32d1e78c2 100644 --- a/src/Runner.Worker/Handlers/OutputManager.cs +++ b/src/Runner.Worker/Handlers/OutputManager.cs @@ -90,6 +90,14 @@ namespace GitHub.Runner.Worker.Handlers } } + // Strip runner-controlled markers from user output to prevent injection + if (!String.IsNullOrEmpty(line) && + (line.Contains("##[start-action") || line.Contains("##[end-action"))) + { + line = line.Replace("##[start-action", @"##[\start-action") + .Replace("##[end-action", @"##[\end-action"); + } + // Problem matchers if (_matchers.Length > 0) { diff --git a/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs b/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs new file mode 100644 index 000000000..09935c3ad --- /dev/null +++ b/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Handlers; +using Moq; +using Xunit; +using DTWebApi = GitHub.DistributedTask.WebApi; + +namespace GitHub.Runner.Common.Tests.Worker.Handlers +{ + public sealed class CompositeActionHandlerL0 + { + // Test EscapeProperty helper logic via reflection or by testing the markers output + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EscapeProperty_EscapesSpecialCharacters() + { + // Test the escaping logic that would be applied + var input = "value;with%special\r\n]chars"; + var escaped = EscapeProperty(input); + Assert.Equal("value%3Bwith%25special%0D%0A%5Dchars", escaped); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EscapeProperty_HandlesNullAndEmpty() + { + Assert.Null(EscapeProperty(null)); + Assert.Equal("", EscapeProperty("")); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SanitizeDisplayName_TruncatesLongNames() + { + var longName = new string('a', 1500); + var sanitized = SanitizeDisplayName(longName); + Assert.Equal(CompositeActionHandler.MaxDisplayNameLength, sanitized.Length); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SanitizeDisplayName_TakesFirstLineOnly() + { + var multiline = "First line\nSecond line\nThird line"; + var sanitized = SanitizeDisplayName(multiline); + Assert.Equal("First line", sanitized); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SanitizeDisplayName_TrimsLeadingWhitespace() + { + var withLeading = " \n \t Actual name\nSecond line"; + var sanitized = SanitizeDisplayName(withLeading); + Assert.Equal("Actual name", sanitized); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SanitizeDisplayName_HandlesCarriageReturn() + { + var withCR = "First line\r\nSecond line"; + var sanitized = SanitizeDisplayName(withCR); + Assert.Equal("First line", sanitized); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SanitizeDisplayName_HandlesNullAndEmpty() + { + Assert.Null(SanitizeDisplayName(null)); + Assert.Equal("", SanitizeDisplayName("")); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EmitMarkers_DisplayNameEscaping() + { + // Verify that special characters in display names get escaped properly + var displayName = "Step with semicolons; and more; here"; + var escaped = EscapeProperty(SanitizeDisplayName(displayName)); + Assert.Equal("Step with semicolons%3B and more%3B here", escaped); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EmitMarkers_DisplayNameWithBrackets() + { + var displayName = "Step with [brackets] inside"; + var escaped = EscapeProperty(SanitizeDisplayName(displayName)); + Assert.Equal("Step with [brackets%5D inside", escaped); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StripUserEmittedMarkers_StartAction() + { + // Simulate what OutputManager does to strip markers + var userLine = "##[start-action display=Fake;id=fake]"; + var stripped = StripMarkers(userLine); + Assert.Equal(@"##[\start-action display=Fake;id=fake]", stripped); + Assert.DoesNotContain("##[start-action", stripped); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StripUserEmittedMarkers_EndAction() + { + var userLine = "##[end-action id=fake;outcome=success;conclusion=success;duration_ms=100]"; + var stripped = StripMarkers(userLine); + Assert.Equal(@"##[\end-action id=fake;outcome=success;conclusion=success;duration_ms=100]", stripped); + Assert.DoesNotContain("##[end-action", stripped); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StripUserEmittedMarkers_PreservesOtherCommands() + { + var userLine = "##[group]My Group"; + var stripped = StripMarkers(userLine); + Assert.Equal("##[group]My Group", stripped); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StripUserEmittedMarkers_HandlesEmbeddedMarkers() + { + var userLine = "Some text ##[start-action display=fake;id=fake] more text"; + var stripped = StripMarkers(userLine); + Assert.Equal(@"Some text ##[\start-action display=fake;id=fake] more text", stripped); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void TaskResultToActionResult_Success() + { + var result = GitHub.DistributedTask.WebApi.TaskResult.Succeeded; + var actionResult = result.ToActionResult(); + Assert.Equal(ActionResult.Success, actionResult); + Assert.Equal("success", actionResult.ToString().ToLowerInvariant()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void TaskResultToActionResult_Failure() + { + var result = GitHub.DistributedTask.WebApi.TaskResult.Failed; + var actionResult = result.ToActionResult(); + Assert.Equal(ActionResult.Failure, actionResult); + Assert.Equal("failure", actionResult.ToString().ToLowerInvariant()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void TaskResultToActionResult_Cancelled() + { + var result = GitHub.DistributedTask.WebApi.TaskResult.Canceled; + var actionResult = result.ToActionResult(); + Assert.Equal(ActionResult.Cancelled, actionResult); + Assert.Equal("cancelled", actionResult.ToString().ToLowerInvariant()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void TaskResultToActionResult_Skipped() + { + var result = GitHub.DistributedTask.WebApi.TaskResult.Skipped; + var actionResult = result.ToActionResult(); + Assert.Equal(ActionResult.Skipped, actionResult); + Assert.Equal("skipped", actionResult.ToString().ToLowerInvariant()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void MarkerFormat_StartAction() + { + var display = "My Step"; + var id = "my-step"; + var marker = $"##[start-action display={EscapeProperty(display)};id={EscapeProperty(id)}]"; + Assert.Equal("##[start-action display=My Step;id=my-step]", marker); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void MarkerFormat_EndAction() + { + var id = "my-step"; + var outcome = "success"; + var conclusion = "success"; + var durationMs = 1234; + var marker = $"##[end-action id={EscapeProperty(id)};outcome={outcome};conclusion={conclusion};duration_ms={durationMs}]"; + Assert.Equal("##[end-action id=my-step;outcome=success;conclusion=success;duration_ms=1234]", marker); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void MarkerFormat_NestedId() + { + var prefix = "outer-composite"; + var contextName = "inner-step"; + var stepId = $"{prefix}.{contextName}"; + Assert.Equal("outer-composite.inner-step", stepId); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void MarkerFormat_SkippedStep() + { + var id = "skipped-step"; + var marker = $"##[end-action id={EscapeProperty(id)};outcome=skipped;conclusion=skipped;duration_ms=0]"; + Assert.Equal("##[end-action id=skipped-step;outcome=skipped;conclusion=skipped;duration_ms=0]", marker); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void MarkerFormat_ContinueOnError() + { + // When continue-on-error is true and step fails: + // outcome = failure (raw result) + // conclusion = success (after continue-on-error applied) + var id = "failing-step"; + var marker = $"##[end-action id={EscapeProperty(id)};outcome=failure;conclusion=success;duration_ms=500]"; + Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker); + } + + // Helper methods that call the real production code + private static string EscapeProperty(string value) => + CompositeActionHandler.EscapeProperty(value); + + private static string SanitizeDisplayName(string displayName) => + CompositeActionHandler.SanitizeDisplayName(displayName); + + private static string StripMarkers(string line) + { + if (!string.IsNullOrEmpty(line) && + (line.Contains("##[start-action") || line.Contains("##[end-action"))) + { + line = line.Replace("##[start-action", @"##[\start-action") + .Replace("##[end-action", @"##[\end-action"); + } + return line; + } + } +} diff --git a/src/Test/L0/Worker/OutputManagerL0.cs b/src/Test/L0/Worker/OutputManagerL0.cs index 7005547b5..3c9cd539a 100644 --- a/src/Test/L0/Worker/OutputManagerL0.cs +++ b/src/Test/L0/Worker/OutputManagerL0.cs @@ -1006,6 +1006,66 @@ namespace GitHub.Runner.Common.Tests.Worker } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StripCompositeMarkers_StartAction() + { + using (Setup()) + using (_outputManager) + { + Process("##[start-action display=Fake;id=fake]"); + Assert.Single(_messages); + Assert.Contains(@"##[\start-action", _messages[0]); + Assert.DoesNotContain("##[start-action", _messages[0]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StripCompositeMarkers_EndAction() + { + using (Setup()) + using (_outputManager) + { + Process("##[end-action id=fake;outcome=success;conclusion=success;duration_ms=100]"); + Assert.Single(_messages); + Assert.Contains(@"##[\end-action", _messages[0]); + Assert.DoesNotContain("##[end-action", _messages[0]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StripCompositeMarkers_PreservesOtherCommands() + { + using (Setup()) + using (_outputManager) + { + Process("##[group]My Group"); + // Should not be stripped (not a composite marker) + Assert.Single(_messages); + Assert.Equal("##[group]My Group", _messages[0]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StripCompositeMarkers_EmbeddedInLine() + { + using (Setup()) + using (_outputManager) + { + Process("Some text ##[start-action display=fake;id=fake] more text"); + Assert.Single(_messages); + Assert.Contains(@"##[\start-action", _messages[0]); + Assert.DoesNotContain("##[start-action", _messages[0]); + } + } + private TestHostContext Setup( [CallerMemberName] string name = "", IssueMatchersConfig matchers = null, From 052dfbdd6864f9335a53e1b685aaa8e3f1f26d04 Mon Sep 17 00:00:00 2001 From: Pavel Iakovenko Date: Tue, 24 Feb 2026 12:19:46 -0500 Subject: [PATCH 12/25] Symlink actions cache (#4260) --- src/Runner.Common/Constants.cs | 1 + src/Runner.Sdk/Util/IOUtil.cs | 10 ++++ src/Runner.Worker/ActionManager.cs | 52 +++++++++++++++++++-- src/Test/L0/Worker/ActionManagerL0.cs | 67 +++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 266c7ae5e..fcb7c5b35 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -289,6 +289,7 @@ namespace GitHub.Runner.Common public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION"; public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT"; public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE"; + public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS"; public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS"; } diff --git a/src/Runner.Sdk/Util/IOUtil.cs b/src/Runner.Sdk/Util/IOUtil.cs index e0b5b3394..d34925b01 100644 --- a/src/Runner.Sdk/Util/IOUtil.cs +++ b/src/Runner.Sdk/Util/IOUtil.cs @@ -93,6 +93,16 @@ namespace GitHub.Runner.Sdk } } + public static FileSystemInfo CreateSymbolicLink(string destDirectory, string srcDirectory) + { + // ensure directory chain exists + Directory.CreateDirectory(destDirectory); + // delete leaf directory + Directory.Delete(destDirectory); + // create symlink for the leaf directory + return Directory.CreateSymbolicLink(destDirectory, srcDirectory); + } + public static void Delete(string path, CancellationToken cancellationToken) { DeleteDirectory(path, cancellationToken); diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index e38ea4d28..71bff32bd 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -773,10 +773,6 @@ namespace GitHub.Runner.Worker } else { - // make sure we get a clean folder ready to use. - IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); - Directory.CreateDirectory(destDirectory); - if (downloadInfo.PackageDetails != null) { executionContext.Output($"##[group]Download immutable action package '{downloadInfo.NameWithOwner}@{downloadInfo.Ref}'"); @@ -811,6 +807,50 @@ namespace GitHub.Runner.Worker if (!string.IsNullOrEmpty(actionArchiveCacheDir) && Directory.Exists(actionArchiveCacheDir)) { + var symlinkCachedActions = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions)); + if (symlinkCachedActions) + { + Trace.Info($"Checking if can symlink '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}'"); + + var cacheDirectory = Path.Combine(actionArchiveCacheDir, downloadInfo.ResolvedNameWithOwner.Replace(Path.DirectorySeparatorChar, '_').Replace(Path.AltDirectorySeparatorChar, '_'), downloadInfo.ResolvedSha); + if (Directory.Exists(cacheDirectory)) + { + try + { + Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'"); + + // repository archive from github always contains a nested folder + var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories(); + if (nestedDirectories.Length != 1) + { + throw new InvalidOperationException($"'{cacheDirectory}' contains '{nestedDirectories.Length}' directories"); + } + else + { + executionContext.Debug($"Symlink '{nestedDirectories[0].Name}' to '{destDirectory}'"); + // make sure we get a clean folder ready to use. + IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); + IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName); + } + + executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'"); + executionContext.Global.JobTelemetry.Add(new JobTelemetry() + { + Type = JobTelemetryType.General, + Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink" + }); + + Trace.Info("Finished getting action repository."); + return; + } + catch (Exception ex) + { + Trace.Error($"Failed to create symlink from cached directory '{cacheDirectory}' to '{destDirectory}'. Error: {ex}"); + // Fall through to normal download logic + } + } + } + hasActionArchiveCache = true; Trace.Info($"Check if action archive '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}' already exists in cache directory '{actionArchiveCacheDir}'"); #if OS_WINDOWS @@ -892,6 +932,10 @@ namespace GitHub.Runner.Worker } #endif + // make sure we get a clean folder ready to use. + IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); + Directory.CreateDirectory(destDirectory); + // repository archive from github always contains a nested folder var subDirectories = new DirectoryInfo(stagingDirectory).GetDirectories(); if (subDirectories.Length != 1) diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index bc4779312..5aa1f2dbc 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -468,6 +468,73 @@ runs: } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SymlinkCacheIsReentrant() + { + try + { + //Arrange + Environment.SetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions, "true"); + Setup(); + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/checkout", + Ref = "master", + RepositoryType = "GitHub" + } + }, + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/checkout", + Ref = "master", + RepositoryType = "GitHub" + } + } + }; + + const string Content = @" +name: 'Test' +runs: + using: 'node20' + main: 'dist/index.js' +"; + + string actionsArchive = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions_archive", "action_checkout"); + Directory.CreateDirectory(actionsArchive); + Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha")); + Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content")); + await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content", "action.yml"), Content); + Environment.SetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory, actionsArchive); + + //Act + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + string destDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions", "checkout", "master"); + Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist"); + var di = new DirectoryInfo(destDirectory); + Assert.NotNull(di.LinkTarget); + } + finally + { + Environment.SetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions, null); + Teardown(); + } + } + #if OS_LINUX [Fact] [Trait("Level", "L0")] From 0fb748220676a28b484c4542022dd3c3a000cf70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:56:32 +0000 Subject: [PATCH 13/25] Bump minimatch in /src/Misc/expressionFunc/hashFiles (#4261) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../hashFiles/package-lock.json | 119 +++++++++++------- 1 file changed, 76 insertions(+), 43 deletions(-) diff --git a/src/Misc/expressionFunc/hashFiles/package-lock.json b/src/Misc/expressionFunc/hashFiles/package-lock.json index 74e07d459..f685ffe36 100644 --- a/src/Misc/expressionFunc/hashFiles/package-lock.json +++ b/src/Misc/expressionFunc/hashFiles/package-lock.json @@ -524,24 +524,34 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1769,23 +1779,34 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/eslint-plugin-github/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/eslint-plugin-github/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-github/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/eslint-plugin-github/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3300,9 +3321,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4992,22 +5013,28 @@ "ts-api-utils": "^2.1.0" }, "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "requires": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" } }, "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" } }, "ts-api-utils": { @@ -5831,22 +5858,28 @@ "eslint-visitor-keys": "^3.4.3" } }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "requires": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" } }, "minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" } } } @@ -6883,9 +6916,9 @@ "dev": true }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "requires": { "brace-expansion": "^1.1.7" } From 86e23605d6ce207fdda89b1f8d2cd1fc34010fd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:02:23 +0000 Subject: [PATCH 14/25] Bump @stylistic/eslint-plugin from 3.1.0 to 5.9.0 in /src/Misc/expressionFunc/hashFiles (#4257) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Salman Chishti --- .../hashFiles/package-lock.json | 67 ++++++++++++------- .../expressionFunc/hashFiles/package.json | 2 +- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/Misc/expressionFunc/hashFiles/package-lock.json b/src/Misc/expressionFunc/hashFiles/package-lock.json index f685ffe36..1666f3b15 100644 --- a/src/Misc/expressionFunc/hashFiles/package-lock.json +++ b/src/Misc/expressionFunc/hashFiles/package-lock.json @@ -12,7 +12,7 @@ "@actions/glob": "^0.4.0" }, "devDependencies": { - "@stylistic/eslint-plugin": "^3.1.0", + "@stylistic/eslint-plugin": "^5.9.0", "@types/node": "^22.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", @@ -75,11 +75,10 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -229,23 +228,36 @@ } }, "node_modules/@stylistic/eslint-plugin": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", - "integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.9.0.tgz", + "integrity": "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.13.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "estraverse": "^5.3.0", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "peerDependencies": { - "eslint": ">=8.40.0" + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { @@ -4735,9 +4747,9 @@ } }, "@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "requires": { "eslint-visitor-keys": "^3.4.3" @@ -4842,18 +4854,25 @@ } }, "@stylistic/eslint-plugin": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", - "integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.9.0.tgz", + "integrity": "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA==", "dev": true, "requires": { - "@typescript-eslint/utils": "^8.13.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "estraverse": "^5.3.0", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "dependencies": { + "@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "dev": true + }, "eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", diff --git a/src/Misc/expressionFunc/hashFiles/package.json b/src/Misc/expressionFunc/hashFiles/package.json index 47a81ae80..33cac214b 100644 --- a/src/Misc/expressionFunc/hashFiles/package.json +++ b/src/Misc/expressionFunc/hashFiles/package.json @@ -35,7 +35,7 @@ "@actions/glob": "^0.4.0" }, "devDependencies": { - "@stylistic/eslint-plugin": "^3.1.0", + "@stylistic/eslint-plugin": "^5.9.0", "@types/node": "^22.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", From bc00800857bd169dcd220174c5c70edb94273071 Mon Sep 17 00:00:00 2001 From: Salman Chishti Date: Wed, 25 Feb 2026 13:36:47 +0000 Subject: [PATCH 15/25] Bump runner version to 2.332.0 and update release notes (#4264) --- releaseNote.md | 48 +++++++++++++++++++++++++++-------------------- src/runnerversion | 2 +- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/releaseNote.md b/releaseNote.md index 7d873e9aa..b4915dd61 100644 --- a/releaseNote.md +++ b/releaseNote.md @@ -1,27 +1,35 @@ ## What's Changed -* Fix owner of /home/runner directory by @nikola-jokic in https://github.com/actions/runner/pull/4132 -* Update Docker to v29.0.2 and Buildx to v0.30.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4135 -* Update workflow around runner docker image. by @TingluoHuang in https://github.com/actions/runner/pull/4133 -* Fix regex for validating runner version format by @TingluoHuang in https://github.com/actions/runner/pull/4136 -* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4144 -* Ensure safe_sleep tries alternative approaches by @TingluoHuang in https://github.com/actions/runner/pull/4146 -* Bump actions/github-script from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4137 -* Bump actions/checkout from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4130 -* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4149 -* Bump docker image to use ubuntu 24.04 by @TingluoHuang in https://github.com/actions/runner/pull/4018 -* Add support for case function by @AllanGuigou in https://github.com/actions/runner/pull/4147 -* Cleanup feature flag actions_container_action_runner_temp by @ericsciple in https://github.com/actions/runner/pull/4163 -* Bump actions/download-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4155 -* Bump actions/upload-artifact from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4157 -* Set ACTIONS_ORCHESTRATION_ID as env to actions. by @TingluoHuang in https://github.com/actions/runner/pull/4178 -* Allow hosted VM report job telemetry via .setup_info file. by @TingluoHuang in https://github.com/actions/runner/pull/4186 -* Bump typescript from 5.9.2 to 5.9.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4184 -* Bump Azure.Storage.Blobs from 12.26.0 to 12.27.0 by @dependabot[bot] in https://github.com/actions/runner/pull/4189 +* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4200 +* Update dotnet sdk to latest version @8.0.417 by @github-actions[bot] in https://github.com/actions/runner/pull/4201 +* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4202 +* Allow empty container options by @ericsciple in https://github.com/actions/runner/pull/4208 +* Update Docker to v29.1.5 and Buildx to v0.31.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4212 +* Report job level annotations by @TingluoHuang in https://github.com/actions/runner/pull/4216 +* Fix local action display name showing `Run /./` instead of `Run ./` by @ericsciple in https://github.com/actions/runner/pull/4218 +* Update Docker to v29.2.0 and Buildx to v0.31.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4219 +* Add support for libssl3 and libssl3t64 for newer Debian/Ubuntu versions by @nekketsuuu in https://github.com/actions/runner/pull/4213 +* Validate work dir during runner start up. by @TingluoHuang in https://github.com/actions/runner/pull/4227 +* Bump hook to 0.8.1 by @nikola-jokic in https://github.com/actions/runner/pull/4222 +* Support return job result as exitcode in hosted runner. by @TingluoHuang in https://github.com/actions/runner/pull/4233 +* Add telemetry tracking for deprecated set-output and save-state commands by @ericsciple in https://github.com/actions/runner/pull/4221 +* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4220 +* Remove unnecessary connection test during some registration flows by @zarenner in https://github.com/actions/runner/pull/4244 +* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4249 +* Update dotnet sdk to latest version @8.0.418 by @github-actions[bot] in https://github.com/actions/runner/pull/4250 +* Fix link to SECURITY.md in README by @TingluoHuang in https://github.com/actions/runner/pull/4253 +* Try to infer runner is on hosted/ghes when githuburl is empty. by @TingluoHuang in https://github.com/actions/runner/pull/4254 +* Add Node.js 20 deprecation warning annotation (Phase 1) by @salmanmkc in https://github.com/actions/runner/pull/4242 +* Update Node.js 20 deprecation date to June 2nd, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4258 +* Composite Action Step Markers by @ericsciple in https://github.com/actions/runner/pull/4243 +* Symlink actions cache by @paveliak in https://github.com/actions/runner/pull/4260 +* Bump minimatch in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4261 +* Bump @stylistic/eslint-plugin from 3.1.0 to 5.9.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4257 ## New Contributors -* @AllanGuigou made their first contribution in https://github.com/actions/runner/pull/4147 +* @nekketsuuu made their first contribution in https://github.com/actions/runner/pull/4213 +* @zarenner made their first contribution in https://github.com/actions/runner/pull/4244 -**Full Changelog**: https://github.com/actions/runner/compare/v2.330.0...v2.331.0 +**Full Changelog**: https://github.com/actions/runner/compare/v2.331.0...v2.332.0 _Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet. To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository. diff --git a/src/runnerversion b/src/runnerversion index 865c3fa9d..96f36b02a 100644 --- a/src/runnerversion +++ b/src/runnerversion @@ -1 +1 @@ -2.331.0 +2.332.0 From 7650fc432e4d749eca416d8a92004d90a04e136a Mon Sep 17 00:00:00 2001 From: Tingluo Huang Date: Wed, 25 Feb 2026 15:44:27 -0500 Subject: [PATCH 16/25] Log inner exception message. (#4265) --- src/Runner.Worker/ActionManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 71bff32bd..38c2ab8b3 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker { // Log the error and fail the PrepareActionsAsync Initialization. Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}"); - executionContext.InfrastructureError(ex.Message, category: "resolve_action"); + executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "resolve_action"); executionContext.Result = TaskResult.Failed; throw; } @@ -818,7 +818,7 @@ namespace GitHub.Runner.Worker try { Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'"); - + // repository archive from github always contains a nested folder var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories(); if (nestedDirectories.Length != 1) @@ -832,14 +832,14 @@ namespace GitHub.Runner.Worker IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName); } - + executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'"); executionContext.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.General, Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink" }); - + Trace.Info("Finished getting action repository."); return; } From ae09a9d7b52be2e5dfe5ee86226a3d03e79c228e Mon Sep 17 00:00:00 2001 From: eric sciple Date: Thu, 26 Feb 2026 08:36:55 -0600 Subject: [PATCH 17/25] Fix composite post-step marker display names (#4267) --- .../Handlers/CompositeActionHandler.cs | 9 ++- .../Handlers/CompositeActionHandlerL0.cs | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Runner.Worker/Handlers/CompositeActionHandler.cs b/src/Runner.Worker/Handlers/CompositeActionHandler.cs index c0bc6f688..6db8995d0 100644 --- a/src/Runner.Worker/Handlers/CompositeActionHandler.cs +++ b/src/Runner.Worker/Handlers/CompositeActionHandler.cs @@ -312,7 +312,14 @@ namespace GitHub.Runner.Worker.Handlers // Emit start marker after full context setup so display name expressions resolve correctly if (emitCompositeMarkers) { - step.TryUpdateDisplayName(out _); + try + { + step.EvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext, out _); + } + catch (Exception ex) + { + Trace.Warning("Caught exception while evaluating embedded step display name. {0}", ex); + } ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]"); stepStopwatch = Stopwatch.StartNew(); } diff --git a/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs b/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs index 09935c3ad..33b00fdb9 100644 --- a/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs +++ b/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs @@ -1,14 +1,18 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; using GitHub.Runner.Worker; using GitHub.Runner.Worker.Handlers; using Moq; +using Newtonsoft.Json.Linq; using Xunit; using DTWebApi = GitHub.DistributedTask.WebApi; +using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Common.Tests.Worker.Handlers { @@ -250,6 +254,66 @@ namespace GitHub.Runner.Common.Tests.Worker.Handlers Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void PostStepMarker_UsesEvaluatedDisplayName() + { + // Arrange: create an ActionRunner with a RepositoryPathReference (simulating actions/cache@v4) + // and Stage = Post. Verify that EvaluateDisplayName produces the correct display name + // so the composite marker emits "Run actions/cache@v4" instead of the fallback "run". + var hc = new TestHostContext(this, nameof(PostStepMarker_UsesEvaluatedDisplayName)); + var actionManifestLegacy = new ActionManifestManagerLegacy(); + actionManifestLegacy.Initialize(hc); + hc.SetSingleton(actionManifestLegacy); + var actionManifestNew = new ActionManifestManager(); + actionManifestNew.Initialize(hc); + hc.SetSingleton(actionManifestNew); + var actionManifestManager = new ActionManifestManagerWrapper(); + actionManifestManager.Initialize(hc); + hc.SetSingleton(actionManifestManager); + + var ec = new Mock(); + var contextData = new DictionaryContextData(); + var githubContext = new GitHubContext(); + githubContext.Add("event", JToken.Parse("{\"foo\":\"bar\"}").ToPipelineContextData()); + contextData.Add("github", githubContext); +#if OS_WINDOWS + contextData["env"] = new DictionaryContextData(); +#else + contextData["env"] = new CaseSensitiveDictionaryContextData(); +#endif + ec.Setup(x => x.Global).Returns(new GlobalContext()); + ec.Setup(x => x.ExpressionValues).Returns(contextData); + ec.Setup(x => x.ExpressionFunctions).Returns(new List()); + ec.Setup(x => x.Write(It.IsAny(), It.IsAny())); + ec.Object.Global.Variables = new Variables(hc, new Dictionary()); + + var actionRunner = new ActionRunner(); + actionRunner.Initialize(hc); + actionRunner.ExecutionContext = ec.Object; + actionRunner.Stage = ActionRunStage.Post; + actionRunner.Action = new Pipelines.ActionStep() + { + Name = "cache", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/cache", + Ref = "v4" + } + }; + + // Act: call EvaluateDisplayName directly, which is what CompositeActionHandler now does + // for embedded steps (including Post stage) instead of TryUpdateDisplayName. + var result = actionRunner.EvaluateDisplayName(contextData, ec.Object, out bool updated); + + // Assert: display name should be "Run actions/cache@v4", not the fallback "run" + Assert.True(result); + Assert.True(updated); + Assert.Equal("Run actions/cache@v4", actionRunner.DisplayName); + } + // Helper methods that call the real production code private static string EscapeProperty(string value) => CompositeActionHandler.EscapeProperty(value); From 985a06fcca95d612869387fca59e417b65c6c2c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:18:13 +0000 Subject: [PATCH 18/25] Bump actions/download-artifact from 7 to 8 (#4269) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3df10b001..528257605 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,37 +133,37 @@ jobs: # Download runner package tar.gz/zip produced by 'build' job - name: Download Artifact (win-x64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-win-x64 path: ./ - name: Download Artifact (win-arm64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-win-arm64 path: ./ - name: Download Artifact (osx-x64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-osx-x64 path: ./ - name: Download Artifact (osx-arm64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-osx-arm64 path: ./ - name: Download Artifact (linux-x64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-linux-x64 path: ./ - name: Download Artifact (linux-arm) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-linux-arm path: ./ - name: Download Artifact (linux-arm64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-linux-arm64 path: ./ From 60a9422599bf11abf799dab8dc5ab5c1de3eb3dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:51:11 +0000 Subject: [PATCH 19/25] chore: update Node versions (#4272) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/Misc/externals.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh index 5cbb6f64d..8806a20fa 100755 --- a/src/Misc/externals.sh +++ b/src/Misc/externals.sh @@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download # When you update Node versions you must also create a new release of alpine_nodejs at that updated version. # Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started NODE20_VERSION="20.20.0" -NODE24_VERSION="24.13.1" +NODE24_VERSION="24.14.0" get_abs_path() { # exploits the fact that pwd will print abs path when no args From a9a07a65532fbea3abfb07550d2a24034073dcd3 Mon Sep 17 00:00:00 2001 From: Tingluo Huang Date: Mon, 2 Mar 2026 22:44:14 -0500 Subject: [PATCH 20/25] Avoid throw in SelfUpdaters. (#4274) --- src/Runner.Listener/SelfUpdater.cs | 4 +++- src/Runner.Listener/SelfUpdaterV2.cs | 4 +++- src/Test/L0/Listener/SelfUpdaterL0.cs | 8 ++++---- src/Test/L0/Listener/SelfUpdaterV2L0.cs | 8 ++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Runner.Listener/SelfUpdater.cs b/src/Runner.Listener/SelfUpdater.cs index 6ebeebd82..9cf6ae8a9 100644 --- a/src/Runner.Listener/SelfUpdater.cs +++ b/src/Runner.Listener/SelfUpdater.cs @@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener } catch (Exception ex) { + Trace.Error(ex); + _terminal.WriteError($"Runner update failed: {ex.Message}"); _updateTrace.Enqueue(ex.ToString()); - throw; + return false; } finally { diff --git a/src/Runner.Listener/SelfUpdaterV2.cs b/src/Runner.Listener/SelfUpdaterV2.cs index b64619b69..78a2acdd3 100644 --- a/src/Runner.Listener/SelfUpdaterV2.cs +++ b/src/Runner.Listener/SelfUpdaterV2.cs @@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener } catch (Exception ex) { + Trace.Error(ex); + _terminal.WriteError($"Runner update failed: {ex.Message}"); _updateTrace.Enqueue(ex.ToString()); - throw; + return false; } finally { diff --git a/src/Test/L0/Listener/SelfUpdaterL0.cs b/src/Test/L0/Listener/SelfUpdaterL0.cs index be095ce90..8003dd071 100644 --- a/src/Test/L0/Listener/SelfUpdaterL0.cs +++ b/src/Test/L0/Listener/SelfUpdaterL0.cs @@ -228,8 +228,8 @@ namespace GitHub.Runner.Common.Tests.Listener .Returns(Task.FromResult(new TaskAgent())); - var ex = await Assert.ThrowsAsync(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken)); - Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message); + var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken); + Assert.False(result); } } finally @@ -281,8 +281,8 @@ namespace GitHub.Runner.Common.Tests.Listener .Returns(Task.FromResult(new TaskAgent())); - var ex = await Assert.ThrowsAsync(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken)); - Assert.Contains("did not match expected Runner Hash", ex.Message); + var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken); + Assert.False(result); } } finally diff --git a/src/Test/L0/Listener/SelfUpdaterV2L0.cs b/src/Test/L0/Listener/SelfUpdaterV2L0.cs index 5115a6bbb..a91e11273 100644 --- a/src/Test/L0/Listener/SelfUpdaterV2L0.cs +++ b/src/Test/L0/Listener/SelfUpdaterV2L0.cs @@ -170,8 +170,8 @@ namespace GitHub.Runner.Common.Tests.Listener DownloadUrl = "https://github.com/actions/runner/notexists" }; - var ex = await Assert.ThrowsAsync(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken)); - Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message); + var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken); + Assert.False(result); } } finally @@ -220,8 +220,8 @@ namespace GitHub.Runner.Common.Tests.Listener SHA256Checksum = "badhash" }; - var ex = await Assert.ThrowsAsync(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken)); - Assert.Contains("did not match expected Runner Hash", ex.Message); + var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken); + Assert.False(result); } } finally From 8a73bccebb88715b3ce5443ec579b4e2d4dae6e4 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 2 Mar 2026 23:38:16 -0600 Subject: [PATCH 21/25] Fix parser comparison mismatches (#4273) --- .../Conversion/WorkflowTemplateConverter.cs | 54 +++- src/Sdk/WorkflowParser/workflow-v1.0.json | 4 +- .../PipelineTemplateEvaluatorWrapperL0.cs | 272 ++++++++++++++++++ 3 files changed, 317 insertions(+), 13 deletions(-) diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index 7c5764cb3..7293ce165 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1079,7 +1079,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion internal static JobContainer ConvertToJobContainer( TemplateContext context, TemplateToken value, - bool isEarlyValidation = false) + bool isEarlyValidation = false, + bool isServiceContainer = false) { var result = new JobContainer(); if (isEarlyValidation && value.Traverse().Any(x => x is ExpressionToken)) @@ -1089,11 +1090,34 @@ namespace GitHub.Actions.WorkflowParser.Conversion if (value is StringToken containerLiteral) { - if (String.IsNullOrEmpty(containerLiteral.Value)) + // Trim "docker://" + var trimmedImage = containerLiteral.Value; + var hasDockerPrefix = containerLiteral.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal); + if (hasDockerPrefix) { + trimmedImage = trimmedImage.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length); + } + + // Empty shorthand after trimming "docker://" ? + if (String.IsNullOrEmpty(trimmedImage)) + { + // Error at parse-time for: + // 1. container: 'docker://' + // 2. services.foo: '' + // 3. services.foo: 'docker://' + // + // Do not error for: + // 1. container: '' + if (isEarlyValidation && (hasDockerPrefix || isServiceContainer)) + { + context.Error(value, "Container image cannot be empty"); + } + + // Short-circuit return null; } + // Store original, trimmed further below result.Image = containerLiteral.Value; } else @@ -1152,22 +1176,30 @@ namespace GitHub.Actions.WorkflowParser.Conversion } } + // Trim "docker://" + var hadDockerPrefix = false; + if (!String.IsNullOrEmpty(result.Image) && result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal)) + { + hadDockerPrefix = true; + result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length); + } + if (String.IsNullOrEmpty(result.Image)) { - // Only error during early validation (parse time) - // At runtime (expression evaluation), empty image = no container - if (isEarlyValidation) + // Error at parse-time for: + // 1. container: {image: 'docker://'} + // 2. services.foo: {image: ''} + // 3. services.foo: {image: 'docker://'} + // + // Do not error for: + // 1. container: {image: ''} + if (isEarlyValidation && (hadDockerPrefix || isServiceContainer)) { context.Error(value, "Container image cannot be empty"); } return null; } - if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal)) - { - result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length); - } - return result; } @@ -1188,7 +1220,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion foreach (var servicePair in servicesMapping) { var networkAlias = servicePair.Key.AssertString("services key").Value; - var container = ConvertToJobContainer(context, servicePair.Value); + var container = ConvertToJobContainer(context, servicePair.Value, isEarlyValidation, isServiceContainer: true); result.Add(new KeyValuePair(networkAlias, container)); } diff --git a/src/Sdk/WorkflowParser/workflow-v1.0.json b/src/Sdk/WorkflowParser/workflow-v1.0.json index 01601dcb5..0f4c91130 100644 --- a/src/Sdk/WorkflowParser/workflow-v1.0.json +++ b/src/Sdk/WorkflowParser/workflow-v1.0.json @@ -2589,7 +2589,7 @@ "mapping": { "properties": { "image": { - "type": "non-empty-string", + "type": "string", "description": "Use `jobs..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": { @@ -2634,7 +2634,7 @@ "matrix" ], "one-of": [ - "non-empty-string", + "string", "container-mapping" ] }, diff --git a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs index 2caf5dba0..e6fae1fa5 100644 --- a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs +++ b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs @@ -281,6 +281,140 @@ namespace GitHub.Runner.Common.Tests.Worker } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_DockerPrefixOnly_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new StringToken(null, null, null, "docker://"); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_DockerPrefixOnlyMapping_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://")); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_EmptyImageMapping_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "")); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_ValidImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new StringToken(null, null, null, "ubuntu:latest"); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.NotNull(result); + Assert.Equal("ubuntu:latest", result.Image); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_DockerPrefixWithImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new StringToken(null, null, null, "docker://ubuntu:latest"); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.NotNull(result); + Assert.Equal("ubuntu:latest", result.Image); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -391,6 +525,144 @@ namespace GitHub.Runner.Common.Tests.Worker } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_EmptyImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + // Build a services mapping token with one service whose image is empty string + // Similar to: services: { db: { image: '' } } + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + // Should get a list with one entry where the container is null (empty image = no container) + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.Null(result[0].Value); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_DockerPrefixOnlyImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.Null(result[0].Value); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_ExpressionEvalsToEmpty_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + // Simulates: services: { db: { image: ${{ condition && 'img' || '' }} } } + // where the expression evaluates to '' at runtime + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new BasicExpressionToken(null, null, null, "''")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.Null(result[0].Value); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_ValidImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.NotNull(result[0].Value); + Assert.Equal("postgres:latest", result[0].Value.Image); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] From 8f01257663719c1a320db9012b5103fe26c74191 Mon Sep 17 00:00:00 2001 From: Max Horstmann Date: Wed, 4 Mar 2026 15:17:25 -0500 Subject: [PATCH 22/25] Devcontainer: bump base image Ubuntu version (#4277) --- .devcontainer/devcontainer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2fd60937e..e0dfafc19 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,8 @@ { "name": "Actions Runner Devcontainer", - "image": "mcr.microsoft.com/devcontainers/base:focal", + "image": "mcr.microsoft.com/devcontainers/base:noble", "features": { - "ghcr.io/devcontainers/features/docker-in-docker:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/features/dotnet": { "version": "8.0.418" }, From 20111cbfda51c4d31e49352a61f897cdc93c4257 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Wed, 4 Mar 2026 17:36:45 -0600 Subject: [PATCH 23/25] Support `entrypoint` and `command` for service containers (#4276) --- src/Runner.Common/Constants.cs | 1 + src/Runner.Worker/Container/ContainerInfo.cs | 2 + src/Runner.Worker/ExecutionContext.cs | 10 +- .../PipelineTemplateEvaluatorWrapper.cs | 18 ++- src/Sdk/DTPipelines/Pipelines/JobContainer.cs | 18 +++ .../PipelineTemplateConstants.cs | 2 + .../PipelineTemplateConverter.cs | 24 +++- .../PipelineTemplateEvaluator.cs | 4 +- src/Sdk/DTPipelines/workflow-v1.0.json | 17 ++- .../Conversion/WorkflowTemplateConstants.cs | 2 + .../Conversion/WorkflowTemplateConverter.cs | 16 +++ src/Sdk/WorkflowParser/JobContainer.cs | 18 +++ src/Sdk/WorkflowParser/WorkflowFeatures.cs | 8 ++ src/Sdk/WorkflowParser/workflow-v1.0.json | 46 ++++++- .../PipelineTemplateEvaluatorWrapperL0.cs | 121 ++++++++++++++---- 15 files changed, 265 insertions(+), 42 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index fcb7c5b35..583958981 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -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 ServiceContainerCommand = "actions_service_container_command"; public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions"; public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations"; public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers"; diff --git a/src/Runner.Worker/Container/ContainerInfo.cs b/src/Runner.Worker/Container/ContainerInfo.cs index 72cd0ada9..66a3daf93 100644 --- a/src/Runner.Worker/Container/ContainerInfo.cs +++ b/src/Runner.Worker/Container/ContainerInfo.cs @@ -36,6 +36,8 @@ namespace GitHub.Runner.Worker.Container this.ContainerImage = containerImage; this.ContainerDisplayName = $"{container.Alias}_{Pipelines.Validation.NameValidation.Sanitize(containerImage)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}"; this.ContainerCreateOptions = container.Options; + this.ContainerEntryPoint = container.Entrypoint; + this.ContainerEntryPointArgs = container.Command; _environmentVariables = container.Environment; this.IsJobContainer = isJobContainer; this.ContainerNetworkAlias = networkAlias; diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 53484e6b6..3a3754fa7 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -1328,9 +1328,9 @@ namespace GitHub.Runner.Worker UpdateGlobalStepsContext(); } - internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null) + internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null) { - return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter); + return new PipelineTemplateEvaluatorWrapper(HostContext, this, allowServiceContainerCommand, traceWriter); } private static void NoOp() @@ -1418,10 +1418,13 @@ namespace GitHub.Runner.Worker public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null) { + var allowServiceContainerCommand = (context.Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false) + || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_SERVICE_CONTAINER_COMMAND")); + // Create wrapper? if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER"))) { - return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter); + return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(allowServiceContainerCommand, traceWriter); } // Legacy @@ -1433,6 +1436,7 @@ namespace GitHub.Runner.Worker return new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable) { MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly + AllowServiceContainerCommand = allowServiceContainerCommand, }; } diff --git a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs index 61dfdacce..d560e45c8 100644 --- a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs +++ b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs @@ -23,6 +23,7 @@ namespace GitHub.Runner.Worker public PipelineTemplateEvaluatorWrapper( IHostContext hostContext, IExecutionContext context, + bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null) { ArgUtil.NotNull(hostContext, nameof(hostContext)); @@ -40,11 +41,14 @@ namespace GitHub.Runner.Worker _legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable) { MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly + AllowServiceContainerCommand = allowServiceContainerCommand, }; // New evaluator var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(); - _newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null) + var features = WorkflowFeatures.GetDefaults(); + features.AllowServiceContainerCommand = allowServiceContainerCommand; + _newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features) { MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly }; @@ -401,6 +405,18 @@ namespace GitHub.Runner.Worker return false; } + if (!string.Equals(legacyResult.Entrypoint, newResult.Entrypoint, StringComparison.Ordinal)) + { + _trace.Info($"CompareJobContainer mismatch - Entrypoint differs (legacy='{legacyResult.Entrypoint}', new='{newResult.Entrypoint}')"); + return false; + } + + if (!string.Equals(legacyResult.Command, newResult.Command, StringComparison.Ordinal)) + { + _trace.Info($"CompareJobContainer mismatch - Command differs (legacy='{legacyResult.Command}', new='{newResult.Command}')"); + return false; + } + if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment")) { return false; diff --git a/src/Sdk/DTPipelines/Pipelines/JobContainer.cs b/src/Sdk/DTPipelines/Pipelines/JobContainer.cs index 901c4fed1..f38066537 100644 --- a/src/Sdk/DTPipelines/Pipelines/JobContainer.cs +++ b/src/Sdk/DTPipelines/Pipelines/JobContainer.cs @@ -39,6 +39,24 @@ namespace GitHub.DistributedTask.Pipelines set; } + /// + /// Gets or sets the container entrypoint override. + /// + public String Entrypoint + { + get; + set; + } + + /// + /// Gets or sets the container command and args (after the image name). + /// + public String Command + { + get; + set; + } + /// /// Gets or sets the volumes which are mounted into the container. /// diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs index 8d81c7d2d..d55a02144 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs @@ -47,6 +47,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating public const String NumberStrategyContext = "number-strategy-context"; public const String On = "on"; public const String Options = "options"; + public const String Entrypoint = "entrypoint"; + public const String Command = "command"; public const String Outputs = "outputs"; public const String OutputsPattern = "needs.*.outputs"; public const String Password = "password"; diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs index 6c9654074..87bb00bae 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs @@ -237,7 +237,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating internal static JobContainer ConvertToJobContainer( TemplateContext context, TemplateToken value, - bool allowExpressions = false) + bool allowExpressions = false, + bool allowServiceContainerCommand = false) { var result = new JobContainer(); if (allowExpressions && value.Traverse().Any(x => x is ExpressionToken)) @@ -280,6 +281,22 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating case PipelineTemplateConstants.Options: result.Options = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value; break; + case PipelineTemplateConstants.Entrypoint: + if (!allowServiceContainerCommand) + { + context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Entrypoint}' is not allowed"); + break; + } + result.Entrypoint = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value; + break; + case PipelineTemplateConstants.Command: + if (!allowServiceContainerCommand) + { + context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Command}' is not allowed"); + break; + } + result.Command = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value; + break; case PipelineTemplateConstants.Ports: var ports = containerPropertyPair.Value.AssertSequence($"{PipelineTemplateConstants.Container} {propertyName}"); var portList = new List(ports.Count); @@ -326,7 +343,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating internal static List> ConvertToJobServiceContainers( TemplateContext context, TemplateToken services, - bool allowExpressions = false) + bool allowExpressions = false, + bool allowServiceContainerCommand = false) { var result = new List>(); @@ -340,7 +358,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating foreach (var servicePair in servicesMapping) { var networkAlias = servicePair.Key.AssertString("services key").Value; - var container = ConvertToJobContainer(context, servicePair.Value); + var container = ConvertToJobContainer(context, servicePair.Value, allowExpressions, allowServiceContainerCommand); result.Add(new KeyValuePair(networkAlias, container)); } diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs index 345058997..55cae82f3 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs @@ -51,6 +51,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating public Int32 MaxResultSize { get; set; } = 10 * 1024 * 1024; // 10 mb + public bool AllowServiceContainerCommand { get; set; } + public Boolean EvaluateStepContinueOnError( TemplateToken token, DictionaryContextData contextData, @@ -357,7 +359,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating { token = TemplateEvaluator.Evaluate(context, PipelineTemplateConstants.Services, token, 0, null, omitHeader: true); context.Errors.Check(); - result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token); + result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token, allowServiceContainerCommand: AllowServiceContainerCommand); } catch (Exception ex) when (!(ex is TemplateValidationException)) { diff --git a/src/Sdk/DTPipelines/workflow-v1.0.json b/src/Sdk/DTPipelines/workflow-v1.0.json index bfd050ed3..432cb75ec 100644 --- a/src/Sdk/DTPipelines/workflow-v1.0.json +++ b/src/Sdk/DTPipelines/workflow-v1.0.json @@ -430,6 +430,21 @@ } }, + "service-container-mapping": { + "mapping": { + "properties": { + "image": "string", + "options": "string", + "entrypoint": "string", + "command": "string", + "env": "container-env", + "ports": "sequence-of-non-empty-string", + "volumes": "sequence-of-non-empty-string", + "credentials": "container-registry-credentials" + } + } + }, + "services": { "context": [ "github", @@ -454,7 +469,7 @@ ], "one-of": [ "string", - "container-mapping" + "service-container-mapping" ] }, diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs index fb2065f41..9dce514a1 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs @@ -62,6 +62,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion public const String NumberStrategyContext = "number-strategy-context"; public const String On = "on"; public const String Options = "options"; + public const String Entrypoint = "entrypoint"; + public const String Command = "command"; public const String Org = "org"; public const String Organization = "organization"; public const String Outputs = "outputs"; diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index 7293ce165..8ae6ea0c9 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1146,6 +1146,22 @@ namespace GitHub.Actions.WorkflowParser.Conversion case WorkflowTemplateConstants.Options: result.Options = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value; break; + case WorkflowTemplateConstants.Entrypoint: + if (!context.GetFeatures().AllowServiceContainerCommand) + { + context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Entrypoint}' is not allowed"); + break; + } + result.Entrypoint = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value; + break; + case WorkflowTemplateConstants.Command: + if (!context.GetFeatures().AllowServiceContainerCommand) + { + context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Command}' is not allowed"); + break; + } + result.Command = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value; + break; case WorkflowTemplateConstants.Ports: var ports = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}"); var portList = new List(ports.Count); diff --git a/src/Sdk/WorkflowParser/JobContainer.cs b/src/Sdk/WorkflowParser/JobContainer.cs index dfa173c10..289400b43 100644 --- a/src/Sdk/WorkflowParser/JobContainer.cs +++ b/src/Sdk/WorkflowParser/JobContainer.cs @@ -35,6 +35,24 @@ namespace GitHub.Actions.WorkflowParser set; } + /// + /// Gets or sets the container entrypoint override. + /// + public String Entrypoint + { + get; + set; + } + + /// + /// Gets or sets the container command and args (after the image name). + /// + public String Command + { + get; + set; + } + /// /// Gets or sets the volumes which are mounted into the container. /// diff --git a/src/Sdk/WorkflowParser/WorkflowFeatures.cs b/src/Sdk/WorkflowParser/WorkflowFeatures.cs index 8b36a5fa3..c3fa33af7 100644 --- a/src/Sdk/WorkflowParser/WorkflowFeatures.cs +++ b/src/Sdk/WorkflowParser/WorkflowFeatures.cs @@ -48,6 +48,13 @@ namespace GitHub.Actions.WorkflowParser [DataMember(EmitDefaultValue = false)] public bool StrictJsonParsing { get; set; } + /// + /// Gets or sets a value indicating whether service containers may specify "entrypoint" and "command". + /// Used during parsing and evaluation. + /// + [DataMember(EmitDefaultValue = false)] + public bool AllowServiceContainerCommand { get; set; } + /// /// Gets the default workflow features. /// @@ -60,6 +67,7 @@ namespace GitHub.Actions.WorkflowParser Snapshot = false, // Default to false since this feature is still in an experimental phase StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments + AllowServiceContainerCommand = false, // Default to false since this feature is gated by actions_service_container_command }; } diff --git a/src/Sdk/WorkflowParser/workflow-v1.0.json b/src/Sdk/WorkflowParser/workflow-v1.0.json index 0f4c91130..66bda31fa 100644 --- a/src/Sdk/WorkflowParser/workflow-v1.0.json +++ b/src/Sdk/WorkflowParser/workflow-v1.0.json @@ -2590,20 +2590,52 @@ "properties": { "image": { "type": "string", - "description": "Use `jobs..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." + "description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name." }, "options": { "type": "string", - "description": "Use `jobs..container.options` to configure additional Docker container resource options." + "description": "Additional Docker container resource options." }, "env": "container-env", "ports": { "type": "sequence-of-non-empty-string", - "description": "Use `jobs..container.ports` to set an array of ports to expose on the container." + "description": "An array of ports to expose on the container." }, "volumes": { "type": "sequence-of-non-empty-string", - "description": "Use `jobs..container.volumes` to set an array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host." + "description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host." + }, + "credentials": "container-registry-credentials" + } + } + }, + "service-container-mapping": { + "mapping": { + "properties": { + "image": { + "type": "string", + "description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name." + }, + "options": { + "type": "string", + "description": "Additional Docker container resource options." + }, + "entrypoint": { + "type": "string", + "description": "Override the default ENTRYPOINT in the service container image." + }, + "command": { + "type": "string", + "description": "Override the default CMD in the service container image." + }, + "env": "container-env", + "ports": { + "type": "sequence-of-non-empty-string", + "description": "An array of ports to expose on the container." + }, + "volumes": { + "type": "sequence-of-non-empty-string", + "description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host." }, "credentials": "container-registry-credentials" } @@ -2635,11 +2667,11 @@ ], "one-of": [ "string", - "container-mapping" + "service-container-mapping" ] }, "container-registry-credentials": { - "description": "If the image's container registry requires authentication to pull the image, you can use `jobs..container.credentials` to set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.", + "description": "If the container registry requires authentication to pull the image, set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.", "context": [ "github", "inputs", @@ -2655,7 +2687,7 @@ } }, "container-env": { - "description": "Use `jobs..container.env` to set a map of variables in the container.", + "description": "A map of environment variables to set in the container.", "mapping": { "loose-key-type": "non-empty-string", "loose-value-type": "string-runner-context" diff --git a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs index e6fae1fa5..0a7427ced 100644 --- a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs +++ b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs @@ -36,7 +36,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "test-value"); var contextData = new DictionaryContextData(); @@ -63,7 +63,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Call EvaluateAndCompare directly: the new evaluator cancels the token // and returns a different value, forcing hasMismatch = true. @@ -98,7 +98,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Different results without cancellation — mismatch SHOULD be recorded. var result = wrapper.EvaluateAndCompare( @@ -130,7 +130,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new BooleanToken(null, null, null, true); var contextData = new DictionaryContextData(); var functions = new List(); @@ -156,7 +156,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "FOO"), new StringToken(null, null, null, "bar")); var contextData = new DictionaryContextData(); @@ -184,7 +184,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new BasicExpressionToken(null, null, null, "true"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -211,7 +211,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "input1"), new StringToken(null, null, null, "val1")); var contextData = new DictionaryContextData(); @@ -239,7 +239,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new NumberToken(null, null, null, 10); var contextData = new DictionaryContextData(); var functions = new List(); @@ -265,7 +265,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, ""); var contextData = new DictionaryContextData(); var functions = new List(); @@ -291,7 +291,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "docker://"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -317,7 +317,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://")); var contextData = new DictionaryContextData(); @@ -344,7 +344,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "")); var contextData = new DictionaryContextData(); @@ -371,7 +371,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "ubuntu:latest"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -398,7 +398,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "docker://ubuntu:latest"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -425,7 +425,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "out1"), new StringToken(null, null, null, "val1")); var contextData = new DictionaryContextData(); @@ -453,7 +453,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "https://example.com"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -482,7 +482,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "shell"), new StringToken(null, null, null, "bash")); var contextData = new DictionaryContextData(); @@ -510,7 +510,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -542,7 +542,7 @@ namespace GitHub.Runner.Common.Tests.Worker serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "")); servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -576,7 +576,7 @@ namespace GitHub.Runner.Common.Tests.Worker serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://")); servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -611,7 +611,7 @@ namespace GitHub.Runner.Common.Tests.Worker serviceMapping.Add(new StringToken(null, null, null, "image"), new BasicExpressionToken(null, null, null, "''")); servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -644,7 +644,7 @@ namespace GitHub.Runner.Common.Tests.Worker serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest")); servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -663,6 +663,75 @@ namespace GitHub.Runner.Common.Tests.Worker } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_EntrypointAndCommand_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest")); + serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash")); + serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: true); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.NotNull(result[0].Value); + Assert.Equal("postgres:latest", result[0].Value.Image); + Assert.Equal("/bin/bash", result[0].Value.Entrypoint); + Assert.Equal("-lc echo hi", result[0].Value.Command); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_EntrypointAndCommand_FlagOff_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest")); + serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash")); + serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); + var contextData = new DictionaryContextData(); + var functions = new List(); + + Assert.Throws(() => + wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions)); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -673,7 +742,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -702,7 +771,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // 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."); @@ -733,7 +802,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Legacy throws Newtonsoft JsonReaderException, new throws System.Text.Json.JsonException var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken"); @@ -764,7 +833,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Both throw non-JSON exceptions with different messages — should record mismatch var legacyEx = new InvalidOperationException("some error"); From bcd04cfbf0d3593ab1e752c30ebc866f8e094cfe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:55:48 +0000 Subject: [PATCH 24/25] Bump actions/upload-artifact from 6 to 7 (#4270) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Salman Chishti --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd315411f..468d11578 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,7 +78,7 @@ jobs: # Upload runner package tar.gz/zip as artifact - name: Publish Artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: runner-package-${{ matrix.runtime }} path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 528257605..dfee41923 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,7 +118,7 @@ jobs: # Upload runner package tar.gz/zip as artifact. - name: Publish Artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: runner-packages-${{ matrix.runtime }} path: | From 99910ca83e8924ebbcfe225271c98cf83f7a96d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:45:49 +0000 Subject: [PATCH 25/25] Bump docker/login-action from 3 to 4 (#4278) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Salman Chishti --- .github/workflows/docker-publish.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 933afdd33..1e83b2895 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,7 +41,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfee41923..1521532f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -312,7 +312,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }}