From bc00800857bd169dcd220174c5c70edb94273071 Mon Sep 17 00:00:00 2001 From: Salman Chishti Date: Wed, 25 Feb 2026 13:36:47 +0000 Subject: [PATCH 01/14] Bump runner version to 2.332.0 and update release notes (#4264) --- releaseNote.md | 48 +++++++++++++++++++++++++++-------------------- src/runnerversion | 2 +- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/releaseNote.md b/releaseNote.md index 7d873e9aa..b4915dd61 100644 --- a/releaseNote.md +++ b/releaseNote.md @@ -1,27 +1,35 @@ ## What's Changed -* Fix owner of /home/runner directory by @nikola-jokic in https://github.com/actions/runner/pull/4132 -* Update Docker to v29.0.2 and Buildx to v0.30.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4135 -* Update workflow around runner docker image. by @TingluoHuang in https://github.com/actions/runner/pull/4133 -* Fix regex for validating runner version format by @TingluoHuang in https://github.com/actions/runner/pull/4136 -* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4144 -* Ensure safe_sleep tries alternative approaches by @TingluoHuang in https://github.com/actions/runner/pull/4146 -* Bump actions/github-script from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4137 -* Bump actions/checkout from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4130 -* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4149 -* Bump docker image to use ubuntu 24.04 by @TingluoHuang in https://github.com/actions/runner/pull/4018 -* Add support for case function by @AllanGuigou in https://github.com/actions/runner/pull/4147 -* Cleanup feature flag actions_container_action_runner_temp by @ericsciple in https://github.com/actions/runner/pull/4163 -* Bump actions/download-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4155 -* Bump actions/upload-artifact from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4157 -* Set ACTIONS_ORCHESTRATION_ID as env to actions. by @TingluoHuang in https://github.com/actions/runner/pull/4178 -* Allow hosted VM report job telemetry via .setup_info file. by @TingluoHuang in https://github.com/actions/runner/pull/4186 -* Bump typescript from 5.9.2 to 5.9.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4184 -* Bump Azure.Storage.Blobs from 12.26.0 to 12.27.0 by @dependabot[bot] in https://github.com/actions/runner/pull/4189 +* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4200 +* Update dotnet sdk to latest version @8.0.417 by @github-actions[bot] in https://github.com/actions/runner/pull/4201 +* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4202 +* Allow empty container options by @ericsciple in https://github.com/actions/runner/pull/4208 +* Update Docker to v29.1.5 and Buildx to v0.31.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4212 +* Report job level annotations by @TingluoHuang in https://github.com/actions/runner/pull/4216 +* Fix local action display name showing `Run /./` instead of `Run ./` by @ericsciple in https://github.com/actions/runner/pull/4218 +* Update Docker to v29.2.0 and Buildx to v0.31.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4219 +* Add support for libssl3 and libssl3t64 for newer Debian/Ubuntu versions by @nekketsuuu in https://github.com/actions/runner/pull/4213 +* Validate work dir during runner start up. by @TingluoHuang in https://github.com/actions/runner/pull/4227 +* Bump hook to 0.8.1 by @nikola-jokic in https://github.com/actions/runner/pull/4222 +* Support return job result as exitcode in hosted runner. by @TingluoHuang in https://github.com/actions/runner/pull/4233 +* Add telemetry tracking for deprecated set-output and save-state commands by @ericsciple in https://github.com/actions/runner/pull/4221 +* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4220 +* Remove unnecessary connection test during some registration flows by @zarenner in https://github.com/actions/runner/pull/4244 +* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4249 +* Update dotnet sdk to latest version @8.0.418 by @github-actions[bot] in https://github.com/actions/runner/pull/4250 +* Fix link to SECURITY.md in README by @TingluoHuang in https://github.com/actions/runner/pull/4253 +* Try to infer runner is on hosted/ghes when githuburl is empty. by @TingluoHuang in https://github.com/actions/runner/pull/4254 +* Add Node.js 20 deprecation warning annotation (Phase 1) by @salmanmkc in https://github.com/actions/runner/pull/4242 +* Update Node.js 20 deprecation date to June 2nd, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4258 +* Composite Action Step Markers by @ericsciple in https://github.com/actions/runner/pull/4243 +* Symlink actions cache by @paveliak in https://github.com/actions/runner/pull/4260 +* Bump minimatch in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4261 +* Bump @stylistic/eslint-plugin from 3.1.0 to 5.9.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4257 ## New Contributors -* @AllanGuigou made their first contribution in https://github.com/actions/runner/pull/4147 +* @nekketsuuu made their first contribution in https://github.com/actions/runner/pull/4213 +* @zarenner made their first contribution in https://github.com/actions/runner/pull/4244 -**Full Changelog**: https://github.com/actions/runner/compare/v2.330.0...v2.331.0 +**Full Changelog**: https://github.com/actions/runner/compare/v2.331.0...v2.332.0 _Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet. To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository. diff --git a/src/runnerversion b/src/runnerversion index 865c3fa9d..96f36b02a 100644 --- a/src/runnerversion +++ b/src/runnerversion @@ -1 +1 @@ -2.331.0 +2.332.0 From 7650fc432e4d749eca416d8a92004d90a04e136a Mon Sep 17 00:00:00 2001 From: Tingluo Huang Date: Wed, 25 Feb 2026 15:44:27 -0500 Subject: [PATCH 02/14] Log inner exception message. (#4265) --- src/Runner.Worker/ActionManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 71bff32bd..38c2ab8b3 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker { // Log the error and fail the PrepareActionsAsync Initialization. Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}"); - executionContext.InfrastructureError(ex.Message, category: "resolve_action"); + executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "resolve_action"); executionContext.Result = TaskResult.Failed; throw; } @@ -818,7 +818,7 @@ namespace GitHub.Runner.Worker try { Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'"); - + // repository archive from github always contains a nested folder var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories(); if (nestedDirectories.Length != 1) @@ -832,14 +832,14 @@ namespace GitHub.Runner.Worker IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName); } - + executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'"); executionContext.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.General, Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink" }); - + Trace.Info("Finished getting action repository."); return; } From ae09a9d7b52be2e5dfe5ee86226a3d03e79c228e Mon Sep 17 00:00:00 2001 From: eric sciple Date: Thu, 26 Feb 2026 08:36:55 -0600 Subject: [PATCH 03/14] Fix composite post-step marker display names (#4267) --- .../Handlers/CompositeActionHandler.cs | 9 ++- .../Handlers/CompositeActionHandlerL0.cs | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Runner.Worker/Handlers/CompositeActionHandler.cs b/src/Runner.Worker/Handlers/CompositeActionHandler.cs index c0bc6f688..6db8995d0 100644 --- a/src/Runner.Worker/Handlers/CompositeActionHandler.cs +++ b/src/Runner.Worker/Handlers/CompositeActionHandler.cs @@ -312,7 +312,14 @@ namespace GitHub.Runner.Worker.Handlers // Emit start marker after full context setup so display name expressions resolve correctly if (emitCompositeMarkers) { - step.TryUpdateDisplayName(out _); + try + { + step.EvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext, out _); + } + catch (Exception ex) + { + Trace.Warning("Caught exception while evaluating embedded step display name. {0}", ex); + } ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]"); stepStopwatch = Stopwatch.StartNew(); } diff --git a/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs b/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs index 09935c3ad..33b00fdb9 100644 --- a/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs +++ b/src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs @@ -1,14 +1,18 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; using GitHub.Runner.Worker; using GitHub.Runner.Worker.Handlers; using Moq; +using Newtonsoft.Json.Linq; using Xunit; using DTWebApi = GitHub.DistributedTask.WebApi; +using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Common.Tests.Worker.Handlers { @@ -250,6 +254,66 @@ namespace GitHub.Runner.Common.Tests.Worker.Handlers Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void PostStepMarker_UsesEvaluatedDisplayName() + { + // Arrange: create an ActionRunner with a RepositoryPathReference (simulating actions/cache@v4) + // and Stage = Post. Verify that EvaluateDisplayName produces the correct display name + // so the composite marker emits "Run actions/cache@v4" instead of the fallback "run". + var hc = new TestHostContext(this, nameof(PostStepMarker_UsesEvaluatedDisplayName)); + var actionManifestLegacy = new ActionManifestManagerLegacy(); + actionManifestLegacy.Initialize(hc); + hc.SetSingleton(actionManifestLegacy); + var actionManifestNew = new ActionManifestManager(); + actionManifestNew.Initialize(hc); + hc.SetSingleton(actionManifestNew); + var actionManifestManager = new ActionManifestManagerWrapper(); + actionManifestManager.Initialize(hc); + hc.SetSingleton(actionManifestManager); + + var ec = new Mock(); + var contextData = new DictionaryContextData(); + var githubContext = new GitHubContext(); + githubContext.Add("event", JToken.Parse("{\"foo\":\"bar\"}").ToPipelineContextData()); + contextData.Add("github", githubContext); +#if OS_WINDOWS + contextData["env"] = new DictionaryContextData(); +#else + contextData["env"] = new CaseSensitiveDictionaryContextData(); +#endif + ec.Setup(x => x.Global).Returns(new GlobalContext()); + ec.Setup(x => x.ExpressionValues).Returns(contextData); + ec.Setup(x => x.ExpressionFunctions).Returns(new List()); + ec.Setup(x => x.Write(It.IsAny(), It.IsAny())); + ec.Object.Global.Variables = new Variables(hc, new Dictionary()); + + var actionRunner = new ActionRunner(); + actionRunner.Initialize(hc); + actionRunner.ExecutionContext = ec.Object; + actionRunner.Stage = ActionRunStage.Post; + actionRunner.Action = new Pipelines.ActionStep() + { + Name = "cache", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/cache", + Ref = "v4" + } + }; + + // Act: call EvaluateDisplayName directly, which is what CompositeActionHandler now does + // for embedded steps (including Post stage) instead of TryUpdateDisplayName. + var result = actionRunner.EvaluateDisplayName(contextData, ec.Object, out bool updated); + + // Assert: display name should be "Run actions/cache@v4", not the fallback "run" + Assert.True(result); + Assert.True(updated); + Assert.Equal("Run actions/cache@v4", actionRunner.DisplayName); + } + // Helper methods that call the real production code private static string EscapeProperty(string value) => CompositeActionHandler.EscapeProperty(value); From 985a06fcca95d612869387fca59e417b65c6c2c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:18:13 +0000 Subject: [PATCH 04/14] Bump actions/download-artifact from 7 to 8 (#4269) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3df10b001..528257605 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,37 +133,37 @@ jobs: # Download runner package tar.gz/zip produced by 'build' job - name: Download Artifact (win-x64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-win-x64 path: ./ - name: Download Artifact (win-arm64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-win-arm64 path: ./ - name: Download Artifact (osx-x64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-osx-x64 path: ./ - name: Download Artifact (osx-arm64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-osx-arm64 path: ./ - name: Download Artifact (linux-x64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-linux-x64 path: ./ - name: Download Artifact (linux-arm) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-linux-arm path: ./ - name: Download Artifact (linux-arm64) - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: runner-packages-linux-arm64 path: ./ From 60a9422599bf11abf799dab8dc5ab5c1de3eb3dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:51:11 +0000 Subject: [PATCH 05/14] chore: update Node versions (#4272) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/Misc/externals.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh index 5cbb6f64d..8806a20fa 100755 --- a/src/Misc/externals.sh +++ b/src/Misc/externals.sh @@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download # When you update Node versions you must also create a new release of alpine_nodejs at that updated version. # Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started NODE20_VERSION="20.20.0" -NODE24_VERSION="24.13.1" +NODE24_VERSION="24.14.0" get_abs_path() { # exploits the fact that pwd will print abs path when no args From a9a07a65532fbea3abfb07550d2a24034073dcd3 Mon Sep 17 00:00:00 2001 From: Tingluo Huang Date: Mon, 2 Mar 2026 22:44:14 -0500 Subject: [PATCH 06/14] Avoid throw in SelfUpdaters. (#4274) --- src/Runner.Listener/SelfUpdater.cs | 4 +++- src/Runner.Listener/SelfUpdaterV2.cs | 4 +++- src/Test/L0/Listener/SelfUpdaterL0.cs | 8 ++++---- src/Test/L0/Listener/SelfUpdaterV2L0.cs | 8 ++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Runner.Listener/SelfUpdater.cs b/src/Runner.Listener/SelfUpdater.cs index 6ebeebd82..9cf6ae8a9 100644 --- a/src/Runner.Listener/SelfUpdater.cs +++ b/src/Runner.Listener/SelfUpdater.cs @@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener } catch (Exception ex) { + Trace.Error(ex); + _terminal.WriteError($"Runner update failed: {ex.Message}"); _updateTrace.Enqueue(ex.ToString()); - throw; + return false; } finally { diff --git a/src/Runner.Listener/SelfUpdaterV2.cs b/src/Runner.Listener/SelfUpdaterV2.cs index b64619b69..78a2acdd3 100644 --- a/src/Runner.Listener/SelfUpdaterV2.cs +++ b/src/Runner.Listener/SelfUpdaterV2.cs @@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener } catch (Exception ex) { + Trace.Error(ex); + _terminal.WriteError($"Runner update failed: {ex.Message}"); _updateTrace.Enqueue(ex.ToString()); - throw; + return false; } finally { diff --git a/src/Test/L0/Listener/SelfUpdaterL0.cs b/src/Test/L0/Listener/SelfUpdaterL0.cs index be095ce90..8003dd071 100644 --- a/src/Test/L0/Listener/SelfUpdaterL0.cs +++ b/src/Test/L0/Listener/SelfUpdaterL0.cs @@ -228,8 +228,8 @@ namespace GitHub.Runner.Common.Tests.Listener .Returns(Task.FromResult(new TaskAgent())); - var ex = await Assert.ThrowsAsync(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken)); - Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message); + var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken); + Assert.False(result); } } finally @@ -281,8 +281,8 @@ namespace GitHub.Runner.Common.Tests.Listener .Returns(Task.FromResult(new TaskAgent())); - var ex = await Assert.ThrowsAsync(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken)); - Assert.Contains("did not match expected Runner Hash", ex.Message); + var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken); + Assert.False(result); } } finally diff --git a/src/Test/L0/Listener/SelfUpdaterV2L0.cs b/src/Test/L0/Listener/SelfUpdaterV2L0.cs index 5115a6bbb..a91e11273 100644 --- a/src/Test/L0/Listener/SelfUpdaterV2L0.cs +++ b/src/Test/L0/Listener/SelfUpdaterV2L0.cs @@ -170,8 +170,8 @@ namespace GitHub.Runner.Common.Tests.Listener DownloadUrl = "https://github.com/actions/runner/notexists" }; - var ex = await Assert.ThrowsAsync(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken)); - Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message); + var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken); + Assert.False(result); } } finally @@ -220,8 +220,8 @@ namespace GitHub.Runner.Common.Tests.Listener SHA256Checksum = "badhash" }; - var ex = await Assert.ThrowsAsync(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken)); - Assert.Contains("did not match expected Runner Hash", ex.Message); + var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken); + Assert.False(result); } } finally From 8a73bccebb88715b3ce5443ec579b4e2d4dae6e4 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 2 Mar 2026 23:38:16 -0600 Subject: [PATCH 07/14] Fix parser comparison mismatches (#4273) --- .../Conversion/WorkflowTemplateConverter.cs | 54 +++- src/Sdk/WorkflowParser/workflow-v1.0.json | 4 +- .../PipelineTemplateEvaluatorWrapperL0.cs | 272 ++++++++++++++++++ 3 files changed, 317 insertions(+), 13 deletions(-) diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index 7c5764cb3..7293ce165 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1079,7 +1079,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion internal static JobContainer ConvertToJobContainer( TemplateContext context, TemplateToken value, - bool isEarlyValidation = false) + bool isEarlyValidation = false, + bool isServiceContainer = false) { var result = new JobContainer(); if (isEarlyValidation && value.Traverse().Any(x => x is ExpressionToken)) @@ -1089,11 +1090,34 @@ namespace GitHub.Actions.WorkflowParser.Conversion if (value is StringToken containerLiteral) { - if (String.IsNullOrEmpty(containerLiteral.Value)) + // Trim "docker://" + var trimmedImage = containerLiteral.Value; + var hasDockerPrefix = containerLiteral.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal); + if (hasDockerPrefix) { + trimmedImage = trimmedImage.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length); + } + + // Empty shorthand after trimming "docker://" ? + if (String.IsNullOrEmpty(trimmedImage)) + { + // Error at parse-time for: + // 1. container: 'docker://' + // 2. services.foo: '' + // 3. services.foo: 'docker://' + // + // Do not error for: + // 1. container: '' + if (isEarlyValidation && (hasDockerPrefix || isServiceContainer)) + { + context.Error(value, "Container image cannot be empty"); + } + + // Short-circuit return null; } + // Store original, trimmed further below result.Image = containerLiteral.Value; } else @@ -1152,22 +1176,30 @@ namespace GitHub.Actions.WorkflowParser.Conversion } } + // Trim "docker://" + var hadDockerPrefix = false; + if (!String.IsNullOrEmpty(result.Image) && result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal)) + { + hadDockerPrefix = true; + result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length); + } + if (String.IsNullOrEmpty(result.Image)) { - // Only error during early validation (parse time) - // At runtime (expression evaluation), empty image = no container - if (isEarlyValidation) + // Error at parse-time for: + // 1. container: {image: 'docker://'} + // 2. services.foo: {image: ''} + // 3. services.foo: {image: 'docker://'} + // + // Do not error for: + // 1. container: {image: ''} + if (isEarlyValidation && (hadDockerPrefix || isServiceContainer)) { context.Error(value, "Container image cannot be empty"); } return null; } - if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal)) - { - result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length); - } - return result; } @@ -1188,7 +1220,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion foreach (var servicePair in servicesMapping) { var networkAlias = servicePair.Key.AssertString("services key").Value; - var container = ConvertToJobContainer(context, servicePair.Value); + var container = ConvertToJobContainer(context, servicePair.Value, isEarlyValidation, isServiceContainer: true); result.Add(new KeyValuePair(networkAlias, container)); } diff --git a/src/Sdk/WorkflowParser/workflow-v1.0.json b/src/Sdk/WorkflowParser/workflow-v1.0.json index 01601dcb5..0f4c91130 100644 --- a/src/Sdk/WorkflowParser/workflow-v1.0.json +++ b/src/Sdk/WorkflowParser/workflow-v1.0.json @@ -2589,7 +2589,7 @@ "mapping": { "properties": { "image": { - "type": "non-empty-string", + "type": "string", "description": "Use `jobs..container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name." }, "options": { @@ -2634,7 +2634,7 @@ "matrix" ], "one-of": [ - "non-empty-string", + "string", "container-mapping" ] }, diff --git a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs index 2caf5dba0..e6fae1fa5 100644 --- a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs +++ b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs @@ -281,6 +281,140 @@ namespace GitHub.Runner.Common.Tests.Worker } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_DockerPrefixOnly_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new StringToken(null, null, null, "docker://"); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_DockerPrefixOnlyMapping_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://")); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_EmptyImageMapping_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "")); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_ValidImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new StringToken(null, null, null, "ubuntu:latest"); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.NotNull(result); + Assert.Equal("ubuntu:latest", result.Image); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_DockerPrefixWithImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new StringToken(null, null, null, "docker://ubuntu:latest"); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.NotNull(result); + Assert.Equal("ubuntu:latest", result.Image); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -391,6 +525,144 @@ namespace GitHub.Runner.Common.Tests.Worker } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_EmptyImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + // Build a services mapping token with one service whose image is empty string + // Similar to: services: { db: { image: '' } } + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + // Should get a list with one entry where the container is null (empty image = no container) + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.Null(result[0].Value); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_DockerPrefixOnlyImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.Null(result[0].Value); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_ExpressionEvalsToEmpty_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + // Simulates: services: { db: { image: ${{ condition && 'img' || '' }} } } + // where the expression evaluates to '' at runtime + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new BasicExpressionToken(null, null, null, "''")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.Null(result[0].Value); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_ValidImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.NotNull(result[0].Value); + Assert.Equal("postgres:latest", result[0].Value.Image); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] From 8f01257663719c1a320db9012b5103fe26c74191 Mon Sep 17 00:00:00 2001 From: Max Horstmann Date: Wed, 4 Mar 2026 15:17:25 -0500 Subject: [PATCH 08/14] Devcontainer: bump base image Ubuntu version (#4277) --- .devcontainer/devcontainer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2fd60937e..e0dfafc19 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,8 @@ { "name": "Actions Runner Devcontainer", - "image": "mcr.microsoft.com/devcontainers/base:focal", + "image": "mcr.microsoft.com/devcontainers/base:noble", "features": { - "ghcr.io/devcontainers/features/docker-in-docker:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/features/dotnet": { "version": "8.0.418" }, From 20111cbfda51c4d31e49352a61f897cdc93c4257 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Wed, 4 Mar 2026 17:36:45 -0600 Subject: [PATCH 09/14] Support `entrypoint` and `command` for service containers (#4276) --- src/Runner.Common/Constants.cs | 1 + src/Runner.Worker/Container/ContainerInfo.cs | 2 + src/Runner.Worker/ExecutionContext.cs | 10 +- .../PipelineTemplateEvaluatorWrapper.cs | 18 ++- src/Sdk/DTPipelines/Pipelines/JobContainer.cs | 18 +++ .../PipelineTemplateConstants.cs | 2 + .../PipelineTemplateConverter.cs | 24 +++- .../PipelineTemplateEvaluator.cs | 4 +- src/Sdk/DTPipelines/workflow-v1.0.json | 17 ++- .../Conversion/WorkflowTemplateConstants.cs | 2 + .../Conversion/WorkflowTemplateConverter.cs | 16 +++ src/Sdk/WorkflowParser/JobContainer.cs | 18 +++ src/Sdk/WorkflowParser/WorkflowFeatures.cs | 8 ++ src/Sdk/WorkflowParser/workflow-v1.0.json | 46 ++++++- .../PipelineTemplateEvaluatorWrapperL0.cs | 121 ++++++++++++++---- 15 files changed, 265 insertions(+), 42 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index fcb7c5b35..583958981 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -172,6 +172,7 @@ namespace GitHub.Runner.Common public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check"; public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check"; public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser"; + public static readonly string ServiceContainerCommand = "actions_service_container_command"; public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions"; public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations"; public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers"; diff --git a/src/Runner.Worker/Container/ContainerInfo.cs b/src/Runner.Worker/Container/ContainerInfo.cs index 72cd0ada9..66a3daf93 100644 --- a/src/Runner.Worker/Container/ContainerInfo.cs +++ b/src/Runner.Worker/Container/ContainerInfo.cs @@ -36,6 +36,8 @@ namespace GitHub.Runner.Worker.Container this.ContainerImage = containerImage; this.ContainerDisplayName = $"{container.Alias}_{Pipelines.Validation.NameValidation.Sanitize(containerImage)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}"; this.ContainerCreateOptions = container.Options; + this.ContainerEntryPoint = container.Entrypoint; + this.ContainerEntryPointArgs = container.Command; _environmentVariables = container.Environment; this.IsJobContainer = isJobContainer; this.ContainerNetworkAlias = networkAlias; diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 53484e6b6..3a3754fa7 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -1328,9 +1328,9 @@ namespace GitHub.Runner.Worker UpdateGlobalStepsContext(); } - internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null) + internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null) { - return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter); + return new PipelineTemplateEvaluatorWrapper(HostContext, this, allowServiceContainerCommand, traceWriter); } private static void NoOp() @@ -1418,10 +1418,13 @@ namespace GitHub.Runner.Worker public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null) { + var allowServiceContainerCommand = (context.Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false) + || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_SERVICE_CONTAINER_COMMAND")); + // Create wrapper? if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER"))) { - return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter); + return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(allowServiceContainerCommand, traceWriter); } // Legacy @@ -1433,6 +1436,7 @@ namespace GitHub.Runner.Worker return new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable) { MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly + AllowServiceContainerCommand = allowServiceContainerCommand, }; } diff --git a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs index 61dfdacce..d560e45c8 100644 --- a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs +++ b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs @@ -23,6 +23,7 @@ namespace GitHub.Runner.Worker public PipelineTemplateEvaluatorWrapper( IHostContext hostContext, IExecutionContext context, + bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null) { ArgUtil.NotNull(hostContext, nameof(hostContext)); @@ -40,11 +41,14 @@ namespace GitHub.Runner.Worker _legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable) { MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly + AllowServiceContainerCommand = allowServiceContainerCommand, }; // New evaluator var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(); - _newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null) + var features = WorkflowFeatures.GetDefaults(); + features.AllowServiceContainerCommand = allowServiceContainerCommand; + _newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features) { MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly }; @@ -401,6 +405,18 @@ namespace GitHub.Runner.Worker return false; } + if (!string.Equals(legacyResult.Entrypoint, newResult.Entrypoint, StringComparison.Ordinal)) + { + _trace.Info($"CompareJobContainer mismatch - Entrypoint differs (legacy='{legacyResult.Entrypoint}', new='{newResult.Entrypoint}')"); + return false; + } + + if (!string.Equals(legacyResult.Command, newResult.Command, StringComparison.Ordinal)) + { + _trace.Info($"CompareJobContainer mismatch - Command differs (legacy='{legacyResult.Command}', new='{newResult.Command}')"); + return false; + } + if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment")) { return false; diff --git a/src/Sdk/DTPipelines/Pipelines/JobContainer.cs b/src/Sdk/DTPipelines/Pipelines/JobContainer.cs index 901c4fed1..f38066537 100644 --- a/src/Sdk/DTPipelines/Pipelines/JobContainer.cs +++ b/src/Sdk/DTPipelines/Pipelines/JobContainer.cs @@ -39,6 +39,24 @@ namespace GitHub.DistributedTask.Pipelines set; } + /// + /// Gets or sets the container entrypoint override. + /// + public String Entrypoint + { + get; + set; + } + + /// + /// Gets or sets the container command and args (after the image name). + /// + public String Command + { + get; + set; + } + /// /// Gets or sets the volumes which are mounted into the container. /// diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs index 8d81c7d2d..d55a02144 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs @@ -47,6 +47,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating public const String NumberStrategyContext = "number-strategy-context"; public const String On = "on"; public const String Options = "options"; + public const String Entrypoint = "entrypoint"; + public const String Command = "command"; public const String Outputs = "outputs"; public const String OutputsPattern = "needs.*.outputs"; public const String Password = "password"; diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs index 6c9654074..87bb00bae 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs @@ -237,7 +237,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating internal static JobContainer ConvertToJobContainer( TemplateContext context, TemplateToken value, - bool allowExpressions = false) + bool allowExpressions = false, + bool allowServiceContainerCommand = false) { var result = new JobContainer(); if (allowExpressions && value.Traverse().Any(x => x is ExpressionToken)) @@ -280,6 +281,22 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating case PipelineTemplateConstants.Options: result.Options = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value; break; + case PipelineTemplateConstants.Entrypoint: + if (!allowServiceContainerCommand) + { + context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Entrypoint}' is not allowed"); + break; + } + result.Entrypoint = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value; + break; + case PipelineTemplateConstants.Command: + if (!allowServiceContainerCommand) + { + context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Command}' is not allowed"); + break; + } + result.Command = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value; + break; case PipelineTemplateConstants.Ports: var ports = containerPropertyPair.Value.AssertSequence($"{PipelineTemplateConstants.Container} {propertyName}"); var portList = new List(ports.Count); @@ -326,7 +343,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating internal static List> ConvertToJobServiceContainers( TemplateContext context, TemplateToken services, - bool allowExpressions = false) + bool allowExpressions = false, + bool allowServiceContainerCommand = false) { var result = new List>(); @@ -340,7 +358,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating foreach (var servicePair in servicesMapping) { var networkAlias = servicePair.Key.AssertString("services key").Value; - var container = ConvertToJobContainer(context, servicePair.Value); + var container = ConvertToJobContainer(context, servicePair.Value, allowExpressions, allowServiceContainerCommand); result.Add(new KeyValuePair(networkAlias, container)); } diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs index 345058997..55cae82f3 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs @@ -51,6 +51,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating public Int32 MaxResultSize { get; set; } = 10 * 1024 * 1024; // 10 mb + public bool AllowServiceContainerCommand { get; set; } + public Boolean EvaluateStepContinueOnError( TemplateToken token, DictionaryContextData contextData, @@ -357,7 +359,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating { token = TemplateEvaluator.Evaluate(context, PipelineTemplateConstants.Services, token, 0, null, omitHeader: true); context.Errors.Check(); - result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token); + result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token, allowServiceContainerCommand: AllowServiceContainerCommand); } catch (Exception ex) when (!(ex is TemplateValidationException)) { diff --git a/src/Sdk/DTPipelines/workflow-v1.0.json b/src/Sdk/DTPipelines/workflow-v1.0.json index bfd050ed3..432cb75ec 100644 --- a/src/Sdk/DTPipelines/workflow-v1.0.json +++ b/src/Sdk/DTPipelines/workflow-v1.0.json @@ -430,6 +430,21 @@ } }, + "service-container-mapping": { + "mapping": { + "properties": { + "image": "string", + "options": "string", + "entrypoint": "string", + "command": "string", + "env": "container-env", + "ports": "sequence-of-non-empty-string", + "volumes": "sequence-of-non-empty-string", + "credentials": "container-registry-credentials" + } + } + }, + "services": { "context": [ "github", @@ -454,7 +469,7 @@ ], "one-of": [ "string", - "container-mapping" + "service-container-mapping" ] }, diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs index fb2065f41..9dce514a1 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs @@ -62,6 +62,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion public const String NumberStrategyContext = "number-strategy-context"; public const String On = "on"; public const String Options = "options"; + public const String Entrypoint = "entrypoint"; + public const String Command = "command"; public const String Org = "org"; public const String Organization = "organization"; public const String Outputs = "outputs"; diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index 7293ce165..8ae6ea0c9 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1146,6 +1146,22 @@ namespace GitHub.Actions.WorkflowParser.Conversion case WorkflowTemplateConstants.Options: result.Options = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value; break; + case WorkflowTemplateConstants.Entrypoint: + if (!context.GetFeatures().AllowServiceContainerCommand) + { + context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Entrypoint}' is not allowed"); + break; + } + result.Entrypoint = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value; + break; + case WorkflowTemplateConstants.Command: + if (!context.GetFeatures().AllowServiceContainerCommand) + { + context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Command}' is not allowed"); + break; + } + result.Command = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value; + break; case WorkflowTemplateConstants.Ports: var ports = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}"); var portList = new List(ports.Count); diff --git a/src/Sdk/WorkflowParser/JobContainer.cs b/src/Sdk/WorkflowParser/JobContainer.cs index dfa173c10..289400b43 100644 --- a/src/Sdk/WorkflowParser/JobContainer.cs +++ b/src/Sdk/WorkflowParser/JobContainer.cs @@ -35,6 +35,24 @@ namespace GitHub.Actions.WorkflowParser set; } + /// + /// Gets or sets the container entrypoint override. + /// + public String Entrypoint + { + get; + set; + } + + /// + /// Gets or sets the container command and args (after the image name). + /// + public String Command + { + get; + set; + } + /// /// Gets or sets the volumes which are mounted into the container. /// diff --git a/src/Sdk/WorkflowParser/WorkflowFeatures.cs b/src/Sdk/WorkflowParser/WorkflowFeatures.cs index 8b36a5fa3..c3fa33af7 100644 --- a/src/Sdk/WorkflowParser/WorkflowFeatures.cs +++ b/src/Sdk/WorkflowParser/WorkflowFeatures.cs @@ -48,6 +48,13 @@ namespace GitHub.Actions.WorkflowParser [DataMember(EmitDefaultValue = false)] public bool StrictJsonParsing { get; set; } + /// + /// Gets or sets a value indicating whether service containers may specify "entrypoint" and "command". + /// Used during parsing and evaluation. + /// + [DataMember(EmitDefaultValue = false)] + public bool AllowServiceContainerCommand { get; set; } + /// /// Gets the default workflow features. /// @@ -60,6 +67,7 @@ namespace GitHub.Actions.WorkflowParser Snapshot = false, // Default to false since this feature is still in an experimental phase StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments + AllowServiceContainerCommand = false, // Default to false since this feature is gated by actions_service_container_command }; } diff --git a/src/Sdk/WorkflowParser/workflow-v1.0.json b/src/Sdk/WorkflowParser/workflow-v1.0.json index 0f4c91130..66bda31fa 100644 --- a/src/Sdk/WorkflowParser/workflow-v1.0.json +++ b/src/Sdk/WorkflowParser/workflow-v1.0.json @@ -2590,20 +2590,52 @@ "properties": { "image": { "type": "string", - "description": "Use `jobs..container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name." + "description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name." }, "options": { "type": "string", - "description": "Use `jobs..container.options` to configure additional Docker container resource options." + "description": "Additional Docker container resource options." }, "env": "container-env", "ports": { "type": "sequence-of-non-empty-string", - "description": "Use `jobs..container.ports` to set an array of ports to expose on the container." + "description": "An array of ports to expose on the container." }, "volumes": { "type": "sequence-of-non-empty-string", - "description": "Use `jobs..container.volumes` to set an array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host." + "description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host." + }, + "credentials": "container-registry-credentials" + } + } + }, + "service-container-mapping": { + "mapping": { + "properties": { + "image": { + "type": "string", + "description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name." + }, + "options": { + "type": "string", + "description": "Additional Docker container resource options." + }, + "entrypoint": { + "type": "string", + "description": "Override the default ENTRYPOINT in the service container image." + }, + "command": { + "type": "string", + "description": "Override the default CMD in the service container image." + }, + "env": "container-env", + "ports": { + "type": "sequence-of-non-empty-string", + "description": "An array of ports to expose on the container." + }, + "volumes": { + "type": "sequence-of-non-empty-string", + "description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host." }, "credentials": "container-registry-credentials" } @@ -2635,11 +2667,11 @@ ], "one-of": [ "string", - "container-mapping" + "service-container-mapping" ] }, "container-registry-credentials": { - "description": "If the image's container registry requires authentication to pull the image, you can use `jobs..container.credentials` to set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.", + "description": "If the container registry requires authentication to pull the image, set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.", "context": [ "github", "inputs", @@ -2655,7 +2687,7 @@ } }, "container-env": { - "description": "Use `jobs..container.env` to set a map of variables in the container.", + "description": "A map of environment variables to set in the container.", "mapping": { "loose-key-type": "non-empty-string", "loose-value-type": "string-runner-context" diff --git a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs index e6fae1fa5..0a7427ced 100644 --- a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs +++ b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs @@ -36,7 +36,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "test-value"); var contextData = new DictionaryContextData(); @@ -63,7 +63,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Call EvaluateAndCompare directly: the new evaluator cancels the token // and returns a different value, forcing hasMismatch = true. @@ -98,7 +98,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Different results without cancellation — mismatch SHOULD be recorded. var result = wrapper.EvaluateAndCompare( @@ -130,7 +130,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new BooleanToken(null, null, null, true); var contextData = new DictionaryContextData(); var functions = new List(); @@ -156,7 +156,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "FOO"), new StringToken(null, null, null, "bar")); var contextData = new DictionaryContextData(); @@ -184,7 +184,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new BasicExpressionToken(null, null, null, "true"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -211,7 +211,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "input1"), new StringToken(null, null, null, "val1")); var contextData = new DictionaryContextData(); @@ -239,7 +239,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new NumberToken(null, null, null, 10); var contextData = new DictionaryContextData(); var functions = new List(); @@ -265,7 +265,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, ""); var contextData = new DictionaryContextData(); var functions = new List(); @@ -291,7 +291,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "docker://"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -317,7 +317,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://")); var contextData = new DictionaryContextData(); @@ -344,7 +344,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "")); var contextData = new DictionaryContextData(); @@ -371,7 +371,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "ubuntu:latest"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -398,7 +398,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "docker://ubuntu:latest"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -425,7 +425,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "out1"), new StringToken(null, null, null, "val1")); var contextData = new DictionaryContextData(); @@ -453,7 +453,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new StringToken(null, null, null, "https://example.com"); var contextData = new DictionaryContextData(); var functions = new List(); @@ -482,7 +482,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var token = new MappingToken(null, null, null); token.Add(new StringToken(null, null, null, "shell"), new StringToken(null, null, null, "bash")); var contextData = new DictionaryContextData(); @@ -510,7 +510,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -542,7 +542,7 @@ namespace GitHub.Runner.Common.Tests.Worker serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "")); servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -576,7 +576,7 @@ namespace GitHub.Runner.Common.Tests.Worker serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://")); servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -611,7 +611,7 @@ namespace GitHub.Runner.Common.Tests.Worker serviceMapping.Add(new StringToken(null, null, null, "image"), new BasicExpressionToken(null, null, null, "''")); servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -644,7 +644,7 @@ namespace GitHub.Runner.Common.Tests.Worker serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest")); servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -663,6 +663,75 @@ namespace GitHub.Runner.Common.Tests.Worker } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_EntrypointAndCommand_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest")); + serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash")); + serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: true); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("db", result[0].Key); + Assert.NotNull(result[0].Value); + Assert.Equal("postgres:latest", result[0].Value.Image); + Assert.Equal("/bin/bash", result[0].Value.Entrypoint); + Assert.Equal("-lc echo hi", result[0].Value.Command); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_EntrypointAndCommand_FlagOff_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var servicesMapping = new MappingToken(null, null, null); + var serviceMapping = new MappingToken(null, null, null); + serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest")); + serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash")); + serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi")); + servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); + var contextData = new DictionaryContextData(); + var functions = new List(); + + Assert.Throws(() => + wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions)); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -673,7 +742,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); var contextData = new DictionaryContextData(); var functions = new List(); @@ -702,7 +771,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Both throw JsonReaderException with different messages — should be treated as equivalent var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken from JsonReader. Path '', line 0, position 0."); @@ -733,7 +802,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Legacy throws Newtonsoft JsonReaderException, new throws System.Text.Json.JsonException var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken"); @@ -764,7 +833,7 @@ namespace GitHub.Runner.Common.Tests.Worker Setup(); _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false); // Both throw non-JSON exceptions with different messages — should record mismatch var legacyEx = new InvalidOperationException("some error"); From bcd04cfbf0d3593ab1e752c30ebc866f8e094cfe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:55:48 +0000 Subject: [PATCH 10/14] Bump actions/upload-artifact from 6 to 7 (#4270) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Salman Chishti --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd315411f..468d11578 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,7 +78,7 @@ jobs: # Upload runner package tar.gz/zip as artifact - name: Publish Artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: runner-package-${{ matrix.runtime }} path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 528257605..dfee41923 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,7 +118,7 @@ jobs: # Upload runner package tar.gz/zip as artifact. - name: Publish Artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: runner-packages-${{ matrix.runtime }} path: | From 99910ca83e8924ebbcfe225271c98cf83f7a96d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:45:49 +0000 Subject: [PATCH 11/14] Bump docker/login-action from 3 to 4 (#4278) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Salman Chishti --- .github/workflows/docker-publish.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 933afdd33..1e83b2895 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,7 +41,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfee41923..1521532f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -312,7 +312,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From 1138dd80f77c1cb306d5403ab6c92d8f38911135 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Thu, 5 Mar 2026 14:56:28 -0600 Subject: [PATCH 12/14] Fix positional arg bug in ExpressionParser.CreateTree (#4279) --- .../Expressions2/ExpressionParser.cs | 2 +- src/Test/L0/Sdk/ExpressionParserL0.cs | 104 ++++++++++++++++++ src/Test/L0/Worker/ActionManifestManagerL0.cs | 52 +++++++++ .../Worker/ActionManifestManagerLegacyL0.cs | 52 +++++++++ .../ActionManifestParserComparisonL0.cs | 34 ++++++ .../dockerfileaction_env_invalid_context.yml | 13 +++ 6 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 src/Test/L0/Sdk/ExpressionParserL0.cs create mode 100644 src/Test/TestData/dockerfileaction_env_invalid_context.yml 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/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/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 }}' From 5ef32703680f6026ea44f61f3e405d749947ea8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:44:58 +0000 Subject: [PATCH 13/14] Bump docker/build-push-action from 6 to 7 (#4283) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 468d11578..34cc4f731 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -114,7 +114,7 @@ jobs: uses: docker/setup-buildx-action@v3 - 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 1e83b2895..cb4fba6b5 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 1521532f6..4b49b9299 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | From 98323280e80f731aa24da12911014681be6462b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:49:28 +0000 Subject: [PATCH 14/14] Bump docker/setup-buildx-action from 3 to 4 (#4282) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Salman Chishti --- .github/workflows/build.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 34cc4f731..5b4a6e758 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -111,7 +111,7 @@ 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@v7 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index cb4fba6b5..af54fbc15 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -38,7 +38,7 @@ 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@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b49b9299..b75ba15ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -309,7 +309,7 @@ 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@v4