Compare commits

..

12 Commits

Author SHA1 Message Date
eric sciple
c5ea90fdcd Fix cancellation token race in EvaluateAndCompare 2026-03-06 12:14:33 -08:00
eric sciple
1138dd80f7 Fix positional arg bug in ExpressionParser.CreateTree (#4279) 2026-03-05 14:56:28 -06:00
dependabot[bot]
99910ca83e Bump docker/login-action from 3 to 4 (#4278)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-03-05 15:45:49 +00:00
dependabot[bot]
bcd04cfbf0 Bump actions/upload-artifact from 6 to 7 (#4270)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-03-05 14:55:48 +00:00
eric sciple
20111cbfda Support entrypoint and command for service containers (#4276) 2026-03-04 23:36:45 +00:00
Max Horstmann
8f01257663 Devcontainer: bump base image Ubuntu version (#4277) 2026-03-04 20:17:25 +00:00
eric sciple
8a73bccebb Fix parser comparison mismatches (#4273) 2026-03-03 05:38:16 +00:00
Tingluo Huang
a9a07a6553 Avoid throw in SelfUpdaters. (#4274) 2026-03-02 22:44:14 -05:00
github-actions[bot]
60a9422599 chore: update Node versions (#4272)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 13:51:11 +00:00
dependabot[bot]
985a06fcca Bump actions/download-artifact from 7 to 8 (#4269)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 09:18:13 +00:00
eric sciple
ae09a9d7b5 Fix composite post-step marker display names (#4267) 2026-02-26 08:36:55 -06:00
Tingluo Huang
7650fc432e Log inner exception message. (#4265) 2026-02-25 15:44:27 -05:00
34 changed files with 993 additions and 92 deletions

View File

@@ -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"
},

View File

@@ -78,7 +78,7 @@ jobs:
# Upload runner package tar.gz/zip as artifact
- name: Publish Artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: runner-package-${{ matrix.runtime }}
path: |

View File

@@ -41,7 +41,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}

View File

@@ -118,7 +118,7 @@ jobs:
# Upload runner package tar.gz/zip as artifact.
- name: Publish Artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: runner-packages-${{ matrix.runtime }}
path: |
@@ -133,37 +133,37 @@ jobs:
# Download runner package tar.gz/zip produced by 'build' job
- name: Download Artifact (win-x64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-win-x64
path: ./
- name: Download Artifact (win-arm64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-win-arm64
path: ./
- name: Download Artifact (osx-x64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-osx-x64
path: ./
- name: Download Artifact (osx-arm64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-osx-arm64
path: ./
- name: Download Artifact (linux-x64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-linux-x64
path: ./
- name: Download Artifact (linux-arm)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-linux-arm
path: ./
- name: Download Artifact (linux-arm64)
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: runner-packages-linux-arm64
path: ./
@@ -312,7 +312,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}

View File

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

View File

@@ -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

View File

@@ -172,6 +172,7 @@ namespace GitHub.Runner.Common
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser";
public static readonly string 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";

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker
{
// Log the error and fail the PrepareActionsAsync Initialization.
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
executionContext.InfrastructureError(ex.Message, category: "resolve_action");
executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "resolve_action");
executionContext.Result = TaskResult.Failed;
throw;
}
@@ -818,7 +818,7 @@ namespace GitHub.Runner.Worker
try
{
Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'");
// repository archive from github always contains a nested folder
var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories();
if (nestedDirectories.Length != 1)
@@ -832,14 +832,14 @@ namespace GitHub.Runner.Worker
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName);
}
executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'");
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
{
Type = JobTelemetryType.General,
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink"
});
Trace.Info("Finished getting action repository.");
return;
}

View File

@@ -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;

View File

@@ -77,8 +77,7 @@ namespace GitHub.Runner.Worker
List<string> StepEnvironmentOverrides { get; }
ExecutionContext Root { get; }
ExecutionContext Parent { get; }
IExecutionContext Root { get; }
// Initialize
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
@@ -251,7 +250,9 @@ namespace GitHub.Runner.Worker
}
}
public ExecutionContext Root
IExecutionContext IExecutionContext.Root => Root;
private ExecutionContext Root
{
get
{
@@ -266,13 +267,7 @@ namespace GitHub.Runner.Worker
}
}
public ExecutionContext Parent
{
get
{
return _parentExecutionContext;
}
}
public JobContext JobContext
{
@@ -1328,9 +1323,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 +1413,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 +1431,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,
};
}

View File

@@ -312,7 +312,14 @@ namespace GitHub.Runner.Worker.Handlers
// Emit start marker after full context setup so display name expressions resolve correctly
if (emitCompositeMarkers)
{
step.TryUpdateDisplayName(out _);
try
{
step.EvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext, out _);
}
catch (Exception ex)
{
Trace.Warning("Caught exception while evaluating embedded step display name. {0}", ex);
}
ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]");
stepStopwatch = Stopwatch.StartNew();
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading;
using GitHub.Actions.WorkflowParser;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
@@ -23,6 +24,7 @@ namespace GitHub.Runner.Worker
public PipelineTemplateEvaluatorWrapper(
IHostContext hostContext,
IExecutionContext context,
bool allowServiceContainerCommand,
ObjectTemplating.ITraceWriter traceWriter = null)
{
ArgUtil.NotNull(hostContext, nameof(hostContext));
@@ -40,11 +42,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
};
@@ -222,8 +227,12 @@ namespace GitHub.Runner.Worker
Func<TNew> newEvaluator,
Func<TLegacy, TNew, bool> resultComparer)
{
// Capture cancellation state before evaluation
var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;
// Use the root (job-level) cancellation token to detect cancellation race conditions.
// The step-level token only fires on step timeout, not on job cancellation.
// Job cancellation mutates JobContext.Status which expression functions read,
// so we need the root token to properly detect cancellation between evaluator runs.
var rootCancellationToken = _context.Root?.CancellationToken ?? CancellationToken.None;
var cancellationRequestedBefore = rootCancellationToken.IsCancellationRequested;
// Legacy evaluator
var legacyException = default(Exception);
@@ -257,7 +266,7 @@ namespace GitHub.Runner.Worker
}
// Capture cancellation state after evaluation
var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested;
var cancellationRequestedAfter = rootCancellationToken.IsCancellationRequested;
// Compare results or exceptions
bool hasMismatch = false;
@@ -401,6 +410,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;

View File

@@ -20,7 +20,7 @@ namespace GitHub.DistributedTask.Expressions2
IEnumerable<IFunctionInfo> functions,
Boolean allowCaseFunction = true)
{
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction);
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
context.Trace.Info($"Parsing expression: <{expression}>");
return CreateTree(context);
}

View File

@@ -39,6 +39,24 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
/// <summary>
/// Gets or sets the container entrypoint override.
/// </summary>
public String Entrypoint
{
get;
set;
}
/// <summary>
/// Gets or sets the container command and args (after the image name).
/// </summary>
public String Command
{
get;
set;
}
/// <summary>
/// Gets or sets the volumes which are mounted into the container.
/// </summary>

View File

@@ -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";

View File

@@ -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<String>(ports.Count);
@@ -326,7 +343,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
internal static List<KeyValuePair<String, JobContainer>> ConvertToJobServiceContainers(
TemplateContext context,
TemplateToken services,
bool allowExpressions = false)
bool allowExpressions = false,
bool allowServiceContainerCommand = false)
{
var result = new List<KeyValuePair<String, JobContainer>>();
@@ -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<String, JobContainer>(networkAlias, container));
}

View File

@@ -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))
{

View File

@@ -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"
]
},

View File

@@ -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";

View File

@@ -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<String>(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<String, JobContainer>(networkAlias, container));
}

View File

@@ -35,6 +35,24 @@ namespace GitHub.Actions.WorkflowParser
set;
}
/// <summary>
/// Gets or sets the container entrypoint override.
/// </summary>
public String Entrypoint
{
get;
set;
}
/// <summary>
/// Gets or sets the container command and args (after the image name).
/// </summary>
public String Command
{
get;
set;
}
/// <summary>
/// Gets or sets the volumes which are mounted into the container.
/// </summary>

View File

@@ -48,6 +48,13 @@ namespace GitHub.Actions.WorkflowParser
[DataMember(EmitDefaultValue = false)]
public bool StrictJsonParsing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether service containers may specify "entrypoint" and "command".
/// Used during parsing and evaluation.
/// </summary>
[DataMember(EmitDefaultValue = false)]
public bool AllowServiceContainerCommand { get; set; }
/// <summary>
/// Gets the default workflow features.
/// </summary>
@@ -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
};
}

View File

@@ -2589,21 +2589,53 @@
"mapping": {
"properties": {
"image": {
"type": "non-empty-string",
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
"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.<job_id>.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.<job_id>.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.<job_id>.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.<job_id>.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.<job_id>.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"

View File

@@ -228,8 +228,8 @@ namespace GitHub.Runner.Common.Tests.Listener
.Returns(Task.FromResult(new TaskAgent()));
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => 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<Exception>(() => 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

View File

@@ -170,8 +170,8 @@ namespace GitHub.Runner.Common.Tests.Listener
DownloadUrl = "https://github.com/actions/runner/notexists"
};
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => 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<Exception>(() => 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

View File

@@ -0,0 +1,104 @@
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Expressions2.Sdk;
using GitHub.DistributedTask.ObjectTemplating;
using System;
using System.Collections.Generic;
using Xunit;
namespace GitHub.Runner.Common.Tests.Sdk
{
/// <summary>
/// Regression tests for ExpressionParser.CreateTree to verify that
/// allowCaseFunction does not accidentally set allowUnknownKeywords.
/// </summary>
public sealed class ExpressionParserL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_RejectsUnrecognizedNamedValue()
{
// Regression: allowCaseFunction was passed positionally into
// the allowUnknownKeywords parameter, causing all named values
// to be silently accepted.
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("inputs"),
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("github.event.repository.private", null, namedValues, null));
Assert.Contains("Unrecognized named-value", ex.Message);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_AcceptsRecognizedNamedValue()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("inputs"),
};
var node = parser.CreateTree("inputs.foo", null, namedValues, null);
Assert.NotNull(node);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionWorks_WhenAllowed()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("github"),
};
var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: true);
Assert.NotNull(node);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionRejected_WhenDisallowed()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("github"),
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: false));
Assert.Contains("Unrecognized function", ex.Message);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionDoesNotAffectUnknownKeywords()
{
// The key regression test: with allowCaseFunction=true (default),
// unrecognized named values must still be rejected.
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("inputs"),
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("github.ref", null, namedValues, null, allowCaseFunction: true));
Assert.Contains("Unrecognized named-value", ex.Message);
}
}
}

View File

@@ -928,6 +928,58 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_ContainerAction_RejectsInvalidExpressionContext()
{
try
{
// Arrange
Setup();
var actionManifest = new ActionManifestManager();
actionManifest.Initialize(_hc);
// Act & Assert — github is not a valid context for container-runs-env (only inputs is allowed)
var ex = Assert.Throws<ArgumentException>(() =>
actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_env_invalid_context.yml")));
Assert.Contains("Failed to load", ex.Message);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_ContainerAction_AcceptsValidExpressionContext()
{
try
{
// Arrange
Setup();
var actionManifest = new ActionManifestManager();
actionManifest.Initialize(_hc);
// Act — inputs is a valid context for container-runs-env
var result = actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_arg_env_expression.yml"));
// Assert
var containerAction = result.Execution as ContainerActionExecutionDataNew;
Assert.NotNull(containerAction);
Assert.Equal("${{ inputs.entryPoint }}", containerAction.Environment[1].Value.ToString());
}
finally
{
Teardown();
}
}
private void Setup([CallerMemberName] string name = "")
{
_ecTokenSource?.Dispose();

View File

@@ -926,6 +926,58 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_ContainerAction_RejectsInvalidExpressionContext()
{
try
{
// Arrange
Setup();
var actionManifest = new ActionManifestManagerLegacy();
actionManifest.Initialize(_hc);
// Act & Assert — github is not a valid context for container-runs-env (only inputs is allowed)
var ex = Assert.Throws<ArgumentException>(() =>
actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_env_invalid_context.yml")));
Assert.Contains("Failed to load", ex.Message);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_ContainerAction_AcceptsValidExpressionContext()
{
try
{
// Arrange
Setup();
var actionManifest = new ActionManifestManagerLegacy();
actionManifest.Initialize(_hc);
// Act — inputs is a valid context for container-runs-env
var result = actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_arg_env_expression.yml"));
// Assert
var containerAction = result.Execution as ContainerActionExecutionData;
Assert.NotNull(containerAction);
Assert.Equal("${{ inputs.entryPoint }}", containerAction.Environment[1].Value.ToString());
}
finally
{
Teardown();
}
}
private void Setup([CallerMemberName] string name = "")
{
_ecTokenSource?.Dispose();

View File

@@ -379,6 +379,40 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_BothParsersRejectInvalidExpressionContext()
{
try
{
// Arrange — regression test: both parsers must reject github context
// in container-runs-env (only inputs is allowed per schema)
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
var manifestPath = Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_env_invalid_context.yml");
// Act & Assert — both parsers should reject, wrapper should throw
Assert.Throws<ArgumentException>(() => wrapper.Load(_ec.Object, manifestPath));
}
finally
{
Teardown();
}
}
private string GetFullExceptionMessage(Exception ex)
{
var messages = new List<string>();

View File

@@ -1,14 +1,18 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Newtonsoft.Json.Linq;
using Xunit;
using DTWebApi = GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Common.Tests.Worker.Handlers
{
@@ -250,6 +254,66 @@ namespace GitHub.Runner.Common.Tests.Worker.Handlers
Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void PostStepMarker_UsesEvaluatedDisplayName()
{
// Arrange: create an ActionRunner with a RepositoryPathReference (simulating actions/cache@v4)
// and Stage = Post. Verify that EvaluateDisplayName produces the correct display name
// so the composite marker emits "Run actions/cache@v4" instead of the fallback "run".
var hc = new TestHostContext(this, nameof(PostStepMarker_UsesEvaluatedDisplayName));
var actionManifestLegacy = new ActionManifestManagerLegacy();
actionManifestLegacy.Initialize(hc);
hc.SetSingleton<IActionManifestManagerLegacy>(actionManifestLegacy);
var actionManifestNew = new ActionManifestManager();
actionManifestNew.Initialize(hc);
hc.SetSingleton<IActionManifestManager>(actionManifestNew);
var actionManifestManager = new ActionManifestManagerWrapper();
actionManifestManager.Initialize(hc);
hc.SetSingleton<IActionManifestManagerWrapper>(actionManifestManager);
var ec = new Mock<IExecutionContext>();
var contextData = new DictionaryContextData();
var githubContext = new GitHubContext();
githubContext.Add("event", JToken.Parse("{\"foo\":\"bar\"}").ToPipelineContextData());
contextData.Add("github", githubContext);
#if OS_WINDOWS
contextData["env"] = new DictionaryContextData();
#else
contextData["env"] = new CaseSensitiveDictionaryContextData();
#endif
ec.Setup(x => x.Global).Returns(new GlobalContext());
ec.Setup(x => x.ExpressionValues).Returns(contextData);
ec.Setup(x => x.ExpressionFunctions).Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()));
ec.Object.Global.Variables = new Variables(hc, new Dictionary<string, VariableValue>());
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
actionRunner.ExecutionContext = ec.Object;
actionRunner.Stage = ActionRunStage.Post;
actionRunner.Action = new Pipelines.ActionStep()
{
Name = "cache",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "actions/cache",
Ref = "v4"
}
};
// Act: call EvaluateDisplayName directly, which is what CompositeActionHandler now does
// for embedded steps (including Post stage) instead of TryUpdateDisplayName.
var result = actionRunner.EvaluateDisplayName(contextData, ec.Object, out bool updated);
// Assert: display name should be "Run actions/cache@v4", not the fallback "run"
Assert.True(result);
Assert.True(updated);
Assert.Equal("Run actions/cache@v4", actionRunner.DisplayName);
}
// Helper methods that call the real production code
private static string EscapeProperty(string value) =>
CompositeActionHandler.EscapeProperty(value);

View File

@@ -19,6 +19,7 @@ namespace GitHub.Runner.Common.Tests.Worker
public sealed class PipelineTemplateEvaluatorWrapperL0
{
private CancellationTokenSource _ecTokenSource;
private CancellationTokenSource _rootTokenSource;
private Mock<IExecutionContext> _ec;
private TestHostContext _hc;
@@ -36,7 +37,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,9 +64,9 @@ 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
// Call EvaluateAndCompare directly: the new evaluator cancels the root token
// and returns a different value, forcing hasMismatch = true.
// Because cancellation flipped during the evaluation window, the
// mismatch should be skipped.
@@ -74,7 +75,7 @@ namespace GitHub.Runner.Common.Tests.Worker
() => "legacy-value",
() =>
{
_ecTokenSource.Cancel();
_rootTokenSource.Cancel();
return "different-value";
},
(legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal));
@@ -88,6 +89,43 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateAndCompare_SkipsMismatchRecording_WhenRootCancellationOccursBetweenEvaluators()
{
// Simulates job-level cancellation firing between legacy and new evaluator runs.
// Root is mocked with a separate CancellationTokenSource to exercise the
// _context.Root?.CancellationToken path (the job-level token).
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
// Legacy evaluator cancels the root token (simulating job cancel) and returns a value.
// The new evaluator returns a different value. The mismatch should be skipped.
var result = wrapper.EvaluateAndCompare<string, string>(
"TestRootCancellationSkip",
() =>
{
var legacyValue = "legacy-value";
_rootTokenSource.Cancel();
return legacyValue;
},
() => "different-value",
(legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal));
Assert.Equal("legacy-value", result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -98,7 +136,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<string, string>(
@@ -130,7 +168,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<LegacyExpressions.IFunctionInfo>();
@@ -156,7 +194,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 +222,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<LegacyExpressions.IFunctionInfo>();
@@ -211,7 +249,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 +277,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<LegacyExpressions.IFunctionInfo>();
@@ -265,7 +303,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<LegacyExpressions.IFunctionInfo>();
@@ -281,6 +319,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<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
Assert.Null(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void 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<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
Assert.Null(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void 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<LegacyExpressions.IFunctionInfo>();
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
Assert.Null(result);
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void 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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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 +463,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 +491,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<LegacyExpressions.IFunctionInfo>();
@@ -348,7 +520,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 +548,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<LegacyExpressions.IFunctionInfo>();
@@ -391,6 +563,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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
Assert.Throws<GitHub.DistributedTask.ObjectTemplating.TemplateValidationException>(() =>
wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions));
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -401,7 +780,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<LegacyExpressions.IFunctionInfo>();
@@ -430,7 +809,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 +840,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 +871,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");
@@ -521,6 +900,8 @@ namespace GitHub.Runner.Common.Tests.Worker
{
_ecTokenSource?.Dispose();
_ecTokenSource = new CancellationTokenSource();
_rootTokenSource?.Dispose();
_rootTokenSource = new CancellationTokenSource();
_hc = new TestHostContext(this, name);
@@ -536,6 +917,9 @@ namespace GitHub.Runner.Common.Tests.Worker
WriteDebug = true,
});
_ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token);
var rootEc = new Mock<IExecutionContext>();
rootEc.Setup(x => x.CancellationToken).Returns(_rootTokenSource.Token);
_ec.Setup(x => x.Root).Returns(rootEc.Object);
_ec.Setup(x => x.ExpressionValues).Returns(expressionValues);
_ec.Setup(x => x.ExpressionFunctions).Returns(expressionFunctions);
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); });
@@ -544,6 +928,7 @@ namespace GitHub.Runner.Common.Tests.Worker
private void Teardown()
{
_rootTokenSource?.Dispose();
_hc?.Dispose();
}
}

View File

@@ -0,0 +1,13 @@
name: 'Action With Invalid Context'
description: 'Docker action that uses github context in env (only inputs is allowed)'
inputs:
my-input:
description: 'A test input'
required: false
default: 'hello'
runs:
using: 'docker'
image: 'Dockerfile'
env:
VALID: '${{ inputs.my-input }}'
INVALID: '${{ github.event.repository.private }}'