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..b76fc7992bc 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($"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..c0d0a814e0b 100644 --- a/src/Test/L0/Worker/HandlerL0.cs +++ b/src/Test/L0/Worker/HandlerL0.cs @@ -1,10 +1,19 @@ using System; +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; @@ -85,5 +94,260 @@ 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")); + } + } + + [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")); + } + } + + [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()); + 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; + } } } diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 7204ff5e9c0..45c64a411c7 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -238,6 +238,54 @@ 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()) + { + _jobEc.Global.Variables.Set("actions_cache_mode", mode); + + 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($"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("Cache mode:")), It.IsAny()), + Times.Never); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")]