From e6f79fc49d485283229ec88225e2006037cbe96c Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Sat, 23 May 2026 22:43:08 -0500 Subject: [PATCH] feat: add dollar-self action reference syntax Implement support for the $/ self-referencing syntax in the actions runner. This syntax resolves to 'this repo, at this SHA' based on the containing YAML file, allowing composite actions to reference sibling actions in the same repository without hardcoding versions. Key changes: - Parse $/path as RepositoryPathReference with RepositoryType='dollar-self' - Resolve dollar-self refs in ActionManager before download: - Depth 0 (workflow level): github.repository + github.sha - Depth > 0 (composite): parent action's Name + Ref - After resolution, refs flow through existing remote action infrastructure - Pre/post steps supported (unlike ./path local refs) - Feature-flagged via actions_dollar_self_reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Common/Constants.cs | 1 + src/Runner.Worker/ActionManager.cs | 93 +++++++++++++- .../PipelineTemplateConverter.cs | 22 +++- .../Pipelines/PipelineConstants.cs | 6 + .../Conversion/WorkflowTemplateConverter.cs | 4 + src/Sdk/WorkflowParser/WorkflowConstants.cs | 7 +- src/Test/L0/Worker/ActionManagerL0.cs | 115 +++++++++++++++++- 7 files changed, 237 insertions(+), 11 deletions(-) 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(); + } + } } }