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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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");