Skip to content

Commit b06c585

Browse files
authored
feat: propagate actions dependencies (#4372)
1 parent c6f978f commit b06c585

9 files changed

Lines changed: 223 additions & 2 deletions

File tree

src/Runner.Worker/ActionManager.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,11 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext,
880880
return new Dictionary<string, WebApi.ActionDownloadInfo>();
881881
}
882882

883+
// Pass lockfile dependencies to Launch when present, so it can
884+
// perform ref-scoped policy matching with the original refs.
885+
var deps = executionContext.Global.ActionsDependencies;
886+
IList<string> dependencies = (deps != null && deps.Count > 0) ? deps : null;
887+
883888
// Resolve download info
884889
var launchServer = HostContext.GetService<ILaunchServer>();
885890
var jobServer = HostContext.GetService<IJobServer>();
@@ -891,7 +896,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext,
891896
if (MessageUtil.IsRunServiceJob(executionContext.Global.Variables.Get(Constants.Variables.System.JobRequestType)))
892897
{
893898
var displayHelpfulActionsDownloadErrors = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.DisplayHelpfulActionsDownloadErrors) ?? false;
894-
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
899+
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences, Dependencies = dependencies }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
895900
}
896901
else
897902
{

src/Runner.Worker/ExecutionContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation
875875
// File table
876876
Global.FileTable = new List<String>(message.FileTable ?? new string[0]);
877877

878+
// Workflow dependencies (lockfile pins)
879+
Global.ActionsDependencies = message.ActionsDependencies;
880+
878881
// What type of job request is running (i.e. Run Service vs. pipelines)
879882
Global.Variables.Set(Constants.Variables.System.JobRequestType, message.MessageType);
880883

src/Runner.Worker/GlobalContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@ public sealed class GlobalContext
3838
public HashSet<string> DeprecatedNode20Actions { get; set; }
3939
public HashSet<string> UpgradedToNode24Actions { get; set; }
4040
public HashSet<string> Arm32Node20Actions { get; set; }
41+
public IList<String> ActionsDependencies { get; set; }
4142
}
4243
}

src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,21 @@ public DebuggerTunnelInfo DebuggerTunnel
267267
set;
268268
}
269269

270+
/// <summary>
271+
/// Gets the workflow-level action dependencies (lockfile entries)
272+
/// </summary>
273+
public IList<String> ActionsDependencies
274+
{
275+
get
276+
{
277+
if (m_actionsDependencies == null)
278+
{
279+
m_actionsDependencies = new List<String>();
280+
}
281+
return m_actionsDependencies;
282+
}
283+
}
284+
270285
/// <summary>
271286
/// Gets the collection of variables associated with the current context.
272287
/// </summary>
@@ -441,6 +456,11 @@ private void OnSerializing(StreamingContext context)
441456
m_variables = null;
442457
}
443458

459+
if (m_actionsDependencies?.Count == 0)
460+
{
461+
m_actionsDependencies = null;
462+
}
463+
444464
// todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere
445465
if (!string.IsNullOrEmpty(m_jobContainerResourceAlias))
446466
{
@@ -466,6 +486,9 @@ private void OnSerializing(StreamingContext context)
466486
[DataMember(Name = "Variables", EmitDefaultValue = false)]
467487
private IDictionary<String, VariableValue> m_variables;
468488

489+
[DataMember(Name = "dependencies", EmitDefaultValue = false)]
490+
private List<String> m_actionsDependencies;
491+
469492
// todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere
470493
[DataMember(Name = "JobSidecarContainers", EmitDefaultValue = false)]
471494
private IDictionary<String, String> m_jobSidecarContainers;

src/Sdk/DTWebApi/WebApi/ActionReferenceList.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,12 @@ public IList<ActionReference> Actions
1212
get;
1313
set;
1414
}
15+
16+
[DataMember(EmitDefaultValue = false)]
17+
public IList<string> Dependencies
18+
{
19+
get;
20+
set;
21+
}
1522
}
1623
}

src/Sdk/WebApi/WebApi/LaunchContracts.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public class ActionReferenceRequestList
2222
{
2323
[DataMember(EmitDefaultValue = false, Name = "actions")]
2424
public IList<ActionReferenceRequest> Actions { get; set; }
25+
26+
[DataMember(EmitDefaultValue = false, Name = "actions_dependencies")]
27+
public IList<string> ActionsDependencies { get; set; }
2528
}
2629

2730
[DataContract]

src/Sdk/WebApi/WebApi/LaunchHttpClient.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ private static ActionReferenceRequestList ToGitHubData(ActionReferenceList actio
9797
{
9898
return new ActionReferenceRequestList
9999
{
100-
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList()
100+
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList(),
101+
ActionsDependencies = actionReferenceList.Dependencies
101102
};
102103
}
103104

src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,48 @@ public void VerifyDebuggerTunnelDeserialization_WithoutTunnel()
119119
Assert.Null(recoveredMessage.DebuggerTunnel);
120120
}
121121

122+
[Fact]
123+
[Trait("Level", "L0")]
124+
[Trait("Category", "Common")]
125+
public void VerifyActionsDependenciesDeserialization_WithDependencies()
126+
{
127+
// Arrange
128+
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
129+
string json = DoubleQuotify("{'dependencies': ['actions/checkout@v4:sha256-abc123', 'actions/setup-node@v4:sha256-def456']}");
130+
131+
// Act
132+
using var stream = new MemoryStream();
133+
stream.Write(Encoding.UTF8.GetBytes(json));
134+
stream.Position = 0;
135+
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
136+
137+
// Assert
138+
Assert.NotNull(recoveredMessage);
139+
Assert.Equal(2, recoveredMessage.ActionsDependencies.Count);
140+
Assert.Equal("actions/checkout@v4:sha256-abc123", recoveredMessage.ActionsDependencies[0]);
141+
Assert.Equal("actions/setup-node@v4:sha256-def456", recoveredMessage.ActionsDependencies[1]);
142+
}
143+
144+
[Fact]
145+
[Trait("Level", "L0")]
146+
[Trait("Category", "Common")]
147+
public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty()
148+
{
149+
// Arrange
150+
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
151+
string json = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
152+
153+
// Act
154+
using var stream = new MemoryStream();
155+
stream.Write(Encoding.UTF8.GetBytes(json));
156+
stream.Position = 0;
157+
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
158+
159+
// Assert
160+
Assert.NotNull(recoveredMessage);
161+
Assert.Empty(recoveredMessage.ActionsDependencies);
162+
}
163+
122164
private static string DoubleQuotify(string text)
123165
{
124166
return text.Replace('\'', '"');

src/Test/L0/Worker/ActionManagerL0.cs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3283,5 +3283,141 @@ private void Teardown()
32833283
Directory.Delete(_workFolder, recursive: true);
32843284
}
32853285
}
3286+
3287+
[Fact]
3288+
[Trait("Level", "L0")]
3289+
[Trait("Category", "Worker")]
3290+
public async Task GetDownloadInfoAsync_PropagatesDependencies_WhenPresent()
3291+
{
3292+
try
3293+
{
3294+
// Arrange
3295+
Setup();
3296+
3297+
// Set RunServiceJob so we hit the Launch path
3298+
_ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest");
3299+
3300+
// Populate lockfile dependencies
3301+
_ec.Object.Global.ActionsDependencies = new List<string>
3302+
{
3303+
"github.com/actions/checkout@v4:sha256-abc123",
3304+
"github.com/actions/setup-node@v4:sha256-def456"
3305+
};
3306+
3307+
// Capture the ActionReferenceList passed to Launch
3308+
ActionReferenceList capturedList = null;
3309+
_launchServer
3310+
.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
3311+
.Callback<Guid, Guid, ActionReferenceList, CancellationToken, bool>((planId, jobId, list, ct, display) => capturedList = list)
3312+
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken ct, bool display) =>
3313+
{
3314+
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
3315+
foreach (var action in actions.Actions)
3316+
{
3317+
var key = $"{action.NameWithOwner}@{action.Ref}";
3318+
result.Actions[key] = new ActionDownloadInfo
3319+
{
3320+
NameWithOwner = action.NameWithOwner,
3321+
Ref = action.Ref,
3322+
ResolvedNameWithOwner = action.NameWithOwner,
3323+
ResolvedSha = $"{action.Ref}-sha",
3324+
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
3325+
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
3326+
};
3327+
}
3328+
return Task.FromResult(result);
3329+
});
3330+
3331+
var actionStep = new Pipelines.ActionStep()
3332+
{
3333+
Name = "action",
3334+
Id = Guid.NewGuid(),
3335+
Reference = new Pipelines.RepositoryPathReference()
3336+
{
3337+
Name = "actions/checkout",
3338+
Ref = "v4",
3339+
RepositoryType = "GitHub"
3340+
}
3341+
};
3342+
3343+
// Act
3344+
var result = await _actionManager.PrepareActionsAsync(_ec.Object, new List<Pipelines.JobStep> { actionStep }, default);
3345+
3346+
// Assert
3347+
Assert.NotNull(capturedList);
3348+
Assert.NotNull(capturedList.Dependencies);
3349+
Assert.Equal(2, capturedList.Dependencies.Count);
3350+
Assert.Equal("github.com/actions/checkout@v4:sha256-abc123", capturedList.Dependencies[0]);
3351+
Assert.Equal("github.com/actions/setup-node@v4:sha256-def456", capturedList.Dependencies[1]);
3352+
}
3353+
finally
3354+
{
3355+
Teardown();
3356+
}
3357+
}
3358+
3359+
[Fact]
3360+
[Trait("Level", "L0")]
3361+
[Trait("Category", "Worker")]
3362+
public async Task GetDownloadInfoAsync_OmitsDependencies_WhenEmpty()
3363+
{
3364+
try
3365+
{
3366+
// Arrange
3367+
Setup();
3368+
3369+
// Set RunServiceJob so we hit the Launch path
3370+
_ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest");
3371+
3372+
// No dependencies set (default empty list from GlobalContext)
3373+
3374+
// Capture the ActionReferenceList passed to Launch
3375+
ActionReferenceList capturedList = null;
3376+
_launchServer
3377+
.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
3378+
.Callback<Guid, Guid, ActionReferenceList, CancellationToken, bool>((planId, jobId, list, ct, display) => capturedList = list)
3379+
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken ct, bool display) =>
3380+
{
3381+
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
3382+
foreach (var action in actions.Actions)
3383+
{
3384+
var key = $"{action.NameWithOwner}@{action.Ref}";
3385+
result.Actions[key] = new ActionDownloadInfo
3386+
{
3387+
NameWithOwner = action.NameWithOwner,
3388+
Ref = action.Ref,
3389+
ResolvedNameWithOwner = action.NameWithOwner,
3390+
ResolvedSha = $"{action.Ref}-sha",
3391+
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
3392+
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
3393+
};
3394+
}
3395+
return Task.FromResult(result);
3396+
});
3397+
3398+
var actionStep = new Pipelines.ActionStep()
3399+
{
3400+
Name = "action",
3401+
Id = Guid.NewGuid(),
3402+
Reference = new Pipelines.RepositoryPathReference()
3403+
{
3404+
Name = "actions/checkout",
3405+
Ref = "v4",
3406+
RepositoryType = "GitHub"
3407+
}
3408+
};
3409+
3410+
// Act
3411+
var result = await _actionManager.PrepareActionsAsync(_ec.Object, new List<Pipelines.JobStep> { actionStep }, default);
3412+
3413+
// Assert
3414+
Assert.NotNull(capturedList);
3415+
Assert.Null(capturedList.Dependencies);
3416+
}
3417+
finally
3418+
{
3419+
Teardown();
3420+
}
3421+
}
32863422
}
32873423
}

0 commit comments

Comments
 (0)