Compare commits

...

3 Commits

Author SHA1 Message Date
Philip Gai
49a88c1161 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.
2026-07-02 13:17:43 -05:00
Philip Gai
b1eb6fd159 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.
2026-07-02 10:49:31 -05:00
Philip Gai
ab28939193 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.
2026-07-02 10:31:11 -05:00
5 changed files with 328 additions and 0 deletions

View File

@@ -239,6 +239,11 @@ namespace GitHub.Runner.Worker.Handlers
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))

View File

@@ -78,6 +78,11 @@ namespace GitHub.Runner.Worker.Handlers
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))

View File

@@ -171,6 +171,12 @@ namespace GitHub.Runner.Worker
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}");

View File

@@ -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 @@ namespace GitHub.Runner.Common.Tests.Worker
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<string, VariableValue>
{
{ "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<string, VariableValue>());
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<string, VariableValue>
{
{ "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<string, VariableValue>
{
{ "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<string, VariableValue>());
// 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<string, VariableValue>
{
{ "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<string, VariableValue>());
Assert.False(container.ContainerEnvironmentVariables.ContainsKey("ACTIONS_CACHE_MODE"));
}
}
private async Task<ContainerInfo> RunContainerActionHandlerAsync(TestHostContext hc, IDictionary<string, VariableValue> 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<ServiceEndpoint>
{
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<string>(),
EnvironmentVariables = new Dictionary<string, string>()
});
_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<IContainerHookManager>();
hookManager.Setup(x => x.RunContainerStepAsync(It.IsAny<IExecutionContext>(), It.IsAny<ContainerInfo>(), It.IsAny<string>()))
.Callback((IExecutionContext ec, ContainerInfo container, string dockerFile) => { captured = container; })
.Returns(Task.CompletedTask);
hc.SetSingleton(hookManager.Object);
hc.SetSingleton(new Mock<IActionManifestManagerWrapper>().Object);
var handler = new ContainerActionHandler();
handler.Initialize(hc);
handler.ExecutionContext = _ec.Object;
handler.Environment = new Dictionary<string, string>();
handler.Inputs = new Dictionary<string, string>();
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<Dictionary<string, string>> RunNodeScriptActionHandlerAsync(TestHostContext hc, IDictionary<string, VariableValue> 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<ServiceEndpoint>
{
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<string>(),
EnvironmentVariables = new Dictionary<string, string>()
});
_ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
_ec.Setup(x => x.GetGitHubContext("workspace")).Returns(actionDirectory);
_ec.Setup(x => x.GetMatchers()).Returns(new List<IssueMatcherConfig>());
_ec.Setup(x => x.ForceCompleted).Returns(new TaskCompletionSource<int>().Task);
_ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
var stepHost = new Mock<IStepHost>();
stepHost.Setup(x => x.DetermineNodeRuntimeVersion(It.IsAny<IExecutionContext>(), It.IsAny<string>())).ReturnsAsync("node20");
stepHost.Setup(x => x.ResolvePathForStepHost(It.IsAny<IExecutionContext>(), It.IsAny<string>())).Returns((IExecutionContext ec, string path) => path);
stepHost.Setup(x => x.ExecuteAsync(
It.IsAny<IExecutionContext>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, string>>(),
It.IsAny<bool>(),
It.IsAny<System.Text.Encoding>(),
It.IsAny<bool>(),
It.IsAny<bool>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>())).ReturnsAsync(0);
var handler = new NodeScriptActionHandler();
handler.Initialize(hc);
handler.ExecutionContext = _ec.Object;
handler.StepHost = stepHost.Object;
handler.Environment = new Dictionary<string, string>();
handler.Inputs = new Dictionary<string, string>();
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;
}
}
}

View File

@@ -238,6 +238,54 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[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<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
await jobExtension.InitializeJob(_jobEc, _message);
_jobServerQueue.Verify(
x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.Is<string>(m => m.Contains($"Actions cache-mode: {mode}")), It.IsAny<long?>()),
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<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
await jobExtension.InitializeJob(_jobEc, _message);
_jobServerQueue.Verify(
x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.Is<string>(m => m.Contains("Actions cache-mode:")), It.IsAny<long?>()),
Times.Never);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]