From ab28939193cebe985c2be2dbe6e4f214832bba6a Mon Sep 17 00:00:00 2001 From: Philip Gai Date: Thu, 2 Jul 2026 10:31:11 -0500 Subject: [PATCH 1/3] feat: expose effective Actions cache-mode to steps and job log Export ACTIONS_CACHE_MODE env to node and container action steps when the actions_cache_mode job variable is present and non-empty, mirroring the existing ACTIONS_CACHE_SERVICE_V2 wiring. Also log the effective cache-mode at job start. When the variable is absent or empty, behavior is unchanged. --- .../Handlers/ContainerActionHandler.cs | 5 + .../Handlers/NodeScriptActionHandler.cs | 5 + src/Runner.Worker/JobExtension.cs | 6 + src/Test/L0/Worker/HandlerL0.cs | 122 ++++++++++++++++++ 4 files changed, 138 insertions(+) diff --git a/src/Runner.Worker/Handlers/ContainerActionHandler.cs b/src/Runner.Worker/Handlers/ContainerActionHandler.cs index 1f67c9dd360..099495cb464 100644 --- a/src/Runner.Worker/Handlers/ContainerActionHandler.cs +++ b/src/Runner.Worker/Handlers/ContainerActionHandler.cs @@ -239,6 +239,11 @@ public async Task RunAsync(ActionRunStage stage) Environment["ACTIONS_RESULTS_URL"] = resultsUrl; } + if (ExecutionContext.Global.Variables.TryGetValue("actions_cache_mode", out var cacheMode) && !string.IsNullOrEmpty(cacheMode)) + { + Environment["ACTIONS_CACHE_MODE"] = cacheMode; + } + if (ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SetOrchestrationIdEnvForActions) ?? false) { if (ExecutionContext.Global.Variables.TryGetValue(Constants.Variables.System.OrchestrationId, out var orchestrationId) && !string.IsNullOrEmpty(orchestrationId)) diff --git a/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs b/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs index 29def039f99..85ff32777f7 100644 --- a/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs +++ b/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs @@ -78,6 +78,11 @@ public async Task RunAsync(ActionRunStage stage) Environment["ACTIONS_CACHE_SERVICE_V2"] = bool.TrueString; } + if (ExecutionContext.Global.Variables.TryGetValue("actions_cache_mode", out var cacheMode) && !string.IsNullOrEmpty(cacheMode)) + { + Environment["ACTIONS_CACHE_MODE"] = cacheMode; + } + if (ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SetOrchestrationIdEnvForActions) ?? false) { if (ExecutionContext.Global.Variables.TryGetValue(Constants.Variables.System.OrchestrationId, out var orchestrationId) && !string.IsNullOrEmpty(orchestrationId)) diff --git a/src/Runner.Worker/JobExtension.cs b/src/Runner.Worker/JobExtension.cs index 33f93825a2f..22bfccc8206 100644 --- a/src/Runner.Worker/JobExtension.cs +++ b/src/Runner.Worker/JobExtension.cs @@ -171,6 +171,12 @@ public async Task> InitializeJob(IExecutionContext jobContext, Pipel context.Output($"Secret source: {secretSource}"); } + var cacheMode = jobContext.Global.Variables.Get("actions_cache_mode"); + if (!string.IsNullOrEmpty(cacheMode)) + { + context.Output($"Actions cache-mode: {cacheMode}"); + } + var repoFullName = context.GetGitHubContext("repository"); ArgUtil.NotNull(repoFullName, nameof(repoFullName)); context.Debug($"Primary repository: {repoFullName}"); diff --git a/src/Test/L0/Worker/HandlerL0.cs b/src/Test/L0/Worker/HandlerL0.cs index f9dbd67c757..111bfc524e4 100644 --- a/src/Test/L0/Worker/HandlerL0.cs +++ b/src/Test/L0/Worker/HandlerL0.cs @@ -1,7 +1,12 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using GitHub.Actions.RunService.WebApi; using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Sdk; using GitHub.Runner.Worker; @@ -85,5 +90,122 @@ public void PrepareExecution_PopulateTelemetry_DockerActions() Assert.Equal("ubuntu:20.04", _stepTelemetry.Action); } } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("read")] + [InlineData("none")] + [InlineData("write")] + [InlineData("write-only")] + public async Task RunAsync_ExportsCacheModeEnv_WhenVariableSet(string mode) + { + using (TestHostContext hc = CreateTestContext()) + { + var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary + { + { "actions_cache_mode", mode } + }); + + Assert.True(environment.TryGetValue("ACTIONS_CACHE_MODE", out var value)); + Assert.Equal(mode, value); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task RunAsync_DoesNotExportCacheModeEnv_WhenVariableAbsent() + { + using (TestHostContext hc = CreateTestContext()) + { + var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary()); + + Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE")); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task RunAsync_DoesNotExportCacheModeEnv_WhenVariableEmpty() + { + using (TestHostContext hc = CreateTestContext()) + { + var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary + { + { "actions_cache_mode", "" } + }); + + Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE")); + } + } + + private async Task> RunNodeScriptActionHandlerAsync(TestHostContext hc, IDictionary variables) + { + var actionDirectory = Path.Combine(hc.GetDirectory(WellKnownDirectory.Work), Guid.NewGuid().ToString()); + Directory.CreateDirectory(actionDirectory); + var scriptFile = "main.js"; + File.WriteAllText(Path.Combine(actionDirectory, scriptFile), "// noop"); + + var serverVariables = new Variables(hc, variables); + var endpoints = new List + { + new ServiceEndpoint() + { + Name = WellKnownServiceEndpointNames.SystemVssConnection, + Url = new Uri("https://pipelines.actions.githubusercontent.com"), + Authorization = new EndpointAuthorization() + { + Scheme = "Test", + Parameters = { { "AccessToken", "token" } } + } + } + }; + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + Endpoints = endpoints, + PrependPath = new List(), + EnvironmentVariables = new Dictionary() + }); + _ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData()); + _ec.Setup(x => x.GetGitHubContext("workspace")).Returns(actionDirectory); + _ec.Setup(x => x.GetMatchers()).Returns(new List()); + _ec.Setup(x => x.ForceCompleted).Returns(new TaskCompletionSource().Task); + _ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None); + + var stepHost = new Mock(); + stepHost.Setup(x => x.DetermineNodeRuntimeVersion(It.IsAny(), It.IsAny())).ReturnsAsync("node20"); + stepHost.Setup(x => x.ResolvePathForStepHost(It.IsAny(), It.IsAny())).Returns((IExecutionContext ec, string path) => path); + stepHost.Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())).ReturnsAsync(0); + + var handler = new NodeScriptActionHandler(); + handler.Initialize(hc); + handler.ExecutionContext = _ec.Object; + handler.StepHost = stepHost.Object; + handler.Environment = new Dictionary(); + handler.Inputs = new Dictionary(); + handler.RuntimeVariables = serverVariables; + handler.ActionDirectory = actionDirectory; + handler.Action = new RepositoryPathReference() { Name = "actions/checkout", Ref = "v2" }; + handler.Data = new NodeJSActionExecutionData() { Script = scriptFile, NodeVersion = "node20" }; + + await handler.RunAsync(ActionRunStage.Main); + + return handler.Environment; + } } } From b1eb6fd15902e2dcbf2de9f47c618fa6caea16e5 Mon Sep 17 00:00:00 2001 From: Philip Gai Date: Thu, 2 Jul 2026 10:49:31 -0500 Subject: [PATCH 2/3] test: add regression and coverage tests for ACTIONS_CACHE_MODE Add JobExtension L0 tests asserting the job-start cache-mode log line is emitted for each mode and absent when the variable is unset, plus handler regression tests covering coexistence with ACTIONS_CACHE_SERVICE_V2 and that baseline runtime env is unaffected when no cache-mode is set. --- src/Test/L0/Worker/HandlerL0.cs | 35 ++++++++++++++++++++ src/Test/L0/Worker/JobExtensionL0.cs | 49 ++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/Test/L0/Worker/HandlerL0.cs b/src/Test/L0/Worker/HandlerL0.cs index 111bfc524e4..f04e1b47fa0 100644 --- a/src/Test/L0/Worker/HandlerL0.cs +++ b/src/Test/L0/Worker/HandlerL0.cs @@ -141,6 +141,41 @@ public async Task RunAsync_DoesNotExportCacheModeEnv_WhenVariableEmpty() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task RunAsync_CacheModeCoexistsWithCacheServiceV2() + { + using (TestHostContext hc = CreateTestContext()) + { + var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary + { + { "actions_uses_cache_service_v2", "true" }, + { "actions_cache_mode", "read" } + }); + + Assert.Equal(bool.TrueString, environment["ACTIONS_CACHE_SERVICE_V2"]); + Assert.Equal("read", environment["ACTIONS_CACHE_MODE"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task RunAsync_DoesNotAffectRuntimeEnv_WhenCacheModeAbsent() + { + using (TestHostContext hc = CreateTestContext()) + { + var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary()); + + // Baseline runtime env is still exported and cache-mode adds nothing. + Assert.Equal("https://pipelines.actions.githubusercontent.com/", environment["ACTIONS_RUNTIME_URL"]); + Assert.Equal("token", environment["ACTIONS_RUNTIME_TOKEN"]); + Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE")); + Assert.False(environment.ContainsKey("ACTIONS_CACHE_SERVICE_V2")); + } + } + private async Task> RunNodeScriptActionHandlerAsync(TestHostContext hc, IDictionary variables) { var actionDirectory = Path.Combine(hc.GetDirectory(WellKnownDirectory.Work), Guid.NewGuid().ToString()); diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 7204ff5e9c0..8dfb7627ef2 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -238,6 +238,55 @@ public async Task JobExtensionBuildPreStepsList() } } + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("read")] + [InlineData("none")] + [InlineData("write")] + [InlineData("write-only")] + public async Task InitializeJob_LogsCacheMode_WhenVariableSet(string mode) + { + using (TestHostContext hc = CreateTestContext()) + { + _message.Variables["actions_cache_mode"] = mode; + _jobEc.InitializeJob(_message, _tokenSource.Token); + + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + _actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Task.FromResult(new PrepareResult(new List(), new Dictionary()))); + + await jobExtension.InitializeJob(_jobEc, _message); + + _jobServerQueue.Verify( + x => x.QueueWebConsoleLine(It.IsAny(), It.Is(m => m.Contains($"Actions cache-mode: {mode}")), It.IsAny()), + Times.Once); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task InitializeJob_DoesNotLogCacheMode_WhenVariableAbsent() + { + using (TestHostContext hc = CreateTestContext()) + { + var jobExtension = new JobExtension(); + jobExtension.Initialize(hc); + + _actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Task.FromResult(new PrepareResult(new List(), new Dictionary()))); + + await jobExtension.InitializeJob(_jobEc, _message); + + _jobServerQueue.Verify( + x => x.QueueWebConsoleLine(It.IsAny(), It.Is(m => m.Contains("Actions cache-mode:")), It.IsAny()), + Times.Never); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] From 49a88c1161a0a3fd39da5f32565fd08d4a0eab60 Mon Sep 17 00:00:00 2001 From: Philip Gai Date: Thu, 2 Jul 2026 13:17:43 -0500 Subject: [PATCH 3/3] test: address review feedback for ACTIONS_CACHE_MODE tests - Add ContainerActionHandler L0 coverage (Linux-gated) asserting ACTIONS_CACHE_MODE is exported to the container env when actions_cache_mode is set and absent otherwise, routed through the container-hooks path. - Set the cache-mode variable directly on the initialized job context instead of re-invoking InitializeJob, avoiding a redundant CancellationTokenSource. --- src/Test/L0/Worker/HandlerL0.cs | 107 +++++++++++++++++++++++++++ src/Test/L0/Worker/JobExtensionL0.cs | 3 +- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/Test/L0/Worker/HandlerL0.cs b/src/Test/L0/Worker/HandlerL0.cs index f04e1b47fa0..c0d0a814e0b 100644 --- a/src/Test/L0/Worker/HandlerL0.cs +++ b/src/Test/L0/Worker/HandlerL0.cs @@ -2,14 +2,18 @@ using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using GitHub.Actions.RunService.WebApi; using GitHub.DistributedTask.Pipelines; using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; using GitHub.Runner.Sdk; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; +using GitHub.Runner.Worker.Container.ContainerHooks; using GitHub.Runner.Worker.Handlers; using Moq; using Xunit; @@ -176,6 +180,109 @@ public async Task RunAsync_DoesNotAffectRuntimeEnv_WhenCacheModeAbsent() } } + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("read")] + [InlineData("none")] + public async Task ContainerRunAsync_ExportsCacheModeEnv_WhenVariableSet(string mode) + { + // Container actions only run on Linux; RunAsync throws on other platforms. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return; + } + + using (TestHostContext hc = CreateTestContext()) + { + var container = await RunContainerActionHandlerAsync(hc, new Dictionary + { + { "actions_cache_mode", mode } + }); + + Assert.True(container.ContainerEnvironmentVariables.TryGetValue("ACTIONS_CACHE_MODE", out var value)); + Assert.Equal(mode, value); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ContainerRunAsync_DoesNotExportCacheModeEnv_WhenVariableAbsent() + { + // Container actions only run on Linux; RunAsync throws on other platforms. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return; + } + + using (TestHostContext hc = CreateTestContext()) + { + var container = await RunContainerActionHandlerAsync(hc, new Dictionary()); + + Assert.False(container.ContainerEnvironmentVariables.ContainsKey("ACTIONS_CACHE_MODE")); + } + } + + private async Task RunContainerActionHandlerAsync(TestHostContext hc, IDictionary variables) + { + // Route through the container-hooks path so the handler skips docker build/run. + variables[Constants.Runner.Features.AllowRunnerContainerHooks] = "true"; + Environment.SetEnvironmentVariable(Constants.Hooks.ContainerHooksPath, Path.Combine(hc.GetDirectory(WellKnownDirectory.Root), "hooks.js")); + + var tempDirectory = hc.GetDirectory(WellKnownDirectory.Temp); + Directory.CreateDirectory(Path.Combine(tempDirectory, "_runner_file_commands")); + Directory.CreateDirectory(Path.Combine(tempDirectory, "_github_workflow")); + var workspace = Path.Combine(hc.GetDirectory(WellKnownDirectory.Work), "workspace"); + Directory.CreateDirectory(workspace); + + var serverVariables = new Variables(hc, variables); + var endpoints = new List + { + new ServiceEndpoint() + { + Name = WellKnownServiceEndpointNames.SystemVssConnection, + Url = new Uri("https://pipelines.actions.githubusercontent.com"), + Authorization = new EndpointAuthorization() + { + Scheme = "Test", + Parameters = { { "AccessToken", "token" } } + } + } + }; + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + Endpoints = endpoints, + PrependPath = new List(), + EnvironmentVariables = new Dictionary() + }); + _ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData()); + _ec.Setup(x => x.JobContext).Returns(new JobContext()); + _ec.Setup(x => x.GetGitHubContext("workspace")).Returns(workspace); + + ContainerInfo captured = null; + var hookManager = new Mock(); + hookManager.Setup(x => x.RunContainerStepAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((IExecutionContext ec, ContainerInfo container, string dockerFile) => { captured = container; }) + .Returns(Task.CompletedTask); + hc.SetSingleton(hookManager.Object); + hc.SetSingleton(new Mock().Object); + + var handler = new ContainerActionHandler(); + handler.Initialize(hc); + handler.ExecutionContext = _ec.Object; + handler.Environment = new Dictionary(); + handler.Inputs = new Dictionary(); + handler.Action = new ContainerRegistryReference() { Image = "alpine:latest" }; + handler.Data = new ContainerActionExecutionData() { Image = "docker://alpine:latest" }; + + await handler.RunAsync(ActionRunStage.Main); + + return captured; + } + private async Task> RunNodeScriptActionHandlerAsync(TestHostContext hc, IDictionary variables) { var actionDirectory = Path.Combine(hc.GetDirectory(WellKnownDirectory.Work), Guid.NewGuid().ToString()); diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 8dfb7627ef2..33954c4f7fe 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -249,8 +249,7 @@ public async Task InitializeJob_LogsCacheMode_WhenVariableSet(string mode) { using (TestHostContext hc = CreateTestContext()) { - _message.Variables["actions_cache_mode"] = mode; - _jobEc.InitializeJob(_message, _tokenSource.Token); + _jobEc.Global.Variables.Set("actions_cache_mode", mode); var jobExtension = new JobExtension(); jobExtension.Initialize(hc);