From 052dfbdd6864f9335a53e1b685aaa8e3f1f26d04 Mon Sep 17 00:00:00 2001 From: Pavel Iakovenko Date: Tue, 24 Feb 2026 12:19:46 -0500 Subject: [PATCH] Symlink actions cache (#4260) --- src/Runner.Common/Constants.cs | 1 + src/Runner.Sdk/Util/IOUtil.cs | 10 ++++ src/Runner.Worker/ActionManager.cs | 52 +++++++++++++++++++-- src/Test/L0/Worker/ActionManagerL0.cs | 67 +++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 266c7ae5e..fcb7c5b35 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -289,6 +289,7 @@ namespace GitHub.Runner.Common public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION"; public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT"; public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE"; + public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS"; public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS"; } diff --git a/src/Runner.Sdk/Util/IOUtil.cs b/src/Runner.Sdk/Util/IOUtil.cs index e0b5b3394..d34925b01 100644 --- a/src/Runner.Sdk/Util/IOUtil.cs +++ b/src/Runner.Sdk/Util/IOUtil.cs @@ -93,6 +93,16 @@ namespace GitHub.Runner.Sdk } } + public static FileSystemInfo CreateSymbolicLink(string destDirectory, string srcDirectory) + { + // ensure directory chain exists + Directory.CreateDirectory(destDirectory); + // delete leaf directory + Directory.Delete(destDirectory); + // create symlink for the leaf directory + return Directory.CreateSymbolicLink(destDirectory, srcDirectory); + } + public static void Delete(string path, CancellationToken cancellationToken) { DeleteDirectory(path, cancellationToken); diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index e38ea4d28..71bff32bd 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -773,10 +773,6 @@ namespace GitHub.Runner.Worker } else { - // make sure we get a clean folder ready to use. - IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); - Directory.CreateDirectory(destDirectory); - if (downloadInfo.PackageDetails != null) { executionContext.Output($"##[group]Download immutable action package '{downloadInfo.NameWithOwner}@{downloadInfo.Ref}'"); @@ -811,6 +807,50 @@ namespace GitHub.Runner.Worker if (!string.IsNullOrEmpty(actionArchiveCacheDir) && Directory.Exists(actionArchiveCacheDir)) { + var symlinkCachedActions = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions)); + if (symlinkCachedActions) + { + Trace.Info($"Checking if can symlink '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}'"); + + var cacheDirectory = Path.Combine(actionArchiveCacheDir, downloadInfo.ResolvedNameWithOwner.Replace(Path.DirectorySeparatorChar, '_').Replace(Path.AltDirectorySeparatorChar, '_'), downloadInfo.ResolvedSha); + if (Directory.Exists(cacheDirectory)) + { + 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) + { + throw new InvalidOperationException($"'{cacheDirectory}' contains '{nestedDirectories.Length}' directories"); + } + else + { + executionContext.Debug($"Symlink '{nestedDirectories[0].Name}' to '{destDirectory}'"); + // make sure we get a clean folder ready to use. + 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; + } + catch (Exception ex) + { + Trace.Error($"Failed to create symlink from cached directory '{cacheDirectory}' to '{destDirectory}'. Error: {ex}"); + // Fall through to normal download logic + } + } + } + hasActionArchiveCache = true; Trace.Info($"Check if action archive '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}' already exists in cache directory '{actionArchiveCacheDir}'"); #if OS_WINDOWS @@ -892,6 +932,10 @@ namespace GitHub.Runner.Worker } #endif + // make sure we get a clean folder ready to use. + IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); + Directory.CreateDirectory(destDirectory); + // repository archive from github always contains a nested folder var subDirectories = new DirectoryInfo(stagingDirectory).GetDirectories(); if (subDirectories.Length != 1) diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index bc4779312..5aa1f2dbc 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -468,6 +468,73 @@ runs: } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SymlinkCacheIsReentrant() + { + try + { + //Arrange + Environment.SetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions, "true"); + Setup(); + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/checkout", + Ref = "master", + RepositoryType = "GitHub" + } + }, + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/checkout", + Ref = "master", + RepositoryType = "GitHub" + } + } + }; + + const string Content = @" +name: 'Test' +runs: + using: 'node20' + main: 'dist/index.js' +"; + + string actionsArchive = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions_archive", "action_checkout"); + Directory.CreateDirectory(actionsArchive); + Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha")); + Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content")); + await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content", "action.yml"), Content); + Environment.SetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory, actionsArchive); + + //Act + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + string destDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions", "checkout", "master"); + Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist"); + var di = new DirectoryInfo(destDirectory); + Assert.NotNull(di.LinkTarget); + } + finally + { + Environment.SetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions, null); + Teardown(); + } + } + #if OS_LINUX [Fact] [Trait("Level", "L0")]