@@ -11,7 +11,6 @@ import (
1111
1212 "github.com/github/gh-aw/pkg/console"
1313 "github.com/github/gh-aw/pkg/logger"
14- "github.com/github/gh-aw/pkg/sliceutil"
1514)
1615
1716var actionPinsLog = logger .New ("workflow:action_pins" )
@@ -48,6 +47,9 @@ type ActionPinsData struct {
4847var (
4948 // cachedActionPins holds the parsed and sorted action pins
5049 cachedActionPins []ActionPin
50+ // cachedActionPinsByRepo maps repo name to its sorted (version-descending) pins.
51+ // Built alongside cachedActionPins to enable O(1) repo lookups without linear scans.
52+ cachedActionPinsByRepo map [string ][]ActionPin
5153 // actionPinsOnce ensures the action pins are loaded only once
5254 actionPinsOnce sync.Once
5355)
@@ -106,11 +108,42 @@ func getActionPins() []ActionPin {
106108
107109 actionPinsLog .Printf ("Successfully unmarshaled and sorted %d action pins from JSON" , len (pins ))
108110 cachedActionPins = pins
111+
112+ // Build per-repo index for O(1) lookups.
113+ // Each repo's slice is sorted by semantic version (descending) so callers can
114+ // pick the highest compatible version without an additional sort pass.
115+ byRepo := make (map [string ][]ActionPin , len (pins ))
116+ for _ , pin := range pins {
117+ byRepo [pin .Repo ] = append (byRepo [pin .Repo ], pin )
118+ }
119+ // The global sort above orders all pins together, so grouping them by repo
120+ // does not guarantee per-repo ordering. Re-sort each repo's slice using the
121+ // semantic comparator (compareVersions) so that the index matches what
122+ // sortPinsByVersion would return for any subset of pins.
123+ for repo , repoPins := range byRepo {
124+ sort .Slice (repoPins , func (i , j int ) bool {
125+ v1 := strings .TrimPrefix (repoPins [i ].Version , "v" )
126+ v2 := strings .TrimPrefix (repoPins [j ].Version , "v" )
127+ return compareVersions (v1 , v2 ) > 0
128+ })
129+ byRepo [repo ] = repoPins
130+ }
131+ cachedActionPinsByRepo = byRepo
132+ actionPinsLog .Printf ("Built per-repo action pin index for %d repos" , len (byRepo ))
109133 })
110134
111135 return cachedActionPins
112136}
113137
138+ // getActionPinsByRepo returns the sorted (version-descending) list of action pins
139+ // for the given repository, initialising the cache on first call.
140+ // Returns nil if the repo has no pins.
141+ func getActionPinsByRepo (repo string ) []ActionPin {
142+ // Ensure the cache is initialised (both slices are populated in one sync.Once call).
143+ getActionPins ()
144+ return cachedActionPinsByRepo [repo ]
145+ }
146+
114147// sortPinsByVersion sorts action pins by version in descending order (highest first).
115148// This function returns a new sorted slice without modifying the input.
116149// This is an immutable operation for better safety and clarity.
@@ -135,23 +168,16 @@ func sortPinsByVersion(pins []ActionPin) []ActionPin {
135168// If no pin is found, it returns an empty string
136169// The returned reference includes a comment with the version tag (e.g., "repo@sha # v1")
137170func GetActionPin (actionRepo string ) string {
138- actionPins := getActionPins ()
139-
140- // Find all pins matching the repo - using functional filter
141- matchingPins := sliceutil .Filter (actionPins , func (pin ActionPin ) bool {
142- return pin .Repo == actionRepo
143- })
171+ // Use the pre-built per-repo index for O(1) lookup (avoids scanning all pins).
172+ matchingPins := getActionPinsByRepo (actionRepo )
144173
145174 if len (matchingPins ) == 0 {
146175 // If no pin exists, return empty string to signal that this action is not pinned
147176 return ""
148177 }
149178
150- // Sort matching pins by version (descending - latest first) - immutable operation
151- sortedPins := sortPinsByVersion (matchingPins )
152-
153- // Return the latest version (first after sorting)
154- latestPin := sortedPins [0 ]
179+ // The per-repo slice is already sorted by version descending; pick the first entry.
180+ latestPin := matchingPins [0 ]
155181 return formatActionReference (actionRepo , latestPin .SHA , latestPin .Version )
156182}
157183
@@ -187,23 +213,18 @@ func GetActionPinWithData(actionRepo, version string, data *WorkflowData) (strin
187213 }
188214 }
189215
190- // Dynamic resolution failed, try hardcoded pins
216+ // Dynamic resolution failed, try hardcoded pins.
217+ // Use the pre-built per-repo index for O(1) lookup (avoids scanning all pins).
191218 actionPinsLog .Printf ("Falling back to hardcoded pins for %s@%s" , actionRepo , version )
192- actionPins := getActionPins ()
193-
194- // Find all pins matching the repo - using functional filter
195- matchingPins := sliceutil .Filter (actionPins , func (pin ActionPin ) bool {
196- return pin .Repo == actionRepo
197- })
219+ matchingPins := getActionPinsByRepo (actionRepo )
198220
199221 if len (matchingPins ) == 0 {
200222 // No pins found for this repo, will handle below
201223 actionPinsLog .Printf ("No hardcoded pins found for %s" , actionRepo )
202224 } else {
203225 actionPinsLog .Printf ("Found %d hardcoded pin(s) for %s" , len (matchingPins ), actionRepo )
204226
205- // Sort matching pins by version (descending - highest first) - immutable operation
206- matchingPins = sortPinsByVersion (matchingPins )
227+ // The per-repo slice is already sorted by version descending; no extra sort needed.
207228
208229 // First, try to find an exact version match (for version tags)
209230 for _ , pin := range matchingPins {
@@ -231,18 +252,21 @@ func GetActionPinWithData(actionRepo, version string, data *WorkflowData) (strin
231252 // Semver compatibility means respecting major version boundaries
232253 // (e.g., v5 -> highest v5.x.x, not v6.x.x)
233254 if ! data .StrictMode && len (matchingPins ) > 0 {
234- // Filter for semver-compatible pins (matching major version) - using functional filter
235- compatiblePins := sliceutil .Filter (matchingPins , func (pin ActionPin ) bool {
236- return isSemverCompatible (pin .Version , version )
237- })
238-
239- // If we found compatible pins, use the highest one (first after sorting)
240- // Otherwise fall back to the highest overall pin
255+ // Find the first semver-compatible pin (already sorted descending, so first match is highest)
241256 var selectedPin ActionPin
242- if len (compatiblePins ) > 0 {
243- selectedPin = compatiblePins [0 ]
257+ foundCompatible := false
258+ for _ , pin := range matchingPins {
259+ if isSemverCompatible (pin .Version , version ) {
260+ selectedPin = pin
261+ foundCompatible = true
262+ break
263+ }
264+ }
265+
266+ if foundCompatible {
244267 actionPinsLog .Printf ("No exact match for version %s, using highest semver-compatible version: %s" , version , selectedPin .Version )
245268 } else {
269+ // Fall back to the highest overall pin
246270 selectedPin = matchingPins [0 ]
247271 actionPinsLog .Printf ("No exact match for version %s, no semver-compatible versions found, using highest available: %s" , version , selectedPin .Version )
248272 }
@@ -409,20 +433,13 @@ func ApplyActionPinsToTypedSteps(steps []*WorkflowStep, data *WorkflowData) []*W
409433// GetActionPinByRepo returns the ActionPin for a given repository, if it exists
410434// When multiple versions exist for the same repo, it returns the latest version by semver
411435func GetActionPinByRepo (repo string ) (ActionPin , bool ) {
412- actionPins := getActionPins ()
413-
414- // Find all pins matching the repo - using functional filter
415- matchingPins := sliceutil .Filter (actionPins , func (pin ActionPin ) bool {
416- return pin .Repo == repo
417- })
436+ // Use the pre-built per-repo index for O(1) lookup (avoids scanning all pins).
437+ matchingPins := getActionPinsByRepo (repo )
418438
419439 if len (matchingPins ) == 0 {
420440 return ActionPin {}, false
421441 }
422442
423- // Sort matching pins by version (descending - latest first) - immutable operation
424- sortedPins := sortPinsByVersion (matchingPins )
425-
426- // Return the latest version (first after sorting)
427- return sortedPins [0 ], true
443+ // The per-repo slice is already sorted by version descending; pick the first entry.
444+ return matchingPins [0 ], true
428445}
0 commit comments