diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index b07d60d39fc..f643ec160a5 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 DollarSelfReference = "actions_dollar_self_reference"; } // Node version migration related constants diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 133129a0177..fe9f5ccf7bf 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 dollarSelfRepoName = null, string dollarSelfRepoRef = null) { ArgUtil.NotNull(executionContext, nameof(executionContext)); if (depth > Constants.CompositeActionsMaxDepth) @@ -186,6 +186,17 @@ public sealed class ActionManager : RunnerService, IActionManager throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}"); } + // Resolve dollar-self ($/path) references before processing + if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.DollarSelfReference) == true) + { + if (string.IsNullOrEmpty(dollarSelfRepoName)) + { + dollarSelfRepoName = executionContext.GetGitHubContext("repository"); + dollarSelfRepoRef = executionContext.GetGitHubContext("sha"); + } + ResolveDollarSelfReferences(executionContext, actions, dollarSelfRepoName, dollarSelfRepoRef); + } + var repositoryActions = new List(); foreach (var action in actions) @@ -287,7 +298,19 @@ public sealed class ActionManager : RunnerService, IActionManager foreach (var group in nextLevel.GroupBy(x => x.parentId)) { var groupActions = group.Select(x => x.action).ToList(); - state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key); + + // Determine dollar-self resolution context for child steps + string childRepoName = dollarSelfRepoName; + string childRepoRef = dollarSelfRepoRef; + 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; + } + + state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key, childRepoName, childRepoRef); } } @@ -363,13 +386,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 dollarSelfRepoName = null, string dollarSelfRepoRef = null) { ArgUtil.NotNull(executionContext, nameof(executionContext)); if (depth > Constants.CompositeActionsMaxDepth) { throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}"); } + + // Resolve dollar-self ($/path) references before processing + if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.DollarSelfReference) == true) + { + if (string.IsNullOrEmpty(dollarSelfRepoName)) + { + dollarSelfRepoName = executionContext.GetGitHubContext("repository"); + dollarSelfRepoRef = executionContext.GetGitHubContext("sha"); + } + ResolveDollarSelfReferences(executionContext, actions, dollarSelfRepoName, dollarSelfRepoRef); + } + var repositoryActions = new List(); foreach (var action in actions) @@ -449,7 +484,18 @@ 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); + // Determine dollar-self resolution context for child steps + string childRepoName = dollarSelfRepoName; + string childRepoRef = dollarSelfRepoRef; + var parentRef = action.Reference as Pipelines.RepositoryPathReference; + 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) @@ -1347,6 +1393,40 @@ private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext execution } } + /// + /// Resolves dollar-self ($/path) references by mutating them in-place + /// to standard GitHub repository references with the containing repo's + /// name and ref. + /// + private void ResolveDollarSelfReferences(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.DollarSelfAlias, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + Trace.Info($"Resolving dollar-self 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; + } + } + private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action) { if (action.Reference.Type != Pipelines.ActionSourceType.Repository) @@ -1362,6 +1442,11 @@ private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action) return null; } + if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.DollarSelfAlias, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Dollar-self ($/path) reference was not resolved before download. This is a runner bug."); + } + 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..417a662a56f 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.DollarSelfAlias, StringComparison.OrdinalIgnoreCase)) + { + name = PipelineConstants.DollarSelfAlias; + } + else + { + name = PipelineConstants.SelfAlias; + } break; } @@ -600,6 +611,15 @@ private static ActionStep ConvertToStep( Path = uses.Value }; } + else if (uses.Value.StartsWith("$/") || uses.Value.StartsWith("$\\")) + { + var dollarSelfPath = uses.Value.Substring("$/".Length).TrimStart('/', '\\'); + result.Reference = new RepositoryPathReference + { + RepositoryType = PipelineConstants.DollarSelfAlias, + Path = dollarSelfPath + }; + } else { var usesSegments = uses.Value.Split('@'); diff --git a/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs b/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs index 2e03671fbb2..af7d4940746 100644 --- a/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs +++ b/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs @@ -42,6 +42,12 @@ public static class PipelineConstants /// public static readonly String SelfAlias = "self"; + /// + /// Alias for dollar-self references ($/path). + /// Resolves to "this repo, at this SHA" based on the containing YAML file. + /// + public static readonly String DollarSelfAlias = "dollar-self"; + /// /// Error code during graph validation. /// diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index df80774d3a6..ac39a8e6026 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 (action.Uses!.Value.StartsWith("$/") || action.Uses!.Value.StartsWith("$\\")) + { + id = WorkflowConstants.DollarSelfAlias; + } else { var usesSegments = action.Uses!.Value.Split('@'); diff --git a/src/Sdk/WorkflowParser/WorkflowConstants.cs b/src/Sdk/WorkflowParser/WorkflowConstants.cs index e35c244e65e..8740306f595 100644 --- a/src/Sdk/WorkflowParser/WorkflowConstants.cs +++ b/src/Sdk/WorkflowParser/WorkflowConstants.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace GitHub.Actions.WorkflowParser { @@ -30,6 +30,11 @@ public static class WorkflowConstants /// internal const String SelfAlias = "self"; + /// + /// Alias for dollar-self references ($/path). + /// + internal const String DollarSelfAlias = "dollar-self"; + 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..721e4fabcc5 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -530,9 +530,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 +2386,7 @@ public void LoadsNode20ActionDefinition() } } - [Fact] + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void LoadsNode24ActionDefinition() @@ -2454,7 +2454,7 @@ public void LoadsNode24ActionDefinition() Teardown(); } } - + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -3468,5 +3468,110 @@ public async Task GetDownloadInfoAsync_OmitsDependencies_WhenEmpty() Teardown(); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_DollarSelf_ResolvesAtDepthZero() + { + 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.DollarSelfReference, "true"); + + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + RepositoryType = Pipelines.PipelineConstants.DollarSelfAlias, + 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 + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_DollarSelf_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.DollarSelfAlias, + Path = "actions/my-action" + } + } + }; + + // Act & Assert — should throw because unresolved dollar-self hits GetDownloadInfoLookupKey + await Assert.ThrowsAsync(async () => + await _actionManager.PrepareActionsAsync(_ec.Object, actions)); + } + finally + { + Teardown(); + } + } } }