From b3526e1f170a1d8e4b248a6fc3642b9403821e64 Mon Sep 17 00:00:00 2001 From: Platform Engineering Bot Date: Fri, 10 Jul 2026 00:22:12 +0000 Subject: [PATCH] =?UTF-8?q?Update=20patches:=20c814d7c=20=E2=86=92=209cd4f?= =?UTF-8?q?293=20[ppc64le,s390x]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/start/envlinux.md | 4 +- images/Dockerfile | 2 +- patches/last_processed_commit.txt | 2 +- patches/runner-main-sdk8-ppc64le.patch | 8 +- patches/runner-main-sdk8-s390x.patch | 8 +- src/Misc/externals.sh | 2 +- src/Runner.Common/Constants.cs | 8 + src/Runner.Common/ExtensionManager.cs | 2 + src/Runner.Worker/ActionManager.cs | 278 +++++++- src/Runner.Worker/ArtifactSubject.cs | 52 ++ src/Runner.Worker/ArtifactsListFileCommand.cs | 92 +++ .../CreateArtifactsFileCommand.cs | 384 ++++++++++ src/Runner.Worker/ExecutionContext.cs | 3 + src/Runner.Worker/FileCommandManager.cs | 20 + src/Runner.Worker/GitHubContext.cs | 2 + src/Runner.Worker/GlobalContext.cs | 4 + .../Handlers/ContainerActionHandler.cs | 5 + .../Handlers/NodeScriptActionHandler.cs | 5 + src/Runner.Worker/JobExtension.cs | 13 + .../PipelineTemplateConverter.cs | 21 +- .../Pipelines/PipelineConstants.cs | 35 +- .../Conversion/WorkflowTemplateConverter.cs | 4 + src/Sdk/WorkflowParser/WorkflowConstants.cs | 7 +- src/Test/L0/Worker/ActionManagerL0.cs | 674 +++++++++++++++++- .../L0/Worker/ArtifactsListFileCommandL0.cs | 214 ++++++ .../L0/Worker/CreateArtifactsFileCommandL0.cs | 627 ++++++++++++++++ src/Test/L0/Worker/FileCommandManagerL0.cs | 105 +++ src/Test/L0/Worker/HandlerL0.cs | 264 +++++++ src/Test/L0/Worker/JobExtensionL0.cs | 111 ++- src/dev.sh | 2 +- src/global.json | 2 +- 31 files changed, 2916 insertions(+), 44 deletions(-) create mode 100644 src/Runner.Worker/ArtifactSubject.cs create mode 100644 src/Runner.Worker/ArtifactsListFileCommand.cs create mode 100644 src/Runner.Worker/CreateArtifactsFileCommand.cs create mode 100644 src/Test/L0/Worker/ArtifactsListFileCommandL0.cs create mode 100644 src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs create mode 100644 src/Test/L0/Worker/FileCommandManagerL0.cs diff --git a/docs/start/envlinux.md b/docs/start/envlinux.md index 031902c2e15..86f1416118f 100644 --- a/docs/start/envlinux.md +++ b/docs/start/envlinux.md @@ -8,7 +8,7 @@ Please see "[Supported architectures and operating systems for self-hosted runne ## Install .Net Core 3.x Linux Dependencies -The `./config.sh` will check .Net Core 3.x dependencies during runner configuration. +The [config.sh](../../src/Misc/layoutroot/config.sh) will check .Net Core 3.x dependencies during runner configuration. You might see something like this which indicate a dependency's missing. ```bash ./config.sh @@ -17,7 +17,7 @@ You might see something like this which indicate a dependency's missing. Dependencies is missing for Dotnet Core 6.0 Execute ./bin/installdependencies.sh to install any missing Dotnet Core 6.0 dependencies. ``` -You can easily correct the problem by executing `./bin/installdependencies.sh`. +You can easily correct the problem by executing [installdependencies.sh](../../src/Misc/layoutbin/installdependencies.sh). The `installdependencies.sh` script should install all required dependencies on all supported Linux versions > Note: The `installdependencies.sh` script will try to use the default package management mechanism on your Linux flavor (ex. `yum`/`apt-get`/`apt`). diff --git a/images/Dockerfile b/images/Dockerfile index 713d4ffe1a2..426e3342b6d 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -5,7 +5,7 @@ ARG TARGETOS ARG TARGETARCH ARG RUNNER_VERSION ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0 -ARG DOCKER_VERSION=29.6.0 +ARG DOCKER_VERSION=29.6.1 ARG BUILDX_VERSION=0.35.0 RUN apt update -y && apt install curl unzip -y diff --git a/patches/last_processed_commit.txt b/patches/last_processed_commit.txt index e573e42dc10..c775ec7c181 100644 --- a/patches/last_processed_commit.txt +++ b/patches/last_processed_commit.txt @@ -1 +1 @@ -c814d7ca46f403a860f84c4e54b78c5a2d17e045 +9cd4f29390c20e762d96a0212be8f03e1e13b0c0 diff --git a/patches/runner-main-sdk8-ppc64le.patch b/patches/runner-main-sdk8-ppc64le.patch index a8bfc6b6c96..e8029c5cd8c 100644 --- a/patches/runner-main-sdk8-ppc64le.patch +++ b/patches/runner-main-sdk8-ppc64le.patch @@ -16,7 +16,7 @@ index 9c069b12..d26b0dc2 100644 diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh -index 46384bb4..fc2a9456 100755 +index 4947a9bd..8103a2db 100755 --- a/src/Misc/externals.sh +++ b/src/Misc/externals.sh @@ -187,3 +187,13 @@ fi @@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755 if ! [ -x "$(command -v ldconfig)" ]; then diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs -index 8536e942..070a8004 100644 +index 66b32c24..6e46eaf4 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -59,7 +59,9 @@ namespace GitHub.Runner.Common @@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644 NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051 diff --git a/src/dev.sh b/src/dev.sh -index fafdbffb..baed6dd2 100755 +index 7d9aa58d..0b485e47 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then @@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644 -# From upstream commit: c814d7ca46f403a860f84c4e54b78c5a2d17e045 +# From upstream commit: 9cd4f29390c20e762d96a0212be8f03e1e13b0c0 diff --git a/patches/runner-main-sdk8-s390x.patch b/patches/runner-main-sdk8-s390x.patch index a8bfc6b6c96..e8029c5cd8c 100644 --- a/patches/runner-main-sdk8-s390x.patch +++ b/patches/runner-main-sdk8-s390x.patch @@ -16,7 +16,7 @@ index 9c069b12..d26b0dc2 100644 diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh -index 46384bb4..fc2a9456 100755 +index 4947a9bd..8103a2db 100755 --- a/src/Misc/externals.sh +++ b/src/Misc/externals.sh @@ -187,3 +187,13 @@ fi @@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755 if ! [ -x "$(command -v ldconfig)" ]; then diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs -index 8536e942..070a8004 100644 +index 66b32c24..6e46eaf4 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -59,7 +59,9 @@ namespace GitHub.Runner.Common @@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644 NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051 diff --git a/src/dev.sh b/src/dev.sh -index fafdbffb..baed6dd2 100755 +index 7d9aa58d..0b485e47 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then @@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644 -# From upstream commit: c814d7ca46f403a860f84c4e54b78c5a2d17e045 +# From upstream commit: 9cd4f29390c20e762d96a0212be8f03e1e13b0c0 diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh index 46384bb4845..4947a9bd43c 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.2" -NODE24_VERSION="24.17.0" +NODE24_VERSION="24.18.0" get_abs_path() { # exploits the fact that pwd will print abs path when no args diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 8536e942a27..66b32c2446c 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -180,6 +180,8 @@ public static class Features public static readonly string BatchActionResolution = "actions_batch_action_resolution"; public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload"; public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message"; + public static readonly string AllowArtifactsFile = "actions_runner_allow_artifacts_file"; + public static readonly string SelfRepository = "actions_self_repository"; } // Node version migration related constants @@ -227,6 +229,12 @@ public static class NodeMigration public static readonly string UnsupportedStopCommandTokenDisabled = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`."; public static readonly string UnsupportedSummarySize = "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of {0}k, got {1}k. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary"; public static readonly string SummaryUploadError = "$GITHUB_STEP_SUMMARY upload aborted, an error occurred when uploading the summary. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary"; + + // $GITHUB_ARTIFACTS file command + public static readonly string ArtifactsFileSizeExceeded = "$GITHUB_ARTIFACTS file exceeds the maximum size of {0} KiB (got {1} KiB)."; + public static readonly string ArtifactsAggregateLimitExceeded = "The job has exceeded the maximum of {0} declared artifacts."; + public static readonly string ArtifactsInvalidLine = "Invalid $GITHUB_ARTIFACTS entry on line {0}: {1}"; + public static readonly string ArtifactsConflictingDigest = "Conflicting digest for artifact '{0}': previously declared as '{1}', now declared as '{2}'."; } public static class RunnerEvent diff --git a/src/Runner.Common/ExtensionManager.cs b/src/Runner.Common/ExtensionManager.cs index 2b7810eca45..b0bc2fef01e 100644 --- a/src/Runner.Common/ExtensionManager.cs +++ b/src/Runner.Common/ExtensionManager.cs @@ -62,6 +62,8 @@ private List LoadExtensions() where T : class, IExtension Add(extensions, "GitHub.Runner.Worker.CreateStepSummaryCommand, Runner.Worker"); Add(extensions, "GitHub.Runner.Worker.SaveStateFileCommand, Runner.Worker"); Add(extensions, "GitHub.Runner.Worker.SetOutputFileCommand, Runner.Worker"); + Add(extensions, "GitHub.Runner.Worker.CreateArtifactsFileCommand, Runner.Worker"); + Add(extensions, "GitHub.Runner.Worker.ArtifactsListFileCommand, Runner.Worker"); break; case "GitHub.Runner.Listener.Check.ICheckExtension": Add(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener"); diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index dea9e7dc032..d9e590cae37 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -178,7 +178,7 @@ public sealed class ActionManager : RunnerService, IActionManager return new PrepareResult(containerSetupSteps, result.PreStepTracker); } - private async Task PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Dictionary resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid)) + private async Task PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Dictionary resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid), string selfRepoName = null, string selfRepoRef = null) { ArgUtil.NotNull(executionContext, nameof(executionContext)); if (depth > Constants.CompositeActionsMaxDepth) @@ -186,6 +186,21 @@ public sealed class ActionManager : RunnerService, IActionManager throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}"); } + // Resolve self-repository ($/) references before processing + if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true) + { + if (string.IsNullOrEmpty(selfRepoName)) + { + // job.workflow_repository/workflow_sha point to the repo + // containing the workflow file — correct for both regular + // and reusable workflows. Always present when the server + // supports $/. See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context + selfRepoName = executionContext.JobContext?.WorkflowRepository; + selfRepoRef = executionContext.JobContext?.WorkflowSha; + } + ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef); + } + var repositoryActions = new List(); foreach (var action in actions) @@ -228,7 +243,30 @@ public sealed class ActionManager : RunnerService, IActionManager { throw new Exception($"Missing download info for {lookupKey}"); } - await DownloadRepositoryActionAsync(executionContext, downloadInfo); + + Exception downloadFailure = null; + try + { + await DownloadRepositoryActionAsync(executionContext, downloadInfo); + } + catch (Exception ex) + { + // record the exception for telemetry, and rethrow the original exception to fail the step. + downloadFailure = ex; + throw; + } + finally + { + executionContext.Global.JobTelemetry.Add(new JobTelemetry() + { + Type = JobTelemetryType.General, + Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload + { + Operation = "download_action", + Result = downloadFailure == null ? "succeeded" : downloadFailure.GetType().Name + }, Newtonsoft.Json.Formatting.None)}" + }); + } } // Parse action.yml and collect composite sub-actions for batched @@ -278,16 +316,53 @@ public sealed class ActionManager : RunnerService, IActionManager // then recurse per parent (which hits the cache, not the API). if (nextLevel.Count > 0) { - var nextLevelRepoActions = nextLevel - .Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository) - .Select(x => x.action) - .ToList(); - await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos); + if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true) + { + // Self-repository path: group by parent so each group's + // $/ refs resolve against the correct parent repo context. + var groups = nextLevel.GroupBy(x => x.parentId).Select(group => + { + string childRepoName = selfRepoName; + string childRepoRef = selfRepoRef; + var parentAction = repositoryActions.FirstOrDefault(a => a.Id == group.Key); + if (parentAction?.Reference is Pipelines.RepositoryPathReference parentRef && + string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase)) + { + childRepoName = parentRef.Name; + childRepoRef = parentRef.Ref; + } + return new { ParentId = group.Key, Actions = group.Select(x => x.action).ToList(), RepoName = childRepoName, RepoRef = childRepoRef }; + }).ToList(); - foreach (var group in nextLevel.GroupBy(x => x.parentId)) + foreach (var group in groups) + { + ResolveSelfRepositoryReferences(executionContext, group.Actions, group.RepoName, group.RepoRef); + } + + var nextLevelRepoActions = nextLevel + .Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository) + .Select(x => x.action) + .ToList(); + await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos); + + foreach (var group in groups) + { + state = await PrepareActionsRecursiveAsync(executionContext, state, group.Actions, resolvedDownloadInfos, depth + 1, group.ParentId, group.RepoName, group.RepoRef); + } + } + else { - var groupActions = group.Select(x => x.action).ToList(); - state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key); + // Original path: no self-repository resolution needed. + var nextLevelActions = nextLevel.Select(x => x.action).ToList(); + var nextLevelRepoActions = nextLevelActions + .Where(x => x.Reference.Type == Pipelines.ActionSourceType.Repository) + .ToList(); + await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos); + + foreach (var grp in nextLevel.GroupBy(x => x.parentId)) + { + state = await PrepareActionsRecursiveAsync(executionContext, state, grp.Select(x => x.action).ToList(), resolvedDownloadInfos, depth + 1, grp.Key); + } } } @@ -363,13 +438,25 @@ public sealed class ActionManager : RunnerService, IActionManager /// sub-actions individually, with no cross-depth deduplication. /// Used when the BatchActionResolution feature flag is disabled. /// - private async Task PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Int32 depth = 0, Guid parentStepId = default(Guid)) + private async Task PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Int32 depth = 0, Guid parentStepId = default(Guid), string selfRepoName = null, string selfRepoRef = null) { ArgUtil.NotNull(executionContext, nameof(executionContext)); if (depth > Constants.CompositeActionsMaxDepth) { throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}"); } + + // Resolve self-repository ($/) references before processing + if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true) + { + if (string.IsNullOrEmpty(selfRepoName)) + { + selfRepoName = executionContext.JobContext?.WorkflowRepository; + selfRepoRef = executionContext.JobContext?.WorkflowSha; + } + ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef); + } + var repositoryActions = new List(); foreach (var action in actions) @@ -398,7 +485,30 @@ public sealed class ActionManager : RunnerService, IActionManager if (repositoryActions.Count > 0) { // Get the download info - var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions); + IDictionary downloadInfos = null; + Exception resolveFailure = null; + try + { + downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions); + } + catch (Exception ex) + { + // record the exception for telemetry, and rethrow the original exception to fail the step. + resolveFailure = ex; + throw; + } + finally + { + executionContext.Global.JobTelemetry.Add(new JobTelemetry() + { + Type = JobTelemetryType.General, + Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload + { + Operation = "resolve_actions", + Result = resolveFailure == null ? "succeeded" : resolveFailure.GetType().Name + }, Newtonsoft.Json.Formatting.None)}" + }); + } // Download each action foreach (var action in repositoryActions) @@ -414,7 +524,29 @@ public sealed class ActionManager : RunnerService, IActionManager throw new Exception($"Missing download info for {lookupKey}"); } - await DownloadRepositoryActionAsync(executionContext, downloadInfo); + Exception downloadFailure = null; + try + { + await DownloadRepositoryActionAsync(executionContext, downloadInfo); + } + catch (Exception ex) + { + // record the exception for telemetry, and rethrow the original exception to fail the step. + downloadFailure = ex; + throw; + } + finally + { + executionContext.Global.JobTelemetry.Add(new JobTelemetry() + { + Type = JobTelemetryType.General, + Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload + { + Operation = "download_action", + Result = downloadFailure == null ? "succeeded" : downloadFailure.GetType().Name + }, Newtonsoft.Json.Formatting.None)}" + }); + } } // More preparation based on content in the repository (action.yml) @@ -449,7 +581,17 @@ public sealed class ActionManager : RunnerService, IActionManager } else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0) { - state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id); + // Propagate parent's repo context for nested self-repository resolution + var parentRef = action.Reference as Pipelines.RepositoryPathReference; + var childRepoName = selfRepoName; + var childRepoRef = selfRepoRef; + if (parentRef != null && + string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase)) + { + childRepoName = parentRef.Name; + childRepoRef = parentRef.Ref; + } + state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id, childRepoName, childRepoRef); } var repoAction = action.Reference as Pipelines.RepositoryPathReference; if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias) @@ -562,6 +704,12 @@ public Definition LoadAction(IExecutionContext executionContext, Pipelines.Actio actionDirectory = Path.Combine(actionDirectory, repoAction.Path); } } + else if (string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase)) + { + // Unresolved self-repository reference at load time — this + // shouldn't happen but guard against NRE if it does. + throw new InvalidOperationException($"Self-repository reference '$/{repoAction.Path}' was not resolved before LoadAction. Ensure the '{Constants.Runner.Features.SelfRepository}' feature flag is enabled."); + } else { actionDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repoAction.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repoAction.Ref); @@ -697,6 +845,27 @@ public Definition LoadAction(IExecutionContext executionContext, Pipelines.Actio _cachedEmbeddedStepIds[action.Id].Add(guid); } } + + // Resolve self-repository refs in composite steps at load time. + // During setup, resolution happens on a separate copy of these + // step objects. At runtime, action.yml is re-parsed, producing + // fresh self-repository refs that need resolution here. + // When the parent is a dot-slash (self local-workspace) action, + // repoAction.Name/Ref are null — fall back to workflow context. + if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true) + { + var parentName = repoAction.Name ?? executionContext.JobContext?.WorkflowRepository; + var parentRef = repoAction.Ref ?? executionContext.JobContext?.WorkflowSha; + ResolveSelfRepositoryReferences(executionContext, compositeAction.Steps, parentName, parentRef); + if (compositeAction.PreSteps != null) + { + ResolveSelfRepositoryReferences(executionContext, compositeAction.PreSteps, parentName, parentRef); + } + if (compositeAction.PostSteps != null) + { + ResolveSelfRepositoryReferences(executionContext, compositeAction.PostSteps, parentName, parentRef); + } + } } else { @@ -980,10 +1149,33 @@ private async Task ResolveNewActionsAsync(IExecutionContext executionContext, Li if (actionsToResolve.Count > 0) { - var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve); - foreach (var kvp in downloadInfos) + IDictionary downloadInfos = null; + Exception resolveFailure = null; + try { - resolvedDownloadInfos[kvp.Key] = kvp.Value; + downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve); + foreach (var kvp in downloadInfos) + { + resolvedDownloadInfos[kvp.Key] = kvp.Value; + } + } + catch (Exception ex) + { + // record the exception for telemetry, and rethrow the original exception to fail the step. + resolveFailure = ex; + throw; + } + finally + { + executionContext.Global.JobTelemetry.Add(new JobTelemetry() + { + Type = JobTelemetryType.General, + Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload + { + Operation = "resolve_actions", + Result = resolveFailure == null ? "succeeded" : resolveFailure.GetType().Name + }, Newtonsoft.Json.Formatting.None)}" + }); } } } @@ -1209,6 +1401,12 @@ private async Task DownloadRepositoryActionAsync(IExecutionContext executionCont private string GetWatermarkFilePath(string directory) => directory + ".completed"; + private sealed class ActionTelemetryPayload + { + public string Operation { get; set; } + public string Result { get; set; } + } + private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext executionContext, Pipelines.ActionStep repositoryAction) { var repositoryReference = repositoryAction.Reference as Pipelines.RepositoryPathReference; @@ -1347,6 +1545,47 @@ private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext execution } } + /// + /// Resolves self-reference ($/) references by mutating them in-place + /// to standard GitHub repository references with the containing repo's + /// name and ref. + /// + private void ResolveSelfRepositoryReferences(IExecutionContext executionContext, IEnumerable actions, string repoName, string repoRef) + { + if (string.IsNullOrEmpty(repoName) || string.IsNullOrEmpty(repoRef)) + { + return; + } + + foreach (var action in actions) + { + if (action.Reference.Type != Pipelines.ActionSourceType.Repository) + { + continue; + } + + var repoAction = action.Reference as Pipelines.RepositoryPathReference; + if (!string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + Trace.Info($"Resolving self-repository reference reference '$/{repoAction.Path}' to '{repoName}/{repoAction.Path}@{repoRef}'"); + executionContext.Debug($"Resolving $/{repoAction.Path} → {repoName}/{repoAction.Path}@{repoRef}"); + + repoAction.RepositoryType = Pipelines.RepositoryTypes.GitHub; + repoAction.Name = repoName; + repoAction.Ref = repoRef; + } + } + + /// + /// If this is a reusable workflow job, ensure the workflow repo tarball + /// is downloaded so self.workspace resolves to a real path on disk. + /// Always downloads for reusable workflows when the feature flag is on, + /// since step expressions are already expanded by the server and can't + /// be scanned for self.* usage. + /// private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action) { if (action.Reference.Type != Pipelines.ActionSourceType.Repository) @@ -1362,6 +1601,11 @@ private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action) return null; } + if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable."); + } + if (!string.Equals(repositoryReference.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase)) { throw new NotSupportedException(repositoryReference.RepositoryType); diff --git a/src/Runner.Worker/ArtifactSubject.cs b/src/Runner.Worker/ArtifactSubject.cs new file mode 100644 index 00000000000..fb90c896646 --- /dev/null +++ b/src/Runner.Worker/ArtifactSubject.cs @@ -0,0 +1,52 @@ +using System; + +namespace GitHub.Runner.Worker +{ + public enum ArtifactSubjectKind + { + File, + OciSubject, + } + + /// + /// Represents a single artifact subject declared via the + /// GITHUB_ARTIFACTS per-step environment file. + /// + public sealed class ArtifactSubject : IEquatable + { + public ArtifactSubject(string name, string digest, ArtifactSubjectKind kind) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Name must not be null or empty.", nameof(name)); + } + if (string.IsNullOrEmpty(digest)) + { + throw new ArgumentException("Digest must not be null or empty.", nameof(digest)); + } + Name = name; + Digest = digest; + Kind = kind; + } + + public string Name { get; } + public string Digest { get; } + public ArtifactSubjectKind Kind { get; } + + public bool Equals(ArtifactSubject other) + { + if (other is null) + { + return false; + } + return string.Equals(Name, other.Name, StringComparison.Ordinal) + && string.Equals(Digest, other.Digest, StringComparison.Ordinal); + } + + public override bool Equals(object obj) => Equals(obj as ArtifactSubject); + + public override int GetHashCode() => HashCode.Combine(Name, Digest); + + public override string ToString() => $"{Name}@{Digest}"; + } +} diff --git a/src/Runner.Worker/ArtifactsListFileCommand.cs b/src/Runner.Worker/ArtifactsListFileCommand.cs new file mode 100644 index 00000000000..aaad5695384 --- /dev/null +++ b/src/Runner.Worker/ArtifactsListFileCommand.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Container; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker +{ + /// + /// File command extension that exposes the job-scoped aggregate of + /// as a read-only JSON + /// file. Subsequent steps in the same job read the file via the + /// GITHUB_ARTIFACTS_LIST environment variable, getting a + /// running view of every artifact declared via + /// $GITHUB_ARTIFACTS in earlier steps. + /// + /// + /// The file uses the existing per-step file-command lifecycle: + /// creates a fresh + /// file, invokes here, exposes + /// the (translated) path to the step's environment, and (for the + /// read-only file) ignores anything the step writes back. + /// + /// The file is always written when the feature is enabled, so + /// consumers never need to branch on "did the runner inject this?". + /// An empty aggregate produces {"version":1,"subjects":[]}. + /// + public sealed class ArtifactsListFileCommand : RunnerService, IFileCommandExtension + { + public const int FormatVersion = 1; + + public string ContextName => "artifacts_list"; + public string FilePrefix => "artifacts_list_"; + + public Type ExtensionType => typeof(IFileCommandExtension); + + public void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container) + { + ArgUtil.NotNull(context, nameof(context)); + + // Feature flag gate. Mirrors CreateArtifactsFileCommand so the + // write side and the read side are toggled together. + var enabled = (context.Global.Variables.GetBoolean(Constants.Runner.Features.AllowArtifactsFile) ?? false) + || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar)); + if (!enabled) + { + Trace.Verbose("$GITHUB_ARTIFACTS_LIST publishing is disabled (feature flag and env-var fallback are both off)."); + return; + } + + var aggregate = context.Global.ArtifactSubjects + ?? new Dictionary(StringComparer.Ordinal); + + var subjects = new JArray(); + // Emit subjects sorted by name so the output is deterministic + // regardless of the backing dictionary's enumeration order + // (which is not contractually guaranteed). + foreach (var entry in aggregate.Values.OrderBy(v => v.Name, StringComparer.Ordinal)) + { + subjects.Add(new JObject + { + ["name"] = entry.Name, + ["digest"] = entry.Digest, + ["kind"] = entry.Kind == ArtifactSubjectKind.OciSubject ? "oci" : "file", + }); + } + + var payload = new JObject + { + ["version"] = FormatVersion, + ["subjects"] = subjects, + }; + + // UTF-8 without BOM; consumers in other languages should not + // have to special-case a leading BOM. + File.WriteAllText(filePath, payload.ToString(Formatting.None), new UTF8Encoding(false)); + Trace.Info($"Wrote $GITHUB_ARTIFACTS_LIST with {aggregate.Count} subject(s) to '{filePath}'"); + } + + public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container) + { + // Read-only file: anything the step writes here is ignored. + // The aggregate is fed only by the write-side $GITHUB_ARTIFACTS + // file processed by CreateArtifactsFileCommand. + } + } +} diff --git a/src/Runner.Worker/CreateArtifactsFileCommand.cs b/src/Runner.Worker/CreateArtifactsFileCommand.cs new file mode 100644 index 00000000000..da0d694d64f --- /dev/null +++ b/src/Runner.Worker/CreateArtifactsFileCommand.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Container; + +namespace GitHub.Runner.Worker +{ + /// + /// File command extension that implements the GITHUB_ARTIFACTS + /// per-step environment file contract. + /// + /// + /// Lifecycle is identical to the other per-step file commands: + /// creates an empty file before each + /// step runs and invokes after the step + /// completes. This class is responsible for parsing the file's + /// contents, validating each entry, and aggregating the resulting + /// (name, digest) pairs onto + /// at job scope. + /// + /// The feature is gated by the actions_runner_allow_artifacts_file + /// feature flag. When the flag is disabled, the env var is still + /// exposed but writes are silently ignored. + /// + public sealed class CreateArtifactsFileCommand : RunnerService, IFileCommandExtension + { + // Each per-step file may contain at most 1 MiB. + public const int MaxFileSizeBytes = 1024 * 1024; + + // A job may declare at most 500 artifacts in aggregate. + public const int MaxAggregateArtifacts = 500; + + public string ContextName => "artifacts"; + public string FilePrefix => "artifacts_"; + + // Runner-side environment variable that enables the feature on + // self-hosted runners where the server-side feature flag is not + // configurable. Mirrors patterns like + // ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER elsewhere in the runner. + public const string EnableEnvVar = "ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE"; + + public Type ExtensionType => typeof(IFileCommandExtension); + + // Recognized scheme prefixes (case-insensitive). + private const string FileScheme = "file://"; + private const string OciScheme = "oci://"; + + // Matches "://...". Used to detect unsupported URI schemes. + // Scheme grammar per RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + private static readonly Regex s_schemeRegex = new( + @"^[A-Za-z][A-Za-z0-9+.\-]*://", + RegexOptions.Compiled); + + // Matches "@:" where is sha256/sha384/sha512. + // Hex length is validated separately so we can produce a precise error. + private static readonly Regex s_ociDigestSuffixRegex = new( + @"^(?.+)@(?sha(?:256|384|512)):(?[0-9a-fA-F]+)$", + RegexOptions.Compiled); + + public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container) + { + ArgUtil.NotNull(context, nameof(context)); + + // Feature flag gate. Enabled when either the server-side + // feature flag is set, or the runner is started with the + // ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE env var set to true + // (the env-var fallback exists so self-hosted runners can + // opt in locally). Silently no-op when disabled. + var enabled = (context.Global.Variables.GetBoolean(Constants.Runner.Features.AllowArtifactsFile) ?? false) + || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(EnableEnvVar)); + if (!enabled) + { + Trace.Verbose("$GITHUB_ARTIFACTS processing is disabled (feature flag and env-var fallback are both off)."); + return; + } + + Trace.Info($"Processing $GITHUB_ARTIFACTS file '{filePath}'"); + + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) + { + Trace.Info("$GITHUB_ARTIFACTS file does not exist; nothing to process."); + return; + } + + var fileSize = new FileInfo(filePath).Length; + if (fileSize == 0) + { + Trace.Info("$GITHUB_ARTIFACTS file is empty; nothing to process."); + return; + } + if (fileSize > MaxFileSizeBytes) + { + throw new Exception(StringUtil.Format( + Constants.Runner.ArtifactsFileSizeExceeded, + MaxFileSizeBytes / 1024, + fileSize / 1024)); + } + + // Per-step subjects parsed from this file; aggregated into the + // job-level set at the end so a single malformed line fails the + // step without partially polluting the aggregate. + var parsed = new List<(int LineNumber, ArtifactSubject Subject)>(); + + // Relative artifact paths are resolved against the workspace + // root (GITHUB_WORKSPACE), not the step's working directory. + // This matches the established runner precedent set by + // hashFiles() which always resolve relative paths against + // the workspace root regardless of any step-level + // `working-directory:`. + var workspaceRoot = ResolveWorkspaceRoot(context); + + var lines = File.ReadAllLines(filePath, Encoding.UTF8); + for (var i = 0; i < lines.Length; i++) + { + var lineNumber = i + 1; + var raw = lines[i]; + var trimmed = raw.Trim(); + if (trimmed.Length == 0) + { + continue; + } + if (trimmed[0] == '#') + { + continue; + } + + ArtifactSubject subject; + try + { + subject = ParseLine(trimmed, workspaceRoot, container); + } + catch (ArtifactsParseException ex) + { + throw new Exception(StringUtil.Format( + Constants.Runner.ArtifactsInvalidLine, + lineNumber, + ex.Message)); + } + + parsed.Add((lineNumber, subject)); + } + + // Aggregate at job scope: dedup identical, reject conflicts, + // enforce the 500-artifact cap (after dedup so identical + // duplicates above the cap do not fail). + var aggregate = context.Global.ArtifactSubjects; + if (aggregate == null) + { + throw new InvalidOperationException("Global.ArtifactSubjects is not initialized."); + } + + var addedThisStep = 0; + foreach (var (lineNumber, subject) in parsed) + { + if (aggregate.TryGetValue(subject.Name, out var existing)) + { + if (string.Equals(existing.Digest, subject.Digest, StringComparison.Ordinal)) + { + // Identical declaration — silently deduplicate. + Trace.Info($"Skipped duplicate artifact subject '{subject.Name}' (digest={subject.Digest})"); + continue; + } + throw new Exception(StringUtil.Format( + Constants.Runner.ArtifactsInvalidLine, + lineNumber, + StringUtil.Format( + Constants.Runner.ArtifactsConflictingDigest, + subject.Name, + existing.Digest, + subject.Digest))); + } + + if (aggregate.Count >= MaxAggregateArtifacts) + { + throw new Exception(StringUtil.Format( + Constants.Runner.ArtifactsInvalidLine, + lineNumber, + StringUtil.Format( + Constants.Runner.ArtifactsAggregateLimitExceeded, + MaxAggregateArtifacts))); + } + + aggregate[subject.Name] = subject; + addedThisStep++; + Trace.Info($"Declared artifact subject '{subject.Name}' (kind={subject.Kind}, digest={subject.Digest})"); + context.Debug($"Declared artifact subject '{subject.Name}' (kind={subject.Kind}, digest={subject.Digest})"); + } + + if (addedThisStep > 0) + { + // Mirror the existing file-command UX: a single, terse + // user-visible line that confirms the declarations landed. + context.Output($"Captured {addedThisStep} artifact subject(s) from this step (job total: {aggregate.Count})."); + } + } + + private ArtifactSubject ParseLine(string trimmed, string workspaceRoot, ContainerInfo container) + { + // Reject lines containing '=' — reserved for a future v2 + // key/value extension to the format. + if (trimmed.IndexOf('=') >= 0) + { + throw new ArtifactsParseException("entries containing '=' are reserved and not permitted"); + } + + // Handle the explicit escape-hatch schemes first + // (case-insensitive). + if (StartsWithIgnoreCase(trimmed, FileScheme)) + { + var path = trimmed.Substring(FileScheme.Length); + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArtifactsParseException("file:// entries must include a path"); + } + return MakeFileSubject(path, workspaceRoot, container); + } + if (StartsWithIgnoreCase(trimmed, OciScheme)) + { + var rest = trimmed.Substring(OciScheme.Length); + var match = s_ociDigestSuffixRegex.Match(rest); + if (!match.Success) + { + throw new ArtifactsParseException("oci:// entries must include an @sha{256,384,512}: digest"); + } + return MakeOciSubject(match); + } + + // Reject any other URI scheme up-front. + if (s_schemeRegex.IsMatch(trimmed)) + { + throw new ArtifactsParseException("unsupported URI scheme"); + } + + // Otherwise discriminate syntactically: an entry that matches + // the OCI digest suffix shape (with the right hex length for + // its algorithm) is an OCI subject; everything else is a path. + var ociMatch = s_ociDigestSuffixRegex.Match(trimmed); + if (ociMatch.Success && IsExpectedHexLength(ociMatch.Groups["algo"].Value, ociMatch.Groups["hex"].Value)) + { + return MakeOciSubject(ociMatch); + } + + return MakeFileSubject(trimmed, workspaceRoot, container); + } + + private static ArtifactSubject MakeOciSubject(Match match) + { + var refName = match.Groups["ref"].Value; + var algo = match.Groups["algo"].Value.ToLowerInvariant(); + var hex = match.Groups["hex"].Value.ToLowerInvariant(); + + if (!IsExpectedHexLength(algo, hex)) + { + throw new ArtifactsParseException( + $"digest '{algo}' must be {ExpectedHexLength(algo)} hex characters, got {hex.Length}"); + } + if (string.IsNullOrEmpty(refName)) + { + throw new ArtifactsParseException("oci subject must include a reference"); + } + + return new ArtifactSubject(refName, $"{algo}:{hex}", ArtifactSubjectKind.OciSubject); + } + + private static ArtifactSubject MakeFileSubject(string declaredPath, string workspaceRoot, ContainerInfo container) + { + var hostPath = ResolveFilePath(declaredPath, workspaceRoot, container); + + if (!File.Exists(hostPath)) + { + if (Directory.Exists(hostPath)) + { + throw new ArtifactsParseException($"'{declaredPath}' is a directory, not a regular file"); + } + // For relative paths, surface where we looked so authors + // aren't surprised that resolution is workspace-relative. + if (!Path.IsPathRooted(declaredPath)) + { + throw new ArtifactsParseException( + $"file '{declaredPath}' does not exist (relative paths are resolved against the workspace root '{workspaceRoot}')"); + } + throw new ArtifactsParseException($"file '{declaredPath}' does not exist"); + } + + // FileInfo + File.GetAttributes guards against named pipes, + // device files, etc. We accept regular files and symlinks + // resolved to regular files. + var attrs = File.GetAttributes(hostPath); + if ((attrs & FileAttributes.Directory) == FileAttributes.Directory) + { + throw new ArtifactsParseException($"'{declaredPath}' is a directory, not a regular file"); + } + + string hex; + using (var stream = File.OpenRead(hostPath)) + using (var sha = SHA256.Create()) + { + var hash = sha.ComputeHash(stream); + var sb = new StringBuilder(hash.Length * 2); + foreach (var b in hash) + { + sb.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + hex = sb.ToString(); + } + + var name = Path.GetFileName(hostPath); + return new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.File); + } + + private static string ResolveFilePath(string declaredPath, string workspaceRoot, ContainerInfo container) + { + if (Path.IsPathRooted(declaredPath)) + { + if (container == null) + { + return declaredPath; + } + + // Absolute path from a container step: it lives in the + // container's filesystem namespace, so translate it to the + // host path via the container's volume mounts. + // TranslateToHostPath returns the input unchanged when the + // path is not under any mount. We must NOT fall back to the + // host file at that same path -- that would hash an arbitrary + // host file the container step never referenced -- so reject + // it instead. + var hostPath = container.TranslateToHostPath(declaredPath); + if (string.Equals(hostPath, declaredPath, StringComparison.Ordinal)) + { + throw new ArtifactsParseException( + $"absolute path '{declaredPath}' is not inside a volume mounted into the container and cannot be resolved"); + } + return hostPath; + } + + // Relative path: resolve against the workspace root + // (GITHUB_WORKSPACE). + var baseDir = workspaceRoot ?? string.Empty; + return Path.GetFullPath(Path.Combine(baseDir, declaredPath)); + } + + private static string ResolveWorkspaceRoot(IExecutionContext context) + { + // The workspace root (GITHUB_WORKSPACE) is the resolution base + // for all relative artifact paths. + var workspace = context.GetGitHubContext("workspace"); + return string.IsNullOrEmpty(workspace) ? null : workspace; + } + + private static bool StartsWithIgnoreCase(string s, string prefix) + { + return s.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + private static int ExpectedHexLength(string algo) + { + return algo.ToLowerInvariant() switch + { + "sha256" => 64, + "sha384" => 96, + "sha512" => 128, + _ => -1, + }; + } + + private static bool IsExpectedHexLength(string algo, string hex) + { + var expected = ExpectedHexLength(algo); + return expected > 0 && hex.Length == expected; + } + + private sealed class ArtifactsParseException : Exception + { + public ArtifactsParseException(string message) : base(message) { } + } + } +} diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 6d7698fdd9c..0f9410821c6 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -973,6 +973,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation // Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation) Global.Arm32Node20Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + // Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS. + Global.ArtifactSubjects = new Dictionary(StringComparer.Ordinal); + // Job Outputs JobOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/Runner.Worker/FileCommandManager.cs b/src/Runner.Worker/FileCommandManager.cs index 7c3c0ef431a..9d8bbebb42b 100644 --- a/src/Runner.Worker/FileCommandManager.cs +++ b/src/Runner.Worker/FileCommandManager.cs @@ -55,6 +55,18 @@ public void InitializeFiles(IExecutionContext context, ContainerInfo container) TryDeleteFile(newPath); File.Create(newPath).Dispose(); + // Give extensions a chance to populate the file before + // the step starts (e.g., read-only views of job state). + // Errors are logged but must not fail step setup. + try + { + fileCommand.PopulateInitialContents(context, newPath, container); + } + catch (Exception ex) + { + _trace.Warning($"Failed to populate initial contents for file command '{fileCommand.ContextName}': {ex}"); + } + var pathToSet = container != null ? container.TranslateToContainerPath(newPath) : newPath; context.SetGitHubContext(fileCommand.ContextName, pathToSet); } @@ -102,6 +114,14 @@ public interface IFileCommandExtension : IExtension string FilePrefix { get; } void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container); + + // Optional hook invoked by FileCommandManager.InitializeFiles + // after creating the empty per-step file. Extensions that need to + // pre-populate the file (e.g., a read-only view of job-scoped + // state) override this; the default no-op preserves the existing + // "empty file at start of step" behavior for write-only file + // commands such as GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, etc. + void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container) { } } public sealed class AddPathFileCommand : RunnerService, IFileCommandExtension diff --git a/src/Runner.Worker/GitHubContext.cs b/src/Runner.Worker/GitHubContext.cs index 710469ddbc1..3d01e4f7db5 100644 --- a/src/Runner.Worker/GitHubContext.cs +++ b/src/Runner.Worker/GitHubContext.cs @@ -15,6 +15,8 @@ public sealed class GitHubContext : DictionaryContextData, IEnvironmentContextDa "actor", "actor_id", "api_url", + "artifacts", + "artifacts_list", "base_ref", "env", "event_name", diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 04abe003633..c2db20bd5ac 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -39,5 +39,9 @@ public sealed class GlobalContext public HashSet UpgradedToNode24Actions { get; set; } public HashSet Arm32Node20Actions { get; set; } public IList ActionsDependencies { get; set; } + + // Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS. + // Keyed by canonical subject name (OCI ref without digest, or file basename). + public IDictionary ArtifactSubjects { get; set; } } } 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..9a2c887f195 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}"); @@ -185,6 +191,13 @@ public async Task> InitializeJob(IExecutionContext jobContext, Pipel context.Output($"Runner is running behind proxy server '{HostContext.WebProxy.HttpsProxyAddress}' for all HTTPS requests."); } + // Signal to the user that the job is running with locked/pinned + // action dependencies (a lockfile is in effect). + if (message.ActionsDependencies != null && message.ActionsDependencies.Count > 0) + { + context.Output("Running with locked dependencies"); + } + // Prepare the workflow directory context.Output("Prepare workflow directory"); var directoryManager = HostContext.GetService(); diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs index 38176eb36c2..e8c5e91afe7 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs @@ -55,7 +55,18 @@ public static List ConvertToSteps( break; case ActionSourceType.Repository: var repositoryReference = step.Reference as RepositoryPathReference; - name = !String.IsNullOrEmpty(repositoryReference.Name) ? repositoryReference.Name : PipelineConstants.SelfAlias; + if (!String.IsNullOrEmpty(repositoryReference.Name)) + { + name = repositoryReference.Name; + } + else if (String.Equals(repositoryReference.RepositoryType, PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase)) + { + name = PipelineConstants.SelfRepositoryAlias; + } + else + { + name = PipelineConstants.SelfAlias; + } break; } @@ -600,6 +611,14 @@ private static ActionStep ConvertToStep( Path = uses.Value }; } + else if (PipelineConstants.TryParseSelfRepository(uses.Value, out var selfPath)) + { + result.Reference = new RepositoryPathReference + { + RepositoryType = PipelineConstants.SelfRepositoryAlias, + Path = selfPath + }; + } else { var usesSegments = uses.Value.Split('@'); diff --git a/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs b/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs index 2e03671fbb2..b2928520cd7 100644 --- a/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs +++ b/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs @@ -38,10 +38,43 @@ public static class PipelineConstants public static readonly Int32 MaxNodeNameLength = 100; /// - /// Alias for the self repository. + /// Alias for the self local-workspace repository type (./ syntax). + /// Resolves to the local checkout on the runner. /// public static readonly String SelfAlias = "self"; + /// + /// RepositoryType for self-repository references ($/ syntax). + /// Resolves to "this repo, at this SHA" based on the containing YAML file. + /// + public static readonly String SelfRepositoryAlias = "selfRepository"; + + /// + /// The prefix for self-repository references in uses: values. + /// + public const String SelfRepositoryPrefix = "$/"; + + /// + /// Returns true if the uses value is a self-repository reference (starts with $/), + /// and outputs the subpath after the prefix. + /// + public static bool TryParseSelfRepository(string usesValue, out string path) + { + if (usesValue != null && usesValue.StartsWith(SelfRepositoryPrefix, StringComparison.Ordinal)) + { + path = usesValue.Substring(SelfRepositoryPrefix.Length).TrimStart('/'); + if (string.IsNullOrEmpty(path)) + { + path = null; + return false; + } + return true; + } + + path = null; + return false; + } + /// /// Error code during graph validation. /// diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index df80774d3a6..7f344c4de63 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1605,6 +1605,10 @@ public static List ConvertToSteps( { id = WorkflowConstants.SelfAlias; } + else if (GitHub.DistributedTask.Pipelines.PipelineConstants.TryParseSelfRepository(action.Uses!.Value, out _)) + { + id = WorkflowConstants.SelfRepositoryAlias; + } else { var usesSegments = action.Uses!.Value.Split('@'); diff --git a/src/Sdk/WorkflowParser/WorkflowConstants.cs b/src/Sdk/WorkflowParser/WorkflowConstants.cs index e35c244e65e..fc168981686 100644 --- a/src/Sdk/WorkflowParser/WorkflowConstants.cs +++ b/src/Sdk/WorkflowParser/WorkflowConstants.cs @@ -26,10 +26,15 @@ public static class WorkflowConstants internal const Int32 MaxNodeNameLength = 100; /// - /// Alias for the self repository. + /// Alias for the self local-workspace repository type (./ syntax). /// internal const String SelfAlias = "self"; + /// + /// RepositoryType for self-repository references ($/ syntax). + /// + internal const String SelfRepositoryAlias = "selfRepository"; + public static class PermissionsPolicy { public const string LimitedRead = "LimitedRead"; diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index c612ac9d0fa..9b3138e5ba0 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -90,6 +90,11 @@ public async void PrepareActions_DownloadActionFromDotCom_OnPremises_Legacy() var actionYamlFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "main", "action.yml"); Assert.True(File.Exists(actionYamlFile)); + + var telemetryMessages = GetTelemetryMessages(); + Assert.True(ContainsTelemetry(telemetryMessages, "resolve_actions")); + Assert.True(ContainsTelemetry(telemetryMessages, "succeeded")); + Assert.True(ContainsTelemetry(telemetryMessages, "download_action")); _hc.GetTrace().Info(File.ReadAllText(actionYamlFile)); } finally @@ -148,6 +153,11 @@ public async void PrepareActions_DownloadActionFromDotCom_ZipFileError() // Act + Assert await Assert.ThrowsAsync(async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions)); + + var telemetryMessages = GetTelemetryMessages(); + Assert.True(ContainsTelemetry(telemetryMessages, "resolve_actions")); + Assert.True(ContainsTelemetry(telemetryMessages, "download_action")); + Assert.True(ContainsTelemetry(telemetryMessages, "InvalidActionArchiveException")); } finally { @@ -215,6 +225,51 @@ public async void PrepareActions_DownloadUnknownActionFromGraph_OnPremises_Legac } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task PrepareActions_ResolveActionDownloadInfo_RecordsTelemetry_OnFailure() + { + try + { + // Arrange + Setup(); + _ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest"); + + _launchServer + .Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("resolve failed")); + + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/checkout", + Ref = "v4", + RepositoryType = "GitHub" + } + } + }; + + // Act + Assert + await Assert.ThrowsAsync(async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions)); + + var telemetryMessages = GetTelemetryMessages(); + Assert.Equal(1, telemetryMessages.Count(message => + message.Contains("resolve_actions", StringComparison.OrdinalIgnoreCase) + && !message.Contains("\"result\":\"succeeded\"", StringComparison.OrdinalIgnoreCase))); + Assert.False(ContainsTelemetry(telemetryMessages, "resolve_actions\",\"result\":\"succeeded")); + } + finally + { + Teardown(); + } + } + #if OS_LINUX [Fact] [Trait("Level", "L0")] @@ -530,9 +585,9 @@ public async void PrepareActions_SymlinkCacheIsReentrant() //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); + Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist"); + var di = new DirectoryInfo(destDirectory); + Assert.NotNull(di.LinkTarget); } finally { @@ -2386,7 +2441,7 @@ public void LoadsNode20ActionDefinition() } } - [Fact] + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void LoadsNode24ActionDefinition() @@ -2454,7 +2509,7 @@ public void LoadsNode24ActionDefinition() Teardown(); } } - + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -3333,6 +3388,16 @@ private void Teardown() } } + private IList GetTelemetryMessages() + { + return _ec.Object.Global.JobTelemetry.Select(x => x.Message).ToList(); + } + + private static bool ContainsTelemetry(IList telemetryMessages, string expectedFragment) + { + return telemetryMessages.Any(message => message.Contains(expectedFragment, StringComparison.OrdinalIgnoreCase)); + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -3468,5 +3533,604 @@ public async Task GetDownloadInfoAsync_OmitsDependencies_WhenEmpty() Teardown(); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SelfRepository_ResolvesAtDepthZero() + { + // Self-references are only supported via run service (batch resolution path) + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true"); + try + { + // Arrange + Setup(); + const string RepoName = "my-org/my-repo"; + const string RepoSha = "abc123def456"; + _ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName); + _ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true"); + var jobContext = new JobContext(); + jobContext.WorkflowRepository = RepoName; + jobContext.WorkflowSha = RepoSha; + _ec.Setup(x => x.JobContext).Returns(jobContext); + + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias, + Path = "actions/my-action" + } + } + }; + + string archiveFile = await CreateRepoArchive(); + using var stream = File.OpenRead(archiveFile); + string archiveLink = GetLinkToActionArchive("https://api.github.com", RepoName, RepoSha); + var mockClientHandler = new Mock(); + mockClientHandler.Protected().Setup>("SendAsync", ItExpr.Is(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }); + var mockHandlerFactory = new Mock(); + mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny())).Returns(mockClientHandler.Object); + _hc.SetSingleton(mockHandlerFactory.Object); + + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com"); + + // Act — resolution mutates the reference in-place before download/prepare. + // The archive doesn't contain the subpath, so prepare will fail, but the + // reference is already resolved by that point. + try + { + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("Can't find")) + { + // Expected: test archive lacks the action.yml at the resolved subpath + } + + // Assert — the reference should be resolved to a GitHub repo reference + var repoRef = actions[0].Reference as Pipelines.RepositoryPathReference; + Assert.Equal(Pipelines.RepositoryTypes.GitHub, repoRef.RepositoryType); + Assert.Equal(RepoName, repoRef.Name); + Assert.Equal(RepoSha, repoRef.Ref); + Assert.Equal("actions/my-action", repoRef.Path); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null); + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SelfRepository_NotResolvedWhenFeatureFlagDisabled() + { + try + { + // Arrange + Setup(); + _ec.Setup(x => x.GetGitHubContext("repository")).Returns("my-org/my-repo"); + _ec.Setup(x => x.GetGitHubContext("sha")).Returns("abc123"); + // Feature flag NOT set + + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias, + Path = "actions/my-action" + } + } + }; + + // Act & Assert — should throw because unresolved self-reference hits GetDownloadInfoLookupKey + await Assert.ThrowsAsync(async () => + await _actionManager.PrepareActionsAsync(_ec.Object, actions)); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SelfRepository_ResolvesNestedInComposite() + { + // Composite action at $/actions/parent uses $/actions/child (same repo). + // This tests the batch path fix: $/ refs in nextLevel must be resolved + // BEFORE ResolveNewActionsAsync, otherwise GetDownloadInfoLookupKey throws. + // We pre-stage only the parent action.yml on disk so the composite steps + // are discovered, but we DON'T stage the child — a download failure for + // the child is fine; the important thing is that $/ was resolved + // (no InvalidOperationException from GetDownloadInfoLookupKey). + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true"); + try + { + // Arrange + Setup(); + const string RepoName = "my-org/my-repo"; + const string RepoSha = "abc123def456"; + _ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName); + _ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha); + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com"); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true"); + var jobContext = new JobContext(); + jobContext.WorkflowRepository = RepoName; + jobContext.WorkflowSha = RepoSha; + _ec.Setup(x => x.JobContext).Returns(jobContext); + + // Stage parent action on disk as a composite that uses $/actions/child. + // We use rootStepId != default to avoid directory deletion, + // and create the watermark + action.yml in the expected location. + string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory); + string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha); + Directory.CreateDirectory(Path.Combine(destDir, "actions", "parent")); + File.WriteAllText(Path.Combine(destDir, "actions", "parent", Constants.Path.ActionManifestYmlFile), @" +name: 'Parent' +description: 'Composite parent' +runs: + using: 'composite' + steps: + - uses: $/actions/child +"); + // Stage child action too (as a leaf node action) + Directory.CreateDirectory(Path.Combine(destDir, "actions", "child")); + File.WriteAllText(Path.Combine(destDir, "actions", "child", Constants.Path.ActionManifestYmlFile), @" +name: 'Child' +description: 'Node child' +runs: + using: 'node20' + main: 'index.js' +"); + // Write watermark + File.WriteAllText($"{destDir}.completed", string.Empty); + + var rootStepId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias, + Path = "actions/parent" + } + } + }; + + // Act — should resolve $/ and not throw InvalidOperationException + await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId); + + // Assert — top-level $/ resolved + var topRef = actions[0].Reference as Pipelines.RepositoryPathReference; + Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType); + Assert.Equal(RepoName, topRef.Name); + Assert.Equal(RepoSha, topRef.Ref); + Assert.Equal("actions/parent", topRef.Path); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null); + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SelfRepository_CrossRepoCompositeResolvesToParentRepo() + { + // External composite (external/foo@v1) uses $/lib/bar. + // $/lib/bar should resolve to external/foo@v1 (the parent's repo), + // NOT to the workflow's root repo. + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true"); + try + { + // Arrange + Setup(); + const string RootRepoName = "my-org/my-repo"; + const string RootRepoSha = "root-sha-111"; + const string ExtRepoName = "external/foo"; + const string ExtRepoRef = "v1"; + _ec.Setup(x => x.GetGitHubContext("repository")).Returns(RootRepoName); + _ec.Setup(x => x.GetGitHubContext("sha")).Returns(RootRepoSha); + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com"); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true"); + var jobContext = new JobContext(); + jobContext.WorkflowRepository = RootRepoName; + jobContext.WorkflowSha = RootRepoSha; + _ec.Setup(x => x.JobContext).Returns(jobContext); + + string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory); + string destDir = Path.Combine(actionsDir, ExtRepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), ExtRepoRef); + Directory.CreateDirectory(destDir); + File.WriteAllText(Path.Combine(destDir, Constants.Path.ActionManifestYmlFile), @" +name: 'External Foo' +description: 'External composite' +runs: + using: 'composite' + steps: + - uses: $/lib/bar +"); + Directory.CreateDirectory(Path.Combine(destDir, "lib", "bar")); + File.WriteAllText(Path.Combine(destDir, "lib", "bar", Constants.Path.ActionManifestYmlFile), @" +name: 'Bar' +description: 'Node action in external repo' +runs: + using: 'node20' + main: 'index.js' +"); + File.WriteAllText($"{destDir}.completed", string.Empty); + + var rootStepId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = ExtRepoName, + Ref = ExtRepoRef, + RepositoryType = Pipelines.RepositoryTypes.GitHub + } + } + }; + + // Act — should resolve $/lib/bar to external/foo@v1/lib/bar + await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId); + + // Assert — the top-level ref is unchanged (it was already concrete) + var topRef = actions[0].Reference as Pipelines.RepositoryPathReference; + Assert.Equal(ExtRepoName, topRef.Name); + Assert.Equal(ExtRepoRef, topRef.Ref); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null); + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SelfRepository_MultiLevelChain() + { + // $/a → composite → $/b → composite → $/c (three levels, same repo) + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true"); + try + { + // Arrange + Setup(); + const string RepoName = "my-org/my-repo"; + const string RepoSha = "chain-sha-222"; + _ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName); + _ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha); + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com"); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true"); + var jobContext = new JobContext(); + jobContext.WorkflowRepository = RepoName; + jobContext.WorkflowSha = RepoSha; + _ec.Setup(x => x.JobContext).Returns(jobContext); + + string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory); + string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha); + Directory.CreateDirectory(Path.Combine(destDir, "a")); + File.WriteAllText(Path.Combine(destDir, "a", Constants.Path.ActionManifestYmlFile), @" +name: 'A' +description: 'Level 0 composite' +runs: + using: 'composite' + steps: + - uses: $/b +"); + Directory.CreateDirectory(Path.Combine(destDir, "b")); + File.WriteAllText(Path.Combine(destDir, "b", Constants.Path.ActionManifestYmlFile), @" +name: 'B' +description: 'Level 1 composite' +runs: + using: 'composite' + steps: + - uses: $/c +"); + Directory.CreateDirectory(Path.Combine(destDir, "c")); + File.WriteAllText(Path.Combine(destDir, "c", Constants.Path.ActionManifestYmlFile), @" +name: 'C' +description: 'Level 2 node leaf' +runs: + using: 'node20' + main: 'index.js' +"); + File.WriteAllText($"{destDir}.completed", string.Empty); + + var rootStepId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias, + Path = "a" + } + } + }; + + // Act — three-level $/ chain should resolve without error + await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId); + + // Assert — top-level ref resolved + var topRef = actions[0].Reference as Pipelines.RepositoryPathReference; + Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType); + Assert.Equal(RepoName, topRef.Name); + Assert.Equal(RepoSha, topRef.Ref); + Assert.Equal("a", topRef.Path); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null); + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SelfRepository_ResolvesAtDepthZero_LegacyPath() + { + // Same as ResolvesAtDepthZero but on the legacy (non-batch) path + try + { + // Arrange + Setup(); + const string RepoName = "my-org/my-repo"; + const string RepoSha = "abc123def456"; + _ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName); + _ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true"); + var jobContext = new JobContext(); + jobContext.WorkflowRepository = RepoName; + jobContext.WorkflowSha = RepoSha; + _ec.Setup(x => x.JobContext).Returns(jobContext); + + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias, + Path = "actions/my-action" + } + } + }; + + string archiveFile = await CreateRepoArchive(); + using var stream = File.OpenRead(archiveFile); + string archiveLink = GetLinkToActionArchive("https://api.github.com", RepoName, RepoSha); + var mockClientHandler = new Mock(); + mockClientHandler.Protected().Setup>("SendAsync", ItExpr.Is(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }); + var mockHandlerFactory = new Mock(); + mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny())).Returns(mockClientHandler.Object); + _hc.SetSingleton(mockHandlerFactory.Object); + + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com"); + + // Act + try + { + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("Can't find")) + { + // Expected: test archive lacks the action.yml at the resolved subpath + } + + // Assert — the reference should be resolved to a GitHub repo reference + var repoRef = actions[0].Reference as Pipelines.RepositoryPathReference; + Assert.Equal(Pipelines.RepositoryTypes.GitHub, repoRef.RepositoryType); + Assert.Equal(RepoName, repoRef.Name); + Assert.Equal(RepoSha, repoRef.Ref); + Assert.Equal("actions/my-action", repoRef.Path); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_SelfRepository_ResolvesNestedInComposite_LegacyPath() + { + // Same as ResolvesNestedInComposite but on the legacy (non-batch) path. + // Verifies that $/ resolution works when batch action resolution is disabled. + try + { + // Arrange + Setup(); + const string RepoName = "my-org/my-repo"; + const string RepoSha = "abc123def456"; + _ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName); + _ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha); + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com"); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true"); + var jobContext = new JobContext(); + jobContext.WorkflowRepository = RepoName; + jobContext.WorkflowSha = RepoSha; + _ec.Setup(x => x.JobContext).Returns(jobContext); + + // Stage parent action on disk as a composite that uses $/actions/child. + string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory); + string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha); + Directory.CreateDirectory(Path.Combine(destDir, "actions", "parent")); + File.WriteAllText(Path.Combine(destDir, "actions", "parent", Constants.Path.ActionManifestYmlFile), @" +name: 'Parent' +description: 'Composite parent' +runs: + using: 'composite' + steps: + - uses: $/actions/child +"); + // Stage child action too (as a leaf node action) + Directory.CreateDirectory(Path.Combine(destDir, "actions", "child")); + File.WriteAllText(Path.Combine(destDir, "actions", "child", Constants.Path.ActionManifestYmlFile), @" +name: 'Child' +description: 'Node child' +runs: + using: 'node20' + main: 'index.js' +"); + // Write watermark + File.WriteAllText($"{destDir}.completed", string.Empty); + + var rootStepId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias, + Path = "actions/parent" + } + } + }; + + // Act — should resolve $/ and not throw InvalidOperationException + await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId); + + // Assert — top-level $/ resolved + var topRef = actions[0].Reference as Pipelines.RepositoryPathReference; + Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType); + Assert.Equal(RepoName, topRef.Name); + Assert.Equal(RepoSha, topRef.Ref); + Assert.Equal("actions/parent", topRef.Path); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void LoadAction_DotSlashCompositeWithNestedSelfRepository_ResolvesViaWorkflowContext() + { + // Regression test: when a dot-slash (./) composite action contains a + // nested $/actions/child step, LoadAction re-parses the action.yml at + // runtime and must resolve the $/ ref. The parent is repositoryType "self" + // so its Name and Ref are null — resolution must fall back to + // WorkflowRepository/WorkflowSha from the job context. Before the fix, + // this path hit a NullReferenceException at repoAction.Name.Replace(). + try + { + // Arrange + Setup(); + const string WorkflowRepo = "my-org/my-repo"; + const string WorkflowSha = "abc123def456"; + _ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true"); + var jobContext = new JobContext(); + jobContext.WorkflowRepository = WorkflowRepo; + jobContext.WorkflowSha = WorkflowSha; + _ec.Setup(x => x.JobContext).Returns(jobContext); + + // Stage the dot-slash composite in the workspace directory. + // It contains a nested $/actions/child step. + string workspaceDir = Path.Combine(_workFolder, "actions", "actions"); + string compositeDir = Path.Combine(workspaceDir, "my-composite"); + Directory.CreateDirectory(compositeDir); + File.WriteAllText(Path.Combine(compositeDir, Constants.Path.ActionManifestYmlFile), @" +name: 'DotSlash Parent' +description: 'Composite loaded via ./ that nests a $/ ref' +runs: + using: 'composite' + steps: + - run: echo 'hello' + shell: bash + - uses: $/actions/child +"); + + // Stage the child action in the actions cache under the workflow repo. + string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory); + string childDir = Path.Combine(actionsDir, WorkflowRepo.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), WorkflowSha, "actions", "child"); + Directory.CreateDirectory(childDir); + File.WriteAllText(Path.Combine(childDir, Constants.Path.ActionManifestYmlFile), @" +name: 'Child' +description: 'Leaf action' +runs: + using: 'node20' + main: 'index.js' +"); + + // Create dot-slash step with Name = null (the real scenario). + var instance = new Pipelines.ActionStep() + { + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = null, + Ref = null, + RepositoryType = Pipelines.PipelineConstants.SelfAlias, + Path = "my-composite" + } + }; + + // Act — should NOT throw NullReferenceException + Definition definition = _actionManager.LoadAction(_ec.Object, instance); + + // Assert — loaded the composite successfully + Assert.NotNull(definition); + Assert.NotNull(definition.Data); + Assert.Equal(ActionExecutionType.Composite, definition.Data.Execution.ExecutionType); + + // Assert — the nested $/ step was resolved to the workflow repo + var compositeData = definition.Data.Execution as CompositeActionExecutionData; + Assert.NotNull(compositeData); + var childStep = compositeData.Steps + .OfType() + .FirstOrDefault(s => s.Reference is Pipelines.RepositoryPathReference r + && r.Path == "actions/child"); + Assert.NotNull(childStep); + var childRef = childStep.Reference as Pipelines.RepositoryPathReference; + Assert.Equal(Pipelines.RepositoryTypes.GitHub, childRef.RepositoryType); + Assert.Equal(WorkflowRepo, childRef.Name); + Assert.Equal(WorkflowSha, childRef.Ref); + } + finally + { + Teardown(); + } + } + } } diff --git a/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs b/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs new file mode 100644 index 00000000000..35f03fdf1f5 --- /dev/null +++ b/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class ArtifactsListFileCommandL0 + { + private Mock _executionContext; + private string _rootDirectory; + private string _outputFile; + private ArtifactsListFileCommand _command; + private GlobalContext _global; + private ITraceWriter _trace; + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EmptyAggregate_WritesVersionedJsonWithEmptySubjects() + { + using (var hostContext = Setup()) + { + _command.PopulateInitialContents(_executionContext.Object, _outputFile, null); + var json = JObject.Parse(File.ReadAllText(_outputFile)); + Assert.Equal(ArtifactsListFileCommand.FormatVersion, json["version"].Value()); + Assert.Empty((JArray)json["subjects"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SingleSubject_SerializedCorrectly() + { + using (var hostContext = Setup()) + { + _global.ArtifactSubjects["myapp"] = new ArtifactSubject( + "myapp", + "sha256:" + new string('a', 64), + ArtifactSubjectKind.File); + + _command.PopulateInitialContents(_executionContext.Object, _outputFile, null); + + var json = JObject.Parse(File.ReadAllText(_outputFile)); + var subjects = (JArray)json["subjects"]; + Assert.Single(subjects); + Assert.Equal("myapp", subjects[0]["name"].Value()); + Assert.Equal("sha256:" + new string('a', 64), subjects[0]["digest"].Value()); + Assert.Equal("file", subjects[0]["kind"].Value()); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciSubject_KindIsLowercaseOci() + { + using (var hostContext = Setup()) + { + _global.ArtifactSubjects["ghcr.io/x:1"] = new ArtifactSubject( + "ghcr.io/x:1", + "sha256:" + new string('b', 64), + ArtifactSubjectKind.OciSubject); + + _command.PopulateInitialContents(_executionContext.Object, _outputFile, null); + + var json = JObject.Parse(File.ReadAllText(_outputFile)); + Assert.Equal("oci", json["subjects"][0]["kind"].Value()); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void MultipleSubjects_SortedByName() + { + using (var hostContext = Setup()) + { + // Insert deliberately out of alphabetical order to prove the + // output is sorted by name rather than by insertion order. + _global.ArtifactSubjects["two"] = new ArtifactSubject("two", "sha256:" + new string('2', 64), ArtifactSubjectKind.File); + _global.ArtifactSubjects["one"] = new ArtifactSubject("one", "sha256:" + new string('1', 64), ArtifactSubjectKind.File); + _global.ArtifactSubjects["three"] = new ArtifactSubject("three", "sha256:" + new string('3', 64), ArtifactSubjectKind.OciSubject); + + _command.PopulateInitialContents(_executionContext.Object, _outputFile, null); + + var subjects = (JArray)JObject.Parse(File.ReadAllText(_outputFile))["subjects"]; + Assert.Equal(3, subjects.Count); + Assert.Equal("one", subjects[0]["name"].Value()); + Assert.Equal("three", subjects[1]["name"].Value()); + Assert.Equal("two", subjects[2]["name"].Value()); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FeatureFlagOff_LeavesFileEmpty() + { + using (var hostContext = Setup(featureFlag: false)) + { + _global.ArtifactSubjects["myapp"] = new ArtifactSubject("myapp", "sha256:" + new string('a', 64), ArtifactSubjectKind.File); + + _command.PopulateInitialContents(_executionContext.Object, _outputFile, null); + + Assert.Equal(string.Empty, File.ReadAllText(_outputFile)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EnvVarFallback_EnablesPublishing() + { + using (var hostContext = Setup(featureFlag: false, envVarOverride: "true")) + { + _global.ArtifactSubjects["myapp"] = new ArtifactSubject("myapp", "sha256:" + new string('a', 64), ArtifactSubjectKind.File); + + _command.PopulateInitialContents(_executionContext.Object, _outputFile, null); + + var json = JObject.Parse(File.ReadAllText(_outputFile)); + Assert.Single((JArray)json["subjects"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OutputFileIsUtf8WithoutBom() + { + using (var hostContext = Setup()) + { + _command.PopulateInitialContents(_executionContext.Object, _outputFile, null); + + var bytes = File.ReadAllBytes(_outputFile); + // UTF-8 BOM is EF BB BF + Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF, + "File should not begin with a UTF-8 BOM."); + // Sanity check that the file is valid JSON. + Assert.NotNull(JObject.Parse(System.Text.Encoding.UTF8.GetString(bytes))); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ProcessCommand_IsNoOp() + { + using (var hostContext = Setup()) + { + // Even if the step writes garbage, ProcessCommand must not touch the aggregate. + File.WriteAllText(_outputFile, "anything the step wrote"); + _command.ProcessCommand(_executionContext.Object, _outputFile, null); + Assert.Empty(_global.ArtifactSubjects); + } + } + + private TestHostContext Setup(bool featureFlag = true, string envVarOverride = null, [CallerMemberName] string name = "") + { + // Reset env-var state across test runs in the same process. + Environment.SetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar, envVarOverride); + + var hostContext = new TestHostContext(this, name); + _trace = hostContext.GetTrace(); + + var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work); + Directory.CreateDirectory(workDirectory); + _rootDirectory = Path.Combine(workDirectory, nameof(ArtifactsListFileCommandL0), name); + if (Directory.Exists(_rootDirectory)) + { + Directory.Delete(_rootDirectory, recursive: true); + } + Directory.CreateDirectory(_rootDirectory); + _outputFile = Path.Combine(_rootDirectory, "artifacts_list"); + File.WriteAllText(_outputFile, string.Empty); + + var variableValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (featureFlag) + { + variableValues[Common.Constants.Runner.Features.AllowArtifactsFile] = new VariableValue("true"); + } + var variables = new Variables(hostContext, variableValues); + + _global = new GlobalContext + { + EnvironmentVariables = new Dictionary(VarUtil.EnvironmentVariableKeyComparer), + Variables = variables, + WriteDebug = true, + ArtifactSubjects = new Dictionary(StringComparer.Ordinal), + }; + + _executionContext = new Mock(); + _executionContext.Setup(x => x.Global).Returns(_global); + _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny())) + .Callback((string tag, string message) => + { + _trace.Info($"{tag}{message}"); + }); + + _command = new ArtifactsListFileCommand(); + _command.Initialize(hostContext); + + return hostContext; + } + } +} diff --git a/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs b/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs new file mode 100644 index 00000000000..79bcc60890a --- /dev/null +++ b/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs @@ -0,0 +1,627 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; +using Moq; +using Xunit; +using DTWebApi = GitHub.DistributedTask.WebApi; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class CreateArtifactsFileCommandL0 + { + private const string FlagOn = "true"; + + private Mock _executionContext; + private List _issues; + private string _rootDirectory; + private string _workspaceDirectory; + private CreateArtifactsFileCommand _command; + private GlobalContext _global; + private ITraceWriter _trace; + + // ---------- Feature flag ---------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FeatureFlagOff_NoOp() + { + using (var hostContext = Setup(featureFlag: false)) + { + var artifactsFile = WriteArtifactsFile("ghcr.io/octocat/myapp:1.0@sha256:" + new string('a', 64)); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Empty(_global.ArtifactSubjects); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EnvVarOverride_EnablesFeature() + { + using (var hostContext = Setup(featureFlag: false, envVarOverride: "true")) + { + var hex = new string('a', 64); + var artifactsFile = WriteArtifactsFile($"ghcr.io/octocat/myapp:1.0@sha256:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Single(_global.ArtifactSubjects); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EnvVarFalse_DoesNotEnable() + { + using (var hostContext = Setup(featureFlag: false, envVarOverride: "false")) + { + var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('a', 64)); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Empty(_global.ArtifactSubjects); + } + } + + // ---------- Trivial cases ---------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileMissing_NoOp() + { + using (var hostContext = Setup()) + { + var artifactsFile = Path.Combine(_rootDirectory, "does-not-exist"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Empty(_global.ArtifactSubjects); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EmptyFile_NoOp() + { + using (var hostContext = Setup()) + { + var artifactsFile = WriteArtifactsFile(string.Empty); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Empty(_global.ArtifactSubjects); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BlankAndCommentLines_Skipped() + { + using (var hostContext = Setup()) + { + var artifactsFile = WriteArtifactsFile( + "", + "# this is a comment", + " # leading-whitespace comment", + "", + " "); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Empty(_global.ArtifactSubjects); + } + } + + // ---------- OCI subjects ---------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciSubject_Sha256_HappyPath() + { + using (var hostContext = Setup()) + { + var hex = new string('a', 64); + var artifactsFile = WriteArtifactsFile($"ghcr.io/octocat/myapp:1.0.0@sha256:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Single(_global.ArtifactSubjects); + var subject = _global.ArtifactSubjects["ghcr.io/octocat/myapp:1.0.0"]; + Assert.Equal($"sha256:{hex}", subject.Digest); + Assert.Equal(ArtifactSubjectKind.OciSubject, subject.Kind); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciSubject_Sha384_HappyPath() + { + using (var hostContext = Setup()) + { + var hex = new string('b', 96); + var artifactsFile = WriteArtifactsFile($"ghcr.io/x/y@sha384:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Single(_global.ArtifactSubjects); + Assert.Equal($"sha384:{hex}", _global.ArtifactSubjects["ghcr.io/x/y"].Digest); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciSubject_Sha512_HappyPath() + { + using (var hostContext = Setup()) + { + var hex = new string('c', 128); + var artifactsFile = WriteArtifactsFile($"ghcr.io/x/y@sha512:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Single(_global.ArtifactSubjects); + Assert.Equal($"sha512:{hex}", _global.ArtifactSubjects["ghcr.io/x/y"].Digest); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciSubject_DigestLowercased() + { + using (var hostContext = Setup()) + { + var hex = new string('A', 64); + var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Equal($"sha256:{hex.ToLowerInvariant()}", _global.ArtifactSubjects["ghcr.io/x"].Digest); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciSubject_PreservesTagAndRegistryPort() + { + using (var hostContext = Setup()) + { + var hex = new string('d', 64); + var artifactsFile = WriteArtifactsFile($"localhost:5000/repo/img:v1@sha256:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Single(_global.ArtifactSubjects); + Assert.True(_global.ArtifactSubjects.ContainsKey("localhost:5000/repo/img:v1")); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciSubject_WrongHexLength_Throws() + { + using (var hostContext = Setup()) + { + // 63 hex chars instead of 64 → falls back to file path parse → file missing → throws + var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('a', 63)); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("line 1", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciSubject_NonHexChars_TreatedAsFile_Throws() + { + using (var hostContext = Setup()) + { + // Non-hex character: digest regex doesn't match → treated as file path → file missing + var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('z', 64)); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("line 1", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciScheme_RejectsWhenDigestMissing() + { + using (var hostContext = Setup()) + { + var artifactsFile = WriteArtifactsFile("oci://ghcr.io/x:1.0"); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("line 1", ex.Message); + Assert.Contains("digest", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void OciScheme_HappyPath() + { + using (var hostContext = Setup()) + { + var hex = new string('e', 64); + var artifactsFile = WriteArtifactsFile($"OCI://ghcr.io/x:1.0@sha256:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Single(_global.ArtifactSubjects); + Assert.True(_global.ArtifactSubjects.ContainsKey("ghcr.io/x:1.0")); + } + } + + // ---------- File subjects ---------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileSubject_Absolute_HappyPath() + { + using (var hostContext = Setup()) + { + var artifactPath = Path.Combine(_rootDirectory, "binary.bin"); + File.WriteAllBytes(artifactPath, new byte[] { 1, 2, 3, 4 }); + var artifactsFile = WriteArtifactsFile(artifactPath); + + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + + Assert.Single(_global.ArtifactSubjects); + var subject = _global.ArtifactSubjects["binary.bin"]; + Assert.Equal(ArtifactSubjectKind.File, subject.Kind); + // sha256("\x01\x02\x03\x04") = 9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a + Assert.Equal("sha256:9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", subject.Digest); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileSubject_RelativeToWorkspace() + { + using (var hostContext = Setup()) + { + Directory.CreateDirectory(Path.Combine(_workspaceDirectory, "dist")); + File.WriteAllBytes(Path.Combine(_workspaceDirectory, "dist", "myapp"), new byte[] { 9 }); + var artifactsFile = WriteArtifactsFile("dist/myapp"); + + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + + Assert.Single(_global.ArtifactSubjects); + Assert.True(_global.ArtifactSubjects.ContainsKey("myapp")); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileScheme_TreatsAsFilePathEvenIfLooksLikeOci() + { + using (var hostContext = Setup()) + { + // File literally named "image@sha256:deadbeef..." — force file path via file:// prefix. + var quirkyName = "image@sha256:" + new string('f', 64); + var artifactPath = Path.Combine(_rootDirectory, quirkyName); + File.WriteAllBytes(artifactPath, new byte[] { 1 }); + var artifactsFile = WriteArtifactsFile("file://" + artifactPath); + + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + + Assert.Single(_global.ArtifactSubjects); + var subject = _global.ArtifactSubjects[quirkyName]; + Assert.Equal(ArtifactSubjectKind.File, subject.Kind); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileSubject_Missing_Throws() + { + using (var hostContext = Setup()) + { + var artifactsFile = WriteArtifactsFile(Path.Combine(_rootDirectory, "does-not-exist")); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("line 1", ex.Message); + Assert.Contains("does not exist", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileSubject_Directory_Throws() + { + using (var hostContext = Setup()) + { + var dir = Path.Combine(_rootDirectory, "a-directory"); + Directory.CreateDirectory(dir); + var artifactsFile = WriteArtifactsFile(dir); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("line 1", ex.Message); + Assert.Contains("not a regular file", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileSubject_ContainerAbsolute_InMount_ResolvesToHostFile() + { + using (var hostContext = Setup()) + { + // The host file lives under a directory that is mounted into + // the container. The step declares the file using its + // container-namespace path, which must translate back to the + // host file so the digest is computed over the right bytes. + var hostDirectory = Path.Combine(_rootDirectory, "mounted"); + Directory.CreateDirectory(hostDirectory); + File.WriteAllBytes(Path.Combine(hostDirectory, "app.bin"), new byte[] { 1, 2, 3, 4 }); + + var container = new ContainerInfo(); + var containerDirectory = "/container-workspace"; + container.AddPathTranslateMapping(hostDirectory, containerDirectory); + + var artifactsFile = WriteArtifactsFile(Path.Combine(containerDirectory, "app.bin")); + _command.ProcessCommand(_executionContext.Object, artifactsFile, container); + + Assert.Single(_global.ArtifactSubjects); + var subject = _global.ArtifactSubjects["app.bin"]; + Assert.Equal(ArtifactSubjectKind.File, subject.Kind); + // sha256("\x01\x02\x03\x04") + Assert.Equal("sha256:9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", subject.Digest); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileSubject_ContainerAbsolute_OutsideMount_Throws() + { + using (var hostContext = Setup()) + { + // An absolute path that does not resolve into any mounted + // volume must be rejected rather than silently hashing the + // host file that happens to live at that same path. + var container = new ContainerInfo(); + container.AddPathTranslateMapping(Path.Combine(_rootDirectory, "mounted"), "/container-workspace"); + + var artifactsFile = WriteArtifactsFile(Path.Combine("/unmapped-container-dir", "secret.bin")); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, container)); + Assert.Contains("line 1", ex.Message); + Assert.Contains("not inside a volume mounted", ex.Message); + } + } + + // ---------- Format / scheme rules ---------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EqualsSign_Rejected() + { + using (var hostContext = Setup()) + { + var artifactsFile = WriteArtifactsFile("name=ghcr.io/x@sha256:" + new string('a', 64)); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("line 1", ex.Message); + Assert.Contains("'='", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void UnsupportedScheme_Rejected() + { + using (var hostContext = Setup()) + { + var artifactsFile = WriteArtifactsFile("https://example.com/artifact"); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("line 1", ex.Message); + Assert.Contains("unsupported URI scheme", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void LineNumberInError_PointsAtRightLine() + { + using (var hostContext = Setup()) + { + var hex = new string('a', 64); + var artifactsFile = WriteArtifactsFile( + "# comment", + $"ghcr.io/ok@sha256:{hex}", + "", + "name=bogus"); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("line 4", ex.Message); + } + } + + // ---------- Size and aggregate limits ---------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FileTooLarge_Throws() + { + using (var hostContext = Setup()) + { + var artifactsFile = Path.Combine(_rootDirectory, "huge"); + // Slightly larger than 1 MiB. + File.WriteAllBytes(artifactsFile, new byte[CreateArtifactsFileCommand.MaxFileSizeBytes + 1]); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("$GITHUB_ARTIFACTS", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void AggregateCap_AllowsDuplicatesAtCap() + { + using (var hostContext = Setup()) + { + // Pre-fill the aggregate to exactly the cap. + var hex = new string('a', 64); + for (var i = 0; i < CreateArtifactsFileCommand.MaxAggregateArtifacts; i++) + { + var name = $"ghcr.io/x{i}"; + _global.ArtifactSubjects[name] = new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.OciSubject); + } + + // A new step redeclares one of the existing artifacts identically — should NOT throw. + var artifactsFile = WriteArtifactsFile($"ghcr.io/x0@sha256:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Equal(CreateArtifactsFileCommand.MaxAggregateArtifacts, _global.ArtifactSubjects.Count); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void AggregateCap_FailsOnFirstDistinctOverflow() + { + using (var hostContext = Setup()) + { + var hex = new string('a', 64); + for (var i = 0; i < CreateArtifactsFileCommand.MaxAggregateArtifacts; i++) + { + var name = $"ghcr.io/x{i}"; + _global.ArtifactSubjects[name] = new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.OciSubject); + } + + var artifactsFile = WriteArtifactsFile($"ghcr.io/new@sha256:{hex}"); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("500", ex.Message); + } + } + + // ---------- Aggregation rules ---------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Aggregation_DedupsIdentical() + { + using (var hostContext = Setup()) + { + var hex = new string('a', 64); + _global.ArtifactSubjects["ghcr.io/x"] = new ArtifactSubject("ghcr.io/x", $"sha256:{hex}", ArtifactSubjectKind.OciSubject); + var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hex}"); + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + Assert.Single(_global.ArtifactSubjects); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Aggregation_FailsOnConflict() + { + using (var hostContext = Setup()) + { + var hexA = new string('a', 64); + var hexB = new string('b', 64); + _global.ArtifactSubjects["ghcr.io/x"] = new ArtifactSubject("ghcr.io/x", $"sha256:{hexA}", ArtifactSubjectKind.OciSubject); + var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hexB}"); + var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null)); + Assert.Contains("Conflicting digest", ex.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void MultipleEntries_AllProcessed() + { + using (var hostContext = Setup()) + { + Directory.CreateDirectory(Path.Combine(_workspaceDirectory, "dist")); + File.WriteAllBytes(Path.Combine(_workspaceDirectory, "dist", "myapp-linux-amd64"), new byte[] { 7 }); + + var hex = new string('a', 64); + var artifactsFile = WriteArtifactsFile( + "# Release binary", + "dist/myapp-linux-amd64", + "", + "# Published container image", + $"ghcr.io/octocat/myapp:1.0.0@sha256:{hex}"); + + _command.ProcessCommand(_executionContext.Object, artifactsFile, null); + + Assert.Equal(2, _global.ArtifactSubjects.Count); + Assert.True(_global.ArtifactSubjects.ContainsKey("myapp-linux-amd64")); + Assert.True(_global.ArtifactSubjects.ContainsKey("ghcr.io/octocat/myapp:1.0.0")); + } + } + + // ---------- Setup helpers ---------- + + private string WriteArtifactsFile(params string[] lines) + { + var path = Path.Combine(_rootDirectory, "artifacts"); + File.WriteAllText(path, string.Join("\n", lines), new UTF8Encoding(false)); + return path; + } + + private TestHostContext Setup(bool featureFlag = true, string envVarOverride = null, [CallerMemberName] string name = "") + { + _issues = new List(); + + // Ensure no leaked state from prior tests in the same process. + Environment.SetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar, envVarOverride); + + var hostContext = new TestHostContext(this, name); + _trace = hostContext.GetTrace(); + + var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work); + Directory.CreateDirectory(workDirectory); + _rootDirectory = Path.Combine(workDirectory, nameof(CreateArtifactsFileCommandL0), name); + if (Directory.Exists(_rootDirectory)) + { + Directory.Delete(_rootDirectory, recursive: true); + } + Directory.CreateDirectory(_rootDirectory); + + _workspaceDirectory = Path.Combine(_rootDirectory, "workspace"); + Directory.CreateDirectory(_workspaceDirectory); + + var variableValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (featureFlag) + { + variableValues[Common.Constants.Runner.Features.AllowArtifactsFile] = new VariableValue(FlagOn); + } + var variables = new Variables(hostContext, variableValues); + + _global = new GlobalContext + { + EnvironmentVariables = new Dictionary(VarUtil.EnvironmentVariableKeyComparer), + Variables = variables, + WriteDebug = true, + ArtifactSubjects = new Dictionary(StringComparer.Ordinal), + }; + + _executionContext = new Mock(); + _executionContext.Setup(x => x.Global).Returns(_global); + _executionContext.Setup(x => x.GetGitHubContext("workspace")).Returns(_workspaceDirectory); + _executionContext.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())) + .Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) => + { + _issues.Add(issue); + _trace.Info($"Issue '{issue.Type}': {issue.Message}"); + }); + _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny())) + .Callback((string tag, string message) => + { + _trace.Info($"{tag}{message}"); + }); + + _command = new CreateArtifactsFileCommand(); + _command.Initialize(hostContext); + + return hostContext; + } + } +} diff --git a/src/Test/L0/Worker/FileCommandManagerL0.cs b/src/Test/L0/Worker/FileCommandManagerL0.cs new file mode 100644 index 00000000000..e09253403d5 --- /dev/null +++ b/src/Test/L0/Worker/FileCommandManagerL0.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class FileCommandManagerL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeFiles_InvokesPopulateInitialContents_OncePerExtension() + { + using (var hostContext = Setup(out var executionContext, out var ext)) + { + var manager = new FileCommandManager(); + manager.Initialize(hostContext); + + manager.InitializeFiles(executionContext, null); + + Assert.Equal(1, ext.PopulateCallCount); + Assert.True(File.Exists(ext.LastPopulatedPath)); + + // A second invocation should populate again with the new + // per-step file (file path rotates between calls). + var firstPath = ext.LastPopulatedPath; + manager.InitializeFiles(executionContext, null); + Assert.Equal(2, ext.PopulateCallCount); + Assert.NotEqual(firstPath, ext.LastPopulatedPath); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeFiles_PopulateException_DoesNotAbortInitialization() + { + using (var hostContext = Setup(out var executionContext, out var ext)) + { + ext.ThrowOnPopulate = true; + + var manager = new FileCommandManager(); + manager.Initialize(hostContext); + + // Must not throw — failures during populate should be + // swallowed so a misbehaving extension cannot block step + // setup. + manager.InitializeFiles(executionContext, null); + + Assert.Equal(1, ext.PopulateCallCount); + } + } + + private TestHostContext Setup(out IExecutionContext executionContext, out RecordingFileCommand recordingExtension, [CallerMemberName] string name = "") + { + var hostContext = new TestHostContext(this, name); + + recordingExtension = new RecordingFileCommand(); + recordingExtension.Initialize(hostContext); + + var extensionManager = new Mock(); + extensionManager.Setup(x => x.GetExtensions()) + .Returns(new List { recordingExtension }); + hostContext.SetSingleton(extensionManager.Object); + + var ec = new Mock(); + ec.Setup(x => x.SetGitHubContext(It.IsAny(), It.IsAny())); + executionContext = ec.Object; + + return hostContext; + } + + private sealed class RecordingFileCommand : RunnerService, IFileCommandExtension + { + public string ContextName => "recording"; + public string FilePrefix => "recording_"; + public Type ExtensionType => typeof(IFileCommandExtension); + + public int PopulateCallCount { get; private set; } + public string LastPopulatedPath { get; private set; } + public bool ThrowOnPopulate { get; set; } + + public void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container) + { + PopulateCallCount++; + LastPopulatedPath = filePath; + if (ThrowOnPopulate) + { + throw new InvalidOperationException("intentional"); + } + } + + public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container) + { + } + } + } +} 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..2f2dd1db51b 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -199,6 +199,54 @@ public async Task JobExtensionBuildStepsList() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task JobExtensionOutputsLockedDependenciesWhenPresent() + { + using (TestHostContext hc = CreateTestContext()) + { + var consoleLines = new List(); + _jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((Guid _, string line, long? __) => consoleLines.Add(line)); + + _message.ActionsDependencies.Add("actions/checkout@v4:sha256-abc123"); + + 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); + + Assert.Contains(consoleLines, line => line.Contains("Running with locked dependencies")); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task JobExtensionDoesNotOutputLockedDependenciesWhenAbsent() + { + using (TestHostContext hc = CreateTestContext()) + { + var consoleLines = new List(); + _jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((Guid _, string line, long? __) => consoleLines.Add(line)); + + 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); + + Assert.DoesNotContain(consoleLines, line => line.Contains("Running with locked dependencies")); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -238,21 +286,76 @@ 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 JobExtensionBuildFailsWithoutContainerIfRequired() + public async Task InitializeJob_DoesNotLogCacheMode_WhenVariableAbsent() { - Environment.SetEnvironmentVariable(Constants.Variables.Actions.RequireJobContainer, "true"); 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 JobExtensionRunner(null, "", "prepare1", null), new JobExtensionRunner(null, "", "prepare2", null) }, new Dictionary()))); + .Returns(Task.FromResult(new PrepareResult(new List(), new Dictionary()))); - await Assert.ThrowsAsync(() => jobExtension.InitializeJob(_jobEc, _message)); + 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")] + public async Task JobExtensionBuildFailsWithoutContainerIfRequired() + { + Environment.SetEnvironmentVariable(Constants.Variables.Actions.RequireJobContainer, "true"); + try + { + 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 JobExtensionRunner(null, "", "prepare1", null), new JobExtensionRunner(null, "", "prepare2", null) }, new Dictionary()))); + + await Assert.ThrowsAsync(() => jobExtension.InitializeJob(_jobEc, _message)); + } + } + finally + { + Environment.SetEnvironmentVariable(Constants.Variables.Actions.RequireJobContainer, null); } } diff --git a/src/dev.sh b/src/dev.sh index fafdbffb360..7d9aa58dd79 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout" DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x" PACKAGE_DIR="$SCRIPT_DIR/../_package" DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk" -DOTNETSDK_VERSION="8.0.421" +DOTNETSDK_VERSION="8.0.422" DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION" RUNNER_VERSION=$(cat runnerversion) diff --git a/src/global.json b/src/global.json index 12e63e7d30c..a928aa42b4a 100644 --- a/src/global.json +++ b/src/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.421" + "version": "8.0.422" } }