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/.github/workflows/build.yml b/.github/workflows/build.yml index fd315411f..5b4a6e758 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,7 +78,7 @@ jobs: # Upload runner package tar.gz/zip as artifact - name: Publish Artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: runner-package-${{ matrix.runtime }} path: | @@ -111,10 +111,10 @@ jobs: core.setOutput('version', version); - name: Setup Docker buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./images load: true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 933afdd33..af54fbc15 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -38,10 +38,10 @@ jobs: core.setOutput('version', runnerVersion); - name: Setup Docker buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -49,7 +49,7 @@ jobs: - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./images platforms: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3df10b001..b75ba15ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,7 +118,7 @@ jobs: # Upload runner package tar.gz/zip as artifact. - name: Publish Artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: runner-packages-${{ matrix.runtime }} path: | @@ -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: ./ @@ -309,10 +309,10 @@ jobs: core.setOutput('version', runnerVersion); - name: Setup Docker buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -320,7 +320,7 @@ jobs: - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./images platforms: | diff --git a/releaseNote.md b/releaseNote.md index 7d873e9aa..b4915dd61 100644 --- a/releaseNote.md +++ b/releaseNote.md @@ -1,27 +1,35 @@ ## What's Changed -* Fix owner of /home/runner directory by @nikola-jokic in https://github.com/actions/runner/pull/4132 -* Update Docker to v29.0.2 and Buildx to v0.30.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4135 -* Update workflow around runner docker image. by @TingluoHuang in https://github.com/actions/runner/pull/4133 -* Fix regex for validating runner version format by @TingluoHuang in https://github.com/actions/runner/pull/4136 -* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4144 -* Ensure safe_sleep tries alternative approaches by @TingluoHuang in https://github.com/actions/runner/pull/4146 -* Bump actions/github-script from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4137 -* Bump actions/checkout from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4130 -* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4149 -* Bump docker image to use ubuntu 24.04 by @TingluoHuang in https://github.com/actions/runner/pull/4018 -* Add support for case function by @AllanGuigou in https://github.com/actions/runner/pull/4147 -* Cleanup feature flag actions_container_action_runner_temp by @ericsciple in https://github.com/actions/runner/pull/4163 -* Bump actions/download-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4155 -* Bump actions/upload-artifact from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4157 -* Set ACTIONS_ORCHESTRATION_ID as env to actions. by @TingluoHuang in https://github.com/actions/runner/pull/4178 -* Allow hosted VM report job telemetry via .setup_info file. by @TingluoHuang in https://github.com/actions/runner/pull/4186 -* Bump typescript from 5.9.2 to 5.9.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4184 -* Bump Azure.Storage.Blobs from 12.26.0 to 12.27.0 by @dependabot[bot] in https://github.com/actions/runner/pull/4189 +* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4200 +* Update dotnet sdk to latest version @8.0.417 by @github-actions[bot] in https://github.com/actions/runner/pull/4201 +* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4202 +* Allow empty container options by @ericsciple in https://github.com/actions/runner/pull/4208 +* Update Docker to v29.1.5 and Buildx to v0.31.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4212 +* Report job level annotations by @TingluoHuang in https://github.com/actions/runner/pull/4216 +* Fix local action display name showing `Run /./` instead of `Run ./` by @ericsciple in https://github.com/actions/runner/pull/4218 +* Update Docker to v29.2.0 and Buildx to v0.31.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4219 +* Add support for libssl3 and libssl3t64 for newer Debian/Ubuntu versions by @nekketsuuu in https://github.com/actions/runner/pull/4213 +* Validate work dir during runner start up. by @TingluoHuang in https://github.com/actions/runner/pull/4227 +* Bump hook to 0.8.1 by @nikola-jokic in https://github.com/actions/runner/pull/4222 +* Support return job result as exitcode in hosted runner. by @TingluoHuang in https://github.com/actions/runner/pull/4233 +* Add telemetry tracking for deprecated set-output and save-state commands by @ericsciple in https://github.com/actions/runner/pull/4221 +* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4220 +* Remove unnecessary connection test during some registration flows by @zarenner in https://github.com/actions/runner/pull/4244 +* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4249 +* Update dotnet sdk to latest version @8.0.418 by @github-actions[bot] in https://github.com/actions/runner/pull/4250 +* Fix link to SECURITY.md in README by @TingluoHuang in https://github.com/actions/runner/pull/4253 +* Try to infer runner is on hosted/ghes when githuburl is empty. by @TingluoHuang in https://github.com/actions/runner/pull/4254 +* Add Node.js 20 deprecation warning annotation (Phase 1) by @salmanmkc in https://github.com/actions/runner/pull/4242 +* Update Node.js 20 deprecation date to June 2nd, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4258 +* Composite Action Step Markers by @ericsciple in https://github.com/actions/runner/pull/4243 +* Symlink actions cache by @paveliak in https://github.com/actions/runner/pull/4260 +* Bump minimatch in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4261 +* Bump @stylistic/eslint-plugin from 3.1.0 to 5.9.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4257 ## New Contributors -* @AllanGuigou made their first contribution in https://github.com/actions/runner/pull/4147 +* @nekketsuuu made their first contribution in https://github.com/actions/runner/pull/4213 +* @zarenner made their first contribution in https://github.com/actions/runner/pull/4244 -**Full Changelog**: https://github.com/actions/runner/compare/v2.330.0...v2.331.0 +**Full Changelog**: https://github.com/actions/runner/compare/v2.331.0...v2.332.0 _Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet. To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository. diff --git a/src/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/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 71bff32bd..38c2ab8b3 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker { // Log the error and fail the PrepareActionsAsync Initialization. Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}"); - executionContext.InfrastructureError(ex.Message, category: "resolve_action"); + executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "resolve_action"); executionContext.Result = TaskResult.Failed; throw; } @@ -818,7 +818,7 @@ namespace GitHub.Runner.Worker try { Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'"); - + // repository archive from github always contains a nested folder var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories(); if (nestedDirectories.Length != 1) @@ -832,14 +832,14 @@ namespace GitHub.Runner.Worker IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName); } - + executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'"); executionContext.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.General, Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink" }); - + Trace.Info("Finished getting action repository."); return; } 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/Handlers/CompositeActionHandler.cs b/src/Runner.Worker/Handlers/CompositeActionHandler.cs index c0bc6f688..6db8995d0 100644 --- a/src/Runner.Worker/Handlers/CompositeActionHandler.cs +++ b/src/Runner.Worker/Handlers/CompositeActionHandler.cs @@ -312,7 +312,14 @@ namespace GitHub.Runner.Worker.Handlers // Emit start marker after full context setup so display name expressions resolve correctly if (emitCompositeMarkers) { - step.TryUpdateDisplayName(out _); + try + { + step.EvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext, out _); + } + catch (Exception ex) + { + Trace.Warning("Caught exception while evaluating embedded step display name. {0}", ex); + } ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]"); stepStopwatch = Stopwatch.StartNew(); } diff --git a/src/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/DTExpressions2/Expressions2/ExpressionParser.cs b/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs index 43b9b4eaf..f0dac3d24 100644 --- a/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs +++ b/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs @@ -20,7 +20,7 @@ namespace GitHub.DistributedTask.Expressions2 IEnumerable 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); } 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/Sdk/ExpressionParserL0.cs b/src/Test/L0/Sdk/ExpressionParserL0.cs new file mode 100644 index 000000000..6512ec9e7 --- /dev/null +++ b/src/Test/L0/Sdk/ExpressionParserL0.cs @@ -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 +{ + /// + /// Regression tests for ExpressionParser.CreateTree to verify that + /// allowCaseFunction does not accidentally set allowUnknownKeywords. + /// + 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 + { + new NamedValueInfo("inputs"), + }; + + var ex = Assert.Throws(() => + 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 + { + new NamedValueInfo("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 + { + new NamedValueInfo("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 + { + new NamedValueInfo("github"), + }; + + var ex = Assert.Throws(() => + 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 + { + new NamedValueInfo("inputs"), + }; + + var ex = Assert.Throws(() => + parser.CreateTree("github.ref", null, namedValues, null, allowCaseFunction: true)); + + Assert.Contains("Unrecognized named-value", ex.Message); + } + } +} diff --git a/src/Test/L0/Worker/ActionManifestManagerL0.cs b/src/Test/L0/Worker/ActionManifestManagerL0.cs index b5da3b304..9d707c3da 100644 --- a/src/Test/L0/Worker/ActionManifestManagerL0.cs +++ b/src/Test/L0/Worker/ActionManifestManagerL0.cs @@ -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(() => + 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(); diff --git a/src/Test/L0/Worker/ActionManifestManagerLegacyL0.cs b/src/Test/L0/Worker/ActionManifestManagerLegacyL0.cs index c11d4b9b6..0166b87b2 100644 --- a/src/Test/L0/Worker/ActionManifestManagerLegacyL0.cs +++ b/src/Test/L0/Worker/ActionManifestManagerLegacyL0.cs @@ -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(() => + 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(); diff --git a/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs b/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs index d7039767c..7551b0f57 100644 --- a/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs +++ b/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs @@ -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(legacyManager); + + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(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(() => wrapper.Load(_ec.Object, manifestPath)); + } + finally + { + Teardown(); + } + } + private string GetFullExceptionMessage(Exception ex) { var messages = new List(); diff --git a/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs b/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs index 09935c3ad..33b00fdb9 100644 --- a/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs +++ b/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs @@ -1,14 +1,18 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; using GitHub.Runner.Worker; using GitHub.Runner.Worker.Handlers; using Moq; +using Newtonsoft.Json.Linq; using Xunit; using DTWebApi = GitHub.DistributedTask.WebApi; +using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Common.Tests.Worker.Handlers { @@ -250,6 +254,66 @@ namespace GitHub.Runner.Common.Tests.Worker.Handlers Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void PostStepMarker_UsesEvaluatedDisplayName() + { + // Arrange: create an ActionRunner with a RepositoryPathReference (simulating actions/cache@v4) + // and Stage = Post. Verify that EvaluateDisplayName produces the correct display name + // so the composite marker emits "Run actions/cache@v4" instead of the fallback "run". + var hc = new TestHostContext(this, nameof(PostStepMarker_UsesEvaluatedDisplayName)); + var actionManifestLegacy = new ActionManifestManagerLegacy(); + actionManifestLegacy.Initialize(hc); + hc.SetSingleton(actionManifestLegacy); + var actionManifestNew = new ActionManifestManager(); + actionManifestNew.Initialize(hc); + hc.SetSingleton(actionManifestNew); + var actionManifestManager = new ActionManifestManagerWrapper(); + actionManifestManager.Initialize(hc); + hc.SetSingleton(actionManifestManager); + + var ec = new Mock(); + var contextData = new DictionaryContextData(); + var githubContext = new GitHubContext(); + githubContext.Add("event", JToken.Parse("{\"foo\":\"bar\"}").ToPipelineContextData()); + contextData.Add("github", githubContext); +#if OS_WINDOWS + contextData["env"] = new DictionaryContextData(); +#else + contextData["env"] = new CaseSensitiveDictionaryContextData(); +#endif + ec.Setup(x => x.Global).Returns(new GlobalContext()); + ec.Setup(x => x.ExpressionValues).Returns(contextData); + ec.Setup(x => x.ExpressionFunctions).Returns(new List()); + ec.Setup(x => x.Write(It.IsAny(), It.IsAny())); + ec.Object.Global.Variables = new Variables(hc, new Dictionary()); + + var actionRunner = new ActionRunner(); + actionRunner.Initialize(hc); + actionRunner.ExecutionContext = ec.Object; + actionRunner.Stage = ActionRunStage.Post; + actionRunner.Action = new Pipelines.ActionStep() + { + Name = "cache", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/cache", + Ref = "v4" + } + }; + + // Act: call EvaluateDisplayName directly, which is what CompositeActionHandler now does + // for embedded steps (including Post stage) instead of TryUpdateDisplayName. + var result = actionRunner.EvaluateDisplayName(contextData, ec.Object, out bool updated); + + // Assert: display name should be "Run actions/cache@v4", not the fallback "run" + Assert.True(result); + Assert.True(updated); + Assert.Equal("Run actions/cache@v4", actionRunner.DisplayName); + } + // Helper methods that call the real production code private static string EscapeProperty(string value) => CompositeActionHandler.EscapeProperty(value); 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"); diff --git a/src/Test/TestData/dockerfileaction_env_invalid_context.yml b/src/Test/TestData/dockerfileaction_env_invalid_context.yml new file mode 100644 index 000000000..0762d69e2 --- /dev/null +++ b/src/Test/TestData/dockerfileaction_env_invalid_context.yml @@ -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 }}' diff --git a/src/runnerversion b/src/runnerversion index 865c3fa9d..96f36b02a 100644 --- a/src/runnerversion +++ b/src/runnerversion @@ -1 +1 @@ -2.331.0 +2.332.0