From 8fbb9c9d96da02a41cc81262b44bec71bf1b02e6 Mon Sep 17 00:00:00 2001 From: Platform Engineering Bot Date: Tue, 7 Jul 2026 00:22:41 +0000 Subject: [PATCH] =?UTF-8?q?Update=20patches:=20c814d7c=20=E2=86=92=20dde96?= =?UTF-8?q?8bf=20[ppc64le,s390x]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- images/Dockerfile | 2 +- patches/last_processed_commit.txt | 2 +- patches/runner-main-sdk8-ppc64le.patch | 6 +- patches/runner-main-sdk8-s390x.patch | 6 +- src/Misc/externals.sh | 2 +- src/Runner.Common/Constants.cs | 1 + src/Runner.Worker/ActionManager.cs | 278 +++++++- .../PipelineTemplateConverter.cs | 21 +- .../Pipelines/PipelineConstants.cs | 35 +- .../Conversion/WorkflowTemplateConverter.cs | 4 + src/Sdk/WorkflowParser/WorkflowConstants.cs | 7 +- src/Test/L0/Worker/ActionManagerL0.cs | 674 +++++++++++++++++- 12 files changed, 1004 insertions(+), 34 deletions(-) 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..cdf9bcd53aa 100644 --- a/patches/last_processed_commit.txt +++ b/patches/last_processed_commit.txt @@ -1 +1 @@ -c814d7ca46f403a860f84c4e54b78c5a2d17e045 +dde968bf5724d5b9ba7a759f9a114a305e2c76e7 diff --git a/patches/runner-main-sdk8-ppc64le.patch b/patches/runner-main-sdk8-ppc64le.patch index a8bfc6b6c96..a391854994f 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 9debaddc..db1d7c5c 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -59,7 +59,9 @@ namespace GitHub.Runner.Common @@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644 -# From upstream commit: c814d7ca46f403a860f84c4e54b78c5a2d17e045 +# From upstream commit: dde968bf5724d5b9ba7a759f9a114a305e2c76e7 diff --git a/patches/runner-main-sdk8-s390x.patch b/patches/runner-main-sdk8-s390x.patch index a8bfc6b6c96..a391854994f 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 9debaddc..db1d7c5c 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -59,7 +59,9 @@ namespace GitHub.Runner.Common @@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644 -# From upstream commit: c814d7ca46f403a860f84c4e54b78c5a2d17e045 +# From upstream commit: dde968bf5724d5b9ba7a759f9a114a305e2c76e7 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..9debaddc53b 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -180,6 +180,7 @@ 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 SelfRepository = "actions_self_repository"; } // Node version migration related constants 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/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(); + } + } + } }