Skip to content

Commit bd99754

Browse files
committed
Lockfile: batch authoritative graph updates
1 parent acc41e1 commit bd99754

12 files changed

Lines changed: 519 additions & 124 deletions

File tree

cmd/gh-actions-lock/command_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,85 @@ jobs:
650650
assert.Contains(t, string(got), " - 'owner/child@v2'")
651651
}
652652

653+
func TestCheckCommand_PathSwitchReplacesSameCommitClosure(t *testing.T) {
654+
reg := &httpmock.Registry{}
655+
defer reg.Verify(t)
656+
657+
parentSHA := strings.Repeat("1", 40)
658+
oldChildSHA := strings.Repeat("2", 40)
659+
newChildSHA := strings.Repeat("3", 40)
660+
compositeYAML := "name: New sub-action\nruns:\n using: composite\n steps:\n - uses: new/child@v2\n"
661+
reg.Register(
662+
httpmock.GraphQLForRepo("owner", "composite"),
663+
httpmock.JSONResponse(map[string]any{
664+
"data": map[string]any{
665+
"a0": testRepoResponse("owner/composite", parentSHA, compositeYAML),
666+
},
667+
}),
668+
)
669+
reg.Register(
670+
httpmock.GraphQLForRepo("new", "child"),
671+
httpmock.JSONResponse(map[string]any{
672+
"data": map[string]any{
673+
"a0": testRepoResponse("new/child", newChildSHA, nodeActionYAML),
674+
},
675+
}),
676+
)
677+
reg.Register(
678+
httpmock.GraphQLForRepo("old", "child"),
679+
httpmock.JSONResponse(map[string]any{
680+
"data": map[string]any{
681+
"a0": testRepoResponse("old/child", oldChildSHA, nodeActionYAML),
682+
},
683+
}),
684+
)
685+
reg.Register(
686+
httpmock.REST(http.MethodGet, `repos/new/child$`),
687+
httpmock.JSONResponse(map[string]any{
688+
"id": 3,
689+
"owner": map[string]any{"id": 2},
690+
}),
691+
)
692+
693+
workflowPath := writeTempWorkflow(t, `
694+
name: ci
695+
on: push
696+
jobs:
697+
test:
698+
runs-on: ubuntu-latest
699+
steps:
700+
- uses: owner/composite/new@v1
701+
`)
702+
lock := "version: '" + parserlock.Version + "'\n" +
703+
"dependencies:\n" +
704+
" 'owner/composite@v1':\n" +
705+
" ref: 'v1'\n" +
706+
" commit: 'sha1-" + parentSHA + "'\n" +
707+
" owner_id: 1\n" +
708+
" repo_id: 1\n" +
709+
" uses:\n" +
710+
" - 'old/child@v1'\n" +
711+
" 'old/child@v1':\n" +
712+
" ref: 'v1'\n" +
713+
" commit: 'sha1-" + oldChildSHA + "'\n" +
714+
" owner_id: 2\n" +
715+
" repo_id: 2\n" +
716+
"workflows:\n" +
717+
" '.github/workflows/workflow.yml':\n" +
718+
" - 'owner/composite@v1'\n"
719+
lockPath := filepath.Join(".github", "workflows", "actions.lock")
720+
require.NoError(t, os.WriteFile(lockPath, []byte(lock), 0o600))
721+
722+
_, _, err := runCommandWithHTTP(t, reg, "--no-narrow", workflowPath)
723+
require.NoError(t, err)
724+
725+
got, readErr := os.ReadFile(lockPath)
726+
require.NoError(t, readErr)
727+
assert.Contains(t, string(got), "'new/child@v2'")
728+
assert.Contains(t, string(got), " - 'new/child@v2'")
729+
assert.NotContains(t, string(got), "'old/child@v1'")
730+
}
731+
653732
func TestCheckCommand_JSONDependenciesIncludesRecordedClosure(t *testing.T) {
654733
reg := &httpmock.Registry{}
655734
defer reg.Verify(t)

internal/dep/dependency.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ func (d Dependency) Key() string {
4949
return d.NWO + "@" + d.Ref
5050
}
5151

52+
// NormalizeKey lowercases the repository portion of an NWO@ref key while
53+
// preserving the case-sensitive git ref.
54+
func NormalizeKey(key string) string {
55+
at := strings.LastIndex(key, "@")
56+
if at < 0 {
57+
return strings.ToLower(key)
58+
}
59+
return strings.ToLower(key[:at]) + key[at:]
60+
}
61+
5262
// OwnerRepo splits NWO into owner and repo components.
5363
func (d Dependency) OwnerRepo() (string, string) {
5464
owner, repo, _ := parserlock.SplitNWO(d.NWO)

internal/lockfile/state.go

Lines changed: 95 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"fmt"
8+
"maps"
89
"os"
910
"path/filepath"
1011
"sort"
@@ -351,104 +352,120 @@ func (s *State) AllDeps() []dep.Dependency {
351352
// Resolution of owner/repo numeric IDs happens lazily per NWO and is cached
352353
// for the lifetime of the store.
353354
func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependency, parentMap map[string][]string, directKeys map[string]bool) error {
354-
return s.set(ctx, workflowKey, deps, parentMap, directKeys, nil)
355+
return s.SetWorkflows(ctx, []WorkflowUpdate{{
356+
WorkflowKey: workflowKey,
357+
Deps: deps,
358+
ParentMap: parentMap,
359+
DirectKeys: directKeys,
360+
ReplaceGraph: true,
361+
}})
355362
}
356363

357-
// SetScoped updates a workflow while preserving existing graph edges on
358-
// dependencies also reached by workflows outside the current command scope.
359-
func (s *State) SetScoped(ctx context.Context, workflowKey string, deps []dep.Dependency, parentMap map[string][]string, directKeys, scopedWorkflows map[string]bool) error {
360-
return s.set(ctx, workflowKey, deps, parentMap, directKeys, scopedWorkflows)
364+
// WorkflowUpdate is one workflow's contribution to a batch lockfile write.
365+
type WorkflowUpdate struct {
366+
WorkflowKey string
367+
Deps []dep.Dependency
368+
ParentMap dep.ParentMap
369+
DirectKeys map[string]bool
370+
ReplaceGraph bool
361371
}
362372

363-
func (s *State) set(ctx context.Context, workflowKey string, deps []dep.Dependency, parentMap map[string][]string, directKeys, scopedWorkflows map[string]bool) error {
373+
// SetWorkflows applies workflow roots and global dependency metadata together.
374+
// Complete live graphs replace recorded edges; incomplete or unscanned
375+
// workflows retain the recorded edge union they still reach.
376+
func (s *State) SetWorkflows(ctx context.Context, updates []WorkflowUpdate) error {
364377
// Resolve repo IDs for every unique owner/repo BEFORE taking s.mu so
365378
// concurrent pin workers don't serialize on the network round-trip.
366379
// lookupIDs is safe to call without s.mu and dedups in-flight fetches
367380
// for the same key via singleflight.
368-
seenRepos := make(map[string]struct{}, len(deps))
369-
for _, d := range deps {
370-
pin, err := depToPin(d)
371-
if err != nil {
372-
return err
373-
}
374-
k := pin.Owner + "/" + pin.Repo
375-
if _, ok := seenRepos[k]; ok {
376-
continue
377-
}
378-
seenRepos[k] = struct{}{}
379-
if _, err := s.lookupIDs(ctx, pin.Owner, pin.Repo); err != nil {
380-
return fmt.Errorf("resolving repo IDs for %s/%s: %w", pin.Owner, pin.Repo, err)
381+
seenRepos := make(map[string]struct{})
382+
for _, update := range updates {
383+
for _, d := range update.Deps {
384+
pin, err := depToPin(d)
385+
if err != nil {
386+
return err
387+
}
388+
k := pin.Owner + "/" + pin.Repo
389+
if _, ok := seenRepos[k]; ok {
390+
continue
391+
}
392+
seenRepos[k] = struct{}{}
393+
if _, err := s.lookupIDs(ctx, pin.Owner, pin.Repo); err != nil {
394+
return fmt.Errorf("resolving repo IDs for %s/%s: %w", pin.Owner, pin.Repo, err)
395+
}
381396
}
382397
}
383398

384399
s.mu.Lock()
385400
defer s.mu.Unlock()
386-
directPins := make([]string, 0)
387-
seenDirect := map[string]bool{}
388-
// keyToPin: Dependency.Key() (NWO@Ref) → canonical pin (NWO@Ref:algo-hex).
389-
keyToPin := make(map[string]string, len(deps))
390-
for _, d := range deps {
391-
pin, err := depToPin(d)
392-
if err != nil {
393-
return err
394-
}
395-
pin = pin.Canonical()
396-
pinKey := pin.String()
397-
keyToPin[d.Key()] = pinKey
398-
var isDirect bool
399-
if directKeys != nil {
400-
isDirect = directKeys[d.Key()]
401-
} else {
402-
_, hasParent := parentMap[d.Key()]
403-
isDirect = !hasParent
404-
}
405-
if isDirect && !seenDirect[pinKey] {
406-
seenDirect[pinKey] = true
407-
directPins = append(directPins, pinKey)
408-
}
409-
}
410-
411-
// Invert parentMap (child → parents) into parent → children, in canonical
412-
// pin-key form. Translate both sides via keyToPin; entries that don't
413-
// resolve to a known dep are skipped (they were filtered out before
414-
// reaching the writer).
401+
recordedDependencies := maps.Clone(s.file.Dependencies)
402+
recordedWorkflows := maps.Clone(s.file.Workflows)
403+
replacingWorkflows := make(map[string]bool, len(updates))
404+
workflowPins := make(map[string][]string, len(updates))
405+
depsByPin := make(map[string]dep.Dependency)
406+
replacePins := make(map[string]bool)
415407
parentToChildren := make(map[string]map[string]bool)
416-
for childDepKey, parents := range parentMap {
417-
childPin, ok := keyToPin[childDepKey]
418-
if !ok {
408+
for _, update := range updates {
409+
replacingWorkflows[update.WorkflowKey] = update.ReplaceGraph
410+
keyToPin := make(map[string]string, len(update.Deps))
411+
seenDirect := make(map[string]bool)
412+
for _, d := range update.Deps {
413+
pin, err := depToPin(d)
414+
if err != nil {
415+
return err
416+
}
417+
pinKey := pin.Canonical().String()
418+
keyToPin[d.Key()] = pinKey
419+
if existing, ok := depsByPin[pinKey]; ok && !strings.EqualFold(existing.SHA, d.SHA) {
420+
return fmt.Errorf("conflicting commits for %s", pinKey)
421+
}
422+
depsByPin[pinKey] = d
423+
if update.ReplaceGraph {
424+
replacePins[pinKey] = true
425+
}
426+
isDirect := update.DirectKeys[d.Key()]
427+
if update.DirectKeys == nil {
428+
_, hasParent := update.ParentMap[d.Key()]
429+
isDirect = !hasParent
430+
}
431+
if isDirect && !seenDirect[pinKey] {
432+
seenDirect[pinKey] = true
433+
workflowPins[update.WorkflowKey] = append(workflowPins[update.WorkflowKey], pinKey)
434+
}
435+
}
436+
if !update.ReplaceGraph {
419437
continue
420438
}
421-
for _, parentDepKey := range parents {
422-
parentPin, ok := keyToPin[parentDepKey]
439+
for childDepKey, parents := range update.ParentMap {
440+
childPin, ok := keyToPin[childDepKey]
423441
if !ok {
424442
continue
425443
}
426-
children, exists := parentToChildren[parentPin]
427-
if !exists {
428-
children = make(map[string]bool)
429-
parentToChildren[parentPin] = children
444+
for _, parentDepKey := range parents {
445+
parentPin, ok := keyToPin[parentDepKey]
446+
if !ok {
447+
continue
448+
}
449+
if parentToChildren[parentPin] == nil {
450+
parentToChildren[parentPin] = make(map[string]bool)
451+
}
452+
parentToChildren[parentPin][childPin] = true
430453
}
431-
children[childPin] = true
432454
}
455+
sort.Strings(workflowPins[update.WorkflowKey])
433456
}
434457

435-
// Now upsert action entries with their per-pin uses lists.
436-
for _, d := range deps {
458+
for pinKey, d := range depsByPin {
437459
pin, err := depToPin(d)
438460
if err != nil {
439461
return err
440462
}
441-
pin = pin.Canonical()
442-
pinKey := pin.String()
443463
// IDs were pre-resolved above (outside the mutex); read from cache
444464
// directly so we don't recursively re-acquire s.mu.
445465
ids, ok := s.idCache[strings.ToLower(pin.Owner+"/"+pin.Repo)]
446466
if !ok {
447467
return fmt.Errorf("resolving repo IDs for %s/%s: not in cache after pre-resolve", pin.Owner, pin.Repo)
448468
}
449-
// Merge uses: each workflow contributes its own transitive edges.
450-
// A dep that is a parent in one workflow but direct (no children)
451-
// in another must not clobber the first workflow's uses list.
452469
usesSet := make(map[string]bool)
453470
if children, ok := parentToChildren[pinKey]; ok {
454471
for c := range children {
@@ -473,10 +490,7 @@ func (s *State) set(ctx context.Context, workflowKey string, deps []dep.Dependen
473490
if ref == "" {
474491
ref = existing.Ref
475492
}
476-
if existing.Commit == commit || s.reachableFromUnscopedWorkflow(pinKey, workflowKey, scopedWorkflows) {
477-
// ponytail: actions.lock stores a global edge union, so keep the
478-
// old union when an untouched workflow reaches this parent.
479-
// A full-scope refresh can replace it exactly.
493+
if !replacePins[pinKey] || reachableFromPreservedWorkflow(pinKey, replacingWorkflows, recordedWorkflows, recordedDependencies) {
480494
for _, u := range existing.Uses {
481495
usesSet[u] = true
482496
}
@@ -498,12 +512,18 @@ func (s *State) set(ctx context.Context, workflowKey string, deps []dep.Dependen
498512
Uses: uses,
499513
}
500514
}
501-
sort.Strings(directPins)
502-
s.file.Workflows[workflowKey] = directPins
515+
for workflowKey, directPins := range workflowPins {
516+
s.file.Workflows[workflowKey] = directPins
517+
}
518+
for _, update := range updates {
519+
if _, ok := workflowPins[update.WorkflowKey]; !ok {
520+
s.file.Workflows[update.WorkflowKey] = nil
521+
}
522+
}
503523
return nil
504524
}
505525

506-
func (s *State) reachableFromUnscopedWorkflow(target, workflowKey string, scopedWorkflows map[string]bool) bool {
526+
func reachableFromPreservedWorkflow(target string, replacingWorkflows map[string]bool, workflows map[string][]string, dependencies map[string]parserlock.Action) bool {
507527
var reaches func(string, map[string]bool) bool
508528
reaches = func(key string, seen map[string]bool) bool {
509529
if key == target {
@@ -513,15 +533,15 @@ func (s *State) reachableFromUnscopedWorkflow(target, workflowKey string, scoped
513533
return false
514534
}
515535
seen[key] = true
516-
for _, child := range s.file.Dependencies[key].Uses {
536+
for _, child := range dependencies[key].Uses {
517537
if reaches(child, seen) {
518538
return true
519539
}
520540
}
521541
return false
522542
}
523-
for workflow, roots := range s.file.Workflows {
524-
if workflow == workflowKey || scopedWorkflows[workflow] {
543+
for workflow, roots := range workflows {
544+
if replacingWorkflows[workflow] {
525545
continue
526546
}
527547
for _, root := range roots {

0 commit comments

Comments
 (0)