diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 79c0de5f706..685240b92a9 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -880,6 +880,11 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, return new Dictionary(); } + // 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(); var jobServer = HostContext.GetService(); @@ -891,7 +896,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..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(); + } + } } }