Decide parity findings in one disposition step; keep unreplaced pins … · github/gh-actions-lock@ec26bdc · GitHub
Skip to content

Commit ec26bdc

Browse files
authored
Decide parity findings in one disposition step; keep unreplaced pins blocking (#197)
1 parent 8bb4bb6 commit ec26bdc

22 files changed

Lines changed: 3194 additions & 272 deletions

‎cmd/gh-actions-lock/parity_test.go‎

Lines changed: 2081 additions & 146 deletions
Large diffs are not rendered by default.

‎cmd/gh-actions-lock/run.go‎

Lines changed: 15 additions & 0 deletions

‎internal/lockfile/preview.go‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package lockfile
2+
3+
import (
4+
"context"
5+
"errors"
6+
"maps"
7+
"slices"
8+
9+
parserlock "github.com/github/actions-lockfile/go/pkg/lockfile"
10+
)
11+
12+
// errPreview reports a write attempted on a preview.
13+
var errPreview = errors.New("lockfile preview is never saved")
14+
15+
// Preview returns an in-memory copy of s for computing what a write would
16+
// leave on disk. It never reaches the network or the file: an owner/repo
17+
// ID missing from s's cache gets a placeholder in the preview's own cache,
18+
// and Save refuses. So a preview matches the real write in its keys, refs,
19+
// commits, edges and workflow lists, which no ID decides, but not in its
20+
// IDs. Writes to the preview leave s alone.
21+
func (s *State) Preview() *State {
22+
s.mu.Lock()
23+
defer s.mu.Unlock()
24+
file := s.file
25+
file.Dependencies = make(map[string]parserlock.Action, len(s.file.Dependencies))
26+
for k, a := range s.file.Dependencies {
27+
a.Uses = slices.Clone(a.Uses)
28+
file.Dependencies[k] = a
29+
}
30+
file.Workflows = make(map[string][]string, len(s.file.Workflows))
31+
for k, w := range s.file.Workflows {
32+
file.Workflows[k] = slices.Clone(w)
33+
}
34+
return &State{
35+
file: file,
36+
originalVersion: s.originalVersion,
37+
meta: previewIDs{},
38+
idCache: maps.Clone(s.idCache),
39+
hostname: s.hostname,
40+
preview: true,
41+
}
42+
}
43+
44+
// GC drops entries no workflow reaches, as Save does.
45+
func (s *State) GC() {
46+
s.mu.Lock()
47+
defer s.mu.Unlock()
48+
s.pruneOrphans()
49+
}
50+
51+
// Entry returns the action recorded under an on-disk key.
52+
func (s *State) Entry(key string) (parserlock.Action, bool) {
53+
s.mu.Lock()
54+
defer s.mu.Unlock()
55+
a, ok := s.file.Dependencies[key]
56+
return a, ok
57+
}
58+
59+
// UsedBy returns the keys of the entries whose recorded `uses:` lists key,
60+
// sorted.
61+
func (s *State) UsedBy(key string) []string {
62+
s.mu.Lock()
63+
defer s.mu.Unlock()
64+
var out []string
65+
for k, a := range s.file.Dependencies {
66+
if slices.Contains(a.Uses, key) {
67+
out = append(out, k)
68+
}
69+
}
70+
slices.Sort(out)
71+
return out
72+
}
73+
74+
// EntryKey is a pin's on-disk key.
75+
func EntryKey(p parserlock.Pin) string {
76+
return p.Canonical().String()
77+
}
78+
79+
// previewIDs stands in for the metadata resolver in a preview.
80+
type previewIDs struct{}
81+
82+
func (previewIDs) RepoIDs(context.Context, string, string, string) (int64, int64, error) {
83+
return 1, 1, nil
84+
}

‎internal/lockfile/preview_test.go‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package lockfile
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/github/gh-actions-lock/internal/dep"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// noNetwork fails the test if a preview asks for repo IDs.
16+
type noNetwork struct{ t *testing.T }
17+
18+
func (n noNetwork) RepoIDs(context.Context, string, string, string) (int64, int64, error) {
19+
n.t.Fatal("a preview must not look up repo IDs")
20+
return 0, 0, nil
21+
}
22+
23+
func TestPreview(t *testing.T) {
24+
a, b := strings.Repeat("a", 40), strings.Repeat("b", 40)
25+
path := filepath.Join(t.TempDir(), "actions.lock")
26+
body := "version: v0.0.3\nworkflows:\n .github/workflows/ci.yml:\n - o/root@v1\ndependencies:\n" +
27+
" o/root@v1:\n ref: v1\n commit: sha1-" + a + "\n owner_id: 1\n repo_id: 2\n uses:\n - o/child@v2\n" +
28+
" o/child@v2:\n ref: v2\n commit: sha1-" + a + "\n owner_id: 1\n repo_id: 3\n"
29+
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
30+
store, err := LoadStateAt(path, noNetwork{t})
31+
require.NoError(t, err)
32+
before := store.File()
33+
beforeUses := append([]string(nil), before.Dependencies["o/root@v1"].Uses...)
34+
35+
preview := store.Preview()
36+
// o/new has no cached IDs: the preview fills a placeholder offline.
37+
newDep := dep.Dependency{NWO: "o/new", Ref: "v1", SHA: b}
38+
rootDep := dep.Dependency{NWO: "o/root", Ref: "v1", SHA: b}
39+
require.NoError(t, preview.Set(context.Background(), ".github/workflows/ci.yml",
40+
[]dep.Dependency{rootDep, newDep}, nil, map[string]bool{rootDep.Key(): true, newDep.Key(): true}))
41+
preview.GC()
42+
43+
got, ok := preview.Entry("o/root@v1")
44+
require.True(t, ok)
45+
assert.Equal(t, "sha1-"+b, got.Commit)
46+
assert.Equal(t, []string{"o/child@v2"}, got.Uses, "writing a workflow keeps recorded edges")
47+
_, ok = preview.Entry("o/child@v2")
48+
assert.True(t, ok, "a recorded edge keeps its child through GC")
49+
assert.Equal(t, []string{"o/root@v1"}, preview.UsedBy("o/child@v2"))
50+
_, ok = preview.Entry("o/new@v1")
51+
assert.True(t, ok)
52+
53+
after := store.File()
54+
assert.Equal(t, "sha1-"+a, after.Dependencies["o/root@v1"].Commit, "the store is untouched")
55+
assert.Equal(t, beforeUses, after.Dependencies["o/root@v1"].Uses)
56+
assert.NotContains(t, after.Dependencies, "o/new@v1")
57+
assert.ErrorIs(t, preview.Save(), errPreview)
58+
raw, err := os.ReadFile(path)
59+
require.NoError(t, err)
60+
assert.Equal(t, body, string(raw), "nothing reaches disk")
61+
}
62+
63+
type fixedIDs struct{}
64+
65+
func (fixedIDs) RepoIDs(context.Context, string, string, string) (int64, int64, error) {
66+
return 7, 8, nil
67+
}
68+
69+
// TestPreviewProjectsRealSet: a write to a preview leaves the same keys,
70+
// refs, commits, edges and workflow lists as the same write to the store;
71+
// only the owner/repo IDs it couldn't look up differ. The preview's
72+
// placeholder IDs never reach the store's cache.
73+
func TestPreviewProjectsRealSet(t *testing.T) {
74+
a, b := strings.Repeat("a", 40), strings.Repeat("b", 40)
75+
path := filepath.Join(t.TempDir(), "actions.lock")
76+
body := "version: v0.0.3\nworkflows:\n .github/workflows/ci.yml:\n - o/root@v1\n .github/workflows/other.yml:\n - o/keep@v1\ndependencies:\n" +
77+
" o/root@v1:\n ref: v1\n commit: sha1-" + a + "\n owner_id: 1\n repo_id: 2\n uses:\n - o/child@v2\n" +
78+
" o/child@v2:\n ref: v2\n commit: sha1-" + a + "\n owner_id: 1\n repo_id: 3\n" +
79+
" o/keep@v1:\n ref: v1\n commit: sha1-" + a + "\n owner_id: 1\n repo_id: 4\n" +
80+
" o/orphan@v1:\n ref: v1\n commit: sha1-" + a + "\n owner_id: 1\n repo_id: 5\n"
81+
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
82+
store, err := LoadStateAt(path, fixedIDs{})
83+
require.NoError(t, err)
84+
preview := store.Preview()
85+
86+
deps := []dep.Dependency{{NWO: "o/root", Ref: "v1", SHA: b}, {NWO: "o/new", Ref: "v1", SHA: b}}
87+
direct := map[string]bool{deps[0].Key(): true, deps[1].Key(): true}
88+
ctx := context.Background()
89+
require.NoError(t, preview.Set(ctx, ".github/workflows/ci.yml", deps, nil, direct))
90+
preview.GC()
91+
assert.Zero(t, store.RecordedRepoID("o", "new"), "a preview never fills the store's ID cache")
92+
93+
require.NoError(t, store.Set(ctx, ".github/workflows/ci.yml", deps, nil, direct))
94+
store.GC()
95+
project := func(s *State) (map[string][3]string, map[string][]string) {
96+
f := s.File()
97+
out := map[string][3]string{}
98+
for k, a := range f.Dependencies {
99+
out[k] = [3]string{a.Ref, a.Commit, strings.Join(a.Uses, ",")}
100+
}
101+
return out, f.Workflows
102+
}
103+
wantDeps, wantWorkflows := project(store)
104+
gotDeps, gotWorkflows := project(preview)
105+
assert.Equal(t, wantDeps, gotDeps)
106+
assert.Equal(t, wantWorkflows, gotWorkflows)
107+
assert.NotContains(t, gotDeps, "o/orphan@v1", "GC drops what no workflow reaches, as Save does")
108+
}

‎internal/lockfile/state.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ type State struct {
4747
idCache map[string][2]int64
4848
idSF singleflight.Group
4949
hostname string
50+
// preview marks a copy from Preview, which Save refuses.
51+
preview bool
5052
}
5153

5254
// ErrCorruptLockfile reports that a lockfile exists on disk but its contents
@@ -674,6 +676,9 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen
674676
// entries (pins referenced by no workflow). When the in-memory file is empty
675677
// after GC, the on-disk file is removed.
676678
func (s *State) Save() error {
679+
if s.preview {
680+
return errPreview
681+
}
677682
s.mu.Lock()
678683
defer s.mu.Unlock()
679684
s.pruneOrphans()

‎internal/pin/audit.go‎

Lines changed: 140 additions & 0 deletions

0 commit comments

Comments
 (0)