@@ -66,6 +66,7 @@ public sealed class ActionManager : RunnerService, IActionManager
6666 private readonly Dictionary < Guid , Stack < Pipelines . ActionStep > > _cachedEmbeddedPostSteps = new ( ) ;
6767 public Dictionary < Guid , Stack < Pipelines . ActionStep > > CachedEmbeddedPostSteps => _cachedEmbeddedPostSteps ;
6868
69+
6970 public async Task < PrepareResult > PrepareActionsAsync ( IExecutionContext executionContext , IEnumerable < Pipelines . JobStep > steps , Guid rootStepId = default ( Guid ) )
7071 {
7172 // Assert inputs
@@ -79,6 +80,9 @@ public sealed class ActionManager : RunnerService, IActionManager
7980 PreStepTracker = new Dictionary < Guid , IActionRunner > ( )
8081 } ;
8182 var containerSetupSteps = new List < JobExtensionRunner > ( ) ;
83+ // Stack-local cache: same action (owner/repo@ref) is resolved only once,
84+ // even if it appears at multiple depths in a composite tree.
85+ var resolvedDownloadInfos = new Dictionary < string , WebApi . ActionDownloadInfo > ( StringComparer . Ordinal ) ;
8286 var depth = 0 ;
8387 // We are running at the start of a job
8488 if ( rootStepId == default ( Guid ) )
@@ -105,7 +109,7 @@ public sealed class ActionManager : RunnerService, IActionManager
105109 PrepareActionsState result = new PrepareActionsState ( ) ;
106110 try
107111 {
108- result = await PrepareActionsRecursiveAsync ( executionContext , state , actions , depth , rootStepId ) ;
112+ result = await PrepareActionsRecursiveAsync ( executionContext , state , actions , resolvedDownloadInfos , depth , rootStepId ) ;
109113 }
110114 catch ( FailedToResolveActionDownloadInfoException ex )
111115 {
@@ -161,13 +165,14 @@ public sealed class ActionManager : RunnerService, IActionManager
161165 return new PrepareResult ( containerSetupSteps , result . PreStepTracker ) ;
162166 }
163167
164- private async Task < PrepareActionsState > PrepareActionsRecursiveAsync ( IExecutionContext executionContext , PrepareActionsState state , IEnumerable < Pipelines . ActionStep > actions , Int32 depth = 0 , Guid parentStepId = default ( Guid ) )
168+ private async Task < PrepareActionsState > PrepareActionsRecursiveAsync ( IExecutionContext executionContext , PrepareActionsState state , IEnumerable < Pipelines . ActionStep > actions , Dictionary < string , WebApi . ActionDownloadInfo > resolvedDownloadInfos , Int32 depth = 0 , Guid parentStepId = default ( Guid ) )
165169 {
166170 ArgUtil . NotNull ( executionContext , nameof ( executionContext ) ) ;
167171 if ( depth > Constants . CompositeActionsMaxDepth )
168172 {
169173 throw new Exception ( $ "Composite action depth exceeded max depth { Constants . CompositeActionsMaxDepth } ") ;
170174 }
175+
171176 var repositoryActions = new List < Pipelines . ActionStep > ( ) ;
172177
173178 foreach ( var action in actions )
@@ -195,27 +200,29 @@ public sealed class ActionManager : RunnerService, IActionManager
195200
196201 if ( repositoryActions . Count > 0 )
197202 {
198- // Get the download info
199- var downloadInfos = await GetDownloadInfoAsync ( executionContext , repositoryActions ) ;
203+ // Resolve download info, skipping any actions already cached.
204+ await ResolveNewActionsAsync ( executionContext , repositoryActions , resolvedDownloadInfos ) ;
200205
201- // Download each action
206+ // Download each action.
202207 foreach ( var action in repositoryActions )
203208 {
204209 var lookupKey = GetDownloadInfoLookupKey ( action ) ;
205210 if ( string . IsNullOrEmpty ( lookupKey ) )
206211 {
207212 continue ;
208213 }
209-
210- if ( ! downloadInfos . TryGetValue ( lookupKey , out var downloadInfo ) )
214+ if ( ! resolvedDownloadInfos . TryGetValue ( lookupKey , out var downloadInfo ) )
211215 {
212216 throw new Exception ( $ "Missing download info for { lookupKey } ") ;
213217 }
214-
215218 await DownloadRepositoryActionAsync ( executionContext , downloadInfo ) ;
216219 }
217220
218- // More preparation based on content in the repository (action.yml)
221+ // Parse action.yml and collect composite sub-actions for batched
222+ // resolution below. Pre/post step registration is deferred until
223+ // after recursion so that HasPre/HasPost reflect the full subtree.
224+ var nextLevel = new List < ( Pipelines . ActionStep action , Guid parentId ) > ( ) ;
225+
219226 foreach ( var action in repositoryActions )
220227 {
221228 var setupInfo = PrepareRepositoryActionAsync ( executionContext , action ) ;
@@ -247,8 +254,35 @@ public sealed class ActionManager : RunnerService, IActionManager
247254 }
248255 else if ( setupInfo != null && setupInfo . Steps != null && setupInfo . Steps . Count > 0 )
249256 {
250- state = await PrepareActionsRecursiveAsync ( executionContext , state , setupInfo . Steps , depth + 1 , action . Id ) ;
257+ foreach ( var step in setupInfo . Steps )
258+ {
259+ nextLevel . Add ( ( step , action . Id ) ) ;
260+ }
261+ }
262+ }
263+
264+ // Resolve all next-level sub-actions in one batch API call,
265+ // then recurse per parent (which hits the cache, not the API).
266+ if ( nextLevel . Count > 0 )
267+ {
268+ var nextLevelRepoActions = nextLevel
269+ . Where ( x => x . action . Reference . Type == Pipelines . ActionSourceType . Repository )
270+ . Select ( x => x . action )
271+ . ToList ( ) ;
272+ await ResolveNewActionsAsync ( executionContext , nextLevelRepoActions , resolvedDownloadInfos ) ;
273+
274+ foreach ( var group in nextLevel . GroupBy ( x => x . parentId ) )
275+ {
276+ var groupActions = group . Select ( x => x . action ) . ToList ( ) ;
277+ state = await PrepareActionsRecursiveAsync ( executionContext , state , groupActions , resolvedDownloadInfos , depth + 1 , group . Key ) ;
251278 }
279+ }
280+
281+ // Register pre/post steps after recursion so that HasPre/HasPost
282+ // are correct (they depend on _cachedEmbeddedPreSteps/PostSteps
283+ // being populated by the recursive calls above).
284+ foreach ( var action in repositoryActions )
285+ {
252286 var repoAction = action . Reference as Pipelines . RepositoryPathReference ;
253287 if ( repoAction . RepositoryType != Pipelines . PipelineConstants . SelfAlias )
254288 {
@@ -754,6 +788,33 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext,
754788 return actionDownloadInfos . Actions ;
755789 }
756790
791+ /// <summary>
792+ /// Only resolves actions not already in resolvedDownloadInfos.
793+ /// Results are cached for reuse at deeper recursion levels.
794+ /// </summary>
795+ private async Task ResolveNewActionsAsync ( IExecutionContext executionContext , List < Pipelines . ActionStep > actions , Dictionary < string , WebApi . ActionDownloadInfo > resolvedDownloadInfos )
796+ {
797+ var actionsToResolve = new List < Pipelines . ActionStep > ( ) ;
798+ foreach ( var action in actions )
799+ {
800+ var lookupKey = GetDownloadInfoLookupKey ( action ) ;
801+ if ( ! string . IsNullOrEmpty ( lookupKey ) && ! resolvedDownloadInfos . ContainsKey ( lookupKey ) )
802+ {
803+ actionsToResolve . Add ( action ) ;
804+ }
805+ }
806+
807+ if ( actionsToResolve . Count > 0 )
808+ {
809+ var downloadInfos = await GetDownloadInfoAsync ( executionContext , actionsToResolve ) ;
810+ foreach ( var kvp in downloadInfos )
811+ {
812+ resolvedDownloadInfos [ kvp . Key ] = kvp . Value ;
813+ }
814+ }
815+ }
816+
817+
757818 private async Task DownloadRepositoryActionAsync ( IExecutionContext executionContext , WebApi . ActionDownloadInfo downloadInfo )
758819 {
759820 Trace . Entering ( ) ;
0 commit comments