From 76ab7f0c98f70bc20a0b317d1bcf4bb4780216b7 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 22 Apr 2026 13:00:52 -0500 Subject: [PATCH 1/4] feat: propagate actions dependencies --- src/Runner.Common/Constants.cs | 1 + src/Runner.Worker/ActionManager.cs | 13 +++++- src/Runner.Worker/ExecutionContext.cs | 3 ++ src/Runner.Worker/GlobalContext.cs | 1 + .../Pipelines/AgentJobRequestMessage.cs | 23 ++++++++++ .../DTWebApi/WebApi/ActionReferenceList.cs | 7 ++++ src/Sdk/WebApi/WebApi/LaunchContracts.cs | 3 ++ src/Sdk/WebApi/WebApi/LaunchHttpClient.cs | 3 +- .../Sdk/RSWebApi/AgentJobRequestMessageL0.cs | 42 +++++++++++++++++++ src/Test/L0/Worker/ActionManagerL0.cs | 2 +- 10 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 7a1f1cbb0a1..318ebf5e1b5 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -179,6 +179,7 @@ public static class Features public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers"; public static readonly string BatchActionResolution = "actions_batch_action_resolution"; public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload"; + public static readonly string PropagateDependencyPins = "actions_lockfile_propagate_dependencies"; } // Node version migration related constants diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 79c0de5f706..21ca1cd0c1e 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -880,6 +880,17 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, return new Dictionary(); } + var propagateDeps = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.PropagateDependencyPins) ?? false; + IList dependencies = null; + if (propagateDeps) + { + var deps = executionContext.Global.ActionsDependencies; + if (deps != null && deps.Count > 0) + { + dependencies = deps; + } + } + // Resolve download info var launchServer = HostContext.GetService(); var jobServer = HostContext.GetService(); @@ -891,7 +902,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, if (MessageUtil.IsRunServiceJob(executionContext.Global.Variables.Get(Constants.Variables.System.JobRequestType))) { var displayHelpfulActionsDownloadErrors = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.DisplayHelpfulActionsDownloadErrors) ?? false; - actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors); + actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences, Dependencies = dependencies }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors); } else { diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 4bdf3baf975..f072335b440 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -875,6 +875,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation // File table Global.FileTable = new List(message.FileTable ?? new string[0]); + // Workflow dependencies (lockfile pins) + Global.ActionsDependencies = message.ActionsDependencies; + // What type of job request is running (i.e. Run Service vs. pipelines) Global.Variables.Set(Constants.Variables.System.JobRequestType, message.MessageType); diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index b22b9f8ad44..04abe003633 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -38,5 +38,6 @@ public sealed class GlobalContext public HashSet DeprecatedNode20Actions { get; set; } public HashSet UpgradedToNode24Actions { get; set; } public HashSet Arm32Node20Actions { get; set; } + public IList ActionsDependencies { get; set; } } } diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index 465af8963fe..96cf07a71c2 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -267,6 +267,21 @@ public DebuggerTunnelInfo DebuggerTunnel set; } + /// + /// Gets the workflow-level action dependencies (lockfile entries) + /// + public IList ActionsDependencies + { + get + { + if (m_actionsDependencies == null) + { + m_actionsDependencies = new List(); + } + return m_actionsDependencies; + } + } + /// /// Gets the collection of variables associated with the current context. /// @@ -441,6 +456,11 @@ private void OnSerializing(StreamingContext context) m_variables = null; } + if (m_actionsDependencies?.Count == 0) + { + m_actionsDependencies = null; + } + // todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere if (!string.IsNullOrEmpty(m_jobContainerResourceAlias)) { @@ -466,6 +486,9 @@ private void OnSerializing(StreamingContext context) [DataMember(Name = "Variables", EmitDefaultValue = false)] private IDictionary m_variables; + [DataMember(Name = "dependencies", EmitDefaultValue = false)] + private List m_actionsDependencies; + // todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere [DataMember(Name = "JobSidecarContainers", EmitDefaultValue = false)] private IDictionary m_jobSidecarContainers; diff --git a/src/Sdk/DTWebApi/WebApi/ActionReferenceList.cs b/src/Sdk/DTWebApi/WebApi/ActionReferenceList.cs index b118b99040e..c3ce8a3dce2 100644 --- a/src/Sdk/DTWebApi/WebApi/ActionReferenceList.cs +++ b/src/Sdk/DTWebApi/WebApi/ActionReferenceList.cs @@ -12,5 +12,12 @@ public IList Actions get; set; } + + [DataMember(EmitDefaultValue = false)] + public IList Dependencies + { + get; + set; + } } } diff --git a/src/Sdk/WebApi/WebApi/LaunchContracts.cs b/src/Sdk/WebApi/WebApi/LaunchContracts.cs index 28b6ce3cf0e..a4fae1753d3 100644 --- a/src/Sdk/WebApi/WebApi/LaunchContracts.cs +++ b/src/Sdk/WebApi/WebApi/LaunchContracts.cs @@ -22,6 +22,9 @@ public class ActionReferenceRequestList { [DataMember(EmitDefaultValue = false, Name = "actions")] public IList Actions { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "actions_dependencies")] + public IList ActionsDependencies { get; set; } } [DataContract] diff --git a/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs b/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs index 24e398636b1..49d4a043592 100644 --- a/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs +++ b/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs @@ -97,7 +97,8 @@ private static ActionReferenceRequestList ToGitHubData(ActionReferenceList actio { return new ActionReferenceRequestList { - Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList() + Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList(), + ActionsDependencies = actionReferenceList.Dependencies }; } diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs index 4756d3de0d8..1a451d28f12 100644 --- a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -119,6 +119,48 @@ public void VerifyDebuggerTunnelDeserialization_WithoutTunnel() Assert.Null(recoveredMessage.DebuggerTunnel); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyActionsDependenciesDeserialization_WithDependencies() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string json = DoubleQuotify("{'dependencies': ['actions/checkout@v4:sha256-abc123', 'actions/setup-node@v4:sha256-def456']}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.Equal(2, recoveredMessage.ActionsDependencies.Count); + Assert.Equal("actions/checkout@v4:sha256-abc123", recoveredMessage.ActionsDependencies[0]); + Assert.Equal("actions/setup-node@v4:sha256-def456", recoveredMessage.ActionsDependencies[1]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string json = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.Empty(recoveredMessage.ActionsDependencies); + } + private static string DoubleQuotify(string text) { return text.Replace('\'', '"'); diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 501402a88b4..28891a5ebcb 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -3164,7 +3164,7 @@ private void Setup([CallerMemberName] string name = "", bool enableComposite = t _ec.Setup(x => x.Global).Returns(new GlobalContext()); _ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token); _ec.Setup(x => x.Root).Returns(new GitHub.Runner.Worker.ExecutionContext()); - var variables = new Dictionary(); + var variables = new Dictionary() {}; _ec.Object.Global.Variables = new Variables(_hc, variables); _ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData()); _ec.Setup(x => x.ExpressionFunctions).Returns(new List()); From 26c17657c307143087fa39185ca8aef00935d2f4 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 22 Apr 2026 15:40:34 -0500 Subject: [PATCH 2/4] fix: drop feature flag --- src/Runner.Common/Constants.cs | 1 - src/Runner.Worker/ActionManager.cs | 14 ++++---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 318ebf5e1b5..7a1f1cbb0a1 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -179,7 +179,6 @@ public static class Features public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers"; public static readonly string BatchActionResolution = "actions_batch_action_resolution"; public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload"; - public static readonly string PropagateDependencyPins = "actions_lockfile_propagate_dependencies"; } // Node version migration related constants diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 21ca1cd0c1e..685240b92a9 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -880,16 +880,10 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, return new Dictionary(); } - var propagateDeps = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.PropagateDependencyPins) ?? false; - IList dependencies = null; - if (propagateDeps) - { - var deps = executionContext.Global.ActionsDependencies; - if (deps != null && deps.Count > 0) - { - dependencies = deps; - } - } + // Pass lockfile dependencies to Launch when present, so it can + // perform ref-scoped policy matching with the original refs. + var deps = executionContext.Global.ActionsDependencies; + IList dependencies = (deps != null && deps.Count > 0) ? deps : null; // Resolve download info var launchServer = HostContext.GetService(); From fae485c2b263823ef990e85f5588ccb0f078a4cf Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 22 Apr 2026 16:30:26 -0500 Subject: [PATCH 3/4] fix: remove unintended change --- src/Test/L0/Worker/ActionManagerL0.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 28891a5ebcb..501402a88b4 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -3164,7 +3164,7 @@ private void Setup([CallerMemberName] string name = "", bool enableComposite = t _ec.Setup(x => x.Global).Returns(new GlobalContext()); _ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token); _ec.Setup(x => x.Root).Returns(new GitHub.Runner.Worker.ExecutionContext()); - var variables = new Dictionary() {}; + var variables = new Dictionary(); _ec.Object.Global.Variables = new Variables(_hc, variables); _ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData()); _ec.Setup(x => x.ExpressionFunctions).Returns(new List()); From cb56f1599892e8b479d7bb5ef9f897e33b4db76f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 22 Apr 2026 16:30:36 -0500 Subject: [PATCH 4/4] fix: extend test coverage --- src/Test/L0/Worker/ActionManagerL0.cs | 136 ++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 501402a88b4..7ed5219a190 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -3283,5 +3283,141 @@ private void Teardown() Directory.Delete(_workFolder, recursive: true); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task GetDownloadInfoAsync_PropagatesDependencies_WhenPresent() + { + try + { + // Arrange + Setup(); + + // Set RunServiceJob so we hit the Launch path + _ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest"); + + // Populate lockfile dependencies + _ec.Object.Global.ActionsDependencies = new List + { + "github.com/actions/checkout@v4:sha256-abc123", + "github.com/actions/setup-node@v4:sha256-def456" + }; + + // Capture the ActionReferenceList passed to Launch + ActionReferenceList capturedList = null; + _launchServer + .Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((planId, jobId, list, ct, display) => capturedList = list) + .Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken ct, bool display) => + { + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + + var actionStep = new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/checkout", + Ref = "v4", + RepositoryType = "GitHub" + } + }; + + // Act + var result = await _actionManager.PrepareActionsAsync(_ec.Object, new List { actionStep }, default); + + // Assert + Assert.NotNull(capturedList); + Assert.NotNull(capturedList.Dependencies); + Assert.Equal(2, capturedList.Dependencies.Count); + Assert.Equal("github.com/actions/checkout@v4:sha256-abc123", capturedList.Dependencies[0]); + Assert.Equal("github.com/actions/setup-node@v4:sha256-def456", capturedList.Dependencies[1]); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task GetDownloadInfoAsync_OmitsDependencies_WhenEmpty() + { + try + { + // Arrange + Setup(); + + // Set RunServiceJob so we hit the Launch path + _ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest"); + + // No dependencies set (default empty list from GlobalContext) + + // Capture the ActionReferenceList passed to Launch + ActionReferenceList capturedList = null; + _launchServer + .Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((planId, jobId, list, ct, display) => capturedList = list) + .Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken ct, bool display) => + { + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + + var actionStep = new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "actions/checkout", + Ref = "v4", + RepositoryType = "GitHub" + } + }; + + // Act + var result = await _actionManager.PrepareActionsAsync(_ec.Object, new List { actionStep }, default); + + // Assert + Assert.NotNull(capturedList); + Assert.Null(capturedList.Dependencies); + } + finally + { + Teardown(); + } + } } }