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"
},
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
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.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/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 7c5764cb3..8ae6ea0c9 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
@@ -1122,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);
@@ -1152,22 +1192,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 +1236,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/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 01601dcb5..66bda31fa 100644
--- a/src/Sdk/WorkflowParser/workflow-v1.0.json
+++ b/src/Sdk/WorkflowParser/workflow-v1.0.json
@@ -2589,21 +2589,53 @@
"mapping": {
"properties": {
"image": {
- "type": "non-empty-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."
+ "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": "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"
}
@@ -2634,12 +2666,12 @@
"matrix"
],
"one-of": [
- "non-empty-string",
- "container-mapping"
+ "string",
+ "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/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
diff --git a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs
index 2caf5dba0..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();
@@ -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, allowServiceContainerCommand: false);
+ 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, 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();
+ 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, 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();
+ 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, allowServiceContainerCommand: false);
+ 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, allowServiceContainerCommand: false);
+ 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")]
@@ -291,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();
@@ -319,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();
@@ -348,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();
@@ -376,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();
@@ -391,6 +525,213 @@ 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, allowServiceContainerCommand: false);
+ 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, allowServiceContainerCommand: false);
+ 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, allowServiceContainerCommand: false);
+ 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, allowServiceContainerCommand: false);
+ 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")]
+ 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")]
@@ -401,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();
@@ -430,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.");
@@ -461,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");
@@ -492,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");