@@ -79,6 +79,9 @@ public sealed class ActionManager : RunnerService, IActionManager
7979 PreStepTracker = new Dictionary < Guid , IActionRunner > ( )
8080 } ;
8181 var containerSetupSteps = new List < JobExtensionRunner > ( ) ;
82+ // Stack-local cache: same action (owner/repo@ref) is resolved only once,
83+ // even if it appears at multiple depths in a composite tree.
84+ var resolvedDownloadInfos = new Dictionary < string , WebApi . ActionDownloadInfo > ( StringComparer . Ordinal ) ;
8285 var depth = 0 ;
8386 // We are running at the start of a job
8487 if ( rootStepId == default ( Guid ) )
@@ -105,7 +108,7 @@ public sealed class ActionManager : RunnerService, IActionManager
105108 PrepareActionsState result = new PrepareActionsState ( ) ;
106109 try
107110 {
108- result = await PrepareActionsRecursiveAsync ( executionContext , state , actions , depth , rootStepId ) ;
111+ result = await PrepareActionsRecursiveAsync ( executionContext , state , actions , resolvedDownloadInfos , depth , rootStepId ) ;
109112 }
110113 catch ( FailedToResolveActionDownloadInfoException ex )
111114 {
@@ -161,13 +164,14 @@ public sealed class ActionManager : RunnerService, IActionManager
161164 return new PrepareResult ( containerSetupSteps , result . PreStepTracker ) ;
162165 }
163166
164- private async Task < PrepareActionsState > PrepareActionsRecursiveAsync ( IExecutionContext executionContext , PrepareActionsState state , IEnumerable < Pipelines . ActionStep > actions , Int32 depth = 0 , Guid parentStepId = default ( Guid ) )
167+ 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 ) )
165168 {
166169 ArgUtil . NotNull ( executionContext , nameof ( executionContext ) ) ;
167170 if ( depth > Constants . CompositeActionsMaxDepth )
168171 {
169172 throw new Exception ( $ "Composite action depth exceeded max depth { Constants . CompositeActionsMaxDepth } ") ;
170173 }
174+
171175 var repositoryActions = new List < Pipelines . ActionStep > ( ) ;
172176
173177 foreach ( var action in actions )
@@ -195,27 +199,29 @@ public sealed class ActionManager : RunnerService, IActionManager
195199
196200 if ( repositoryActions . Count > 0 )
197201 {
198- // Get the download info
199- var downloadInfos = await GetDownloadInfoAsync ( executionContext , repositoryActions ) ;
202+ // Resolve download info, skipping any actions already cached.
203+ await ResolveNewActionsAsync ( executionContext , repositoryActions , resolvedDownloadInfos ) ;
200204
201- // Download each action
205+ // Download each action.
202206 foreach ( var action in repositoryActions )
203207 {
204208 var lookupKey = GetDownloadInfoLookupKey ( action ) ;
205209 if ( string . IsNullOrEmpty ( lookupKey ) )
206210 {
207211 continue ;
208212 }
209-
210- if ( ! downloadInfos . TryGetValue ( lookupKey , out var downloadInfo ) )
213+ if ( ! resolvedDownloadInfos . TryGetValue ( lookupKey , out var downloadInfo ) )
211214 {
212215 throw new Exception ( $ "Missing download info for { lookupKey } ") ;
213216 }
214-
215217 await DownloadRepositoryActionAsync ( executionContext , downloadInfo ) ;
216218 }
217219
218- // More preparation based on content in the repository (action.yml)
220+ // Parse action.yml and collect composite sub-actions for batched
221+ // resolution below. Pre/post step registration is deferred until
222+ // after recursion so that HasPre/HasPost reflect the full subtree.
223+ var nextLevel = new List < ( Pipelines . ActionStep action , Guid parentId ) > ( ) ;
224+
219225 foreach ( var action in repositoryActions )
220226 {
221227 var setupInfo = PrepareRepositoryActionAsync ( executionContext , action ) ;
@@ -247,8 +253,35 @@ public sealed class ActionManager : RunnerService, IActionManager
247253 }
248254 else if ( setupInfo != null && setupInfo . Steps != null && setupInfo . Steps . Count > 0 )
249255 {
250- state = await PrepareActionsRecursiveAsync ( executionContext , state , setupInfo . Steps , depth + 1 , action . Id ) ;
256+ foreach ( var step in setupInfo . Steps )
257+ {
258+ nextLevel . Add ( ( step , action . Id ) ) ;
259+ }
251260 }
261+ }
262+
263+ // Resolve all next-level sub-actions in one batch API call,
264+ // then recurse per parent (which hits the cache, not the API).
265+ if ( nextLevel . Count > 0 )
266+ {
267+ var nextLevelRepoActions = nextLevel
268+ . Where ( x => x . action . Reference . Type == Pipelines . ActionSourceType . Repository )
269+ . Select ( x => x . action )
270+ . ToList ( ) ;
271+ await ResolveNewActionsAsync ( executionContext , nextLevelRepoActions , resolvedDownloadInfos ) ;
272+
273+ foreach ( var group in nextLevel . GroupBy ( x => x . parentId ) )
274+ {
275+ var groupActions = group . Select ( x => x . action ) . ToList ( ) ;
276+ state = await PrepareActionsRecursiveAsync ( executionContext , state , groupActions , resolvedDownloadInfos , depth + 1 , group . Key ) ;
277+ }
278+ }
279+
280+ // Register pre/post steps after recursion so that HasPre/HasPost
281+ // are correct (they depend on _cachedEmbeddedPreSteps/PostSteps
282+ // being populated by the recursive calls above).
283+ foreach ( var action in repositoryActions )
284+ {
252285 var repoAction = action . Reference as Pipelines . RepositoryPathReference ;
253286 if ( repoAction . RepositoryType != Pipelines . PipelineConstants . SelfAlias )
254287 {
@@ -754,6 +787,33 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext,
754787 return actionDownloadInfos . Actions ;
755788 }
756789
790+ /// <summary>
791+ /// Only resolves actions not already in resolvedDownloadInfos.
792+ /// Results are cached for reuse at deeper recursion levels.
793+ /// </summary>
794+ private async Task ResolveNewActionsAsync ( IExecutionContext executionContext , List < Pipelines . ActionStep > actions , Dictionary < string , WebApi . ActionDownloadInfo > resolvedDownloadInfos )
795+ {
796+ var actionsToResolve = new List < Pipelines . ActionStep > ( ) ;
797+ var pendingKeys = new HashSet < string > ( StringComparer . Ordinal ) ;
798+ foreach ( var action in actions )
799+ {
800+ var lookupKey = GetDownloadInfoLookupKey ( action ) ;
801+ if ( ! string . IsNullOrEmpty ( lookupKey ) && ! resolvedDownloadInfos . ContainsKey ( lookupKey ) && pendingKeys . Add ( 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+
757817 private async Task DownloadRepositoryActionAsync ( IExecutionContext executionContext , WebApi . ActionDownloadInfo downloadInfo )
758818 {
759819 Trace . Entering ( ) ;
0 commit comments