From c54b2408d5aa7c8ba6e67b527944d97811fa4675 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 22 Jun 2026 18:21:16 -0500 Subject: [PATCH 01/67] lockfile: bump actions-lockfile, emit ref instead of tag/branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump github.com/github/actions-lockfile/go to 1313b0a which drops the branch field and renames tag to ref (optional). The lockfile now carries a single 'ref' field per dependency instead of separate tag + branch. Serialization picks the best ref for each dep: tag (semver-ish release) wins, then protected branch, then default branch — matching the DiscoverContaining priority. Branch is no longer required: deps without a discoverable ref emit an empty ref field. Updates all fixtures, goldens, and test assertions. --- cmd/gh-actions-lock/command_test.go | 16 ++- .../.github/workflows/actions.lock | 10 +- go.mod | 2 +- go.sum | 6 + internal/dep/dependency.go | 13 +- internal/lockfile/state.go | 32 ++--- internal/lockfile/state_marshal.go | 11 +- internal/lockfile/state_test.go | 126 +++++++----------- internal/resolve/pick.go | 25 ++-- internal/resolve/pick_test.go | 5 +- internal/resolve/reverse_lookup.go | 7 +- .../transitive_closure_cross_repo.lock | 10 +- 12 files changed, 120 insertions(+), 143 deletions(-) diff --git a/cmd/gh-actions-lock/command_test.go b/cmd/gh-actions-lock/command_test.go index fcc30768..3006f426 100644 --- a/cmd/gh-actions-lock/command_test.go +++ b/cmd/gh-actions-lock/command_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" "github.com/github/gh-actions-lock/cmd/gh-actions-lock/format" "github.com/github/gh-actions-lock/internal/ghapi/httpmock" "github.com/github/gh-actions-lock/internal/pinpool" @@ -143,8 +144,13 @@ func writeTempLockfile(t *testing.T, repoDir, wfName string, pinStrings []string t.Helper() var sb strings.Builder sb.WriteString("version: 'v0.0.1'\ndependencies:\n") - for _, pin := range pinStrings { - sb.WriteString(" '" + pin + "':\n branch: 'main'\n commit: 'sha1-deadbeef'\n owner_id: 1\n repo_id: 1\n") + for _, raw := range pinStrings { + pin, ok := parserlock.ParsePin(raw) + if !ok { + t.Fatalf("writeTempLockfile: invalid pin %q", raw) + } + commit := pin.Algo + "-" + pin.Hex + sb.WriteString(" '" + raw + "':\n ref: 'main'\n commit: '" + commit + "'\n owner_id: 1\n repo_id: 1\n") } sb.WriteString("workflows:\n '.github/workflows/" + wfName + "':\n") for _, pin := range pinStrings { @@ -861,7 +867,7 @@ jobs: // Lockfile records ONLY checkout — setup-go is "new". lockYAML := "version: 'v0.0.1'\ndependencies:\n" + " 'actions/checkout@v6:sha1-" + checkoutSHA + "':\n" + - " branch: 'main'\n commit: 'sha1-" + checkoutSHA + "'\n owner_id: 1\n repo_id: 1\n" + + " ref: 'main'\n commit: 'sha1-" + checkoutSHA + "'\n owner_id: 1\n repo_id: 1\n" + "workflows:\n" + " '.github/workflows/workflow.yml':\n" + " - 'actions/checkout@v6:sha1-" + checkoutSHA + "'\n" @@ -930,7 +936,7 @@ jobs: lockYAML := "version: 'v0.0.1'\ndependencies:\n" + " 'actions/checkout@v6:sha1-" + checkoutSHA + "':\n" + - " branch: 'main'\n commit: 'sha1-" + checkoutSHA + "'\n owner_id: 1\n repo_id: 1\n" + + " ref: 'main'\n commit: 'sha1-" + checkoutSHA + "'\n owner_id: 1\n repo_id: 1\n" + "workflows:\n" + " '.github/workflows/workflow.yml':\n" + " - 'actions/checkout@v6:sha1-" + checkoutSHA + "'\n" @@ -1158,7 +1164,7 @@ jobs: lockYAML := "version: v0.0.1\n" + "dependencies:\n" + " actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd:\n" + - " branch: main\n commit: sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd\n owner_id: 1\n repo_id: 1\n" + + " ref: main\n commit: sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd\n owner_id: 1\n repo_id: 1\n" + "workflows:\n" + " .github/workflows/workflow.yml:\n" + " - actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd\n" + diff --git a/cmd/gh-actions-lock/testdata/golden-json/.github/workflows/actions.lock b/cmd/gh-actions-lock/testdata/golden-json/.github/workflows/actions.lock index b1c39866..32e85dba 100644 --- a/cmd/gh-actions-lock/testdata/golden-json/.github/workflows/actions.lock +++ b/cmd/gh-actions-lock/testdata/golden-json/.github/workflows/actions.lock @@ -1,17 +1,17 @@ version: 'v0.0.1' dependencies: 'actions/cache@v4:sha1-cccccccccccccccccccccccccccccccccccccccc': - branch: 'main' + ref: 'main' commit: 'sha1-cccccccccccccccccccccccccccccccccccccccc' owner_id: 3 repo_id: 3 'actions/checkout@v6:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa': - branch: 'main' + ref: 'main' commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' owner_id: 1 repo_id: 1 'actions/setup-go@v6:sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb': - branch: 'main' + ref: 'main' commit: 'sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' owner_id: 2 repo_id: 2 @@ -19,12 +19,12 @@ dependencies: - 'actions/cache@v4:sha1-cccccccccccccccccccccccccccccccccccccccc' - 'helper/only-transitive@v1:sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' 'helper/only-transitive@v1:sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee': - branch: 'main' + ref: 'main' commit: 'sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' owner_id: 5 repo_id: 5 'old/dead@v1:sha1-dddddddddddddddddddddddddddddddddddddddd': - branch: 'main' + ref: 'main' commit: 'sha1-dddddddddddddddddddddddddddddddddddddddd' owner_id: 4 repo_id: 4 diff --git a/go.mod b/go.mod index 39724c3e..6ebfe0fe 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.3 +require github.com/github/actions-lockfile/go v0.0.4-0.20260622233938-e9a0b36d29c8 require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index 350eb139..c0901b02 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,12 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/github/actions-lockfile/go v0.0.3 h1:pvvbEnsmKtBiiUJ2I5n35W3Uei3EvmnZe1PKJ3q4YrY= github.com/github/actions-lockfile/go v0.0.3/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260622225325-1313b0aabde9 h1:kumVX2Ngu40DNtmRb670KkxEn27WF5qNqyIRI0ezt24= +github.com/github/actions-lockfile/go v0.0.4-0.20260622225325-1313b0aabde9/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260622233836-15801014a086 h1:WEJR8Y8yJ65p0lAEQwyFSDdA3lqZTPNPQ5Y8HMNXe6E= +github.com/github/actions-lockfile/go v0.0.4-0.20260622233836-15801014a086/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260622233938-e9a0b36d29c8 h1:sScZ+WDdC6IucVpiWjcpYosvUgTCq2Hmgq5rWCD9ePc= +github.com/github/actions-lockfile/go v0.0.4-0.20260622233938-e9a0b36d29c8/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= diff --git a/internal/dep/dependency.go b/internal/dep/dependency.go index 456c9680..86dc7f03 100644 --- a/internal/dep/dependency.go +++ b/internal/dep/dependency.go @@ -11,10 +11,9 @@ import ( // Dependency is the resolver's in-memory view of a single pinned action: // the lockfile-grammar pin (NWO@Ref:Algo-SHA) plus the discovered Tag / -// Branch / sub-action Path that the lockfile-on-disk format does not -// carry. It is the working shape between `uses:` parsing, resolver -// traversal, and lockfile serialization — never persisted on disk and -// not part of any public API. +// Branch / sub-action Path. It is the working shape between `uses:` parsing, +// resolver traversal, and lockfile serialization — never persisted on disk +// and not part of any public API. type Dependency struct { NWO string // owner/repo (no path) // Path is the optional sub-action subpath as written in `uses:` @@ -31,9 +30,9 @@ type Dependency struct { // Tag is the discovered release/tag pointing at SHA, if any. Optional. // Populated by the pin-time discovery pass; not read from `uses:`. Tag string - // Branch is the discovered branch containing SHA. Required at write - // time — a commit not on any branch is an impostor / fork-network - // signal. Populated by the pin-time discovery pass. + // Branch is the discovered branch containing SHA. Optional. Populated + // by the pin-time discovery pass. Used for branch-hint seeding and + // resolver caches; serialized as the lockfile `ref` when no tag exists. Branch string } diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index 23ae064b..983c2898 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -189,9 +189,9 @@ func (s *State) Get(workflowKey string) ([]dep.Dependency, error) { } // AllDeps returns every action entry in the lockfile as a Dependency, -// populated with Tag and Branch from the action metadata block. Order is -// undefined. Intended for callers that need the union of recorded pins -// across all workflows (e.g. seeding resolver caches on startup). +// populated with Tag and Branch inferred from the action's ref field. +// Order is undefined. Intended for callers that need the union of recorded +// pins across all workflows (e.g. seeding resolver caches on startup). func (s *State) AllDeps() []dep.Dependency { s.mu.Lock() defer s.mu.Unlock() @@ -202,8 +202,7 @@ func (s *State) AllDeps() []dep.Dependency { continue } d := pinToDep(pin) - d.Tag = action.Tag - d.Branch = action.Branch + d.Tag, d.Branch = parserlock.SplitRef(action.Ref) out = append(out, d) } return out @@ -266,13 +265,6 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen } pin = pin.Canonical() pinKey := pin.String() - // The read path (parserlock.Pin) drops branch, so an unchanged carried - // dep arrives branchless; reuse the recorded branch. Only a new pin errors. - if d.Branch == "" { - if existing, ok := s.file.Dependencies[pinKey]; !ok || existing.Branch == "" { - return fmt.Errorf("%s@%s: branch is required in lockfile metadata; run `gh actions-lock` to populate it", d.NWO, d.Ref) - } - } keyToPin[d.Key()] = pinKey var isDirect bool if directKeys != nil { @@ -334,14 +326,13 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen usesSet[c] = true } } - // Preserve branch/tag and existing uses from prior Set calls. - branch, tag := d.Branch, d.Tag + // Compute the best ref for this dep: tag > branch. Preserve + // existing ref from prior Set calls when the dep arrives without + // discovery metadata (carried unchanged from a previous lockfile). + ref := parserlock.BestRef(d.Tag, d.Branch) if existing, ok := s.file.Dependencies[pinKey]; ok { - if branch == "" { - branch = existing.Branch - } - if tag == "" { - tag = existing.Tag + if ref == "" { + ref = existing.Ref } for _, u := range existing.Uses { usesSet[u] = true @@ -356,8 +347,7 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen sort.Strings(uses) } s.file.Dependencies[pinKey] = parserlock.Action{ - Tag: tag, - Branch: branch, + Ref: ref, Commit: pin.Algo + "-" + pin.Hex, OwnerID: ids[0], RepoID: ids[1], diff --git a/internal/lockfile/state_marshal.go b/internal/lockfile/state_marshal.go index 9ff9878d..30d152c9 100644 --- a/internal/lockfile/state_marshal.go +++ b/internal/lockfile/state_marshal.go @@ -12,11 +12,11 @@ import ( // maps are randomized; for a lockfile we want byte-stable output across runs. // // All string keys and values whose content is user-supplied (pin strings, -// workflow paths, tag/branch/commit, version) are emitted single-quoted so +// workflow paths, ref/commit, version) are emitted single-quoted so // the file round-trips identically regardless of YAML scalar-resolution // quirks: pin keys carry colons, tags can look like floats ("1.0"), refs // can collide with YAML 1.1 booleans ("y", "no", "on", "off"). Schema -// field names (version, dependencies, workflows, tag, branch, …) stay +// field names (version, dependencies, workflows, ref, …) stay // unquoted because they're hardcoded and trivially safe. func marshalDeterministic(file parserlock.File) ([]byte, error) { root := &yaml.Node{Kind: yaml.MappingNode} @@ -57,11 +57,8 @@ func marshalDeterministic(file parserlock.File) ([]byte, error) { for _, k := range keys { a := file.Dependencies[k] entry := &yaml.Node{Kind: yaml.MappingNode} - if a.Tag != "" { - addQuotedField(entry, "tag", a.Tag) - } - if a.Branch != "" { - addQuotedField(entry, "branch", a.Branch) + if a.Ref != "" { + addQuotedField(entry, "ref", a.Ref) } if a.Commit != "" { addQuotedField(entry, "commit", a.Commit) diff --git a/internal/lockfile/state_test.go b/internal/lockfile/state_test.go index e00bc60b..0f6456f7 100644 --- a/internal/lockfile/state_test.go +++ b/internal/lockfile/state_test.go @@ -10,7 +10,6 @@ import ( parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/workflowfile" ) type fakeMetadataResolver struct{} @@ -19,7 +18,7 @@ func (fakeMetadataResolver) RepoIDs(_ context.Context, owner, repo string) (int6 return 1, 2, nil } -func TestState_PersistsTagAndBranch(t *testing.T) { +func TestState_PersistsRef(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".github", "workflows"), 0o755); err != nil { t.Fatal(err) @@ -49,7 +48,7 @@ func TestState_PersistsTagAndBranch(t *testing.T) { }, } - if err := store.Set(context.Background(), workflowfile.KeyFromPath(filepath.Join(dir, ".github", "workflows", "ci.yml")), deps, nil, nil); err != nil { + if err := store.Set(context.Background(), ".github/workflows/ci.yml", deps, nil, nil); err != nil { t.Fatalf("Set: %v", err) } if err := store.Save(); err != nil { @@ -61,29 +60,24 @@ func TestState_PersistsTagAndBranch(t *testing.T) { t.Fatalf("reading lockfile: %v", err) } got := string(raw) + // Tag dep should serialize ref as the tag value (tag > branch). for _, want := range []string{ "'actions/checkout@v4.2.1:sha1-", - "tag: 'v4.2.1'", - "branch: 'main'", + "ref: 'v4.2.1'", "'internal/branch-only@main:sha1-", + "ref: 'main'", } { if !strings.Contains(got, want) { t.Errorf("lockfile missing %q\n--- contents ---\n%s", want, got) } } - // Branch-only entry should NOT emit tag:, only branch:. - branchOnlyIdx := strings.Index(got, "internal/branch-only@main:sha1-") - if branchOnlyIdx < 0 { - t.Fatalf("expected branch-only entry in lockfile") + // The lockfile should NOT contain separate tag:/branch: fields. + if strings.Contains(got, "tag:") { + t.Errorf("lockfile should not contain tag: field\n%s", got) } - branchSection := got[branchOnlyIdx:] - nextEntryIdx := strings.Index(branchSection[1:], " 'actions/") - if nextEntryIdx >= 0 { - branchSection = branchSection[:nextEntryIdx+1] - } - if strings.Contains(branchSection, "tag:") { - t.Errorf("branch-only entry should not emit tag:\n%s", branchSection) + if strings.Contains(got, "branch:") { + t.Errorf("lockfile should not contain branch: field\n%s", got) } // Reload and verify roundtrip. @@ -96,22 +90,16 @@ func TestState_PersistsTagAndBranch(t *testing.T) { if !ok { t.Fatalf("expected %s in reloaded lockfile, keys=%v", checkoutKey, actionKeys(store2.file.Dependencies)) } - if a.Tag != "v4.2.1" { - t.Errorf("expected Tag=v4.2.1, got %q", a.Tag) - } - if a.Branch != "main" { - t.Errorf("expected Branch=main, got %q", a.Branch) + if a.Ref != "v4.2.1" { + t.Errorf("expected Ref=v4.2.1, got %q", a.Ref) } branchOnlyKey := "internal/branch-only@main:sha1-def456def456def456def456def456def456def4" b, ok := store2.file.Dependencies[branchOnlyKey] if !ok { t.Fatalf("expected %s, keys=%v", branchOnlyKey, actionKeys(store2.file.Dependencies)) } - if b.Tag != "" { - t.Errorf("expected empty Tag, got %q", b.Tag) - } - if b.Branch != "main" { - t.Errorf("expected Branch=main, got %q", b.Branch) + if b.Ref != "main" { + t.Errorf("expected Ref=main, got %q", b.Ref) } } @@ -123,7 +111,9 @@ func actionKeys[V any](m map[string]V) []string { return keys } -func TestState_SetRejectsEmptyBranch(t *testing.T) { +// TestState_SetAcceptsEmptyBranch verifies that deps with no discovered +// branch are accepted — the new schema makes ref optional. +func TestState_SetAcceptsEmptyBranch(t *testing.T) { dir := t.TempDir() store, err := LoadState(dir, fakeMetadataResolver{}) if err != nil { @@ -136,28 +126,22 @@ func TestState_SetRejectsEmptyBranch(t *testing.T) { Ref: "v4", SHA: "abc123abc123abc123abc123abc123abc123abc1", HashAlgo: "sha1", - // Branch intentionally empty — should be rejected. }, } err = store.Set(context.Background(), ".github/workflows/ci.yml", deps, nil, nil) - if err == nil { - t.Fatal("expected error for dep with empty Branch, got nil") - } - if !strings.Contains(err.Error(), "branch is required") { - t.Errorf("expected 'branch is required' in error, got: %v", err) + if err != nil { + t.Fatalf("Set should accept dep with empty Branch, got: %v", err) } } -// TestState_SetPreservesBranchForUnchangedPin reproduces the write-path bug -// where adding a new action to an already-tracked workflow failed with -// "branch is required". The lockfile read path (parserlock.Pin) drops branch, -// so a carried Verified dep arrives at Set branchless; Set must fall back to -// the branch already recorded on disk for that unchanged pin instead of -// rejecting the whole write. -func TestState_SetPreservesBranchForUnchangedPin(t *testing.T) { +// TestState_SetPreservesRefForUnchangedPin reproduces the write-path where +// adding a new action to an already-tracked workflow must not lose the ref +// for carried (Verified) deps that arrive without Tag/Branch from the read +// path. Set must fall back to the ref already recorded on disk. +func TestState_SetPreservesRefForUnchangedPin(t *testing.T) { dir := t.TempDir() - wfKey := workflowfile.KeyFromPath(filepath.Join(dir, ".github", "workflows", "ci.yml")) + wfKey := ".github/workflows/ci.yml" store, err := LoadState(dir, fakeMetadataResolver{}) if err != nil { @@ -205,11 +189,8 @@ func TestState_SetPreservesBranchForUnchangedPin(t *testing.T) { if !ok { t.Fatalf("expected %s preserved, keys=%v", checkoutKey, actionKeys(store3.file.Dependencies)) } - if a.Branch != "main" { - t.Errorf("expected preserved Branch=main for unchanged pin, got %q", a.Branch) - } - if a.Tag != "v4" { - t.Errorf("expected preserved Tag=v4 for unchanged pin, got %q", a.Tag) + if a.Ref != "v4" { + t.Errorf("expected preserved Ref=v4 for unchanged pin, got %q", a.Ref) } } @@ -298,7 +279,9 @@ func TestState_DiamondTransitiveDepEmittedCorrectly(t *testing.T) { // TestState_SaveGCHandlesCyclicUses verifies that Save()'s garbage collection // walk (which follows uses: edges) terminates when the uses: graph contains a -// cycle (A uses B, B uses A). Both entries should be retained. +// cycle (A uses B, B uses A). Both entries should be retained in the write. +// The new parser rejects cycles on reload, so we verify Save doesn't hang +// and the cycle is caught on re-parse. func TestState_SaveGCHandlesCyclicUses(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".github", "workflows"), 0o755); err != nil { @@ -319,7 +302,6 @@ func TestState_SaveGCHandlesCyclicUses(t *testing.T) { "owner/b@v1": {"owner/a@v1"}, "owner/a@v1": {"owner/b@v1"}, } - // A is the workflow-direct entry. directKeys := map[string]bool{ "owner/a@v1": true, } @@ -327,25 +309,18 @@ func TestState_SaveGCHandlesCyclicUses(t *testing.T) { if err := store.Set(context.Background(), ".github/workflows/ci.yml", deps, parentMap, directKeys); err != nil { t.Fatalf("Set: %v", err) } + // Save should not hang (GC walk terminates despite cycle). if err := store.Save(); err != nil { t.Fatalf("Save: %v", err) } - // Reload and verify both entries survived GC (cycle didn't cause infinite - // loop or premature GC). - store2, err := LoadState(dir, fakeMetadataResolver{}) - if err != nil { - t.Fatalf("reopening store: %v", err) - } - - aPin := "owner/a@v1:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - bPin := "owner/b@v1:sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - - if _, ok := store2.file.Dependencies[aPin]; !ok { - t.Errorf("expected %s to survive GC, keys=%v", aPin, actionKeys(store2.file.Dependencies)) + // The new parser rejects cycles on reload. + _, err = LoadState(dir, fakeMetadataResolver{}) + if err == nil { + t.Fatal("expected parser to reject cyclic uses on reload") } - if _, ok := store2.file.Dependencies[bPin]; !ok { - t.Errorf("expected %s to survive GC (reachable via cyclic uses:), keys=%v", bPin, actionKeys(store2.file.Dependencies)) + if !strings.Contains(err.Error(), "cycle") { + t.Errorf("expected cycle error, got: %v", err) } } @@ -514,8 +489,7 @@ func setupClosure(t *testing.T, dir string) { } parentMap := map[string][]string{"actions/cache@v4": {"actions/setup-go@v6"}} directKeys := map[string]bool{"actions/setup-go@v6": true} - wfKey := workflowfile.KeyFromPath(filepath.Join(dir, ".github", "workflows", "ci.yml")) - if err := store.Set(context.Background(), wfKey, deps, parentMap, directKeys); err != nil { + if err := store.Set(context.Background(), ".github/workflows/ci.yml", deps, parentMap, directKeys); err != nil { t.Fatalf("Set: %v", err) } if err := store.Save(); err != nil { @@ -746,20 +720,19 @@ func TestState_SaveFormatIsStable(t *testing.T) { " - 'actions/setup-go@v5:sha1-22222222222222222222222222222222222222bb'\n" + "dependencies:\n" + " 'actions/checkout@v4:sha1-11111111111111111111111111111111111111aa':\n" + - " tag: 'v4'\n" + - " branch: 'main'\n" + + " ref: 'v4'\n" + " commit: 'sha1-11111111111111111111111111111111111111aa'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " uses:\n" + " - 'shared/dep@v1:sha1-33333333333333333333333333333333333333cc'\n" + " 'actions/setup-go@v5:sha1-22222222222222222222222222222222222222bb':\n" + - " branch: 'main'\n" + + " ref: 'main'\n" + " commit: 'sha1-22222222222222222222222222222222222222bb'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " 'shared/dep@v1:sha1-33333333333333333333333333333333333333cc':\n" + - " branch: 'main'\n" + + " ref: 'main'\n" + " commit: 'sha1-33333333333333333333333333333333333333cc'\n" + " owner_id: 1\n" + " repo_id: 2\n" @@ -831,14 +804,12 @@ func TestState_TransitiveClosureGolden(t *testing.T) { " - 'my-org/leaf@main:sha1-dddddddddddddddddddddddddddddddddddddddd'\n" + "dependencies:\n" + " 'actions/checkout@v4:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa':\n" + - " tag: 'v4'\n" + - " branch: 'main'\n" + + " ref: 'v4'\n" + " commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " 'my-org/composite-a@v1:sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb':\n" + - " tag: 'v1'\n" + - " branch: 'main'\n" + + " ref: 'v1'\n" + " commit: 'sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'\n" + " owner_id: 1\n" + " repo_id: 2\n" + @@ -846,29 +817,26 @@ func TestState_TransitiveClosureGolden(t *testing.T) { " - 'my-org/composite-b@v2:sha1-cccccccccccccccccccccccccccccccccccccccc'\n" + " - 'other-org/external@v1:sha1-ffffffffffffffffffffffffffffffffffffffff'\n" + " 'my-org/composite-b@v2:sha1-cccccccccccccccccccccccccccccccccccccccc':\n" + - " tag: 'v2'\n" + - " branch: 'main'\n" + + " ref: 'v2'\n" + " commit: 'sha1-cccccccccccccccccccccccccccccccccccccccc'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " uses:\n" + " - 'my-org/leaf@main:sha1-dddddddddddddddddddddddddddddddddddddddd'\n" + " 'my-org/composite-c@v1:sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee':\n" + - " tag: 'v1'\n" + - " branch: 'main'\n" + + " ref: 'v1'\n" + " commit: 'sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " uses:\n" + " - 'my-org/composite-b@v2:sha1-cccccccccccccccccccccccccccccccccccccccc'\n" + " 'my-org/leaf@main:sha1-dddddddddddddddddddddddddddddddddddddddd':\n" + - " branch: 'main'\n" + + " ref: 'main'\n" + " commit: 'sha1-dddddddddddddddddddddddddddddddddddddddd'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " 'other-org/external@v1:sha1-ffffffffffffffffffffffffffffffffffffffff':\n" + - " tag: 'v1'\n" + - " branch: 'main'\n" + + " ref: 'v1'\n" + " commit: 'sha1-ffffffffffffffffffffffffffffffffffffffff'\n" + " owner_id: 1\n" + " repo_id: 2\n" diff --git a/internal/resolve/pick.go b/internal/resolve/pick.go index a4c33d4e..4388c1ce 100644 --- a/internal/resolve/pick.go +++ b/internal/resolve/pick.go @@ -41,25 +41,34 @@ func pickPreferred(candidates []string, hintRef, defaultPick string) string { } // pickPreferredTag selects the canonical tag from the set of tags pointing at -// a SHA. Prefers hintRef, then highest semver, then lexicographic first. +// a SHA. Priority: hintRef (if present), then full semver (v1.2.3 — no splat), +// then any semver (including major-only like v4), then lexicographic first. func pickPreferredTag(candidates []string, hintRef string) string { if hit := hintMatch(candidates, hintRef); hit != "" { return hit } - var best string - var bestVer parserlock.SemVer - haveSemver := false + var bestFull string + var bestFullVer parserlock.SemVer + var bestAny string + var bestAnyVer parserlock.SemVer + haveFull, haveAny := false, false for _, c := range candidates { sv, ok := parserlock.ParseSemVer(c) if !ok { continue } - if !haveSemver || sv.Greater(bestVer) { - best, bestVer, haveSemver = c, sv, true + if sv.IsFull() && (!haveFull || sv.Greater(bestFullVer)) { + bestFull, bestFullVer, haveFull = c, sv, true } + if !haveAny || sv.Greater(bestAnyVer) { + bestAny, bestAnyVer, haveAny = c, sv, true + } + } + if haveFull { + return bestFull } - if haveSemver { - return best + if haveAny { + return bestAny } return pickPreferred(candidates, hintRef, "") } diff --git a/internal/resolve/pick_test.go b/internal/resolve/pick_test.go index 2ebf286e..445445a8 100644 --- a/internal/resolve/pick_test.go +++ b/internal/resolve/pick_test.go @@ -58,7 +58,10 @@ func TestPickPreferredTag(t *testing.T) { want string }{ {"hint wins over semver", []string{"v1.0.0", "v2.0.0"}, "v1.0.0", "v1.0.0"}, - {"highest semver", []string{"v1.0.0", "v3.2.1", "v2.0.0"}, "", "v3.2.1"}, + {"highest full semver", []string{"v1.0.0", "v3.2.1", "v2.0.0"}, "", "v3.2.1"}, + {"full semver beats major-only", []string{"v5", "v4.3.1"}, "", "v4.3.1"}, + {"full semver beats higher major-only", []string{"v9", "v2.1.0"}, "", "v2.1.0"}, + {"major-only when no full semver", []string{"v4", "v3"}, "", "v4"}, {"no semver falls to lex", []string{"beta", "alpha"}, "", "alpha"}, {"mixed semver and non-semver", []string{"latest", "v1.0.0", "v2.0.0"}, "", "v2.0.0"}, {"single candidate", []string{"v1.0.0"}, "", "v1.0.0"}, diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index 5eaea58b..cbc3d249 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -246,9 +246,10 @@ func (r *Resolver) LikelyBranches(ctx context.Context, owner, repo, sha, ref, de // ReverseLookup performs a reverse lookup (SHA → containing tag/branch) // for every entry in deps via DiscoverContaining, populates dep.Tag and -// dep.Branch, and computes the canonical @ref. When the canonical ref -// differs from dep.Ref the change is recorded in the returned rewrites -// map and dep.Ref is updated in place. +// dep.Branch, and computes the canonical @ref. The ref priority is: +// tag (semver-ish release) > protected branch > default branch > any branch. +// When the canonical ref differs from dep.Ref the change is recorded in +// the returned rewrites map and dep.Ref is updated in place. func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (map[string]string, error) { rewrites := map[string]string{} for i := range deps { diff --git a/test/scenarios/testdata/transitive_closure_cross_repo.lock b/test/scenarios/testdata/transitive_closure_cross_repo.lock index 7cf96160..b62bc6a3 100644 --- a/test/scenarios/testdata/transitive_closure_cross_repo.lock +++ b/test/scenarios/testdata/transitive_closure_cross_repo.lock @@ -13,26 +13,24 @@ workflows: - 'nodeselector/actions-test-fixtures@main:sha1-e67943468a2f9790006afa217db1ea22c71433a4' dependencies: 'actions/checkout@v4.3.1:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5': - tag: 'v4.3.1' - branch: 'releases/v4' + ref: 'v4.3.1' commit: 'sha1-34e114876b0b11c390a56381ad16ebd13914f8d5' owner_id: 44036562 repo_id: 197814629 'nodeselector/actions-test-fixtures-b@main:sha1-92b7b0058bc223c6e9dd4e19ef9247c934ba7637': - branch: 'main' + ref: 'main' commit: 'sha1-92b7b0058bc223c6e9dd4e19ef9247c934ba7637' owner_id: 29457092 repo_id: 1205442499 'nodeselector/actions-test-fixtures@main:sha1-e67943468a2f9790006afa217db1ea22c71433a4': - branch: 'main' + ref: 'main' commit: 'sha1-e67943468a2f9790006afa217db1ea22c71433a4' owner_id: 29457092 repo_id: 1203329948 uses: - 'nodeselector/actions-test-fixtures-b@main:sha1-92b7b0058bc223c6e9dd4e19ef9247c934ba7637' 'nodeselector/actions-test-fixtures@updated:sha1-ea53476fdc172d8552df5af9658a45a367e4f41d': - tag: 'updated' - branch: 'updated' + ref: 'updated' commit: 'sha1-ea53476fdc172d8552df5af9658a45a367e4f41d' owner_id: 29457092 repo_id: 1203329948 From bc46c92a160928d6be2bfe27439543e1e5b0d40b Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 22 Jun 2026 20:08:26 -0500 Subject: [PATCH 02/67] lockfile: bump parser to v0.0.2 (simplified pin keys) Adopt eff8f62 from actions-lockfile which drops the commit hash from pin keys. Keys are now `owner/repo@ref` instead of `owner/repo@ref:algo-hex`. The commit field in each entry remains the sole source of truth for integrity. - Pin struct no longer carries Algo/Hex; SHA comes from Action.Commit - Introduce lockedPin type in checks package pairing Pin + Commit - state.go Get()/AllDeps() populate dep SHA from Action.Commit - state.go Set() uses d.Ref directly (parser validates ref == pin key) - pickPreferredTag uses IsFull() for full-semver-over-major priority - All test fixtures updated to v0.0.2 format --- cmd/gh-actions-lock/command_test.go | 93 +++++++++++-------- .../.github/workflows/actions.lock | 36 +++---- go.mod | 2 +- go.sum | 2 + internal/lockfile/convertor.go | 8 +- internal/lockfile/state.go | 24 +++-- internal/lockfile/state_test.go | 72 +++++++------- internal/pipeline/checks/misleading.go | 22 ++--- internal/pipeline/checks/run.go | 36 +++++-- internal/pipeline/checks/run_test.go | 50 ++++++++-- internal/pipeline/checks/structural.go | 20 ++-- .../transitive_closure_cross_repo.lock | 24 ++--- 12 files changed, 235 insertions(+), 154 deletions(-) diff --git a/cmd/gh-actions-lock/command_test.go b/cmd/gh-actions-lock/command_test.go index 3006f426..cfbfc6ab 100644 --- a/cmd/gh-actions-lock/command_test.go +++ b/cmd/gh-actions-lock/command_test.go @@ -50,8 +50,8 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 `, - "actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd", - "actions/setup-go@v6:sha1-4a3601121dd01d1626a1e23e37211e3254c1c06c", + "actions/checkout@v6=sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd", + "actions/setup-go@v6=sha1-4a3601121dd01d1626a1e23e37211e3254c1c06c", ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), @@ -140,21 +140,38 @@ func writeTempWorkflow(t *testing.T, body string, pins ...string) string { // workflow file. Owner/repo IDs are stubbed; the read path doesn't validate // them. All user-supplied scalars are single-quoted to mirror the // production emitter (see internal/lockfile/store.go::marshalDeterministic). +// writeTempLockfile writes a minimal lockfile for the given pins. Each +// pinString has the form "owner/repo@ref" (the v0.0.2 pin key format). +// A synthetic commit hash is generated from the pin key for each entry. +// The ref field in each action body matches the pin key's ref. Overrides +// can pass "owner/repo@ref=sha1-hex" to supply an explicit commit. func writeTempLockfile(t *testing.T, repoDir, wfName string, pinStrings []string) { t.Helper() var sb strings.Builder - sb.WriteString("version: 'v0.0.1'\ndependencies:\n") + sb.WriteString("version: '" + parserlock.Version + "'\ndependencies:\n") for _, raw := range pinStrings { - pin, ok := parserlock.ParsePin(raw) + key, commit := raw, "" + if idx := strings.Index(raw, "="); idx >= 0 { + key = raw[:idx] + commit = raw[idx+1:] + } + pin, ok := parserlock.ParsePin(key) if !ok { - t.Fatalf("writeTempLockfile: invalid pin %q", raw) + t.Fatalf("writeTempLockfile: invalid pin %q", key) + } + if commit == "" { + // Generate deterministic fake commit from pin key. + commit = "sha1-" + strings.Repeat(string("abcdef0123456789"[len(key)%16]), 40) } - commit := pin.Algo + "-" + pin.Hex - sb.WriteString(" '" + raw + "':\n ref: 'main'\n commit: '" + commit + "'\n owner_id: 1\n repo_id: 1\n") + sb.WriteString(" '" + key + "':\n ref: '" + pin.Ref + "'\n commit: '" + commit + "'\n owner_id: 1\n repo_id: 1\n") } sb.WriteString("workflows:\n '.github/workflows/" + wfName + "':\n") - for _, pin := range pinStrings { - sb.WriteString(" - '" + pin + "'\n") + for _, raw := range pinStrings { + key := raw + if idx := strings.Index(raw, "="); idx >= 0 { + key = raw[:idx] + } + sb.WriteString(" - '" + key + "'\n") } p := filepath.Join(repoDir, ".github", "workflows", "actions.lock") require.NoError(t, os.WriteFile(p, []byte(sb.String()), 0o600)) @@ -253,7 +270,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1:sha1-"+pinnedSHA, + "example/action@v1=sha1-" + pinnedSHA, ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), @@ -305,7 +322,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1:sha1-"+sha, + "example/action@v1", ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), @@ -355,7 +372,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1:sha1-"+sha, + "example/action@v1", ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, unknownReachFunc(), @@ -415,7 +432,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1:sha1-"+sha, + "example/action@v1=sha1-"+sha, ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), @@ -481,7 +498,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1:sha1-"+pinnedSHA, + "example/action@v1=sha1-" + pinnedSHA, ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), @@ -543,7 +560,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1:sha1-"+pinnedSHA, + "example/action@v1=sha1-" + pinnedSHA, ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), @@ -601,7 +618,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1:sha1-"+pinnedSHA, + "example/action@v1=sha1-" + pinnedSHA, ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), @@ -676,10 +693,10 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 `, - "actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd", - "actions/setup-go@v6:sha1-d35c59abb061a4a6fb18e82ac0862c26744d6ab5", + "actions/checkout@v6", + "actions/setup-go@v6", // Transitive dependency (via actions/setup-go@v6). - "actions/cache@v4:sha1-5a3ec84eff668545956fd18022155c47e93e2684", + "actions/cache@v4", ) // Test per-workflow dependencies view @@ -754,8 +771,8 @@ jobs: steps: - uses: actions/setup-go@v6 `, - "actions/setup-go@v6:sha1-d35c59abb061a4a6fb18e82ac0862c26744d6ab5", - "actions/cache@v4:sha1-5a3ec84eff668545956fd18022155c47e93e2684", + "actions/setup-go@v6", + "actions/cache@v4", ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), @@ -805,7 +822,7 @@ jobs: steps: - uses: actions/checkout@v6 `, - "actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd", + "actions/checkout@v6", ) // --json with no value should use the default fields (valid,findings,workflows) @@ -865,12 +882,12 @@ jobs: require.NoError(t, os.WriteFile(wfPath, []byte(wfBody), 0o600)) // Lockfile records ONLY checkout — setup-go is "new". - lockYAML := "version: 'v0.0.1'\ndependencies:\n" + - " 'actions/checkout@v6:sha1-" + checkoutSHA + "':\n" + - " ref: 'main'\n commit: 'sha1-" + checkoutSHA + "'\n owner_id: 1\n repo_id: 1\n" + + lockYAML := "version: '" + parserlock.Version + "'\ndependencies:\n" + + " 'actions/checkout@v6" + "':\n" + + " ref: 'v6'\n commit: 'sha1-" + checkoutSHA + "'\n owner_id: 1\n repo_id: 1\n" + "workflows:\n" + " '.github/workflows/workflow.yml':\n" + - " - 'actions/checkout@v6:sha1-" + checkoutSHA + "'\n" + " - 'actions/checkout@v6" + "'\n" require.NoError(t, os.WriteFile(filepath.Join(dir, ".github", "workflows", "actions.lock"), []byte(lockYAML), 0o600)) t.Chdir(dir) @@ -934,12 +951,12 @@ jobs: wfPath := filepath.Join(dir, ".github", "workflows", "workflow.yml") require.NoError(t, os.WriteFile(wfPath, []byte(wfBody), 0o600)) - lockYAML := "version: 'v0.0.1'\ndependencies:\n" + - " 'actions/checkout@v6:sha1-" + checkoutSHA + "':\n" + - " ref: 'main'\n commit: 'sha1-" + checkoutSHA + "'\n owner_id: 1\n repo_id: 1\n" + + lockYAML := "version: '" + parserlock.Version + "'\ndependencies:\n" + + " 'actions/checkout@v6" + "':\n" + + " ref: 'v6'\n commit: 'sha1-" + checkoutSHA + "'\n owner_id: 1\n repo_id: 1\n" + "workflows:\n" + " '.github/workflows/workflow.yml':\n" + - " - 'actions/checkout@v6:sha1-" + checkoutSHA + "'\n" + " - 'actions/checkout@v6" + "'\n" lockPath := filepath.Join(dir, ".github", "workflows", "actions.lock") require.NoError(t, os.WriteFile(lockPath, []byte(lockYAML), 0o600)) t.Chdir(dir) @@ -1096,7 +1113,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1:sha1-"+staleSHA, + "example/action@v1=sha1-" + staleSHA, ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), @@ -1143,7 +1160,7 @@ jobs: steps: - uses: actions/checkout@v6 `, - "actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd", + "actions/checkout@v6", ) wf2Path := filepath.Join(filepath.Dir(wf1), "workflow2.yml") @@ -1159,17 +1176,17 @@ jobs: // Add wf2's deps to the lockfile (writeTempWorkflow only seeded wf1). writeTempLockfile(t, ".", "workflow.yml", - []string{"actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd"}) + []string{"actions/checkout@v6"}) // Replace with a multi-workflow lockfile. - lockYAML := "version: v0.0.1\n" + + lockYAML := "version: " + parserlock.Version + "\n" + "dependencies:\n" + - " actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd:\n" + - " ref: main\n commit: sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd\n owner_id: 1\n repo_id: 1\n" + + " actions/checkout@v6:\n" + + " ref: v6\n commit: sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd\n owner_id: 1\n repo_id: 1\n" + "workflows:\n" + " .github/workflows/workflow.yml:\n" + - " - actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd\n" + + " - actions/checkout@v6\n" + " .github/workflows/workflow2.yml:\n" + - " - actions/checkout@v6:sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd\n" + " - actions/checkout@v6\n" require.NoError(t, os.WriteFile(filepath.Join(".github", "workflows", "actions.lock"), []byte(lockYAML), 0o600)) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), diff --git a/cmd/gh-actions-lock/testdata/golden-json/.github/workflows/actions.lock b/cmd/gh-actions-lock/testdata/golden-json/.github/workflows/actions.lock index 32e85dba..19293eba 100644 --- a/cmd/gh-actions-lock/testdata/golden-json/.github/workflows/actions.lock +++ b/cmd/gh-actions-lock/testdata/golden-json/.github/workflows/actions.lock @@ -1,37 +1,37 @@ -version: 'v0.0.1' +version: 'v0.0.2' dependencies: - 'actions/cache@v4:sha1-cccccccccccccccccccccccccccccccccccccccc': - ref: 'main' + 'actions/cache@v4': + ref: 'v4' commit: 'sha1-cccccccccccccccccccccccccccccccccccccccc' owner_id: 3 repo_id: 3 - 'actions/checkout@v6:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa': - ref: 'main' + 'actions/checkout@v6': + ref: 'v6' commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' owner_id: 1 repo_id: 1 - 'actions/setup-go@v6:sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb': - ref: 'main' + 'actions/setup-go@v6': + ref: 'v6' commit: 'sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' owner_id: 2 repo_id: 2 uses: - - 'actions/cache@v4:sha1-cccccccccccccccccccccccccccccccccccccccc' - - 'helper/only-transitive@v1:sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' - 'helper/only-transitive@v1:sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee': - ref: 'main' + - 'actions/cache@v4' + - 'helper/only-transitive@v1' + 'helper/only-transitive@v1': + ref: 'v1' commit: 'sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' owner_id: 5 repo_id: 5 - 'old/dead@v1:sha1-dddddddddddddddddddddddddddddddddddddddd': - ref: 'main' + 'old/dead@v1': + ref: 'v1' commit: 'sha1-dddddddddddddddddddddddddddddddddddddddd' owner_id: 4 repo_id: 4 workflows: '.github/workflows/ci.yml': - - 'actions/checkout@v6:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - - 'actions/setup-go@v6:sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' - - 'actions/cache@v4:sha1-cccccccccccccccccccccccccccccccccccccccc' - - 'helper/only-transitive@v1:sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' - - 'old/dead@v1:sha1-dddddddddddddddddddddddddddddddddddddddd' + - 'actions/checkout@v6' + - 'actions/setup-go@v6' + - 'actions/cache@v4' + - 'helper/only-transitive@v1' + - 'old/dead@v1' diff --git a/go.mod b/go.mod index 6ebfe0fe..59f6cc1f 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.4-0.20260622233938-e9a0b36d29c8 +require github.com/github/actions-lockfile/go v0.0.4-0.20260623005021-eff8f62231d4 require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index c0901b02..8c5262f1 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,8 @@ github.com/github/actions-lockfile/go v0.0.4-0.20260622233836-15801014a086 h1:WE github.com/github/actions-lockfile/go v0.0.4-0.20260622233836-15801014a086/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/github/actions-lockfile/go v0.0.4-0.20260622233938-e9a0b36d29c8 h1:sScZ+WDdC6IucVpiWjcpYosvUgTCq2Hmgq5rWCD9ePc= github.com/github/actions-lockfile/go v0.0.4-0.20260622233938-e9a0b36d29c8/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623005021-eff8f62231d4 h1:lN99X1026osmkE+nYZWDkHAII4Ofm7fAj3lCbk6ai7o= +github.com/github/actions-lockfile/go v0.0.4-0.20260623005021-eff8f62231d4/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= diff --git a/internal/lockfile/convertor.go b/internal/lockfile/convertor.go index 2bcf78ec..7b0b9877 100644 --- a/internal/lockfile/convertor.go +++ b/internal/lockfile/convertor.go @@ -29,8 +29,6 @@ func depToPin(d dep.Dependency) (parserlock.Pin, error) { Owner: owner, Repo: repo, Ref: d.Ref, - Algo: d.HashAlgoOrDetect(), - Hex: d.SHA, }, nil } @@ -40,9 +38,7 @@ func depToPin(d dep.Dependency) (parserlock.Pin, error) { // workflow uses: strings. func pinToDep(p parserlock.Pin) dep.Dependency { return dep.Dependency{ - NWO: p.NWO, - Ref: p.Ref, - SHA: p.Hex, - HashAlgo: p.Algo, + NWO: p.NWO, + Ref: p.Ref, } } diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index 983c2898..f922aa75 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -183,7 +183,15 @@ func (s *State) Get(workflowKey string) ([]dep.Dependency, error) { if !ok { return nil, fmt.Errorf("invalid pin %q in %s for workflow %q", raw, parserlock.Path, workflowKey) } - out = append(out, pinToDep(pin)) + d := pinToDep(pin) + if action, found := s.file.Dependencies[raw]; found { + d.Tag, d.Branch = parserlock.SplitRef(action.Ref) + if idx := strings.Index(action.Commit, "-"); idx >= 0 { + d.HashAlgo = action.Commit[:idx] + d.SHA = action.Commit[idx+1:] + } + } + out = append(out, d) } return out, nil } @@ -203,6 +211,10 @@ func (s *State) AllDeps() []dep.Dependency { } d := pinToDep(pin) d.Tag, d.Branch = parserlock.SplitRef(action.Ref) + if idx := strings.Index(action.Commit, "-"); idx >= 0 { + d.HashAlgo = action.Commit[:idx] + d.SHA = action.Commit[idx+1:] + } out = append(out, d) } return out @@ -326,10 +338,10 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen usesSet[c] = true } } - // Compute the best ref for this dep: tag > branch. Preserve - // existing ref from prior Set calls when the dep arrives without - // discovery metadata (carried unchanged from a previous lockfile). - ref := parserlock.BestRef(d.Tag, d.Branch) + // The action's Ref must match the pin key's ref (which is d.Ref). + // Preserve existing ref when the dep arrives without one (carried + // unchanged from a previous lockfile). + ref := d.Ref if existing, ok := s.file.Dependencies[pinKey]; ok { if ref == "" { ref = existing.Ref @@ -348,7 +360,7 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen } s.file.Dependencies[pinKey] = parserlock.Action{ Ref: ref, - Commit: pin.Algo + "-" + pin.Hex, + Commit: d.HashAlgoOrDetect() + "-" + d.SHA, OwnerID: ids[0], RepoID: ids[1], Uses: uses, diff --git a/internal/lockfile/state_test.go b/internal/lockfile/state_test.go index 0f6456f7..7f66f942 100644 --- a/internal/lockfile/state_test.go +++ b/internal/lockfile/state_test.go @@ -62,9 +62,9 @@ func TestState_PersistsRef(t *testing.T) { got := string(raw) // Tag dep should serialize ref as the tag value (tag > branch). for _, want := range []string{ - "'actions/checkout@v4.2.1:sha1-", + "'actions/checkout@v4.2.1':", "ref: 'v4.2.1'", - "'internal/branch-only@main:sha1-", + "'internal/branch-only@main':", "ref: 'main'", } { if !strings.Contains(got, want) { @@ -85,7 +85,7 @@ func TestState_PersistsRef(t *testing.T) { if err != nil { t.Fatalf("reopening store: %v", err) } - checkoutKey := "actions/checkout@v4.2.1:sha1-abc123abc123abc123abc123abc123abc123abc1" + checkoutKey := "actions/checkout@v4.2.1" a, ok := store2.file.Dependencies[checkoutKey] if !ok { t.Fatalf("expected %s in reloaded lockfile, keys=%v", checkoutKey, actionKeys(store2.file.Dependencies)) @@ -93,7 +93,7 @@ func TestState_PersistsRef(t *testing.T) { if a.Ref != "v4.2.1" { t.Errorf("expected Ref=v4.2.1, got %q", a.Ref) } - branchOnlyKey := "internal/branch-only@main:sha1-def456def456def456def456def456def456def4" + branchOnlyKey := "internal/branch-only@main" b, ok := store2.file.Dependencies[branchOnlyKey] if !ok { t.Fatalf("expected %s, keys=%v", branchOnlyKey, actionKeys(store2.file.Dependencies)) @@ -184,7 +184,7 @@ func TestState_SetPreservesRefForUnchangedPin(t *testing.T) { if err != nil { t.Fatalf("reopening store: %v", err) } - checkoutKey := "actions/checkout@v4:sha1-abc123abc123abc123abc123abc123abc123abc1" + checkoutKey := "actions/checkout@v4" a, ok := store3.file.Dependencies[checkoutKey] if !ok { t.Fatalf("expected %s preserved, keys=%v", checkoutKey, actionKeys(store3.file.Dependencies)) @@ -236,11 +236,11 @@ func TestState_DiamondTransitiveDepEmittedCorrectly(t *testing.T) { got := string(raw) // Shared dep pin that both A and B should reference. - sharedPin := "shared/dep@v1:sha1-cccccccccccccccccccccccccccccccccccccccc" + sharedPin := "shared/dep@v1" // Both A and B should have uses: containing the shared dep. - aPin := "owner/a@v1:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - bPin := "owner/b@v1:sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + aPin := "owner/a@v1" + bPin := "owner/b@v1" // Verify structure via reload. store2, err := LoadState(dir, fakeMetadataResolver{}) @@ -579,7 +579,7 @@ func TestState_BumpYieldsMinimalDiff(t *testing.T) { if strings.Contains(string(after), "owner/b@v2") { t.Fatalf("expected owner/b@v2 to be gone after bump, got:\n%s", after) } - if !strings.Contains(string(after), "owner/b@v6:sha1-9999999999999999999999999999999999999999") { + if !strings.Contains(string(after), "owner/b@v6") { t.Fatalf("expected bumped owner/b@v6 pin, got:\n%s", after) } } @@ -645,7 +645,7 @@ func TestState_BumpTransitiveRemoval(t *testing.T) { t.Fatal(err) } before, _ := os.ReadFile(filepath.Join(dir, parserlock.Path)) - sharedPin := "shared/s@v1:sha1-5555555555555555555555555555555555555555" + sharedPin := "shared/s@v1" if !strings.Contains(string(before), sharedPin) { t.Fatalf("setup: expected shared pin present, got:\n%s", before) } @@ -713,26 +713,26 @@ func TestState_SaveFormatIsStable(t *testing.T) { const golden = "# This file is machine-generated by `gh actions-lock`.\n" + "# Do not edit by hand; run `gh actions-lock` to update.\n" + "# Docs: https://gh.io/actions-lockfile\n" + - "version: 'v0.0.1'\n" + + "version: '" + parserlock.Version + "'\n" + "workflows:\n" + " '.github/workflows/ci.yml':\n" + - " - 'actions/checkout@v4:sha1-11111111111111111111111111111111111111aa'\n" + - " - 'actions/setup-go@v5:sha1-22222222222222222222222222222222222222bb'\n" + + " - 'actions/checkout@v4'\n" + + " - 'actions/setup-go@v5'\n" + "dependencies:\n" + - " 'actions/checkout@v4:sha1-11111111111111111111111111111111111111aa':\n" + + " 'actions/checkout@v4':\n" + " ref: 'v4'\n" + " commit: 'sha1-11111111111111111111111111111111111111aa'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " uses:\n" + - " - 'shared/dep@v1:sha1-33333333333333333333333333333333333333cc'\n" + - " 'actions/setup-go@v5:sha1-22222222222222222222222222222222222222bb':\n" + - " ref: 'main'\n" + + " - 'shared/dep@v1'\n" + + " 'actions/setup-go@v5':\n" + + " ref: 'v5'\n" + " commit: 'sha1-22222222222222222222222222222222222222bb'\n" + " owner_id: 1\n" + " repo_id: 2\n" + - " 'shared/dep@v1:sha1-33333333333333333333333333333333333333cc':\n" + - " ref: 'main'\n" + + " 'shared/dep@v1':\n" + + " ref: 'v1'\n" + " commit: 'sha1-33333333333333333333333333333333333333cc'\n" + " owner_id: 1\n" + " repo_id: 2\n" @@ -791,51 +791,51 @@ func TestState_TransitiveClosureGolden(t *testing.T) { const golden = "# This file is machine-generated by `gh actions-lock`.\n" + "# Do not edit by hand; run `gh actions-lock` to update.\n" + "# Docs: https://gh.io/actions-lockfile\n" + - "version: 'v0.0.1'\n" + + "version: '" + parserlock.Version + "'\n" + "workflows:\n" + " '.github/workflows/workflow-a.yml':\n" + - " - 'actions/checkout@v4:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'\n" + - " - 'my-org/composite-a@v1:sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'\n" + - " - 'my-org/composite-c@v1:sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'\n" + + " - 'actions/checkout@v4'\n" + + " - 'my-org/composite-a@v1'\n" + + " - 'my-org/composite-c@v1'\n" + " '.github/workflows/workflow-b.yml':\n" + - " - 'actions/checkout@v4:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'\n" + - " - 'my-org/composite-b@v2:sha1-cccccccccccccccccccccccccccccccccccccccc'\n" + + " - 'actions/checkout@v4'\n" + + " - 'my-org/composite-b@v2'\n" + " '.github/workflows/workflow-c.yml':\n" + - " - 'my-org/leaf@main:sha1-dddddddddddddddddddddddddddddddddddddddd'\n" + + " - 'my-org/leaf@main'\n" + "dependencies:\n" + - " 'actions/checkout@v4:sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa':\n" + + " 'actions/checkout@v4':\n" + " ref: 'v4'\n" + " commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'\n" + " owner_id: 1\n" + " repo_id: 2\n" + - " 'my-org/composite-a@v1:sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb':\n" + + " 'my-org/composite-a@v1':\n" + " ref: 'v1'\n" + " commit: 'sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " uses:\n" + - " - 'my-org/composite-b@v2:sha1-cccccccccccccccccccccccccccccccccccccccc'\n" + - " - 'other-org/external@v1:sha1-ffffffffffffffffffffffffffffffffffffffff'\n" + - " 'my-org/composite-b@v2:sha1-cccccccccccccccccccccccccccccccccccccccc':\n" + + " - 'my-org/composite-b@v2'\n" + + " - 'other-org/external@v1'\n" + + " 'my-org/composite-b@v2':\n" + " ref: 'v2'\n" + " commit: 'sha1-cccccccccccccccccccccccccccccccccccccccc'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " uses:\n" + - " - 'my-org/leaf@main:sha1-dddddddddddddddddddddddddddddddddddddddd'\n" + - " 'my-org/composite-c@v1:sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee':\n" + + " - 'my-org/leaf@main'\n" + + " 'my-org/composite-c@v1':\n" + " ref: 'v1'\n" + " commit: 'sha1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'\n" + " owner_id: 1\n" + " repo_id: 2\n" + " uses:\n" + - " - 'my-org/composite-b@v2:sha1-cccccccccccccccccccccccccccccccccccccccc'\n" + - " 'my-org/leaf@main:sha1-dddddddddddddddddddddddddddddddddddddddd':\n" + + " - 'my-org/composite-b@v2'\n" + + " 'my-org/leaf@main':\n" + " ref: 'main'\n" + " commit: 'sha1-dddddddddddddddddddddddddddddddddddddddd'\n" + " owner_id: 1\n" + " repo_id: 2\n" + - " 'other-org/external@v1:sha1-ffffffffffffffffffffffffffffffffffffffff':\n" + + " 'other-org/external@v1':\n" + " ref: 'v1'\n" + " commit: 'sha1-ffffffffffffffffffffffffffffffffffffffff'\n" + " owner_id: 1\n" + diff --git a/internal/pipeline/checks/misleading.go b/internal/pipeline/checks/misleading.go index 7a1a3d44..fcd16297 100644 --- a/internal/pipeline/checks/misleading.go +++ b/internal/pipeline/checks/misleading.go @@ -52,7 +52,7 @@ func checkMisleadingSha(ctx context.Context, pw ParsedWorkflow, r CheckResolver) // ImpostorCommit finding is emitted alongside ref-moved / // ancestry-unknown. Forgery suppresses the observed-SHA impostor: the // lockfile-tampering claim is stronger. -func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex map[string]parserlock.Pin, r CheckResolver) []Finding { +func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex map[string]lockedPin, r CheckResolver) []Finding { var out []Finding for _, ref := range pw.Refs { if parserlock.IsFullSha(ref.Ref) { @@ -66,13 +66,13 @@ func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex ma if !ok || sha == "" { continue } - if strings.EqualFold(sha, pin.Hex) { + if strings.EqualFold(sha, pin.SHA()) { continue } - ancestry, ancestryDetail := r.CheckAncestry(ctx, ref.Owner, ref.Repo, pin.Hex, sha) + ancestry, ancestryDetail := r.CheckAncestry(ctx, ref.Owner, ref.Repo, pin.SHA(), sha) f := newRefFinding(pw, ref, "", "", "") f.ObservedSHA = sha - f.Dependency = synthDep(ref, pin.Hex) + f.Dependency = synthDep(ref, pin.SHA()) switch ancestry { case resolve.AncestryNotAncestor: // Compare API gave an authoritative not-an-ancestor verdict. @@ -81,7 +81,7 @@ func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex ma f.Category = LockfileForgery f.Severity = SeverityError f.Confidence = ConfidenceHigh - f.Detail = fmt.Sprintf("pinned %s is not an ancestor of %s — lockfile may have been tampered with", parserlock.ShortSHA(pin.Hex), parserlock.ShortSHA(sha)) + f.Detail = fmt.Sprintf("pinned %s is not an ancestor of %s — lockfile may have been tampered with", parserlock.ShortSHA(pin.SHA()), parserlock.ShortSHA(sha)) f.Remediation = "investigate immediately — verify the lockfile entry against upstream history" out = append(out, f) case resolve.AncestryUnknown: @@ -93,7 +93,7 @@ func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex ma f.Category = AncestryUnknown f.Severity = SeverityWarning f.Confidence = ConfidenceMedium - f.Detail = fmt.Sprintf("ref %s now resolves to %s, lockfile pins %s (ancestry check inconclusive%s)", ref.Ref, parserlock.ShortSHA(sha), parserlock.ShortSHA(pin.Hex), suffixWith(ancestryDetail)) + f.Detail = fmt.Sprintf("ref %s now resolves to %s, lockfile pins %s (ancestry check inconclusive%s)", ref.Ref, parserlock.ShortSHA(sha), parserlock.ShortSHA(pin.SHA()), suffixWith(ancestryDetail)) f.Remediation = "retry when the Compare API is available to classify this as ref-moved or lockfile-forgery" out = append(out, f) // Inconclusive ancestry doesn't block a branch_commits check @@ -106,7 +106,7 @@ func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex ma f.Category = RefMoved f.Severity = SeverityWarning f.Confidence = ConfidenceHigh - f.Detail = fmt.Sprintf("ref %s now resolves to %s, lockfile pins %s", ref.Ref, parserlock.ShortSHA(sha), parserlock.ShortSHA(pin.Hex)) + f.Detail = fmt.Sprintf("ref %s now resolves to %s, lockfile pins %s", ref.Ref, parserlock.ShortSHA(sha), parserlock.ShortSHA(pin.SHA())) f.Remediation = "re-run `gh actions-lock` to refresh the lock entry" out = append(out, f) if imp, ok := liveRefImpostorFinding(pw, ref, sha, r); ok { @@ -146,7 +146,7 @@ func suffixWith(detail string) string { // checkImpostorCommit emits ImpostorCommit when the locked SHA is // not reachable from the ref's history. Skips entries already covered by // a forgery finding (forgery is the stronger signal). -func checkImpostorCommit(pw ParsedWorkflow, depIndex map[string]parserlock.Pin, r CheckResolver, forgeryKeys map[string]bool) []Finding { +func checkImpostorCommit(pw ParsedWorkflow, depIndex map[string]lockedPin, r CheckResolver, forgeryKeys map[string]bool) []Finding { if len(depIndex) == 0 { return nil } @@ -162,7 +162,7 @@ func checkImpostorCommit(pw ParsedWorkflow, depIndex map[string]parserlock.Pin, if forgeryKeys[parserlock.IndexKey(ref.Owner, ref.Repo, ref.Ref)] { continue } - status := r.CheckReachability(ref.Owner, ref.Repo, pin.Hex, ref.Ref) + status := r.CheckReachability(ref.Owner, ref.Repo, pin.SHA(), ref.Ref) if status != resolve.Unreachable { // Fail open on ReachabilityUnknown by design: only an // authoritative Unreachable is an impostor. The inconclusive @@ -174,8 +174,8 @@ func checkImpostorCommit(pw ParsedWorkflow, depIndex map[string]parserlock.Pin, // branch_commits gave an authoritative answer: the locked SHA is // not on any branch of the upstream repo (fork-network impostor). f := newRefFinding(pw, ref, ImpostorCommit, SeverityError, ConfidenceHigh) - f.Dependency = synthDep(ref, pin.Hex) - f.Detail = fmt.Sprintf("locked %s is not reachable from %s — classic fork-network impostor-commit shape", parserlock.ShortSHA(pin.Hex), ref.Ref) + f.Dependency = synthDep(ref, pin.SHA()) + f.Detail = fmt.Sprintf("locked %s is not reachable from %s — classic fork-network impostor-commit shape", parserlock.ShortSHA(pin.SHA()), ref.Ref) f.Remediation = "investigate immediately — the lockfile entry may have been injected" out = append(out, f) } diff --git a/internal/pipeline/checks/run.go b/internal/pipeline/checks/run.go index 63f586a6..63760074 100644 --- a/internal/pipeline/checks/run.go +++ b/internal/pipeline/checks/run.go @@ -21,7 +21,7 @@ import ( // because they need lookup tables runChecks doesn't carry. func RunChecks(ctx context.Context, pw ParsedWorkflow, lf parserlock.File, r CheckResolver) []Finding { wfEntry, _ := lf.LookupWorkflow(workflowfile.KeyFromPath(pw.Path)) - depPins, depIndex := parseWorkflowDeps(wfEntry) + depPins, depIndex := parseWorkflowDeps(wfEntry, lf.Dependencies) var out []Finding out = append(out, checkNotPinned(pw, depPins, depIndex)...) @@ -38,19 +38,39 @@ func RunChecks(ctx context.Context, pw ParsedWorkflow, lf parserlock.File, r Che return out } +// lockedPin pairs a parsed Pin with the commit hash from the lockfile's +// Action.Commit field. Pin keys no longer embed the hash (v0.0.2 schema), +// so the SHA must be retrieved from the Action metadata. +type lockedPin struct { + parserlock.Pin + Commit string // full "algo-hex" from Action.Commit +} + +// SHA returns just the hex portion of the commit (strips "algo-" prefix). +func (lp lockedPin) SHA() string { + if idx := strings.Index(lp.Commit, "-"); idx >= 0 { + return lp.Commit[idx+1:] + } + return lp.Commit +} + // parseWorkflowDeps decodes a workflow's dependency pin strings into Pins -// plus an index keyed by "owner/repo@ref". Unparseable entries are -// dropped silently — they're surfaced separately by workflowfile.Parse callers. -func parseWorkflowDeps(rawDeps []string) ([]parserlock.Pin, map[string]parserlock.Pin) { - pins := make([]parserlock.Pin, 0, len(rawDeps)) - idx := make(map[string]parserlock.Pin, len(rawDeps)) +// plus an index keyed by "owner/repo@ref" carrying commit info from the +// lockfile. Unparseable entries are dropped silently. +func parseWorkflowDeps(rawDeps []string, deps map[string]parserlock.Action) ([]lockedPin, map[string]lockedPin) { + pins := make([]lockedPin, 0, len(rawDeps)) + idx := make(map[string]lockedPin, len(rawDeps)) for _, raw := range rawDeps { pin, ok := parserlock.ParsePin(raw) if !ok { continue } - pins = append(pins, pin) - idx[pin.IndexKey()] = pin + lp := lockedPin{Pin: pin} + if action, found := deps[raw]; found { + lp.Commit = action.Commit + } + pins = append(pins, lp) + idx[pin.IndexKey()] = lp } return pins, idx } diff --git a/internal/pipeline/checks/run_test.go b/internal/pipeline/checks/run_test.go index aeba24b2..d1a96cf5 100644 --- a/internal/pipeline/checks/run_test.go +++ b/internal/pipeline/checks/run_test.go @@ -75,15 +75,49 @@ const ( shaImpostor = "ffffffffffffffffffffffffffffffffffffffff" ) +// checkPinKey returns a decorated pin string "owner/repo@ref#sha1-hex" that +// checkNewLockfile parses to build both the workflow key (before #) and the +// Dependencies entry (Commit from the suffix). func checkPinKey(owner, repo, ref, sha string) string { - return owner + "/" + repo + "@" + ref + ":sha1-" + sha + return owner + "/" + repo + "@" + ref + "#sha1-" + sha } -func checkNewLockfile(workflows map[string][]string) parserlock.File { +// checkNewLockfile builds a File from decorated pin keys. +func checkNewLockfile(workflows map[string][]string, deps ...map[string]parserlock.Action) parserlock.File { + d := make(map[string]parserlock.Action) + wf := make(map[string][]string, len(workflows)) + if len(deps) > 0 && deps[0] != nil { + // Explicit deps override, pass through workflows as-is. + d = deps[0] + wf = workflows + } else { + for path, pins := range workflows { + keys := make([]string, 0, len(pins)) + for _, decorated := range pins { + key, commit := splitDecoratedPin(decorated) + keys = append(keys, key) + if commit != "" { + // Extract ref from key (after @) + ref := key[strings.LastIndex(key, "@")+1:] + d[key] = parserlock.Action{Ref: ref, Commit: commit} + } + } + wf[path] = keys + } + } return parserlock.File{ - Version: parserlock.Version, - Workflows: workflows, + Version: parserlock.Version, + Workflows: wf, + Dependencies: d, + } +} + +// splitDecoratedPin splits "owner/repo@ref#sha1-hex" into key and commit. +func splitDecoratedPin(s string) (key, commit string) { + if idx := strings.Index(s, "#"); idx >= 0 { + return s[:idx], s[idx+1:] } + return s, "" } func checkParsedWF(path string, uses ...parserlock.ActionRef) ParsedWorkflow { @@ -666,13 +700,13 @@ func TestRunChecks_AllFindingsCarryConfidence(t *testing.T) { // "injected lockfile entry" every time the GitHub API hiccupped would be // worse than useless. These tests pin that contract. -func impostorFixture(reach resolve.ReachabilityStatus) (ParsedWorkflow, map[string]parserlock.Pin, *stubCheckResolver) { +func impostorFixture(reach resolve.ReachabilityStatus) (ParsedWorkflow, map[string]lockedPin, *stubCheckResolver) { ref := checkRef("actions", "checkout", "v4") pw := checkParsedWF(".github/workflows/ci.yml", ref) - depIndex := map[string]parserlock.Pin{ + depIndex := map[string]lockedPin{ parserlock.IndexKey("actions", "checkout", "v4"): { - NWO: "actions/checkout", Owner: "actions", Repo: "checkout", - Ref: "v4", Algo: "sha1", Hex: shaImpostor, + Pin: parserlock.Pin{NWO: "actions/checkout", Owner: "actions", Repo: "checkout", Ref: "v4"}, + Commit: "sha1-" + shaImpostor, }, } r := &stubCheckResolver{ diff --git a/internal/pipeline/checks/structural.go b/internal/pipeline/checks/structural.go index 93d78a0a..6690543c 100644 --- a/internal/pipeline/checks/structural.go +++ b/internal/pipeline/checks/structural.go @@ -12,7 +12,7 @@ import ( // matching lockfile entry. SHA-shaped refs are reported under their own // category. When the lockfile has an entry for the same action at a // different ref, RefChanged wins. -func checkNotPinned(pw ParsedWorkflow, depPins []parserlock.Pin, depIndex map[string]parserlock.Pin) []Finding { +func checkNotPinned(pw ParsedWorkflow, depPins []lockedPin, depIndex map[string]lockedPin) []Finding { if len(pw.Refs) == 0 { return nil } @@ -43,7 +43,7 @@ func checkNotPinned(pw ParsedWorkflow, depPins []parserlock.Pin, depIndex map[st // commit SHA — both bare-SHA uses with no lock entry and bare-SHA uses // whose lock entry just mirrors the same SHA. The anti-pattern (no // human-readable ref) is the same in both cases. -func checkShaAsRef(pw ParsedWorkflow, depIndex map[string]parserlock.Pin) []Finding { +func checkShaAsRef(pw ParsedWorkflow, depIndex map[string]lockedPin) []Finding { var out []Finding for _, ref := range pw.Refs { if !parserlock.IsFullSha(ref.Ref) { @@ -54,7 +54,7 @@ func checkShaAsRef(pw ParsedWorkflow, depIndex map[string]parserlock.Pin) []Find f.Remediation = fmt.Sprintf("pin to a tag instead: https://github.com/%s/releases", nwoLower(ref.Owner, ref.Repo)) lockedSha := ref.Ref if locked, ok := depIndex[parserlock.IndexKey(ref.Owner, ref.Repo, ref.Ref)]; ok { - lockedSha = locked.Hex + lockedSha = locked.SHA() } f.Dependency = synthDep(ref, lockedSha) out = append(out, f) @@ -66,11 +66,11 @@ func checkShaAsRef(pw ParsedWorkflow, depIndex map[string]parserlock.Pin) []Find // differs from the lockfile entry's ref for the same action (owner/repo). // A single action may legitimately have multiple pinned refs across // workflows, so this only fires when no pin matches the workflow's ref. -func checkRefChanged(pw ParsedWorkflow, depPins []parserlock.Pin) []Finding { +func checkRefChanged(pw ParsedWorkflow, depPins []lockedPin) []Finding { if len(depPins) == 0 { return nil } - pinsByAction := make(map[string][]parserlock.Pin, len(depPins)) + pinsByAction := make(map[string][]lockedPin, len(depPins)) for _, p := range depPins { k := nwoLower(p.Owner, p.Repo) pinsByAction[k] = append(pinsByAction[k], p) @@ -99,7 +99,7 @@ func checkRefChanged(pw ParsedWorkflow, depPins []parserlock.Pin) []Finding { f := newRefFinding(pw, ref, RefChanged, SeverityError, ConfidenceHigh) f.Detail = fmt.Sprintf("workflow uses ref %q but lockfile pins %q", ref.Ref, p.Ref) f.Remediation = "re-run `gh actions-lock` to refresh the lockfile, or revert the uses: line" - f.Dependency = synthDep(ref, p.Hex) + f.Dependency = synthDep(ref, p.SHA()) out = append(out, f) } return out @@ -109,7 +109,7 @@ func checkRefChanged(pw ParsedWorkflow, depPins []parserlock.Pin) []Finding { // ref in the workflow references. If the workflow has already been // rewritten to pin by SHA, the lockfile entry (keyed by the original tag) // is still valid — surface keys both ways so we don't false-flag. -func checkStale(pw ParsedWorkflow, depPins []parserlock.Pin) []Finding { +func checkStale(pw ParsedWorkflow, depPins []lockedPin) []Finding { if len(depPins) == 0 { return nil } @@ -125,9 +125,9 @@ func checkStale(pw ParsedWorkflow, depPins []parserlock.Pin) []Finding { if used[p.IndexKey()] { continue } - if p.Hex != "" { + if p.SHA() != "" { nwo := strings.ToLower(p.NWO) - if usedBySHA[nwo+"@"+strings.ToLower(p.Hex)] { + if usedBySHA[nwo+"@"+strings.ToLower(p.SHA())] { continue } } @@ -141,7 +141,7 @@ func checkStale(pw ParsedWorkflow, depPins []parserlock.Pin) []Finding { Dependency: &dep.Dependency{ NWO: strings.ToLower(p.NWO), Ref: p.Ref, - SHA: p.Hex, + SHA: p.SHA(), }, } out = append(out, f) diff --git a/test/scenarios/testdata/transitive_closure_cross_repo.lock b/test/scenarios/testdata/transitive_closure_cross_repo.lock index b62bc6a3..17ce188f 100644 --- a/test/scenarios/testdata/transitive_closure_cross_repo.lock +++ b/test/scenarios/testdata/transitive_closure_cross_repo.lock @@ -1,38 +1,38 @@ # This file is machine-generated by `gh actions-lock`. # Do not edit by hand; run `gh actions-lock` to update. # Docs: https://gh.io/actions-lockfile -version: 'v0.0.1' +version: 'v0.0.2' workflows: '.github/workflows/happy-path.yml': - - 'actions/checkout@v4.3.1:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5' - - 'nodeselector/actions-test-fixtures@updated:sha1-ea53476fdc172d8552df5af9658a45a367e4f41d' + - 'actions/checkout@v4.3.1' + - 'nodeselector/actions-test-fixtures@updated' '.github/workflows/imposter-commit.yml': - - 'actions/checkout@v4.3.1:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5' + - 'actions/checkout@v4.3.1' '.github/workflows/reusable-build.yml': - - 'actions/checkout@v4.3.1:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5' - - 'nodeselector/actions-test-fixtures@main:sha1-e67943468a2f9790006afa217db1ea22c71433a4' + - 'actions/checkout@v4.3.1' + - 'nodeselector/actions-test-fixtures@main' dependencies: - 'actions/checkout@v4.3.1:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5': + 'actions/checkout@v4.3.1': ref: 'v4.3.1' commit: 'sha1-34e114876b0b11c390a56381ad16ebd13914f8d5' owner_id: 44036562 repo_id: 197814629 - 'nodeselector/actions-test-fixtures-b@main:sha1-92b7b0058bc223c6e9dd4e19ef9247c934ba7637': + 'nodeselector/actions-test-fixtures-b@main': ref: 'main' commit: 'sha1-92b7b0058bc223c6e9dd4e19ef9247c934ba7637' owner_id: 29457092 repo_id: 1205442499 - 'nodeselector/actions-test-fixtures@main:sha1-e67943468a2f9790006afa217db1ea22c71433a4': + 'nodeselector/actions-test-fixtures@main': ref: 'main' commit: 'sha1-e67943468a2f9790006afa217db1ea22c71433a4' owner_id: 29457092 repo_id: 1203329948 uses: - - 'nodeselector/actions-test-fixtures-b@main:sha1-92b7b0058bc223c6e9dd4e19ef9247c934ba7637' - 'nodeselector/actions-test-fixtures@updated:sha1-ea53476fdc172d8552df5af9658a45a367e4f41d': + - 'nodeselector/actions-test-fixtures-b@main' + 'nodeselector/actions-test-fixtures@updated': ref: 'updated' commit: 'sha1-ea53476fdc172d8552df5af9658a45a367e4f41d' owner_id: 29457092 repo_id: 1203329948 uses: - - 'nodeselector/actions-test-fixtures@main:sha1-e67943468a2f9790006afa217db1ea22c71433a4' + - 'nodeselector/actions-test-fixtures@main' From 2ca73073e7875506400ace6bf512096c3245d06b Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 22 Jun 2026 21:09:28 -0500 Subject: [PATCH 03/67] integration: update fixtures to v0.0.2 lockfile format Pin keys now use owner/repo@ref (no :sha1-hex suffix), ref field replaces tag/branch, golden_json expectations updated. --- test/integration/run.rb | 30 +++++++++++++----------------- test/scenarios/catalog.yml | 12 ++++++------ 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/test/integration/run.rb b/test/integration/run.rb index 0fc1eb90..1e326a9c 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -66,7 +66,7 @@ def build_lockfile(workflows:, dependencies: {}) # This file is machine-generated by `gh actions-lock`. # Do not edit by hand; run `gh actions-lock` to update. # Docs: https://gh.io/actions-lockfile - version: 'v0.0.1' + version: 'v0.0.2' workflows: #{wf_section} dependencies: @@ -342,13 +342,12 @@ def golden_json_diff(expected, actual, path) build_lockfile( workflows: { ".github/workflows/ci.yml" => [ - "actions/checkout@v4:sha1-#{CHECKOUT_SHA}" + "actions/checkout@v4" ] }, dependencies: { - "actions/checkout@v4:sha1-#{CHECKOUT_SHA}" => { - "tag" => "v4", - "branch" => "main", + "actions/checkout@v4" => { + "ref" => "v4", "commit" => "sha1-#{CHECKOUT_SHA}", "owner_id" => 44036562, "repo_id" => 197814629 @@ -362,13 +361,12 @@ def golden_json_diff(expected, actual, path) build_lockfile( workflows: { ".github/workflows/ci.yml" => [ - "actions/checkout@v4:sha1-#{CHECKOUT_SHA}" + "actions/checkout@v4" ] }, dependencies: { - "actions/checkout@v4:sha1-#{CHECKOUT_SHA}" => { - "tag" => "v4", - "branch" => "main", + "actions/checkout@v4" => { + "ref" => "v4", "commit" => "sha1-#{CHECKOUT_SHA}", "owner_id" => 44036562, "repo_id" => 197814629 @@ -382,13 +380,12 @@ def golden_json_diff(expected, actual, path) build_lockfile( workflows: { ".github/workflows/ci.yml" => [ - "actions/checkout@v4.2.0:sha1-#{CHECKOUT_SHA}" + "actions/checkout@v4.2.0" ] }, dependencies: { - "actions/checkout@v4.2.0:sha1-#{CHECKOUT_SHA}" => { - "tag" => "v4.2.0", - "branch" => "main", + "actions/checkout@v4.2.0" => { + "ref" => "v4.2.0", "commit" => "sha1-#{CHECKOUT_SHA}", "owner_id" => 44036562, "repo_id" => 197814629 @@ -402,13 +399,12 @@ def golden_json_diff(expected, actual, path) build_lockfile( workflows: { ".github/workflows/ci.yml" => [ - "actions/checkout@main:sha1-#{CHECKOUT_SHA}" + "actions/checkout@main" ] }, dependencies: { - "actions/checkout@main:sha1-#{CHECKOUT_SHA}" => { - "tag" => "", - "branch" => "main", + "actions/checkout@main" => { + "ref" => "main", "commit" => "sha1-#{CHECKOUT_SHA}", "owner_id" => 44036562, "repo_id" => 197814629 diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 731ecc0d..648e074b 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -1126,7 +1126,7 @@ scenarios: golden_json: cli_version: (devel) findings: [] - lockfile_version: v0.0.1 + lockfile_version: v0.0.2 valid: true workflows: - dependencies: @@ -1174,7 +1174,7 @@ scenarios: remediation: onboard it first with `gh actions-lock check` (without --no-onboard) severity: info workflow: .github/workflows/ci.yml - lockfile_version: v0.0.1 + lockfile_version: v0.0.2 valid: true workflows: - findings: @@ -1221,7 +1221,7 @@ scenarios: remediation: onboard it first with `gh actions-lock check` (without --no-onboard) severity: info workflow: .github/workflows/ci.yml - lockfile_version: v0.0.1 + lockfile_version: v0.0.2 valid: true workflows: - dependencies: @@ -1297,7 +1297,7 @@ scenarios: remediation: onboard it first with `gh actions-lock check` (without --no-onboard) severity: info workflow: .github/workflows/deploy.yml - lockfile_version: v0.0.1 + lockfile_version: v0.0.2 valid: true workflows: - dependencies: @@ -1349,7 +1349,7 @@ scenarios: golden_json: cli_version: (devel) findings: [] - lockfile_version: v0.0.1 + lockfile_version: v0.0.2 valid: true - name: dbot_transient_403_drops_pin category: dependabot @@ -1375,7 +1375,7 @@ scenarios: golden_json: cli_version: (devel) findings: [] - lockfile_version: v0.0.1 + lockfile_version: v0.0.2 valid: true - name: dbot_impostor_blocks category: dependabot From 779f2ca57ae9185f317a2980141f47361d32dfba Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 07:18:55 -0500 Subject: [PATCH 04/67] integration: harden golden assertions for v0.0.2 schema Add golden_json assertions to dbot_impostor_blocks and dbot_forgery_blocks so schema regressions in finding output are caught. Add lockfile_contains assertions to dbot stub scenarios verifying key format, ref field, and commit field presence. Add onboard_roundtrip_v002 live test against nodeselector/actions-test-fixtures that verifies a fresh onboard produces valid v0.0.2 lockfile structure. Update transitive_closure_cross_repo golden to track current main SHA. --- test/integration/run.rb | 19 ++++++ test/scenarios/catalog.yml | 66 +++++++++++++++++++ .../transitive_closure_cross_repo.lock | 2 +- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/test/integration/run.rb b/test/integration/run.rb index 1e326a9c..7eb12051 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -438,6 +438,17 @@ def checkout_repo_rest(srv) })] end + # GET .../tags — tag list for narrowing/reverse lookup + srv.on(:GET, %r{/repos/actions/checkout/tags}) do |_req| + [200, { "Content-Type" => "application/json" }, + JSON.generate([ + { name: "v4", commit: { sha: CHECKOUT_SHA } }, + { name: "v4.2.2", commit: { sha: CHECKOUT_SHA } }, + { name: "v4.2.1", commit: { sha: "1111111111111111111111111111111111111111" } }, + { name: "v3", commit: { sha: "2222222222222222222222222222222222222222" } } + ])] + end + # GET .../git/ref/heads/main — branch head SHA srv.on(:GET, %r{/repos/actions/checkout/git/ref/heads/main$}) do |_req| [200, { "Content-Type" => "application/json" }, @@ -710,6 +721,14 @@ def checkout_graphql_forgery(srv) s.live_repo(live_repo) end + # Delete lockfile before running (for migration/schema tests) + if fixtures["delete_lockfile"] + s.setup do |dir| + lockpath = File.join(dir, ".github", "workflows", "actions.lock") + File.delete(lockpath) if File.exist?(lockpath) + end + end + # Stub server wiring if STUB_WIRING[name] STUB_WIRING[name].call(s) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 648e074b..49bf0de6 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -582,6 +582,8 @@ scenarios: needs_token: true tags: [real_repo] live_repo: nodeselector/actions-test-fixtures + fixtures: + delete_lockfile: true expect: exit: 1 lockfile_golden: transitive_closure_cross_repo.lock @@ -1372,6 +1374,12 @@ scenarios: - expr: '.findings | length' equals: "0" + lockfile_contains: + - "version: 'v0.0.2'" + - "'actions/checkout@v4':" + - "ref: 'v4'" + - "commit: 'sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd'" + golden_json: cli_version: (devel) findings: [] @@ -1402,6 +1410,25 @@ scenarios: - expr: '.findings[] | select(.category == "impostor-commit") | .severity' equals: "error" + golden_json: + cli_version: (devel) + lockfile_version: v0.0.2 + valid: false + findings: + - category: impostor-commit + confidence: high + dependency: actions/checkout@v4 + detail: "locked de0fac2e4500 is not reachable from v4 \u2014 classic fork-network impostor-commit shape" + doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" + remediation: "investigate immediately \u2014 the lockfile entry may have been injected" + severity: error + workflow: .github/workflows/ci.yml + + lockfile_contains: + - "'actions/checkout@v4':" + - "ref: 'v4'" + - "commit: 'sha1-" + - name: dbot_forgery_blocks category: dependabot description: "Lockfile forgery (pin doesn't match resolved commit) produces lockfile-forgery/error finding" @@ -1426,3 +1453,42 @@ scenarios: equals: "lockfile-forgery" - expr: '.findings[] | select(.category == "lockfile-forgery") | .severity' equals: "error" + + golden_json: + cli_version: (devel) + lockfile_version: v0.0.2 + valid: false + findings: + - category: lockfile-forgery + confidence: high + dependency: actions/checkout@v4 + detail: "pinned de0fac2e4500 is not an ancestor of bbbbbbbbbbbb \u2014 lockfile may have been tampered with" + doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" + remediation: "investigate immediately \u2014 verify the lockfile entry against upstream history" + severity: error + workflow: .github/workflows/ci.yml + + lockfile_contains: + - "'actions/checkout@v4':" + - "ref: 'v4'" + - "commit: 'sha1-" + + - name: onboard_roundtrip_v002 + category: onboarding + description: "Fresh onboard writes a v0.0.2 lockfile with correct key format, ref field, and commit field" + needs_token: true + tags: [real_repo] + live_repo: nodeselector/actions-test-fixtures + fixtures: + delete_lockfile: true + expect: + exit: 1 + lockfile_exists: true + lockfile_contains: + - "version: 'v0.0.2'" + - "ref:" + - "commit: 'sha1-" + - "owner_id:" + - "repo_id:" + lockfile_comment_excludes: '^\s+tag:' + lockfile_comment_matches: "ref: '" diff --git a/test/scenarios/testdata/transitive_closure_cross_repo.lock b/test/scenarios/testdata/transitive_closure_cross_repo.lock index 17ce188f..74b9a915 100644 --- a/test/scenarios/testdata/transitive_closure_cross_repo.lock +++ b/test/scenarios/testdata/transitive_closure_cross_repo.lock @@ -24,7 +24,7 @@ dependencies: repo_id: 1205442499 'nodeselector/actions-test-fixtures@main': ref: 'main' - commit: 'sha1-e67943468a2f9790006afa217db1ea22c71433a4' + commit: 'sha1-a132be34de3441f8d2970a3fd6b8a7a86bdbbd0c' owner_id: 29457092 repo_id: 1203329948 uses: From 6dc25c542d69326f96a5d1af6821c674c6f12e8b Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 07:36:34 -0500 Subject: [PATCH 05/67] integration: add lockfile coherence assertions for direct and indirect deps Port the dependency coverage validation from actions-workflow-parser into the test harness as two independent assertions: - lockfile_deps_cover_direct: every pin in workflows: exists in dependencies: - lockfile_deps_cover_indirect: every ref in a dependency's uses: list exists in dependencies: Applied to composite_action_transitive, transitive_closure_cross_repo, onboard_roundtrip_v002 (both), and fresh_first_run (direct only). --- test/integration/harness.rb | 52 +++++++++++++++++++++++++++++++++++++ test/integration/run.rb | 8 ++++++ test/scenarios/catalog.yml | 7 +++++ 3 files changed, 67 insertions(+) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index dc65318b..51465fdd 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -289,6 +289,48 @@ def assert_lockfile_comment_excludes(pattern) self end + # Verify every action ref listed in workflows: exists as a key in dependencies:. + def assert_lockfile_deps_cover_direct + @assertions << -> (r) { + lockpath = File.join(r.dir, ".github", "workflows", "actions.lock") + content = File.read(lockpath) rescue "" + lf = YAML.safe_load(content) rescue nil + unless lf.is_a?(Hash) && lf["workflows"].is_a?(Hash) && lf["dependencies"].is_a?(Hash) + assert_true("lockfile deps cover direct: parseable lockfile", false) + next + end + dep_keys = lf["dependencies"].keys.to_set + lf["workflows"].each do |wf_path, refs| + next unless refs.is_a?(Array) + refs.each do |ref| + assert_true("direct dep covered: #{ref} (from #{wf_path})", dep_keys.include?(ref)) + end + end + } + self + end + + # Verify every ref in a dependency's uses: list exists as a key in dependencies:. + def assert_lockfile_deps_cover_indirect + @assertions << -> (r) { + lockpath = File.join(r.dir, ".github", "workflows", "actions.lock") + content = File.read(lockpath) rescue "" + lf = YAML.safe_load(content) rescue nil + unless lf.is_a?(Hash) && lf["dependencies"].is_a?(Hash) + assert_true("lockfile deps cover indirect: parseable lockfile", false) + next + end + dep_keys = lf["dependencies"].keys.to_set + lf["dependencies"].each do |pin, meta| + next unless meta.is_a?(Hash) && meta["uses"].is_a?(Array) + meta["uses"].each do |used_ref| + assert_true("indirect dep covered: #{used_ref} (used by #{pin})", dep_keys.include?(used_ref)) + end + end + } + self + end + def assert_custom(&block) @assertions << block self @@ -1561,6 +1603,8 @@ def format_expect_lines(spec) lines << "lockfile matches /#{spec['lockfile_comment_matches']}/" if spec["lockfile_comment_matches"] lines << "lockfile excludes /#{spec['lockfile_comment_excludes']}/" if spec["lockfile_comment_excludes"] lines << "lockfile exists" if spec["lockfile_exists"] + lines << "lockfile deps cover direct" if spec["lockfile_deps_cover_direct"] + lines << "lockfile deps cover indirect" if spec["lockfile_deps_cover_indirect"] lines << "lockfile == golden #{spec['lockfile_golden']}" if spec["lockfile_golden"] if spec["jq"] spec["jq"].each do |check| @@ -1636,6 +1680,14 @@ def format_expect_checks(spec, result, failures) ok = !failures.any? { |f| f.include?("lockfile exists") } checks << ["lockfile exists", ok] end + if spec["lockfile_deps_cover_direct"] + ok = !failures.any? { |f| f.include?("direct dep covered:") } + checks << ["lockfile deps cover direct", ok] + end + if spec["lockfile_deps_cover_indirect"] + ok = !failures.any? { |f| f.include?("indirect dep covered:") } + checks << ["lockfile deps cover indirect", ok] + end if spec["lockfile_golden"] ok = !failures.any? { |f| f.include?("lockfile does not match golden") } checks << ["lockfile matches golden #{spec["lockfile_golden"]}", ok] diff --git a/test/integration/run.rb b/test/integration/run.rb index 7eb12051..e95c5b44 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -176,6 +176,14 @@ def hydrate_assertions(s, expect, needs_token: false) s.assert_lockfile_contains(*expect["lockfile_contains"]) end + if expect["lockfile_deps_cover_direct"] + s.assert_lockfile_deps_cover_direct + end + + if expect["lockfile_deps_cover_indirect"] + s.assert_lockfile_deps_cover_indirect + end + if expect["lockfile_golden"] golden_path = File.expand_path("../../scenarios/testdata/#{expect["lockfile_golden"]}", __FILE__) s.assert_custom do |r| diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 49bf0de6..a42e00c2 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -104,6 +104,8 @@ scenarios: actions: ["actions/create-github-app-token@v1"] expect: exit: 0 + lockfile_deps_cover_direct: true + lockfile_deps_cover_indirect: true # ╔═════════════════════════════════════════════════════════════════════════╗ # ║═══════════════════════════════ sso_auth ════════════════════════════════║ @@ -278,6 +280,7 @@ scenarios: expect: exit: 0 lockfile_exists: true + lockfile_deps_cover_direct: true - name: onboarded_corrupt_recovery category: lockfile @@ -587,6 +590,8 @@ scenarios: expect: exit: 1 lockfile_golden: transitive_closure_cross_repo.lock + lockfile_deps_cover_direct: true + lockfile_deps_cover_indirect: true output_contains: - "transitive" @@ -1484,6 +1489,8 @@ scenarios: expect: exit: 1 lockfile_exists: true + lockfile_deps_cover_direct: true + lockfile_deps_cover_indirect: true lockfile_contains: - "version: 'v0.0.2'" - "ref:" From c5fd8009dc9bce2a46e576d0297d251215bb0def Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 07:40:30 -0500 Subject: [PATCH 06/67] integration: fix stale transitive narrowing scenarios cli/gh-extension-precompile@v2.1.0 no longer includes actions/setup-go in its composite deps. Update transitive_major_ref_not_narrowed to assert on actions/attest-build-provenance@v1 instead (same concept: major ref not narrowed). Fix regex patterns broken by v0.0.2 quote-wrapped keys -- switch from lockfile_comment_matches to lockfile_contains where appropriate. Add coherence assertions to transitive_major_ref_not_narrowed. --- test/scenarios/catalog.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index a42e00c2..cf12f04c 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -917,7 +917,7 @@ scenarios: - name: transitive_major_ref_not_narrowed category: narrowing - description: "Transitive dep of a composite keeps the major ref the composite declares (actions/setup-go@v5 stays v5, not narrowed to v5.x.y) — we don't own composite-internal refs" + description: "Transitive dep of a composite keeps the major ref the composite declares (actions/attest-build-provenance@v1 stays v1, not narrowed to v1.x.y) — we don't own composite-internal refs" needs_token: true fixtures: workflows: @@ -926,8 +926,11 @@ scenarios: actions: ["cli/gh-extension-precompile@v2.1.0"] expect: exit: 0 - lockfile_comment_matches: 'actions/setup-go@v5:' - lockfile_comment_excludes: 'actions/setup-go@v5\.\d+\.\d+' + lockfile_deps_cover_direct: true + lockfile_deps_cover_indirect: true + lockfile_contains: + - "'actions/attest-build-provenance@v1':" + lockfile_comment_excludes: 'actions/attest-build-provenance@v1\.\d+\.\d+' - name: transitive_version_ref_nudge_suppressed category: narrowing @@ -944,7 +947,7 @@ scenarios: - name: transitive_provenance_ref_not_narrowed category: narrowing - description: "Second-hop transitive dep (actions/attest-build-provenance@v1, pulled in via cli/gh-extension-precompile) keeps its declared major ref — not narrowed to v1.x.y. This dep also declares a bare-SHA subpath internally, which must stay verbatim rather than being reverse-looked-up into a malformed ref." + description: "Second-hop transitive dep (actions/attest-build-provenance pinned by bare SHA, pulled in via attest-build-provenance@v1) stays verbatim rather than being reverse-looked-up into a malformed ref" needs_token: true fixtures: workflows: @@ -953,7 +956,9 @@ scenarios: actions: ["cli/gh-extension-precompile@v2.1.0"] expect: exit: 0 - lockfile_comment_matches: 'actions/attest-build-provenance@v1:' + lockfile_contains: + - "'actions/attest-build-provenance@v1':" + - "ref: 'v1'" lockfile_comment_excludes: 'actions/attest-build-provenance@v1\.\d+\.\d+' # ╔═════════════════════════════════════════════════════════════════════════╗ From 1a697972963e9f1d2eb32d5bc96237fae04b8b90 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 07:45:35 -0500 Subject: [PATCH 07/67] pin: respect sticky precision on the verified fast path narrowVerifiedEntries upgraded already-recorded direct deps to full semver tags without consulting prevImpreciseNWO, so an already-pinned imprecise ref (e.g. actions/checkout@v4) was silently narrowed to v4.x.y on every no-op re-pin. That churns the lockfile and violates the sticky-precision invariant PlanOptions documents. Cherry-picked from nodeselector/code-quality-review (fa6c680). --- internal/pin/plan.go | 14 +++++--- internal/pin/plan_test.go | 68 ++++++++++++++++++++++++++++++++++++++ test/scenarios/catalog.yml | 12 +++++-- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 6e09172b..d6ea21f1 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -610,11 +610,9 @@ func verifiedEntries(inventory []checks.InventoryEntry, path string) []Entry { return out } -// narrowVerifiedEntries upgrades already-recorded deps from imprecise refs -// (main, v4, etc.) to full semver tags when possible. Returns rewrites for -// the workflow YAML. Skipped when --no-narrow is set. Only direct entries -// are narrowed: a transitive dep's ref belongs to the composite that -// declares it and never appears in our workflow YAML. +// narrowVerifiedEntries upgrades already-recorded direct deps to full semver +// tags when possible, returning the workflow-YAML rewrites. Skipped for +// --no-narrow, transitive deps, and refs the user kept imprecise (sticky v4). func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOptions) map[string]string { if opts.NoNarrow || opts.Tagger == nil { return nil @@ -629,6 +627,12 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption if owner == "" { continue } + // Respect a prior imprecise precision choice, mirroring the + // slow-path guard in narrowDirectDeps: a verified v4 entry the + // user kept as v4 must not be narrowed on a no-op re-pin. + if opts.prevImpreciseNWO[strings.ToLower(e.NWO)] { + continue + } // Already full semver — nothing to do. sv, ok := parserlock.ParseSemVer(e.Ref) if ok && sv.IsFull() { diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index bfbc3afb..10382541 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -429,6 +429,74 @@ func TestPlanWorkflow_DoesNotNarrowTransitiveDeps(t *testing.T) { assert.True(t, comp.Direct, "composite is a direct workflow use") } +// TestNarrowVerifiedEntries_StickyPrecision locks the fast-path narrowing +// guard: an already-recorded direct dep the user kept at an imprecise semver +// ref (v4) must not be narrowed to a full tag on a no-op re-pin, while a +// non-sticky branch ref (main) still narrows. Mirrors narrowDirectDeps. +func TestNarrowVerifiedEntries_StickyPrecision(t *testing.T) { + const sha = "abc1230000000000000000000000000000000000" + + // A live Tagger that *would* narrow: actions/checkout publishes a full + // semver tag at the same commit as the imprecise ref. Only the guard, not + // the absence of a Tagger, may spare a sticky entry. + newTagger := func(t *testing.T) (*tag.Lister, *httpmock.Registry) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/tags`), + httpmock.JSONResponse([]any{ + map[string]any{"name": "v4", "commit": map[string]any{"sha": sha}}, + map[string]any{"name": "v4.2.1", "commit": map[string]any{"sha": sha}}, + }), + ) + return tag.NewListerForTest(t, reg), reg + } + + // Empty Findings => NeedsAttention() false => verified fast path. + fastPathReport := func(ref string) checks.WorkflowReport { + return checks.WorkflowReport{ + Path: ".github/workflows/ci.yml", + Inventory: []checks.InventoryEntry{{ + Dep: dep.Dependency{NWO: "actions/checkout", Ref: ref, SHA: sha}, + File: ".github/workflows/ci.yml", + Direct: true, + }}, + } + } + + t.Run("imprecise v4 marked sticky is left as v4", func(t *testing.T) { + tagger, _ := newTagger(t) + opts := PlanOptions{ + Tagger: tagger, + prevImpreciseNWO: map[string]bool{"actions/checkout": true}, + } + + result, err := planWorkflow(context.Background(), fastPathReport("v4"), opts, func(string) {}) + require.NoError(t, err) + + require.Len(t, result.entries, 1) + assert.Equal(t, "v4", result.entries[0].Ref, "sticky v4 must not be narrowed") + assert.Empty(t, result.entries[0].AutoFixedRef, "no auto-fix should be recorded") + require.Len(t, result.wplans, 1) + assert.Empty(t, result.wplans[0].Rewrites, "no workflow rewrite for a sticky entry") + }) + + t.Run("branch ref main is still narrowed", func(t *testing.T) { + tagger, reg := newTagger(t) + defer reg.Verify(t) // the Tagger must actually run on this path + // main is not a semver ref, so Plan never marks it imprecise. + opts := PlanOptions{Tagger: tagger, prevImpreciseNWO: map[string]bool{}} + + result, err := planWorkflow(context.Background(), fastPathReport("main"), opts, func(string) {}) + require.NoError(t, err) + + require.Len(t, result.entries, 1) + assert.Equal(t, "v4.2.1", result.entries[0].Ref, "branch ref should narrow to the full tag") + assert.Equal(t, "main", result.entries[0].AutoFixedRef) + require.Len(t, result.wplans, 1) + assert.Equal(t, map[string]string{"actions/checkout@main": "actions/checkout@v4.2.1"}, result.wplans[0].Rewrites) + }) +} + // TestPlanWorkflow_CrossRefTransitiveClosure verifies that a composite at // ref "updated" whose action.yml references a sibling subpath at ref "main" // produces the full transitive closure: the sibling (same NWO, different diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index cf12f04c..1f494d5d 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -66,7 +66,7 @@ scenarios: - name: already_pinned_all_valid category: happy_path - description: "Already-pinned workflow validates without network calls (fast path)" + description: "Already-pinned v4 workflow re-pins on the verified fast path; sticky precision keeps the v4 dep key (no narrowing)" needs_token: true tags: [smoke] fixtures: @@ -77,6 +77,8 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 + lockfile_contains: ["actions/checkout@v4:sha1-"] + lockfile_comment_excludes: 'actions/checkout@v4\.\d' - name: multi_action_happy category: happy_path @@ -981,7 +983,7 @@ scenarios: - name: onboarded_no_onboard_repin category: onboarding - description: "--no-onboard: already-tracked workflow re-pins normally" + description: "--no-onboard: already-tracked workflow re-pins normally, keeping sticky v4 precision" needs_token: true flags: ["--no-onboard"] fixtures: @@ -992,10 +994,12 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 + lockfile_contains: ["actions/checkout@v4:sha1-"] + lockfile_comment_excludes: 'actions/checkout@v4\.\d' - name: onboarded_no_onboard_mixed category: onboarding - description: "--no-onboard: mix of tracked and untracked — tracked re-pinned, untracked refused" + description: "--no-onboard: mix of tracked and untracked — tracked re-pinned (sticky v4), untracked refused" needs_token: true flags: ["--no-onboard"] fixtures: @@ -1009,6 +1013,8 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 + lockfile_contains: ["actions/checkout@v4:sha1-"] + lockfile_comment_excludes: 'actions/checkout@v4\.\d' - name: fresh_no_fix_no_onboard_json_findings category: onboarding From 0418388d04452cbc4c9d5ccde52daa837bbf5e26 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 20:13:57 -0500 Subject: [PATCH 08/67] Apply gofmt to the tree Seven files had drifted from gofmt (struct-tag and map-literal alignment, a stray blank line). Pure formatting, no behavior change. This clears the way for a gofmt gate in CI. --- cmd/gh-actions-lock/pin_summary_test.go | 1 - internal/ghapi/client.go | 2 +- internal/lockfile/state_test.go | 10 +++++----- internal/resolve/resolver_test.go | 3 ++- internal/workflowfile/runson.go | 14 ++++++------- internal/workflowfile/runson_test.go | 12 ++++++------ test/scenarios/catalog.go | 26 ++++++++++++------------- 7 files changed, 34 insertions(+), 34 deletions(-) diff --git a/cmd/gh-actions-lock/pin_summary_test.go b/cmd/gh-actions-lock/pin_summary_test.go index e8a90b34..fbcde1b5 100644 --- a/cmd/gh-actions-lock/pin_summary_test.go +++ b/cmd/gh-actions-lock/pin_summary_test.go @@ -365,7 +365,6 @@ func TestRenderUnresolvedWarnings_MixedReasonsNotDeduped(t *testing.T) { } } - func TestRenderUnresolvedWarnings_PlainErrorNoHint(t *testing.T) { entries := []pin.Entry{ { diff --git a/internal/ghapi/client.go b/internal/ghapi/client.go index 6576d5ff..d76ad17a 100644 --- a/internal/ghapi/client.go +++ b/internal/ghapi/client.go @@ -179,7 +179,7 @@ func (t *ssoTransport) RoundTrip(req *http.Request) (*http.Response, error) { type retryTransport struct { inner http.RoundTripper maxRetries int - sleepFn func(context.Context, time.Duration) // for testing; defaults to DefaultSleep + sleepFn func(context.Context, time.Duration) // for testing; defaults to DefaultSleep } func newRetryTransport(t http.RoundTripper, maxRetries int) http.RoundTripper { diff --git a/internal/lockfile/state_test.go b/internal/lockfile/state_test.go index 7f66f942..39630d3d 100644 --- a/internal/lockfile/state_test.go +++ b/internal/lockfile/state_test.go @@ -863,12 +863,12 @@ func TestState_TransitiveClosureGolden(t *testing.T) { if err := store.Set(ctx, ".github/workflows/workflow-a.yml", []dep.Dependency{checkout, compositeA, compositeB, leaf, compositeC, external}, map[string][]string{ - "my-org/composite-b@v2": {"my-org/composite-a@v1", "my-org/composite-c@v1"}, - "other-org/external@v1": {"my-org/composite-a@v1"}, - "my-org/leaf@main": {"my-org/composite-b@v2"}, + "my-org/composite-b@v2": {"my-org/composite-a@v1", "my-org/composite-c@v1"}, + "other-org/external@v1": {"my-org/composite-a@v1"}, + "my-org/leaf@main": {"my-org/composite-b@v2"}, }, map[string]bool{ - "actions/checkout@v4": true, + "actions/checkout@v4": true, "my-org/composite-a@v1": true, "my-org/composite-c@v1": true, }, @@ -885,7 +885,7 @@ func TestState_TransitiveClosureGolden(t *testing.T) { []dep.Dependency{checkout, compositeB}, nil, map[string]bool{ - "actions/checkout@v4": true, + "actions/checkout@v4": true, "my-org/composite-b@v2": true, }, ); err != nil { diff --git a/internal/resolve/resolver_test.go b/internal/resolve/resolver_test.go index fc0f8087..6362f69e 100644 --- a/internal/resolve/resolver_test.go +++ b/internal/resolve/resolver_test.go @@ -300,7 +300,8 @@ func TestResolveAllRecursiveSiblingSubpathTransitive(t *testing.T) { // ref "updated" references a sibling subpath at a DIFFERENT ref "main", the // BFS discovers the full transitive closure through the second composite. // This mirrors nodeselector/actions-test-fixtures where: -// nested-composite@updated → simple-composite@main → (simple-node@main + fixtures-b/simple-echo@main) +// +// nested-composite@updated → simple-composite@main → (simple-node@main + fixtures-b/simple-echo@main) func TestResolveAllRecursiveCrossRefTransitive(t *testing.T) { r := seedCache(&Resolver{ MaxRecursionDepth: DefaultMaxRecursionDepth, diff --git a/internal/workflowfile/runson.go b/internal/workflowfile/runson.go index 2bc54a5c..250824aa 100644 --- a/internal/workflowfile/runson.go +++ b/internal/workflowfile/runson.go @@ -25,18 +25,18 @@ var hostedRunnerLabels = map[string]bool{ "ubuntu-slim": true, // Linux firewall (feature-flagged) - "ubuntu-24.04-firewall": true, + "ubuntu-24.04-firewall": true, "ubuntu-latest-firewall": true, // Windows x64 - "windows-latest": true, - "windows-2022": true, - "windows-2025": true, - "windows-2025-vs2026": true, + "windows-latest": true, + "windows-2022": true, + "windows-2025": true, + "windows-2025-vs2026": true, // Windows ARM64 - "windows-11-arm": true, - "windows-11-vs2026-arm": true, + "windows-11-arm": true, + "windows-11-vs2026-arm": true, // macOS (Apple Silicon / arm64) "macos-latest": true, diff --git a/internal/workflowfile/runson_test.go b/internal/workflowfile/runson_test.go index 4db0c892..f34d7653 100644 --- a/internal/workflowfile/runson_test.go +++ b/internal/workflowfile/runson_test.go @@ -28,12 +28,12 @@ func TestIsHostedRunnerLabel(t *testing.T) { notHosted := []string{ "self-hosted", "linux", "my-custom-runner", "gpu", "ARM64", - "ubuntu-20.04", // EOL, not in map - "macos-13", // removed - "macos-12", // removed - "macos-11", // removed - "windows-2019", // removed - "ubuntu-18.04", // removed + "ubuntu-20.04", // EOL, not in map + "macos-13", // removed + "macos-12", // removed + "macos-11", // removed + "windows-2019", // removed + "ubuntu-18.04", // removed } for _, l := range notHosted { assert.False(t, IsHostedRunnerLabel(l), "expected non-hosted: %s", l) diff --git a/test/scenarios/catalog.go b/test/scenarios/catalog.go index e773fc58..e627f0f9 100644 --- a/test/scenarios/catalog.go +++ b/test/scenarios/catalog.go @@ -41,9 +41,9 @@ type Scenario struct { // Fixtures describes the file-system setup for a scenario. type Fixtures struct { - Workflows map[string]WorkflowFixture `yaml:"workflows"` - Lockfile string `yaml:"lockfile"` - LockfileTemplate string `yaml:"lockfile_template"` + Workflows map[string]WorkflowFixture `yaml:"workflows"` + Lockfile string `yaml:"lockfile"` + LockfileTemplate string `yaml:"lockfile_template"` } // WorkflowFixture is either a structured action list or raw YAML. @@ -65,16 +65,16 @@ type JQCheck struct { // Expect declares assertions on the scenario outcome. type Expect struct { - Exit *int `yaml:"exit"` - ExitAny []int `yaml:"exit_any"` - OutputContains []string `yaml:"output_contains"` - OutputExcludes []string `yaml:"output_excludes"` - StdoutContains []string `yaml:"stdout_contains"` - StdoutIsJSON bool `yaml:"stdout_is_json"` - LockfileExists bool `yaml:"lockfile_exists"` - Custom string `yaml:"custom"` - JQ []JQCheck `yaml:"jq,omitempty"` - GoldenJSON map[string]interface{} `yaml:"golden_json,omitempty"` + Exit *int `yaml:"exit"` + ExitAny []int `yaml:"exit_any"` + OutputContains []string `yaml:"output_contains"` + OutputExcludes []string `yaml:"output_excludes"` + StdoutContains []string `yaml:"stdout_contains"` + StdoutIsJSON bool `yaml:"stdout_is_json"` + LockfileExists bool `yaml:"lockfile_exists"` + Custom string `yaml:"custom"` + JQ []JQCheck `yaml:"jq,omitempty"` + GoldenJSON map[string]interface{} `yaml:"golden_json,omitempty"` } // HasTag reports whether the scenario has the given tag. From b2c095c32e0403e3e50b0ac37acd6bc6ac268197 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 20:14:03 -0500 Subject: [PATCH 09/67] Add go vet, race, and gofmt gates to CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test workflow ran only `go test ./...` — no vet, no race detector, no formatting check — so unformatted code and data races could land unnoticed. Match the sibling gh-stack repo's lean CI: gofmt check, go vet, and go test -race -count=1, all folded into the existing go-test job (no new linter framework, no extra workflow). Add fmt, fmt-check, and vet Makefile targets so local matches CI. --- .github/workflows/test.yml | 11 ++++++++++- Makefile | 19 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3ba4c4a4..e1ad8423 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,16 @@ jobs: - uses: actions/setup-go@v6.4.0 with: go-version-file: go.mod - - run: go test ./... + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + - run: go vet ./... + - run: go test -race -count=1 ./... integration-stub: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index b32414a0..5706463e 100644 --- a/Makefile +++ b/Makefile @@ -7,13 +7,28 @@ EXT_DIR := $(XDG_DATA_HOME)/gh/extensions/$(EXT_NAME) RUBY := $(shell command -v /opt/homebrew/opt/ruby/bin/ruby 2>/dev/null || echo ruby) -.PHONY: build test test-integration test-shell test-live test-smoke test-stub test-real install reinstall uninstall +.PHONY: build test vet fmt fmt-check test-integration test-shell test-live test-smoke test-stub test-real install reinstall uninstall build: go build -o $(BIN) ./cmd/gh-actions-lock test: - go test ./... + go test -race -count=1 ./... + +vet: + go vet ./... + +fmt: + gofmt -w . + +# Mirrors the CI gofmt gate: fails (non-zero) if anything is unformatted. +fmt-check: + @unformatted="$$(gofmt -l .)"; \ + if [ -n "$$unformatted" ]; then \ + echo "These files are not gofmt-clean:"; \ + echo "$$unformatted"; \ + exit 1; \ + fi test-integration: build $(RUBY) test/integration/run.rb From 1d14b2f0fd144475f8a6631216171851ffe8ab4d Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 20:17:55 -0500 Subject: [PATCH 10/67] Lock UI output surface behind golden characterization tests Snapshot the exact bytes every status, Term*, and color-wrapper method emits across color, plain, and headless modes. These golden files fence the upcoming ui.go file-split: a pure code move must leave all three transcripts byte-identical. Regenerate intentional changes with UPDATE_GOLDEN=1, matching the existing JSON golden convention. Spinner and progress methods are excluded; they are time-based and already covered by ui_test.go. --- internal/ui/golden_test.go | 130 ++++++++++++++++++ internal/ui/testdata/ui_surface_color.golden | 31 +++++ .../ui/testdata/ui_surface_headless.golden | 31 +++++ internal/ui/testdata/ui_surface_plain.golden | 31 +++++ 4 files changed, 223 insertions(+) create mode 100644 internal/ui/golden_test.go create mode 100644 internal/ui/testdata/ui_surface_color.golden create mode 100644 internal/ui/testdata/ui_surface_headless.golden create mode 100644 internal/ui/testdata/ui_surface_plain.golden diff --git a/internal/ui/golden_test.go b/internal/ui/golden_test.go new file mode 100644 index 00000000..4188580e --- /dev/null +++ b/internal/ui/golden_test.go @@ -0,0 +1,130 @@ +package ui + +// Golden-file characterization tests for the UI output surface. +// +// These lock the exact bytes every styling and narration method emits across +// the three rendering modes (color, plain, headless) so a refactor that only +// moves code between files can prove it changed no output. Each method is +// captured into its own buffer and strconv.Quote'd, so ANSI escapes and +// hyperlinks are visible and diffs point straight at the method that drifted. +// +// To regenerate after an intentional output change: +// +// UPDATE_GOLDEN=1 go test ./internal/ui/ -run TestUISurfaceGolden +// +// CI runs without the env var, so any output change must be intentional and +// committed alongside the code change. + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/muesli/termenv" + "github.com/stretchr/testify/require" +) + +type uiMode struct { + name string + noColor bool + headless bool +} + +var uiModes = []uiMode{ + {name: "color", noColor: false, headless: false}, + {name: "plain", noColor: true, headless: false}, + {name: "headless", noColor: true, headless: true}, +} + +// renderUISurface drives every deterministic UI method with fixed inputs and +// returns a label-keyed transcript of the exact bytes each one emitted. The +// termenv profile is pinned to ANSI so color escapes don't depend on the host +// terminal. Spinner and progress methods are intentionally excluded: they are +// time-based and covered by the behavioral tests in ui_test.go. +func renderUISurface(mode uiMode) string { + u := &UI{ + output: termenv.NewOutput(io.Discard, termenv.WithProfile(termenv.ANSI)), + noColor: mode.noColor, + headless: mode.headless, + } + + var out bytes.Buffer + sub := &bytes.Buffer{} + u.w = sub + + // record captures the bytes a writing method emits to u.w. + record := func(label string, fn func()) { + sub.Reset() + fn() + fmt.Fprintf(&out, "%-12s %s\n", label, strconv.Quote(sub.String())) + } + // recordStr captures the value a string-returning method produces. + recordStr := func(label, s string) { + fmt.Fprintf(&out, "%-12s %s\n", label, strconv.Quote(s)) + } + + // Narration methods. + record("Success", func() { u.Success("pinned %d actions", 3) }) + record("Error", func() { u.Error("could not resolve %s", "actions/checkout") }) + record("Warning", func() { u.Warning("ref moved upstream") }) + record("Skip", func() { u.Skip("already pinned") }) + record("Info", func() { u.Info("scanning %d workflows", 2) }) + record("Infof", func() { u.Infof("no trailing newline") }) + record("Header", func() { u.Header(".github/workflows/ci.yml") }) + record("Hint", func() { u.Hint("run gh actions-lock to fix") }) + record("Detail", func() { u.Detail("actions/checkout@v4") }) + record("Blank", func() { u.Blank() }) + + // Term* summary methods (write directly to the terminal writer). + record("TermSuccess", func() { u.TermSuccess("all dependencies locked") }) + record("TermError", func() { u.TermError("1 finding needs review") }) + record("TermWarn", func() { u.TermWarn("2 refs moved") }) + record("TermCaution", func() { u.TermCaution("pinned after branch scan") }) + record("TermDetail", func() { u.TermDetail("see %s", "actions.lock") }) + record("TermNeutral", func() { u.TermNeutral("resolution recorded") }) + record("TermBlank", func() { u.TermBlank() }) + + // Color and style string helpers. + recordStr("Bold", u.Bold("sample")) + recordStr("Dim", u.Dim("sample")) + recordStr("Red", u.Red("sample")) + recordStr("Green", u.Green("sample")) + recordStr("Yellow", u.Yellow("sample")) + recordStr("Cyan", u.Cyan("sample")) + recordStr("Hyperlink", u.Hyperlink("text", "https://example.com/x")) + recordStr("DocLink", u.DocLink("https://example.com/docs")) + recordStr("TermYellow", u.TermYellow("sample")) + recordStr("TermDim", u.TermDim("sample")) + recordStr("TermBold", u.TermBold("sample")) + recordStr("TermLink", u.TermLink("text", "https://example.com/x")) + + // Free functions. + recordStr("Pluralize1", Pluralize(1, "action", "actions")) + recordStr("PluralizeN", Pluralize(2, "action", "actions")) + + return out.String() +} + +func TestUISurfaceGolden(t *testing.T) { + for _, mode := range uiModes { + t.Run(mode.name, func(t *testing.T) { + got := renderUISurface(mode) + goldenPath := filepath.Join("testdata", "ui_surface_"+mode.name+".golden") + + if os.Getenv("UPDATE_GOLDEN") == "1" { + require.NoError(t, os.MkdirAll("testdata", 0o755)) + require.NoError(t, os.WriteFile(goldenPath, []byte(got), 0o644)) + return + } + + want, err := os.ReadFile(goldenPath) + require.NoError(t, err, "missing golden; regenerate with UPDATE_GOLDEN=1") + require.Equal(t, string(want), got, + "UI output drifted for mode %q; if intentional, regenerate with UPDATE_GOLDEN=1", mode.name) + }) + } +} diff --git a/internal/ui/testdata/ui_surface_color.golden b/internal/ui/testdata/ui_surface_color.golden new file mode 100644 index 00000000..ac6dc0c3 --- /dev/null +++ b/internal/ui/testdata/ui_surface_color.golden @@ -0,0 +1,31 @@ +Success "\x1b[32m✓\x1b[0m pinned 3 actions\n" +Error "\x1b[31m✗\x1b[0m could not resolve actions/checkout\n" +Warning "\x1b[33m!\x1b[0m ref moved upstream\n" +Skip "\x1b[2m-\x1b[0m \x1b[2malready pinned\x1b[0m\n" +Info "scanning 2 workflows\n" +Infof "no trailing newline" +Header "\n\x1b[1m.github/workflows/ci.yml\x1b[0m\n" +Hint " \x1b[2mrun gh actions-lock to fix\x1b[0m\n" +Detail " actions/checkout@v4\n" +Blank "\n" +TermSuccess "\x1b[32m✓\x1b[0m all dependencies locked\n" +TermError "\x1b[31m✗\x1b[0m 1 finding needs review\n" +TermWarn "\x1b[33m!\x1b[0m 2 refs moved\n" +TermCaution "\x1b[33m!\x1b[0m pinned after branch scan\n" +TermDetail " see actions.lock\n" +TermNeutral "\x1b[2m-\x1b[0m \x1b[2mresolution recorded\x1b[0m\n" +TermBlank "\n" +Bold "\x1b[1msample\x1b[0m" +Dim "\x1b[2msample\x1b[0m" +Red "\x1b[31msample\x1b[0m" +Green "\x1b[32msample\x1b[0m" +Yellow "\x1b[33msample\x1b[0m" +Cyan "\x1b[36msample\x1b[0m" +Hyperlink "\x1b]8;;https://example.com/x\x1b\\text\x1b]8;;\x1b\\" +DocLink "\x1b[2m\x1b]8;;https://example.com/docs\x1b\\docs\x1b]8;;\x1b\\\x1b[0m" +TermYellow "\x1b[33msample\x1b[0m" +TermDim "\x1b[2msample\x1b[0m" +TermBold "\x1b[1msample\x1b[0m" +TermLink "\x1b]8;;https://example.com/x\x1b\\text\x1b]8;;\x1b\\" +Pluralize1 "action" +PluralizeN "actions" diff --git a/internal/ui/testdata/ui_surface_headless.golden b/internal/ui/testdata/ui_surface_headless.golden new file mode 100644 index 00000000..242ac3e0 --- /dev/null +++ b/internal/ui/testdata/ui_surface_headless.golden @@ -0,0 +1,31 @@ +Success "pinned 3 actions\n" +Error "could not resolve actions/checkout\n" +Warning "ref moved upstream\n" +Skip "already pinned\n" +Info "scanning 2 workflows\n" +Infof "no trailing newline\n" +Header ".github/workflows/ci.yml\n" +Hint "run gh actions-lock to fix\n" +Detail "actions/checkout@v4\n" +Blank "" +TermSuccess "all dependencies locked\n" +TermError "1 finding needs review\n" +TermWarn "2 refs moved\n" +TermCaution "pinned after branch scan\n" +TermDetail "see actions.lock\n" +TermNeutral "resolution recorded\n" +TermBlank "" +Bold "sample" +Dim "sample" +Red "sample" +Green "sample" +Yellow "sample" +Cyan "sample" +Hyperlink "text" +DocLink "docs" +TermYellow "sample" +TermDim "sample" +TermBold "sample" +TermLink "text" +Pluralize1 "action" +PluralizeN "actions" diff --git a/internal/ui/testdata/ui_surface_plain.golden b/internal/ui/testdata/ui_surface_plain.golden new file mode 100644 index 00000000..7666b38f --- /dev/null +++ b/internal/ui/testdata/ui_surface_plain.golden @@ -0,0 +1,31 @@ +Success "✓ pinned 3 actions\n" +Error "✗ could not resolve actions/checkout\n" +Warning "! ref moved upstream\n" +Skip "- already pinned\n" +Info "scanning 2 workflows\n" +Infof "no trailing newline" +Header "\n.github/workflows/ci.yml\n" +Hint " run gh actions-lock to fix\n" +Detail " actions/checkout@v4\n" +Blank "\n" +TermSuccess "✓ all dependencies locked\n" +TermError "✗ 1 finding needs review\n" +TermWarn "! 2 refs moved\n" +TermCaution "! pinned after branch scan\n" +TermDetail " see actions.lock\n" +TermNeutral "- resolution recorded\n" +TermBlank "\n" +Bold "sample" +Dim "sample" +Red "sample" +Green "sample" +Yellow "sample" +Cyan "sample" +Hyperlink "text" +DocLink "docs" +TermYellow "sample" +TermDim "sample" +TermBold "sample" +TermLink "text" +Pluralize1 "action" +PluralizeN "actions" From 88f27703f383c1c711d5fea54260c5612a06df4f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 20:24:28 -0500 Subject: [PATCH 11/67] Split internal/ui/ui.go into cohesive files The 1375-line ui.go mixed six concerns. Split it by responsibility with no behavior change: style.go (color/hyperlink helpers), status.go (narration + Term* summaries), spinner.go (spinner writer + lifecycle), progress.go (worker-slot API + debug tracing), text.go (width/truncation utilities). ui.go keeps the struct, constructors, mode detection, and the emit/log plumbing. Pure code movement: the UI golden characterization tests and the existing spinner tests pass unchanged, proving the output surface is byte-identical. --- internal/ui/progress.go | 196 +++++++ internal/ui/spinner.go | 568 +++++++++++++++++++ internal/ui/status.go | 214 ++++++++ internal/ui/style.go | 115 ++++ internal/ui/text.go | 80 +++ internal/ui/ui.go | 1145 +-------------------------------------- 6 files changed, 1184 insertions(+), 1134 deletions(-) create mode 100644 internal/ui/progress.go create mode 100644 internal/ui/spinner.go create mode 100644 internal/ui/status.go create mode 100644 internal/ui/style.go create mode 100644 internal/ui/text.go diff --git a/internal/ui/progress.go b/internal/ui/progress.go new file mode 100644 index 00000000..5798125b --- /dev/null +++ b/internal/ui/progress.go @@ -0,0 +1,196 @@ +package ui + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "time" +) + +// progressTrace caches the result of GH_ACTIONS_LOCK_DEBUG_PROGRESS once. When +// set, every UpdateLabel and SetWorkerStatus call writes a timestamped JSONL +// line to a dedicated trace file so we can audit phase transitions and verify +// the worker pool is actually fanning out without depending on visual +// inspection of the spinner. The path is resolved from the env var: "1" or +// "true" maps to $TMPDIR/gh-actions-lock-progress.log, anything else is treated +// as a literal path. +var ( + progressTraceMu sync.Mutex + progressTraceFile *os.File + progressTracePath = resolveProgressTracePath() +) + +func resolveProgressTracePath() string { + v := os.Getenv("GH_ACTIONS_LOCK_DEBUG_PROGRESS") + switch v { + case "": + return "" + case "1", "true", "yes": + dir := os.TempDir() + return dir + "/gh-actions-lock-progress.log" + default: + return v + } +} + +// CloseProgressTrace closes the progress trace file if one was opened. It is +// safe to call unconditionally; it no-ops when tracing is off or already closed. +func CloseProgressTrace() { + progressTraceMu.Lock() + defer progressTraceMu.Unlock() + if progressTraceFile != nil { + progressTraceFile.Close() + progressTraceFile = nil + } +} + +// traceProgress emits a structured trace event to the progress trace file when +// progress tracing is enabled. kind is "label" or "slot[N]"; payload is the new +// value. No-op when tracing is off. +func (u *UI) traceProgress(kind, payload string) { + if progressTracePath == "" { + return + } + progressTraceMu.Lock() + defer progressTraceMu.Unlock() + if progressTraceFile == nil { + f, err := os.OpenFile(progressTracePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + progressTraceFile = f + fmt.Fprintf(f, "# gh-actions-lock progress trace %s\n", time.Now().Format(time.RFC3339)) + } + rec := struct { + Time string `json:"time"` + Kind string `json:"kind"` + Payload string `json:"payload"` + }{ + Time: time.Now().Format(time.RFC3339Nano), + Kind: kind, + Payload: payload, + } + b, err := json.Marshal(rec) + if err != nil { + return + } + fmt.Fprintln(progressTraceFile, string(b)) +} + +// UpdateProgress sets a detail string in worker slot 0 (backward-compat +// single-detail shim). No-op when no spinner is active. +func (u *UI) UpdateProgress(detail string) { + if u.spinner == nil { + return + } + u.progDetail = detail + u.renderProgress() +} + +// SetWorkerStatus sets or clears one worker slot's status line, shown as a +// subdued line below the spinner. slot indexes from 0. No-op when no spinner +// is active. +func (u *UI) SetWorkerStatus(slot int, status string) { + u.traceProgress(fmt.Sprintf("slot[%d]", slot), status) + if u.spinWriter == nil { + return + } + if !u.noColor { + width := u.termWidth() + if width > 4 { + status = truncateBytes(status, width-4) + } + } + u.spinWriter.setWorkerStatus(slot, status) +} + +// SetWorkerHint sets or clears a dim suffix appended after the worker slot's +// status text (e.g. "→ workflow.yml (still working…)"). Used by pinpool's +// stall watcher to surface that a worker has been on the same job for longer +// than the stall threshold without clobbering the slot's main status. No-op +// when no spinner is active. +func (u *UI) SetWorkerHint(slot int, hint string) { + u.traceProgress(fmt.Sprintf("hint[%d]", slot), hint) + if u.spinWriter == nil { + return + } + u.spinWriter.setWorkerHint(slot, hint) +} + +// ClearWorkerStatuses wipes every worker slot so stale "✓ NWO" rows from a +// completed phase don't carry into the next one. No-op when no spinner is +// active. +func (u *UI) ClearWorkerStatuses() { + if u.spinWriter == nil { + return + } + u.spinWriter.mu.Lock() + for i := range u.spinWriter.workers { + u.spinWriter.workers[i] = "" + } + for i := range u.spinWriter.hints { + u.spinWriter.hints[i] = "" + } + // Immediately erase the old rows from the terminal rather than + // waiting for the next spinner tick (~120ms). Without this, the + // stale rows sit on screen for one tick and then all vanish at once, + // which looks like a jump at phase transitions. + u.spinWriter.renderWorkersLocked() + u.spinWriter.mu.Unlock() +} + +// UpdateLabel is a no-op on TTY — the spinner label is static for the +// lifetime of the spinner. In headless mode it logs a plain-text phase +// boundary when the label changes. +func (u *UI) UpdateLabel(label string) { + u.traceProgress("label", label) + if !u.headless { + return + } + stem := labelStem(label) + if stem != "" && stem != u.headlessLabelStem { + u.headlessEmit(stem) + u.headlessLabelStem = stem + } +} + +// renderProgress updates worker slot 0 with the current detail string. +// The label (top-line prefix) is managed separately by UpdateLabel. +func (u *UI) renderProgress() { + if u.spinner == nil { + return + } + + detail := u.progDetail + if detail == "" { + if u.progHasDetail { + if u.spinWriter != nil { + u.spinWriter.setDetail("") + } + u.progHasDetail = false + } + return + } + + // Worker rows animate only when text starts with "→ "; UpdateProgress + // callers (resolver hooks) pass plain strings. Prepend the arrow so + // the slot pulses instead of looking frozen. + if !strings.HasPrefix(detail, "→ ") { + detail = "→ " + detail + } + width := u.termWidth() + if width > 4 { + budget := width - 4 + if !u.noColor { + budget -= 7 + } + detail = truncateBytes(detail, budget) + } + + if u.spinWriter != nil { + u.spinWriter.setDetail(detail) + } + u.progHasDetail = true +} diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go new file mode 100644 index 00000000..f5b11637 --- /dev/null +++ b/internal/ui/spinner.go @@ -0,0 +1,568 @@ +package ui + +import ( + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/briandowns/spinner" + "github.com/muesli/termenv" +) + +// spinnerWriter wraps the terminal writer used by the spinner. It intercepts +// each write from the spinner goroutine (every write starts with '\r') and, +// when worker status lines are set, appends them as dim lines below the spinner +// after the spinner has written its own content. Worker text is kept entirely +// out of the spinner Suffix so the library's byte-count wrap detection never +// sees the extra lines — eliminating the runaway multi-line erase bug. +type spinnerWriter struct { + mu sync.Mutex + w io.Writer + workers []string // per-slot status; empty string = idle + hints []string // per-slot dim suffix appended after the status + nRendered int // number of worker lines written in the last tick + noColor bool + output *termenv.Output + // prefix is the static label text written before the spinner glyph + // (e.g. "Resolving actions "). Stored here so startAnimator can write + // an immediate frame on resume, avoiding the one-tick blank gap that + // occurs because briandowns never fires a tick immediately on Start(). + prefix string + // stop closes to signal the independent worker-redraw ticker to exit. + // done is closed once the ticker goroutine has returned. The ticker + // keeps worker glyphs animating even when the spinner library coalesces, + // throttles, or briefly stalls its own writes (e.g. under network + // contention) — without the ticker, animation freezes whenever Write + // isn't called. + stop chan struct{} + done chan struct{} + + // deferredWrites buffers setWorkerStatus calls that happen while + // printLine has snapshotted-and-cleared the workers slice for an + // inline message print. When non-nil, setWorkerStatus writes into + // this map instead of workers; printLine merges the buffered writes + // back onto its snapshot on restore so concurrent clears/updates + // from the pin pool aren't clobbered by the restore. Nil during + // normal operation. + deferredWrites map[int]string + // deferredHints mirrors deferredWrites for hint state so concurrent + // stall-watcher updates aren't clobbered by printLine's restore. + deferredHints map[int]string +} + +// workerSpinFrames is the rotating glyph shown next to each ACTIVE worker row +// (rows starting with "→") so subtasks visibly pulse instead of looking +// frozen. Matches the main spinner's braille charset (CharSets[11]) so the +// motion stays cohesive. +var workerSpinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// workerFrameInterval is how often (wall-clock) each worker glyph advances one +// step. Matches the main spinner's 120ms tick so motion feels cohesive. +const workerFrameInterval = 120 * time.Millisecond + +func (sw *spinnerWriter) Write(p []byte) (n int, err error) { + sw.mu.Lock() + defer sw.mu.Unlock() + + if len(p) == 0 || p[0] != '\r' { + n, err = sw.w.Write(p) + return + } + + // briandowns calls Write twice per tick: + // 1. erase: \r\033[K — wipe the previous frame + // 2. frame: \r{Prefix}{glyph}{Suffix} — paint the new frame + // + // Only render worker rows on the frame write. Rendering them on the + // erase write too means two cursor-down/up sequences per 120ms tick, + // which is the primary cause of residual flicker on embedded terminals. + // + // Match the specific erase sequence the library emits rather than any + // generic CSI prefix — frame writes that start with a color SGR + // (e.g. \r\033[36m…) must not be misclassified as erases. + // briandowns.erase() always writes "\r\033[K" (4 bytes) for single-line + // spinners, optionally followed by "\033[F\033[K" per additional line. + isErase := len(p) >= 4 && p[0] == '\r' && p[1] == '\033' && p[2] == '[' && p[3] == 'K' + if isErase { + // Just pass the erase through; worker rows are still on screen + // from the previous frame and will be refreshed momentarily. + n, err = sw.w.Write(p) + return + } + + // Combine the spinner frame and worker rows into a single + // synchronized write so the terminal never shows a partial frame. + var buf strings.Builder + buf.WriteString("\033[?2026h") // begin synchronized output + // Replace the leading \r with \r\033[2K (go-to-col-0 + erase line) + // so that when the label shrinks between ticks the old longer text + // is fully cleared instead of leaving leftover characters visible. + buf.WriteString("\r\033[2K") + buf.Write(p[1:]) // p[0] is the \r we already emitted above + sw.buildWorkerFrameLocked(&buf) + buf.WriteString("\033[?2026l") // end synchronized output + _, err = io.WriteString(sw.w, buf.String()) + n = len(p) + return +} + +// buildWorkerFrameLocked appends the escape sequences for worker rows into +// buf, leaving the cursor back on the spinner line. Caller must hold sw.mu +// and the cursor must currently be on the spinner line. +func (sw *spinnerWriter) buildWorkerFrameLocked(buf *strings.Builder) { + step := int(time.Now().UnixNano() / int64(workerFrameInterval)) + // Per-slot phase offset so rows visibly cascade instead of all hitting + // the same frame in lockstep — that lockstep was what made them look + // frozen even when they weren't. + width := termWidthOf(sw.w) + var lines []string + for slot, w := range sw.workers { + if w == "" { + continue + } + body := w + if len(w) >= len("→ ") && w[:len("→ ")] == "→ " { + frame := workerSpinFrames[(step+slot)%len(workerSpinFrames)] + body = w[len("→ "):] + " " + frame + } + hint := "" + if slot < len(sw.hints) { + hint = sw.hints[slot] + } + // Combined width budget: " " indent + body + " " + hint must + // fit one terminal row. Hint loses first if there isn't room. + if width > 4 { + budget := width - 2 // " " indent + if len(body)+1+len(hint) > budget { + if len(body) >= budget { + body = truncateBytes(body, budget) + hint = "" + } else if hint != "" { + hintBudget := budget - len(body) - 1 + if hintBudget < 4 { + hint = "" + } else { + hint = truncateBytes(hint, hintBudget) + } + } + } + } + var line string + if !sw.noColor { + line = sw.output.String(" " + body).Faint().String() + if hint != "" { + line += " " + sw.output.String(hint).Faint().String() + } + } else { + line = " " + body + if hint != "" { + line += " " + hint + } + } + lines = append(lines, line) + } + nLines := len(lines) + for _, line := range lines { + // Use \n (newline) rather than \033[1B (cursor-down) so the + // buffer scrolls when we're at the bottom of the viewport. ESC[1B + // is a no-op at the last row and the subsequent ESC[NA cursor-up + // would then overshoot, landing on (and clobbering) lines above + // the spinner — including the user's typed command line. + fmt.Fprintf(buf, "\n\r\033[2K%s", line) + } + // Erase stale lines left over from a previous render that had more + // active workers. Without this, ghost "→ dep" rows from finished + // workers persist below the current set. + for i := nLines; i < sw.nRendered; i++ { + buf.WriteString("\n\r\033[2K") + } + totalDown := nLines + if sw.nRendered > nLines { + totalDown = sw.nRendered + } + if totalDown > 0 { + fmt.Fprintf(buf, "\033[%dA\r", totalDown) + } + sw.nRendered = nLines +} + +// renderWorkersLocked redraws the worker rows below the spinner line as a +// single synchronized write. Caller must hold sw.mu. +func (sw *spinnerWriter) renderWorkersLocked() { + var buf strings.Builder + buf.WriteString("\033[?2026h") // begin synchronized output + sw.buildWorkerFrameLocked(&buf) + buf.WriteString("\033[?2026l") // end synchronized output + io.WriteString(sw.w, buf.String()) +} + +// startAnimator launches a goroutine that periodically redraws the worker +// rows so their glyphs keep pulsing even when the spinner library's own +// writes stall or coalesce. Caller MUST NOT hold sw.mu. +func (sw *spinnerWriter) startAnimator() { + sw.mu.Lock() + if sw.stop != nil { + sw.mu.Unlock() + return + } + sw.stop = make(chan struct{}) + sw.done = make(chan struct{}) + stop := sw.stop + done := sw.done + sw.mu.Unlock() + + sw.mu.Lock() + // Wrap cursor-hide in a synchronized block so it can't interleave + // with a concurrent spinner frame write mid-output. + io.WriteString(sw.w, "\033[?2026h\033[?25l\033[?2026l") + sw.mu.Unlock() + + // Write a synthetic first frame immediately so the spinner line is never + // blank during the ~120ms gap before the library's first ticker tick. + // briandowns never writes a frame on Start() — it always waits for the + // first tick — so without this, every Resume causes a visible blank line. + sw.mu.Lock() + var buf strings.Builder + buf.WriteString("\033[?2026h") + buf.WriteString("\r\033[2K") + buf.WriteString(sw.prefix) + buf.WriteString(workerSpinFrames[0]) // placeholder glyph from shared braille charset; real tick replaces it + sw.buildWorkerFrameLocked(&buf) + buf.WriteString("\033[?2026l") + io.WriteString(sw.w, buf.String()) + sw.mu.Unlock() + + go func() { + defer close(done) + t := time.NewTicker(workerFrameInterval) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + sw.mu.Lock() + hasActive := false + for _, w := range sw.workers { + if len(w) >= len("→ ") && w[:len("→ ")] == "→ " { + hasActive = true + break + } + } + if hasActive { + sw.renderWorkersLocked() + } + sw.mu.Unlock() + } + } + }() +} + +// stopAnimator signals the redraw ticker to exit and waits for it. Caller +// MUST NOT hold sw.mu. +func (sw *spinnerWriter) stopAnimator() { + sw.mu.Lock() + stop := sw.stop + done := sw.done + sw.stop = nil + sw.done = nil + sw.mu.Unlock() + if stop == nil { + return + } + close(stop) + <-done + // Restore the cursor that startAnimator hid, synchronized so it can't + // interleave with any write still draining from the ticker goroutine. + sw.mu.Lock() + io.WriteString(sw.w, "\033[?2026h\033[?25h\033[?2026l") + sw.mu.Unlock() +} + +// setDetail is a backward-compat shim that sets a single worker slot (slot 0). +func (sw *spinnerWriter) setDetail(det string) { + sw.setWorkerStatus(0, det) +} + +// setWorkerStatus sets or clears one worker's status slot. While printLine +// has the workers slice snapshotted (deferredWrites != nil) the write is +// buffered into the deferred map so printLine's restore phase can merge it +// onto the snapshot — preventing the restore from clobbering clears and +// updates issued by the pin pool during the print window. Setting a slot to +// any value also clears that slot's hint so a stale "(still working…)" +// suffix from a previous job can't bleed into the next one. +func (sw *spinnerWriter) setWorkerStatus(slot int, status string) { + sw.mu.Lock() + if sw.deferredWrites != nil { + sw.deferredWrites[slot] = status + if sw.deferredHints != nil { + sw.deferredHints[slot] = "" + } + sw.mu.Unlock() + return + } + for len(sw.workers) <= slot { + sw.workers = append(sw.workers, "") + } + sw.workers[slot] = status + if slot < len(sw.hints) { + sw.hints[slot] = "" + } + sw.mu.Unlock() +} + +// setWorkerHint sets or clears one worker's dim suffix without touching the +// status text. Defers like setWorkerStatus while printLine has the slices +// snapshotted. +func (sw *spinnerWriter) setWorkerHint(slot int, hint string) { + sw.mu.Lock() + defer sw.mu.Unlock() + if sw.deferredHints != nil { + sw.deferredHints[slot] = hint + return + } + for len(sw.hints) <= slot { + sw.hints = append(sw.hints, "") + } + sw.hints[slot] = hint +} + +// clearSpinnerLines erases the spinner line and any worker lines that were +// rendered below it on the last tick. Uses \033[J (erase to end of screen) +// rather than the per-line cursor-down/clear/cursor-up dance the previous +// implementation used: ESC[1B doesn't scroll the buffer at the bottom of the +// viewport, so the followup ESC[NA could overshoot into already-rendered +// rows above and clobber them on the next spinner tick. +func (u *UI) clearSpinnerLines() { + var lines int + if u.spinWriter != nil { + u.spinWriter.mu.Lock() + lines = u.spinWriter.nRendered + u.spinWriter.nRendered = 0 + u.spinWriter.mu.Unlock() + } + // Buffer the entire clear into a single write so the terminal + // never shows a partially erased frame. + var buf strings.Builder + buf.WriteString("\r\033[2K") + for i := 0; i < lines; i++ { + buf.WriteString("\n\033[2K") + } + if lines > 0 { + fmt.Fprintf(&buf, "\033[%dA\r", lines) + } + fmt.Fprint(u.w, buf.String()) + u.progHasDetail = false +} + +// printLine writes one line of output, transparently pausing an active +// spinner so its animation frame doesn't interleave with (and corrupt) the +// text. The spinner resumes after the write. When no spinner is active it +// just runs write. +func (u *UI) printLine(write func()) { + if u.spinner != nil && u.spinner.Active() { + // Snapshot and zero all worker slots so the stop-write doesn't render + // extra lines that we'd then fail to clear. Buffer any concurrent + // setWorkerStatus calls into deferredWrites so the restore merges + // them instead of clobbering — without the buffer, pin-pool workers + // that clear or repaint their slot during the write window lose + // those updates, which is what leaves stale "→ path" rows visible + // after their owner worker has exited. + var savedWorkers []string + var savedHints []string + if u.spinWriter != nil { + u.spinWriter.mu.Lock() + savedWorkers = make([]string, len(u.spinWriter.workers)) + copy(savedWorkers, u.spinWriter.workers) + savedHints = make([]string, len(u.spinWriter.hints)) + copy(savedHints, u.spinWriter.hints) + u.spinWriter.workers = nil + u.spinWriter.hints = nil + u.spinWriter.deferredWrites = map[int]string{} + u.spinWriter.deferredHints = map[int]string{} + u.spinWriter.mu.Unlock() + } + u.spinner.Stop() + u.clearSpinnerLines() + write() + if u.spinWriter != nil { + u.spinWriter.mu.Lock() + for slot, status := range u.spinWriter.deferredWrites { + for len(savedWorkers) <= slot { + savedWorkers = append(savedWorkers, "") + } + savedWorkers[slot] = status + // A status update implicitly clears the hint, mirroring + // setWorkerStatus's normal-path semantics. + for len(savedHints) <= slot { + savedHints = append(savedHints, "") + } + savedHints[slot] = "" + } + for slot, hint := range u.spinWriter.deferredHints { + for len(savedHints) <= slot { + savedHints = append(savedHints, "") + } + // Don't overwrite a hint clear caused by a same-window + // status update: if deferredWrites also touched this slot, + // the status reset already cleared the hint above. + if _, statusUpdated := u.spinWriter.deferredWrites[slot]; statusUpdated { + continue + } + savedHints[slot] = hint + } + u.spinWriter.workers = savedWorkers + u.spinWriter.hints = savedHints + u.spinWriter.deferredWrites = nil + u.spinWriter.deferredHints = nil + u.spinWriter.mu.Unlock() + } + u.spinner.Start() + return + } + write() +} + +// ProgressActive reports whether a spinner is currently running. Callers use +// this to adopt an already-running spinner (keeping it continuous across +// phases) instead of stopping and restarting one, which would leave a visible +// gap on the terminal. +func (u *UI) ProgressActive() bool { + return u.spinner != nil +} + +// progressGrace is how long StartProgress waits before showing the spinner. +// Runs that complete within this window never flicker a spinner at all. +const progressGrace = 500 * time.Millisecond + +// StartProgress starts an animated spinner with the given label on stderr. +// On non-TTY outputs, prints a static label instead. Matches gh CLI's Primer +// progress indicator: braille dots, 120ms, cyan. +// +// The spinner is not rendered immediately: a short grace period suppresses +// flicker for fast runs. If StopProgress is called before the grace period +// expires, no spinner is ever shown. +func (u *UI) StartProgress(label string) { + if u.headless { + if label != "" { + u.headlessEmit(label) + u.headlessLabelStem = labelStem(label) + } + return + } + sw := &spinnerWriter{ + w: u.w, + noColor: u.noColor, + output: u.output, + } + u.spinWriter = sw + opts := []spinner.Option{spinner.WithWriter(sw)} + if !u.noColor { + opts = append(opts, spinner.WithColor("fgCyan")) + } + sp := spinner.New(spinner.CharSets[11], 120*time.Millisecond, opts...) + // The label is static for the lifetime of this spinner. Set Prefix + // before sp.Start() — the goroutine isn't running yet so no lock needed. + if label != "" { + sp.Prefix = label + " " + sw.prefix = label + " " + } + u.spinner = sp + u.progDetail = "" + u.progPaused = false + + // Defer the visible start so fast runs never flicker. + done := make(chan struct{}) + u.progGraceDone = done + u.progGrace = time.AfterFunc(progressGrace, func() { + defer close(done) + sp.Start() + sw.startAnimator() + }) +} + +// PauseProgress temporarily halts the spinner and clears its line so other +// output (typically an interactive prompt) can render cleanly. The label and +// detail are retained; ResumeProgress restarts the spinner where it left off. +// Safe to call when no spinner is active or one is already paused. +func (u *UI) PauseProgress() { + if u.spinner == nil || u.progPaused { + return + } + // Cancel grace timer — if the spinner hasn't appeared yet, keep it hidden. + if u.progGrace != nil { + if !u.progGrace.Stop() { + <-u.progGraceDone + } + u.progGrace = nil + u.progGraceDone = nil + } + if u.spinWriter != nil { + u.spinWriter.stopAnimator() + } + // Clear worker lines before stopping the spinner (see StopProgress). + if u.isTTY { + u.clearSpinnerLines() + } + if u.spinWriter != nil { + u.spinWriter.mu.Lock() + u.spinWriter.workers = nil + u.spinWriter.hints = nil + u.spinWriter.mu.Unlock() + } + u.spinner.Stop() + u.progPaused = true +} + +// ResumeProgress restarts a spinner previously paused by PauseProgress, +// redrawing the retained label/detail. Safe to call when no spinner is active +// or one is not paused. +func (u *UI) ResumeProgress() { + if u.spinner == nil || !u.progPaused { + return + } + u.progPaused = false + u.renderProgress() + u.spinner.Start() + if u.spinWriter != nil { + u.spinWriter.startAnimator() + } +} + +// StopProgress stops the spinner. Safe to call if no spinner is active. +func (u *UI) StopProgress() { + if u.spinner != nil { + // Cancel the grace timer. If the timer already fired (Stop returns + // false) the spinner is running — wait for the goroutine to finish + // before tearing down so we don't race with Start(). + if u.progGrace != nil { + if !u.progGrace.Stop() { + // Timer already fired — spinner is starting or started. + <-u.progGraceDone + } + u.progGrace = nil + u.progGraceDone = nil + } + if u.spinWriter != nil { + u.spinWriter.stopAnimator() + } + // Erase worker lines BEFORE stopping the spinner — at this point + // the cursor is on the spinner line, so the down/up dance to + // clear worker rows below is safe. Doing this after Stop() races + // with the shell prompt redraw and clobbers it. + u.clearSpinnerLines() + if u.spinWriter != nil { + u.spinWriter.mu.Lock() + u.spinWriter.workers = nil + u.spinWriter.hints = nil + u.spinWriter.mu.Unlock() + } + u.spinner.Stop() + u.spinner = nil + u.spinWriter = nil + u.progDetail = "" + u.progPaused = false + } +} diff --git a/internal/ui/status.go b/internal/ui/status.go new file mode 100644 index 00000000..20c80092 --- /dev/null +++ b/internal/ui/status.go @@ -0,0 +1,214 @@ +package ui + +import "fmt" + +// Icons used in status-prefixed output. These always appear regardless of +// color setting — PRimer says don't rely solely on color. +const ( + IconSuccess = "✓" + IconError = "✗" + IconWarning = "!" + IconSkip = "-" +) + +// Success prints a green "✓" prefixed message. +func (u *UI) Success(msg string, args ...any) { + text := fmt.Sprintf(msg, args...) + if u.logging() { + u.logTagged("success", text) + return + } + if u.headless { + u.headlessEmit(text) + return + } + u.emit(fmt.Sprintf("%s %s\n", u.Green(IconSuccess), text)) +} + +// Error prints a red "✗" prefixed message. +func (u *UI) Error(msg string, args ...any) { + text := fmt.Sprintf(msg, args...) + if u.logging() { + u.logTagged("error", text) + return + } + if u.headless { + u.headlessEmit(text) + return + } + u.emit(fmt.Sprintf("%s %s\n", u.Red(IconError), text)) +} + +// Warning prints a yellow "!" prefixed message. +func (u *UI) Warning(msg string, args ...any) { + text := fmt.Sprintf(msg, args...) + if u.logging() { + u.logTagged("warning", text) + return + } + if u.headless { + u.headlessEmit(text) + return + } + u.emit(fmt.Sprintf("%s %s\n", u.Yellow(IconWarning), text)) +} + +// Skip prints a gray "-" prefixed message. +func (u *UI) Skip(msg string, args ...any) { + text := fmt.Sprintf(msg, args...) + if u.logging() { + u.logTagged("skip", text) + return + } + if u.headless { + u.headlessEmit(text) + return + } + u.emit(fmt.Sprintf("%s %s\n", u.Dim(IconSkip), u.Dim(text))) +} + +// Info prints a message with no prefix. +func (u *UI) Info(msg string, args ...any) { + if u.logging() { + u.logTagged("info", fmt.Sprintf(msg, args...)) + return + } + if u.headless { + u.headlessEmit(fmt.Sprintf(msg, args...)) + return + } + u.emit(fmt.Sprintf(msg+"\n", args...)) +} + +// Infof prints a message with no prefix and no trailing newline. +func (u *UI) Infof(msg string, args ...any) { + if u.logging() { + u.logTagged("info", fmt.Sprintf(msg, args...)) + return + } + if u.headless { + u.headlessEmit(fmt.Sprintf(msg, args...)) + return + } + u.emit(fmt.Sprintf(msg, args...)) +} + +// Header prints a bold message, used for file/section headers. +func (u *UI) Header(msg string, args ...any) { + text := fmt.Sprintf(msg, args...) + if u.logging() { + u.logTagged("header", text) + return + } + if u.headless { + u.headlessEmit(text) + return + } + u.emit(fmt.Sprintf("\n%s\n", u.Bold(text))) +} + +// Hint prints a dim, indented message — typically a suggested command. +func (u *UI) Hint(msg string, args ...any) { + text := fmt.Sprintf(msg, args...) + if u.logging() { + u.logTagged("hint", text) + return + } + if u.headless { + u.headlessEmit(text) + return + } + u.emit(fmt.Sprintf(" %s\n", u.Dim(text))) +} + +// Detail prints an indented detail line (2-space indent). +func (u *UI) Detail(msg string, args ...any) { + if u.logging() { + u.logTagged("", fmt.Sprintf(msg, args...)) + return + } + if u.headless { + u.headlessEmit(fmt.Sprintf(msg, args...)) + return + } + u.emit(fmt.Sprintf(" "+msg+"\n", args...)) +} + +// Blank prints an empty line. In log mode it is a no-op so the JSONL transcript +// stays one valid object per line. In headless mode it is also a no-op so CI +// logs stay flat — phase boundaries do the visual separating instead. +func (u *UI) Blank() { + if u.headless || u.logging() { + return + } + u.emit("\n") +} + +// TermSuccess prints a green "✓" summary line directly to the terminal, +// bypassing the narration log. Use for the final run summary. +func (u *UI) TermSuccess(msg string, args ...any) { + if u.headless { + u.headlessEmit(fmt.Sprintf(msg, args...)) + return + } + fmt.Fprintf(u.w, "%s %s\n", u.paint("2", IconSuccess), fmt.Sprintf(msg, args...)) +} + +// TermError prints a red "✗" summary line directly to the terminal. +func (u *UI) TermError(msg string, args ...any) { + if u.headless { + u.headlessEmit(fmt.Sprintf(msg, args...)) + return + } + fmt.Fprintf(u.w, "%s %s\n", u.paint("1", IconError), fmt.Sprintf(msg, args...)) +} + +// TermWarn prints a yellow "!" summary line directly to the terminal. +func (u *UI) TermWarn(msg string, args ...any) { + if u.headless { + u.headlessEmit(fmt.Sprintf(msg, args...)) + return + } + fmt.Fprintf(u.w, "%s %s\n", u.paint("3", IconWarning), fmt.Sprintf(msg, args...)) +} + +// TermCaution prints a yellow "!" summary line directly to the terminal. Use for +// non-fatal but attention-worthy signals (e.g. a commit pinned only after a +// full-branch-scan fallback) that warrant emphasis without the "✗ failure" framing. +func (u *UI) TermCaution(msg string, args ...any) { + if u.headless { + u.headlessEmit(fmt.Sprintf(msg, args...)) + return + } + fmt.Fprintf(u.w, "%s %s\n", u.paint("3", IconWarning), fmt.Sprintf(msg, args...)) +} + +// TermDetail prints an indented summary detail line directly to the terminal. +func (u *UI) TermDetail(msg string, args ...any) { + if u.headless { + u.headlessEmit(fmt.Sprintf(msg, args...)) + return + } + fmt.Fprintf(u.w, " "+msg+"\n", args...) +} + +// TermNeutral prints a dimmed, neutral "-" summary line directly to the +// terminal. Per cli/cli iconography, "-" denotes neutral/informational +// status (not success, alert, or failure). Used for footer pointers such +// as the resolution-record path. +func (u *UI) TermNeutral(msg string, args ...any) { + text := fmt.Sprintf(msg, args...) + if u.headless { + u.headlessEmit(text) + return + } + fmt.Fprintf(u.w, "%s %s\n", u.TermDim(IconSkip), u.TermDim(text)) +} + +// TermBlank prints an empty line directly to the terminal. +func (u *UI) TermBlank() { + if u.headless { + return + } + fmt.Fprintln(u.w) +} diff --git a/internal/ui/style.go b/internal/ui/style.go new file mode 100644 index 00000000..fda053dc --- /dev/null +++ b/internal/ui/style.go @@ -0,0 +1,115 @@ +package ui + +// Color and style helpers. The plain (non-Term*) wrappers suppress styling +// while a narration log sink is attached so the JSONL transcript stays plain +// text; the Term* variants ignore the log sink because they always target the +// terminal. + +// paint applies an ANSI foreground color regardless of the log sink. Used by +// the Term* summary methods, which always target the terminal. +func (u *UI) paint(colorCode, s string) string { + if u.noColor { + return s + } + return u.output.String(s).Foreground(u.output.Color(colorCode)).String() +} + +// TermYellow returns s in yellow for use in Term* output. Unlike Yellow, this +// does not suppress color when a narration log sink is attached, since Term* +// methods write directly to the terminal rather than the log. +func (u *UI) TermYellow(s string) string { + return u.paint("3", s) +} + +// TermDim returns s in dim/faint for use in Term* output. +func (u *UI) TermDim(s string) string { + if u.noColor { + return s + } + return u.output.String(s).Faint().String() +} + +// TermBold returns s in bold for use in Term* output. +func (u *UI) TermBold(s string) string { + if u.noColor { + return s + } + return u.output.String(s).Bold().String() +} + +// TermLink wraps text in an OSC 8 hyperlink for use in Term* output. Falls +// back to plain text when color is disabled or url is empty. +func (u *UI) TermLink(text, url string) string { + if u.noColor || url == "" { + return text + } + return u.output.Hyperlink(url, text) +} + +// Bold returns s in bold if color is enabled. +func (u *UI) Bold(s string) string { + if u.noColor || u.logging() { + return s + } + return u.output.String(s).Bold().String() +} + +// Dim returns s in dim/faint if color is enabled. +func (u *UI) Dim(s string) string { + if u.noColor || u.logging() { + return s + } + return u.output.String(s).Faint().String() +} + +// Red returns s in red if color is enabled. +func (u *UI) Red(s string) string { + if u.noColor || u.logging() { + return s + } + return u.output.String(s).Foreground(u.output.Color("1")).String() +} + +// Green returns s in green if color is enabled. +func (u *UI) Green(s string) string { + if u.noColor || u.logging() { + return s + } + return u.output.String(s).Foreground(u.output.Color("2")).String() +} + +// Yellow returns s in yellow if color is enabled. +func (u *UI) Yellow(s string) string { + if u.noColor || u.logging() { + return s + } + return u.output.String(s).Foreground(u.output.Color("3")).String() +} + +// Cyan returns s in cyan if color is enabled. +func (u *UI) Cyan(s string) string { + if u.noColor || u.logging() { + return s + } + return u.output.String(s).Foreground(u.output.Color("6")).String() +} + +// Hyperlink returns text as a clickable OSC 8 hyperlink when the terminal +// supports it, otherwise returns text as-is. Most modern terminals (iTerm2, +// WezTerm, kitty, GNOME Terminal, Windows Terminal) support this. +func (u *UI) Hyperlink(text, url string) string { + if u.noColor || u.logging() { + return text + } + return u.output.Hyperlink(url, text) +} + +// DocLink renders a documentation reference: the bare URL when writing to the +// log (so the transcript stays actionable), otherwise a dim "docs" hyperlink +// for the terminal. +func (u *UI) DocLink(url string) string { + if u.logging() { + return url + } + return u.Dim(u.Hyperlink("docs", url)) +} diff --git a/internal/ui/text.go b/internal/ui/text.go new file mode 100644 index 00000000..a1caed86 --- /dev/null +++ b/internal/ui/text.go @@ -0,0 +1,80 @@ +package ui + +import ( + "io" + "os" + "strings" + "unicode/utf8" + + "golang.org/x/term" +) + +// labelStem returns the label trimmed of whitespace, used as a phase +// identifier for headless dedup so repeated UpdateLabel calls with the +// same text don't spam the log. +func labelStem(label string) string { + return strings.TrimSpace(label) +} + +// termWidth returns the terminal column count for the spinner writer, or 0 if +// it cannot be determined (in which case callers skip truncation). +func (u *UI) termWidth() int { + return termWidthOf(u.w) +} + +// termWidthOf returns the terminal width of w, or 0 when it isn't a TTY. +func termWidthOf(w io.Writer) int { + f, ok := w.(*os.File) + if !ok { + return 0 + } + cols, _, err := term.GetSize(int(f.Fd())) + if err != nil || cols <= 0 { + return 0 + } + return cols +} + +// truncateBytes shortens s so its UTF-8 byte length is at most max, never +// splitting a multibyte rune. When truncation occurs the tail is replaced with +// a single ellipsis ("…", 3 bytes). The budget is byte-based because the +// spinner library measures wrap width in bytes; a rune/column budget lets +// multibyte characters (the "—" separator, non-ASCII paths) push the real byte +// width past the terminal edge and trigger its two-line erase. +func truncateBytes(s string, max int) string { + if max <= 0 { + return "" + } + if len(s) <= max { + return s + } + const ellipsis = "…" // 3 bytes + if max < len(ellipsis) { + return trimToRuneBoundary(s, max) + } + return trimToRuneBoundary(s, max-len(ellipsis)) + ellipsis +} + +// trimToRuneBoundary returns the longest prefix of s whose byte length is at +// most max, cut on a rune boundary so multibyte characters aren't split. +func trimToRuneBoundary(s string, max int) string { + if max <= 0 { + return "" + } + if len(s) <= max { + return s + } + end := max + for end > 0 && !utf8.RuneStart(s[end]) { + end-- + } + return s[:end] +} + +// Pluralize returns singular when n==1, plural otherwise. +func Pluralize(n int, singular, plural string) string { + if n == 1 { + return singular + } + return plural +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 843aee35..4819b49e 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,6 +1,17 @@ // Package ui provides terminal-aware output formatting for gh-actions-lock. // It respects NO_COLOR, CLICOLOR, and TTY detection on stderr (not stdout) // so that color works correctly even when stdout is piped (e.g. --json mode). +// +// The implementation is split across files within the package: +// +// - ui.go: the UI struct, constructors, mode detection, and the low-level +// emit/log plumbing. +// - style.go: color and hyperlink helpers. +// - status.go: status-prefixed narration (Success, Error, …) and the Term* +// summary methods. +// - spinner.go: the spinner writer and progress spinner lifecycle. +// - progress.go: the worker-slot progress API and debug tracing. +// - text.go: width-aware truncation and other string utilities. package ui import ( @@ -9,24 +20,13 @@ import ( "io" "os" "strings" - "sync" "time" - "unicode/utf8" "github.com/briandowns/spinner" "github.com/muesli/termenv" "golang.org/x/term" ) -// Icons used in status-prefixed output. These always appear regardless of -// color setting — PRimer says don't rely solely on color. -const ( - IconSuccess = "✓" - IconError = "✗" - IconWarning = "!" - IconSkip = "-" -) - // UI writes human-readable output to an io.Writer with optional ANSI styling. // // When a log sink is attached via SetLog, all narration (Success, Detail, @@ -98,76 +98,6 @@ func (u *UI) MarkHeadless() { u.output = termenv.NewOutput(u.w, termenv.WithProfile(termenv.Ascii)) } -// progressTrace caches the result of GH_ACTIONS_LOCK_DEBUG_PROGRESS once. When -// set, every UpdateLabel and SetWorkerStatus call writes a timestamped JSONL -// line to a dedicated trace file so we can audit phase transitions and verify -// the worker pool is actually fanning out without depending on visual -// inspection of the spinner. The path is resolved from the env var: "1" or -// "true" maps to $TMPDIR/gh-actions-lock-progress.log, anything else is treated -// as a literal path. -var ( - progressTraceMu sync.Mutex - progressTraceFile *os.File - progressTracePath = resolveProgressTracePath() -) - -func resolveProgressTracePath() string { - v := os.Getenv("GH_ACTIONS_LOCK_DEBUG_PROGRESS") - switch v { - case "": - return "" - case "1", "true", "yes": - dir := os.TempDir() - return dir + "/gh-actions-lock-progress.log" - default: - return v - } -} - -// CloseProgressTrace closes the progress trace file if one was opened. It is -// safe to call unconditionally; it no-ops when tracing is off or already closed. -func CloseProgressTrace() { - progressTraceMu.Lock() - defer progressTraceMu.Unlock() - if progressTraceFile != nil { - progressTraceFile.Close() - progressTraceFile = nil - } -} - -// traceProgress emits a structured trace event to the progress trace file when -// progress tracing is enabled. kind is "label" or "slot[N]"; payload is the new -// value. No-op when tracing is off. -func (u *UI) traceProgress(kind, payload string) { - if progressTracePath == "" { - return - } - progressTraceMu.Lock() - defer progressTraceMu.Unlock() - if progressTraceFile == nil { - f, err := os.OpenFile(progressTracePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return - } - progressTraceFile = f - fmt.Fprintf(f, "# gh-actions-lock progress trace %s\n", time.Now().Format(time.RFC3339)) - } - rec := struct { - Time string `json:"time"` - Kind string `json:"kind"` - Payload string `json:"payload"` - }{ - Time: time.Now().Format(time.RFC3339Nano), - Kind: kind, - Payload: payload, - } - b, err := json.Marshal(rec) - if err != nil { - return - } - fmt.Fprintln(progressTraceFile, string(b)) -} - // logging reports whether a narration log sink is attached. func (u *UI) logging() bool { return u.logw != nil @@ -212,15 +142,6 @@ func (u *UI) logTagged(level, text string) { fmt.Fprintf(u.logw, "%s\n", b) } -// paint applies an ANSI foreground color regardless of the log sink. Used by -// the Term* summary methods, which always target the terminal. -func (u *UI) paint(colorCode, s string) string { - if u.noColor { - return s - } - return u.output.String(s).Foreground(u.output.Color(colorCode)).String() -} - // New creates a UI that writes to stderr with color auto-detected from the // stderr file descriptor. Respects NO_COLOR and CLICOLOR environment variables. func New() *UI { @@ -316,722 +237,6 @@ func (u *UI) headlessEmit(text string) { fmt.Fprintln(u.w, text) } -// spinnerWriter wraps the terminal writer used by the spinner. It intercepts -// each write from the spinner goroutine (every write starts with '\r') and, -// when worker status lines are set, appends them as dim lines below the spinner -// after the spinner has written its own content. Worker text is kept entirely -// out of the spinner Suffix so the library's byte-count wrap detection never -// sees the extra lines — eliminating the runaway multi-line erase bug. -type spinnerWriter struct { - mu sync.Mutex - w io.Writer - workers []string // per-slot status; empty string = idle - hints []string // per-slot dim suffix appended after the status - nRendered int // number of worker lines written in the last tick - noColor bool - output *termenv.Output - // prefix is the static label text written before the spinner glyph - // (e.g. "Resolving actions "). Stored here so startAnimator can write - // an immediate frame on resume, avoiding the one-tick blank gap that - // occurs because briandowns never fires a tick immediately on Start(). - prefix string - // stop closes to signal the independent worker-redraw ticker to exit. - // done is closed once the ticker goroutine has returned. The ticker - // keeps worker glyphs animating even when the spinner library coalesces, - // throttles, or briefly stalls its own writes (e.g. under network - // contention) — without the ticker, animation freezes whenever Write - // isn't called. - stop chan struct{} - done chan struct{} - - // deferredWrites buffers setWorkerStatus calls that happen while - // printLine has snapshotted-and-cleared the workers slice for an - // inline message print. When non-nil, setWorkerStatus writes into - // this map instead of workers; printLine merges the buffered writes - // back onto its snapshot on restore so concurrent clears/updates - // from the pin pool aren't clobbered by the restore. Nil during - // normal operation. - deferredWrites map[int]string - // deferredHints mirrors deferredWrites for hint state so concurrent - // stall-watcher updates aren't clobbered by printLine's restore. - deferredHints map[int]string -} - -// workerSpinFrames is the rotating glyph shown next to each ACTIVE worker row -// (rows starting with "→") so subtasks visibly pulse instead of looking -// frozen. Matches the main spinner's braille charset (CharSets[11]) so the -// motion stays cohesive. -var workerSpinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - -// workerFrameInterval is how often (wall-clock) each worker glyph advances one -// step. Matches the main spinner's 120ms tick so motion feels cohesive. -const workerFrameInterval = 120 * time.Millisecond - -func (sw *spinnerWriter) Write(p []byte) (n int, err error) { - sw.mu.Lock() - defer sw.mu.Unlock() - - if len(p) == 0 || p[0] != '\r' { - n, err = sw.w.Write(p) - return - } - - // briandowns calls Write twice per tick: - // 1. erase: \r\033[K — wipe the previous frame - // 2. frame: \r{Prefix}{glyph}{Suffix} — paint the new frame - // - // Only render worker rows on the frame write. Rendering them on the - // erase write too means two cursor-down/up sequences per 120ms tick, - // which is the primary cause of residual flicker on embedded terminals. - // - // Match the specific erase sequence the library emits rather than any - // generic CSI prefix — frame writes that start with a color SGR - // (e.g. \r\033[36m…) must not be misclassified as erases. - // briandowns.erase() always writes "\r\033[K" (4 bytes) for single-line - // spinners, optionally followed by "\033[F\033[K" per additional line. - isErase := len(p) >= 4 && p[0] == '\r' && p[1] == '\033' && p[2] == '[' && p[3] == 'K' - if isErase { - // Just pass the erase through; worker rows are still on screen - // from the previous frame and will be refreshed momentarily. - n, err = sw.w.Write(p) - return - } - - // Combine the spinner frame and worker rows into a single - // synchronized write so the terminal never shows a partial frame. - var buf strings.Builder - buf.WriteString("\033[?2026h") // begin synchronized output - // Replace the leading \r with \r\033[2K (go-to-col-0 + erase line) - // so that when the label shrinks between ticks the old longer text - // is fully cleared instead of leaving leftover characters visible. - buf.WriteString("\r\033[2K") - buf.Write(p[1:]) // p[0] is the \r we already emitted above - sw.buildWorkerFrameLocked(&buf) - buf.WriteString("\033[?2026l") // end synchronized output - _, err = io.WriteString(sw.w, buf.String()) - n = len(p) - return -} - -// buildWorkerFrameLocked appends the escape sequences for worker rows into -// buf, leaving the cursor back on the spinner line. Caller must hold sw.mu -// and the cursor must currently be on the spinner line. -func (sw *spinnerWriter) buildWorkerFrameLocked(buf *strings.Builder) { - step := int(time.Now().UnixNano() / int64(workerFrameInterval)) - // Per-slot phase offset so rows visibly cascade instead of all hitting - // the same frame in lockstep — that lockstep was what made them look - // frozen even when they weren't. - width := termWidthOf(sw.w) - var lines []string - for slot, w := range sw.workers { - if w == "" { - continue - } - body := w - if len(w) >= len("→ ") && w[:len("→ ")] == "→ " { - frame := workerSpinFrames[(step+slot)%len(workerSpinFrames)] - body = w[len("→ "):] + " " + frame - } - hint := "" - if slot < len(sw.hints) { - hint = sw.hints[slot] - } - // Combined width budget: " " indent + body + " " + hint must - // fit one terminal row. Hint loses first if there isn't room. - if width > 4 { - budget := width - 2 // " " indent - if len(body)+1+len(hint) > budget { - if len(body) >= budget { - body = truncateBytes(body, budget) - hint = "" - } else if hint != "" { - hintBudget := budget - len(body) - 1 - if hintBudget < 4 { - hint = "" - } else { - hint = truncateBytes(hint, hintBudget) - } - } - } - } - var line string - if !sw.noColor { - line = sw.output.String(" " + body).Faint().String() - if hint != "" { - line += " " + sw.output.String(hint).Faint().String() - } - } else { - line = " " + body - if hint != "" { - line += " " + hint - } - } - lines = append(lines, line) - } - nLines := len(lines) - for _, line := range lines { - // Use \n (newline) rather than \033[1B (cursor-down) so the - // buffer scrolls when we're at the bottom of the viewport. ESC[1B - // is a no-op at the last row and the subsequent ESC[NA cursor-up - // would then overshoot, landing on (and clobbering) lines above - // the spinner — including the user's typed command line. - fmt.Fprintf(buf, "\n\r\033[2K%s", line) - } - // Erase stale lines left over from a previous render that had more - // active workers. Without this, ghost "→ dep" rows from finished - // workers persist below the current set. - for i := nLines; i < sw.nRendered; i++ { - buf.WriteString("\n\r\033[2K") - } - totalDown := nLines - if sw.nRendered > nLines { - totalDown = sw.nRendered - } - if totalDown > 0 { - fmt.Fprintf(buf, "\033[%dA\r", totalDown) - } - sw.nRendered = nLines -} - -// renderWorkersLocked redraws the worker rows below the spinner line as a -// single synchronized write. Caller must hold sw.mu. -func (sw *spinnerWriter) renderWorkersLocked() { - var buf strings.Builder - buf.WriteString("\033[?2026h") // begin synchronized output - sw.buildWorkerFrameLocked(&buf) - buf.WriteString("\033[?2026l") // end synchronized output - io.WriteString(sw.w, buf.String()) -} - -// startAnimator launches a goroutine that periodically redraws the worker -// rows so their glyphs keep pulsing even when the spinner library's own -// writes stall or coalesce. Caller MUST NOT hold sw.mu. -func (sw *spinnerWriter) startAnimator() { - sw.mu.Lock() - if sw.stop != nil { - sw.mu.Unlock() - return - } - sw.stop = make(chan struct{}) - sw.done = make(chan struct{}) - stop := sw.stop - done := sw.done - sw.mu.Unlock() - - sw.mu.Lock() - // Wrap cursor-hide in a synchronized block so it can't interleave - // with a concurrent spinner frame write mid-output. - io.WriteString(sw.w, "\033[?2026h\033[?25l\033[?2026l") - sw.mu.Unlock() - - // Write a synthetic first frame immediately so the spinner line is never - // blank during the ~120ms gap before the library's first ticker tick. - // briandowns never writes a frame on Start() — it always waits for the - // first tick — so without this, every Resume causes a visible blank line. - sw.mu.Lock() - var buf strings.Builder - buf.WriteString("\033[?2026h") - buf.WriteString("\r\033[2K") - buf.WriteString(sw.prefix) - buf.WriteString(workerSpinFrames[0]) // placeholder glyph from shared braille charset; real tick replaces it - sw.buildWorkerFrameLocked(&buf) - buf.WriteString("\033[?2026l") - io.WriteString(sw.w, buf.String()) - sw.mu.Unlock() - - go func() { - defer close(done) - t := time.NewTicker(workerFrameInterval) - defer t.Stop() - for { - select { - case <-stop: - return - case <-t.C: - sw.mu.Lock() - hasActive := false - for _, w := range sw.workers { - if len(w) >= len("→ ") && w[:len("→ ")] == "→ " { - hasActive = true - break - } - } - if hasActive { - sw.renderWorkersLocked() - } - sw.mu.Unlock() - } - } - }() -} - -// stopAnimator signals the redraw ticker to exit and waits for it. Caller -// MUST NOT hold sw.mu. -func (sw *spinnerWriter) stopAnimator() { - sw.mu.Lock() - stop := sw.stop - done := sw.done - sw.stop = nil - sw.done = nil - sw.mu.Unlock() - if stop == nil { - return - } - close(stop) - <-done - // Restore the cursor that startAnimator hid, synchronized so it can't - // interleave with any write still draining from the ticker goroutine. - sw.mu.Lock() - io.WriteString(sw.w, "\033[?2026h\033[?25h\033[?2026l") - sw.mu.Unlock() -} - -// setDetail is a backward-compat shim that sets a single worker slot (slot 0). -func (sw *spinnerWriter) setDetail(det string) { - sw.setWorkerStatus(0, det) -} - -// setWorkerStatus sets or clears one worker's status slot. While printLine -// has the workers slice snapshotted (deferredWrites != nil) the write is -// buffered into the deferred map so printLine's restore phase can merge it -// onto the snapshot — preventing the restore from clobbering clears and -// updates issued by the pin pool during the print window. Setting a slot to -// any value also clears that slot's hint so a stale "(still working…)" -// suffix from a previous job can't bleed into the next one. -func (sw *spinnerWriter) setWorkerStatus(slot int, status string) { - sw.mu.Lock() - if sw.deferredWrites != nil { - sw.deferredWrites[slot] = status - if sw.deferredHints != nil { - sw.deferredHints[slot] = "" - } - sw.mu.Unlock() - return - } - for len(sw.workers) <= slot { - sw.workers = append(sw.workers, "") - } - sw.workers[slot] = status - if slot < len(sw.hints) { - sw.hints[slot] = "" - } - sw.mu.Unlock() -} - -// setWorkerHint sets or clears one worker's dim suffix without touching the -// status text. Defers like setWorkerStatus while printLine has the slices -// snapshotted. -func (sw *spinnerWriter) setWorkerHint(slot int, hint string) { - sw.mu.Lock() - defer sw.mu.Unlock() - if sw.deferredHints != nil { - sw.deferredHints[slot] = hint - return - } - for len(sw.hints) <= slot { - sw.hints = append(sw.hints, "") - } - sw.hints[slot] = hint -} - -// clearSpinnerLines erases the spinner line and any worker lines that were -// rendered below it on the last tick. Uses \033[J (erase to end of screen) -// rather than the per-line cursor-down/clear/cursor-up dance the previous -// implementation used: ESC[1B doesn't scroll the buffer at the bottom of the -// viewport, so the followup ESC[NA could overshoot into already-rendered -// rows above and clobber them on the next spinner tick. -func (u *UI) clearSpinnerLines() { - var lines int - if u.spinWriter != nil { - u.spinWriter.mu.Lock() - lines = u.spinWriter.nRendered - u.spinWriter.nRendered = 0 - u.spinWriter.mu.Unlock() - } - // Buffer the entire clear into a single write so the terminal - // never shows a partially erased frame. - var buf strings.Builder - buf.WriteString("\r\033[2K") - for i := 0; i < lines; i++ { - buf.WriteString("\n\033[2K") - } - if lines > 0 { - fmt.Fprintf(&buf, "\033[%dA\r", lines) - } - fmt.Fprint(u.w, buf.String()) - u.progHasDetail = false -} - -// printLine writes one line of output, transparently pausing an active -// spinner so its animation frame doesn't interleave with (and corrupt) the -// text. The spinner resumes after the write. When no spinner is active it -// just runs write. -func (u *UI) printLine(write func()) { - if u.spinner != nil && u.spinner.Active() { - // Snapshot and zero all worker slots so the stop-write doesn't render - // extra lines that we'd then fail to clear. Buffer any concurrent - // setWorkerStatus calls into deferredWrites so the restore merges - // them instead of clobbering — without the buffer, pin-pool workers - // that clear or repaint their slot during the write window lose - // those updates, which is what leaves stale "→ path" rows visible - // after their owner worker has exited. - var savedWorkers []string - var savedHints []string - if u.spinWriter != nil { - u.spinWriter.mu.Lock() - savedWorkers = make([]string, len(u.spinWriter.workers)) - copy(savedWorkers, u.spinWriter.workers) - savedHints = make([]string, len(u.spinWriter.hints)) - copy(savedHints, u.spinWriter.hints) - u.spinWriter.workers = nil - u.spinWriter.hints = nil - u.spinWriter.deferredWrites = map[int]string{} - u.spinWriter.deferredHints = map[int]string{} - u.spinWriter.mu.Unlock() - } - u.spinner.Stop() - u.clearSpinnerLines() - write() - if u.spinWriter != nil { - u.spinWriter.mu.Lock() - for slot, status := range u.spinWriter.deferredWrites { - for len(savedWorkers) <= slot { - savedWorkers = append(savedWorkers, "") - } - savedWorkers[slot] = status - // A status update implicitly clears the hint, mirroring - // setWorkerStatus's normal-path semantics. - for len(savedHints) <= slot { - savedHints = append(savedHints, "") - } - savedHints[slot] = "" - } - for slot, hint := range u.spinWriter.deferredHints { - for len(savedHints) <= slot { - savedHints = append(savedHints, "") - } - // Don't overwrite a hint clear caused by a same-window - // status update: if deferredWrites also touched this slot, - // the status reset already cleared the hint above. - if _, statusUpdated := u.spinWriter.deferredWrites[slot]; statusUpdated { - continue - } - savedHints[slot] = hint - } - u.spinWriter.workers = savedWorkers - u.spinWriter.hints = savedHints - u.spinWriter.deferredWrites = nil - u.spinWriter.deferredHints = nil - u.spinWriter.mu.Unlock() - } - u.spinner.Start() - return - } - write() -} - -// Success prints a green "✓" prefixed message. -func (u *UI) Success(msg string, args ...any) { - text := fmt.Sprintf(msg, args...) - if u.logging() { - u.logTagged("success", text) - return - } - if u.headless { - u.headlessEmit(text) - return - } - u.emit(fmt.Sprintf("%s %s\n", u.Green(IconSuccess), text)) -} - -// Error prints a red "✗" prefixed message. -func (u *UI) Error(msg string, args ...any) { - text := fmt.Sprintf(msg, args...) - if u.logging() { - u.logTagged("error", text) - return - } - if u.headless { - u.headlessEmit(text) - return - } - u.emit(fmt.Sprintf("%s %s\n", u.Red(IconError), text)) -} - -// Warning prints a yellow "!" prefixed message. -func (u *UI) Warning(msg string, args ...any) { - text := fmt.Sprintf(msg, args...) - if u.logging() { - u.logTagged("warning", text) - return - } - if u.headless { - u.headlessEmit(text) - return - } - u.emit(fmt.Sprintf("%s %s\n", u.Yellow(IconWarning), text)) -} - -// Skip prints a gray "-" prefixed message. -func (u *UI) Skip(msg string, args ...any) { - text := fmt.Sprintf(msg, args...) - if u.logging() { - u.logTagged("skip", text) - return - } - if u.headless { - u.headlessEmit(text) - return - } - u.emit(fmt.Sprintf("%s %s\n", u.Dim(IconSkip), u.Dim(text))) -} - -// Info prints a message with no prefix. -func (u *UI) Info(msg string, args ...any) { - if u.logging() { - u.logTagged("info", fmt.Sprintf(msg, args...)) - return - } - if u.headless { - u.headlessEmit(fmt.Sprintf(msg, args...)) - return - } - u.emit(fmt.Sprintf(msg+"\n", args...)) -} - -// Infof prints a message with no prefix and no trailing newline. -func (u *UI) Infof(msg string, args ...any) { - if u.logging() { - u.logTagged("info", fmt.Sprintf(msg, args...)) - return - } - if u.headless { - u.headlessEmit(fmt.Sprintf(msg, args...)) - return - } - u.emit(fmt.Sprintf(msg, args...)) -} - -// Header prints a bold message, used for file/section headers. -func (u *UI) Header(msg string, args ...any) { - text := fmt.Sprintf(msg, args...) - if u.logging() { - u.logTagged("header", text) - return - } - if u.headless { - u.headlessEmit(text) - return - } - u.emit(fmt.Sprintf("\n%s\n", u.Bold(text))) -} - -// Hint prints a dim, indented message — typically a suggested command. -func (u *UI) Hint(msg string, args ...any) { - text := fmt.Sprintf(msg, args...) - if u.logging() { - u.logTagged("hint", text) - return - } - if u.headless { - u.headlessEmit(text) - return - } - u.emit(fmt.Sprintf(" %s\n", u.Dim(text))) -} - -// Detail prints an indented detail line (2-space indent). -func (u *UI) Detail(msg string, args ...any) { - if u.logging() { - u.logTagged("", fmt.Sprintf(msg, args...)) - return - } - if u.headless { - u.headlessEmit(fmt.Sprintf(msg, args...)) - return - } - u.emit(fmt.Sprintf(" "+msg+"\n", args...)) -} - -// Blank prints an empty line. In log mode it is a no-op so the JSONL transcript -// stays one valid object per line. In headless mode it is also a no-op so CI -// logs stay flat — phase boundaries do the visual separating instead. -func (u *UI) Blank() { - if u.headless || u.logging() { - return - } - u.emit("\n") -} - -// TermSuccess prints a green "✓" summary line directly to the terminal, -// bypassing the narration log. Use for the final run summary. -func (u *UI) TermSuccess(msg string, args ...any) { - if u.headless { - u.headlessEmit(fmt.Sprintf(msg, args...)) - return - } - fmt.Fprintf(u.w, "%s %s\n", u.paint("2", IconSuccess), fmt.Sprintf(msg, args...)) -} - -// TermError prints a red "✗" summary line directly to the terminal. -func (u *UI) TermError(msg string, args ...any) { - if u.headless { - u.headlessEmit(fmt.Sprintf(msg, args...)) - return - } - fmt.Fprintf(u.w, "%s %s\n", u.paint("1", IconError), fmt.Sprintf(msg, args...)) -} - -// TermWarn prints a yellow "!" summary line directly to the terminal. -func (u *UI) TermWarn(msg string, args ...any) { - if u.headless { - u.headlessEmit(fmt.Sprintf(msg, args...)) - return - } - fmt.Fprintf(u.w, "%s %s\n", u.paint("3", IconWarning), fmt.Sprintf(msg, args...)) -} - -// TermCaution prints a yellow "!" summary line directly to the terminal. Use for -// non-fatal but attention-worthy signals (e.g. a commit pinned only after a -// full-branch-scan fallback) that warrant emphasis without the "✗ failure" framing. -func (u *UI) TermCaution(msg string, args ...any) { - if u.headless { - u.headlessEmit(fmt.Sprintf(msg, args...)) - return - } - fmt.Fprintf(u.w, "%s %s\n", u.paint("3", IconWarning), fmt.Sprintf(msg, args...)) -} - -// TermDetail prints an indented summary detail line directly to the terminal. -func (u *UI) TermDetail(msg string, args ...any) { - if u.headless { - u.headlessEmit(fmt.Sprintf(msg, args...)) - return - } - fmt.Fprintf(u.w, " "+msg+"\n", args...) -} - -// TermNeutral prints a dimmed, neutral "-" summary line directly to the -// terminal. Per cli/cli iconography, "-" denotes neutral/informational -// status (not success, alert, or failure). Used for footer pointers such -// as the resolution-record path. -func (u *UI) TermNeutral(msg string, args ...any) { - text := fmt.Sprintf(msg, args...) - if u.headless { - u.headlessEmit(text) - return - } - fmt.Fprintf(u.w, "%s %s\n", u.TermDim(IconSkip), u.TermDim(text)) -} - -// TermYellow returns s in yellow for use in Term* output. Unlike Yellow, this -// does not suppress color when a narration log sink is attached, since Term* -// methods write directly to the terminal rather than the log. -func (u *UI) TermYellow(s string) string { - return u.paint("3", s) -} - -// TermDim returns s in dim/faint for use in Term* output. -func (u *UI) TermDim(s string) string { - if u.noColor { - return s - } - return u.output.String(s).Faint().String() -} - -// TermBold returns s in bold for use in Term* output. -func (u *UI) TermBold(s string) string { - if u.noColor { - return s - } - return u.output.String(s).Bold().String() -} - -// TermLink wraps text in an OSC 8 hyperlink for use in Term* output. Falls -// back to plain text when color is disabled or url is empty. -func (u *UI) TermLink(text, url string) string { - if u.noColor || url == "" { - return text - } - return u.output.Hyperlink(url, text) -} - -// TermBlank prints an empty line directly to the terminal. -func (u *UI) TermBlank() { - if u.headless { - return - } - fmt.Fprintln(u.w) -} - -// Bold returns s in bold if color is enabled. -func (u *UI) Bold(s string) string { - if u.noColor || u.logging() { - return s - } - return u.output.String(s).Bold().String() -} - -// Dim returns s in dim/faint if color is enabled. -func (u *UI) Dim(s string) string { - if u.noColor || u.logging() { - return s - } - return u.output.String(s).Faint().String() -} - -// Red returns s in red if color is enabled. -func (u *UI) Red(s string) string { - if u.noColor || u.logging() { - return s - } - return u.output.String(s).Foreground(u.output.Color("1")).String() -} - -// Green returns s in green if color is enabled. -func (u *UI) Green(s string) string { - if u.noColor || u.logging() { - return s - } - return u.output.String(s).Foreground(u.output.Color("2")).String() -} - -// Yellow returns s in yellow if color is enabled. -func (u *UI) Yellow(s string) string { - if u.noColor || u.logging() { - return s - } - return u.output.String(s).Foreground(u.output.Color("3")).String() -} - -// Cyan returns s in cyan if color is enabled. -func (u *UI) Cyan(s string) string { - if u.noColor || u.logging() { - return s - } - return u.output.String(s).Foreground(u.output.Color("6")).String() -} - -// Hyperlink returns text as a clickable OSC 8 hyperlink when the terminal -// supports it, otherwise returns text as-is. Most modern terminals (iTerm2, -// WezTerm, kitty, GNOME Terminal, Windows Terminal) support this. -func (u *UI) Hyperlink(text, url string) string { - if u.noColor || u.logging() { - return text - } - return u.output.Hyperlink(url, text) -} - -// DocLink renders a documentation reference: the bare URL when writing to the -// log (so the transcript stays actionable), otherwise a dim "docs" hyperlink -// for the terminal. -func (u *UI) DocLink(url string) string { - if u.logging() { - return url - } - return u.Dim(u.Hyperlink("docs", url)) -} - // IsTTY returns true if the output is a terminal. func (u *UI) IsTTY() bool { return u.isTTY @@ -1045,331 +250,3 @@ func (u *UI) IsTTY() bool { func (u *UI) Headless() bool { return u.headless } - -// ProgressActive reports whether a spinner is currently running. Callers use -// this to adopt an already-running spinner (keeping it continuous across -// phases) instead of stopping and restarting one, which would leave a visible -// gap on the terminal. -func (u *UI) ProgressActive() bool { - return u.spinner != nil -} - -// progressGrace is how long StartProgress waits before showing the spinner. -// Runs that complete within this window never flicker a spinner at all. -const progressGrace = 500 * time.Millisecond - -// StartProgress starts an animated spinner with the given label on stderr. -// On non-TTY outputs, prints a static label instead. Matches gh CLI's Primer -// progress indicator: braille dots, 120ms, cyan. -// -// The spinner is not rendered immediately: a short grace period suppresses -// flicker for fast runs. If StopProgress is called before the grace period -// expires, no spinner is ever shown. -func (u *UI) StartProgress(label string) { - if u.headless { - if label != "" { - u.headlessEmit(label) - u.headlessLabelStem = labelStem(label) - } - return - } - sw := &spinnerWriter{ - w: u.w, - noColor: u.noColor, - output: u.output, - } - u.spinWriter = sw - opts := []spinner.Option{spinner.WithWriter(sw)} - if !u.noColor { - opts = append(opts, spinner.WithColor("fgCyan")) - } - sp := spinner.New(spinner.CharSets[11], 120*time.Millisecond, opts...) - // The label is static for the lifetime of this spinner. Set Prefix - // before sp.Start() — the goroutine isn't running yet so no lock needed. - if label != "" { - sp.Prefix = label + " " - sw.prefix = label + " " - } - u.spinner = sp - u.progDetail = "" - u.progPaused = false - - // Defer the visible start so fast runs never flicker. - done := make(chan struct{}) - u.progGraceDone = done - u.progGrace = time.AfterFunc(progressGrace, func() { - defer close(done) - sp.Start() - sw.startAnimator() - }) -} - -// PauseProgress temporarily halts the spinner and clears its line so other -// output (typically an interactive prompt) can render cleanly. The label and -// detail are retained; ResumeProgress restarts the spinner where it left off. -// Safe to call when no spinner is active or one is already paused. -func (u *UI) PauseProgress() { - if u.spinner == nil || u.progPaused { - return - } - // Cancel grace timer — if the spinner hasn't appeared yet, keep it hidden. - if u.progGrace != nil { - if !u.progGrace.Stop() { - <-u.progGraceDone - } - u.progGrace = nil - u.progGraceDone = nil - } - if u.spinWriter != nil { - u.spinWriter.stopAnimator() - } - // Clear worker lines before stopping the spinner (see StopProgress). - if u.isTTY { - u.clearSpinnerLines() - } - if u.spinWriter != nil { - u.spinWriter.mu.Lock() - u.spinWriter.workers = nil - u.spinWriter.hints = nil - u.spinWriter.mu.Unlock() - } - u.spinner.Stop() - u.progPaused = true -} - -// ResumeProgress restarts a spinner previously paused by PauseProgress, -// redrawing the retained label/detail. Safe to call when no spinner is active -// or one is not paused. -func (u *UI) ResumeProgress() { - if u.spinner == nil || !u.progPaused { - return - } - u.progPaused = false - u.renderProgress() - u.spinner.Start() - if u.spinWriter != nil { - u.spinWriter.startAnimator() - } -} - -// StopProgress stops the spinner. Safe to call if no spinner is active. -func (u *UI) StopProgress() { - if u.spinner != nil { - // Cancel the grace timer. If the timer already fired (Stop returns - // false) the spinner is running — wait for the goroutine to finish - // before tearing down so we don't race with Start(). - if u.progGrace != nil { - if !u.progGrace.Stop() { - // Timer already fired — spinner is starting or started. - <-u.progGraceDone - } - u.progGrace = nil - u.progGraceDone = nil - } - if u.spinWriter != nil { - u.spinWriter.stopAnimator() - } - // Erase worker lines BEFORE stopping the spinner — at this point - // the cursor is on the spinner line, so the down/up dance to - // clear worker rows below is safe. Doing this after Stop() races - // with the shell prompt redraw and clobbers it. - u.clearSpinnerLines() - if u.spinWriter != nil { - u.spinWriter.mu.Lock() - u.spinWriter.workers = nil - u.spinWriter.hints = nil - u.spinWriter.mu.Unlock() - } - u.spinner.Stop() - u.spinner = nil - u.spinWriter = nil - u.progDetail = "" - u.progPaused = false - } -} - -// UpdateProgress sets a detail string in worker slot 0 (backward-compat -// single-detail shim). No-op when no spinner is active. -func (u *UI) UpdateProgress(detail string) { - if u.spinner == nil { - return - } - u.progDetail = detail - u.renderProgress() -} - -// SetWorkerStatus sets or clears one worker slot's status line, shown as a -// subdued line below the spinner. slot indexes from 0. No-op when no spinner -// is active. -func (u *UI) SetWorkerStatus(slot int, status string) { - u.traceProgress(fmt.Sprintf("slot[%d]", slot), status) - if u.spinWriter == nil { - return - } - if !u.noColor { - width := u.termWidth() - if width > 4 { - status = truncateBytes(status, width-4) - } - } - u.spinWriter.setWorkerStatus(slot, status) -} - -// SetWorkerHint sets or clears a dim suffix appended after the worker slot's -// status text (e.g. "→ workflow.yml (still working…)"). Used by pinpool's -// stall watcher to surface that a worker has been on the same job for longer -// than the stall threshold without clobbering the slot's main status. No-op -// when no spinner is active. -func (u *UI) SetWorkerHint(slot int, hint string) { - u.traceProgress(fmt.Sprintf("hint[%d]", slot), hint) - if u.spinWriter == nil { - return - } - u.spinWriter.setWorkerHint(slot, hint) -} - -// ClearWorkerStatuses wipes every worker slot so stale "✓ NWO" rows from a -// completed phase don't carry into the next one. No-op when no spinner is -// active. -func (u *UI) ClearWorkerStatuses() { - if u.spinWriter == nil { - return - } - u.spinWriter.mu.Lock() - for i := range u.spinWriter.workers { - u.spinWriter.workers[i] = "" - } - for i := range u.spinWriter.hints { - u.spinWriter.hints[i] = "" - } - // Immediately erase the old rows from the terminal rather than - // waiting for the next spinner tick (~120ms). Without this, the - // stale rows sit on screen for one tick and then all vanish at once, - // which looks like a jump at phase transitions. - u.spinWriter.renderWorkersLocked() - u.spinWriter.mu.Unlock() -} - -// UpdateLabel is a no-op on TTY — the spinner label is static for the -// lifetime of the spinner. In headless mode it logs a plain-text phase -// boundary when the label changes. -func (u *UI) UpdateLabel(label string) { - u.traceProgress("label", label) - if !u.headless { - return - } - stem := labelStem(label) - if stem != "" && stem != u.headlessLabelStem { - u.headlessEmit(stem) - u.headlessLabelStem = stem - } -} - -// labelStem returns the label trimmed of whitespace, used as a phase -// identifier for headless dedup so repeated UpdateLabel calls with the -// same text don't spam the log. -func labelStem(label string) string { - return strings.TrimSpace(label) -} - -// renderProgress updates worker slot 0 with the current detail string. -// The label (top-line prefix) is managed separately by UpdateLabel. -func (u *UI) renderProgress() { - if u.spinner == nil { - return - } - - detail := u.progDetail - if detail == "" { - if u.progHasDetail { - if u.spinWriter != nil { - u.spinWriter.setDetail("") - } - u.progHasDetail = false - } - return - } - - // Worker rows animate only when text starts with "→ "; UpdateProgress - // callers (resolver hooks) pass plain strings. Prepend the arrow so - // the slot pulses instead of looking frozen. - if !strings.HasPrefix(detail, "→ ") { - detail = "→ " + detail - } - width := u.termWidth() - if width > 4 { - budget := width - 4 - if !u.noColor { - budget -= 7 - } - detail = truncateBytes(detail, budget) - } - - if u.spinWriter != nil { - u.spinWriter.setDetail(detail) - } - u.progHasDetail = true -} - -// termWidth returns the terminal column count for the spinner writer, or 0 if -// it cannot be determined (in which case callers skip truncation). -func (u *UI) termWidth() int { - return termWidthOf(u.w) -} - -// termWidthOf returns the terminal width of w, or 0 when it isn't a TTY. -func termWidthOf(w io.Writer) int { - f, ok := w.(*os.File) - if !ok { - return 0 - } - cols, _, err := term.GetSize(int(f.Fd())) - if err != nil || cols <= 0 { - return 0 - } - return cols -} - -// truncateBytes shortens s so its UTF-8 byte length is at most max, never -// splitting a multibyte rune. When truncation occurs the tail is replaced with -// a single ellipsis ("…", 3 bytes). The budget is byte-based because the -// spinner library measures wrap width in bytes; a rune/column budget lets -// multibyte characters (the "—" separator, non-ASCII paths) push the real byte -// width past the terminal edge and trigger its two-line erase. -func truncateBytes(s string, max int) string { - if max <= 0 { - return "" - } - if len(s) <= max { - return s - } - const ellipsis = "…" // 3 bytes - if max < len(ellipsis) { - return trimToRuneBoundary(s, max) - } - return trimToRuneBoundary(s, max-len(ellipsis)) + ellipsis -} - -// trimToRuneBoundary returns the longest prefix of s whose byte length is at -// most max, cut on a rune boundary so multibyte characters aren't split. -func trimToRuneBoundary(s string, max int) string { - if max <= 0 { - return "" - } - if len(s) <= max { - return s - } - end := max - for end > 0 && !utf8.RuneStart(s[end]) { - end-- - } - return s[:end] -} - -// Pluralize returns singular when n==1, plural otherwise. -func Pluralize(n int, singular, plural string) string { - if n == 1 { - return singular - } - return plural -} From e17a3ea3b05f9acb7201bdbdf00e3b0c0b735970 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 20:26:05 -0500 Subject: [PATCH 12/67] Fix PRimer typo in status icon comment Primer is GitHub's design system; the stray capital R was a typo. A full-repo misspell scan found no other spelling errors. --- internal/ui/status.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/status.go b/internal/ui/status.go index 20c80092..a67060bf 100644 --- a/internal/ui/status.go +++ b/internal/ui/status.go @@ -3,7 +3,7 @@ package ui import "fmt" // Icons used in status-prefixed output. These always appear regardless of -// color setting — PRimer says don't rely solely on color. +// color setting — Primer says don't rely solely on color. const ( IconSuccess = "✓" IconError = "✗" From fef04abc4febf647c28220bb29384fc61dad8374 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 20:36:01 -0500 Subject: [PATCH 13/67] Flatten the redundant check subcommand into the root command The root command and the check subcommand were two cobra commands that both called the identical runCheck, with a code comment conceding "Root is just the default check invocation." check was a redundant alias that doubled the command surface, the help text, and the example blocks. Collapse to a single `gh actions-lock [paths]` command: delete newCheckCmd, drop the AddCommand wiring, and rename check.go to run.go to reflect that it now holds run logic rather than a command. The richer Issue-types and Exit-status help and the runner-label example fold into the root command's Long/Example so no documentation is lost. User-facing remediation strings that pointed at `gh actions-lock check` now point at `gh actions-lock`, with the integration catalog updated to match. The JSON golden (expected.json) is byte-identical, proving the diagnosis contract is preserved. --- cmd/gh-actions-lock/check_json_golden_test.go | 4 +- cmd/gh-actions-lock/command_test.go | 34 ++++---- cmd/gh-actions-lock/lockrecovery.go | 2 +- cmd/gh-actions-lock/onboard_gate.go | 2 +- cmd/gh-actions-lock/root.go | 28 ++++++- cmd/gh-actions-lock/{check.go => run.go} | 81 +------------------ internal/pipeline/checks/category.go | 2 +- test/scenarios/catalog.yml | 16 ++-- 8 files changed, 55 insertions(+), 114 deletions(-) rename cmd/gh-actions-lock/{check.go => run.go} (82%) diff --git a/cmd/gh-actions-lock/check_json_golden_test.go b/cmd/gh-actions-lock/check_json_golden_test.go index 408c56ad..7d5ac602 100644 --- a/cmd/gh-actions-lock/check_json_golden_test.go +++ b/cmd/gh-actions-lock/check_json_golden_test.go @@ -1,6 +1,6 @@ package main -// Golden-file snapshot test for the `gh actions-lock check --json` contract. +// Golden-file snapshot test for the `gh actions-lock --json` contract. // // We promised Dependabot the JSON shape is additive-only: no field renames, // no removals, no type shifts. New optional fields are allowed. This test @@ -94,7 +94,7 @@ func TestCheckCommand_JSONGolden(t *testing.T) { stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), // The fixture's lockfile addresses the workflow as // .github/workflows/ci.yml, so we run check on that exact path. - "check", "--rescan", "--no-fix", "--json=valid,findings,workflows,dependencies", + "--rescan", "--no-fix", "--json=valid,findings,workflows,dependencies", ".github/workflows/ci.yml", ) // We expect findings (ref-changed + stale), so the command exits diff --git a/cmd/gh-actions-lock/command_test.go b/cmd/gh-actions-lock/command_test.go index cfbfc6ab..8c051f60 100644 --- a/cmd/gh-actions-lock/command_test.go +++ b/cmd/gh-actions-lock/command_test.go @@ -55,7 +55,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err) @@ -274,7 +274,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.ErrorIs(t, err, errSilent, "JSON mode should exit non-zero when findings are invalid") @@ -326,7 +326,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.ErrorIs(t, err, errSilent, "JSON mode should exit non-zero when findings are invalid") @@ -376,7 +376,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, unknownReachFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err, "unknown reachability should not fail the check") @@ -436,7 +436,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err) @@ -502,7 +502,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.ErrorIs(t, err, errSilent, "JSON mode should exit non-zero for forgery findings") @@ -564,7 +564,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err, "ref-moved is a warning, should not error") @@ -622,7 +622,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err, "ref-moved is a warning, should not error") @@ -701,7 +701,7 @@ jobs: // Test per-workflow dependencies view stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json=workflows", workflowPath, + "--rescan", "--no-fix", "--json=workflows", workflowPath, ) require.NoError(t, err) @@ -776,7 +776,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json=workflows", workflowPath, + "--rescan", "--no-fix", "--json=workflows", workflowPath, ) require.NoError(t, err) @@ -827,7 +827,7 @@ jobs: // --json with no value should use the default fields (valid,findings,workflows) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json", workflowPath, + "--rescan", "--no-fix", "--json", workflowPath, ) require.NoError(t, err) @@ -893,7 +893,7 @@ jobs: // Run WITHOUT --rescan so SeedFromLockfile is active. stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--no-fix", "--json=valid,findings", + "--no-fix", "--json=valid,findings", ".github/workflows/workflow.yml", ) // setup-go is resolved but not yet pinned → "not-pinned" finding → errSilent. @@ -969,7 +969,7 @@ jobs: // Terminal mode (no --json), read-only. setup-go is unpinned → !valid → // errSilent. _, _, runErr := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--no-fix", ".github/workflows/workflow.yml", + "--no-fix", ".github/workflows/workflow.yml", ) require.ErrorIs(t, runErr, errSilent) @@ -1054,7 +1054,7 @@ jobs: // Bare --json: renderer only, autofix still runs. stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--json=valid,findings", + "--json=valid,findings", ".github/workflows/workflow.yml", ) require.NoError(t, err) @@ -1117,7 +1117,7 @@ jobs: ) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--rescan", "--no-fix", "--json=valid,findings", workflowPath, + "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) // ref-moved is a warning (valid=true), not an error. require.NoError(t, err, "ref-moved is a warning, should not error") @@ -1190,7 +1190,7 @@ jobs: require.NoError(t, os.WriteFile(filepath.Join(".github", "workflows", "actions.lock"), []byte(lockYAML), 0o600)) stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), - "check", "--no-fix", "--json=dependencies", wf1, wf2Path, + "--no-fix", "--json=dependencies", wf1, wf2Path, ) require.NoError(t, err) @@ -1247,7 +1247,7 @@ func TestCheckCommand_JSONLoadErrorIsInvalid(t *testing.T) { defer reg.Verify(t) missingPath := filepath.Join(t.TempDir(), "missing.yml") - stdout, _, err := runCommandWithHTTP(t, reg, "check", "--no-fix", "--json=valid,findings", missingPath) + stdout, _, err := runCommandWithHTTP(t, reg, "--no-fix", "--json=valid,findings", missingPath) require.ErrorIs(t, err, errSilent) var payload struct { diff --git a/cmd/gh-actions-lock/lockrecovery.go b/cmd/gh-actions-lock/lockrecovery.go index 08a08360..f6dbc59e 100644 --- a/cmd/gh-actions-lock/lockrecovery.go +++ b/cmd/gh-actions-lock/lockrecovery.go @@ -67,7 +67,7 @@ type lockRecovery func(lockPath string, parseErr error) (recovered bool, err err func newLockRecovery(noInteractive bool, console *ui.UI, newConfirm confirmFactory, allowDelete bool) lockRecovery { return func(lockPath string, parseErr error) (bool, error) { if !allowDelete { - return false, fmt.Errorf("%w; run `gh actions-lock check` to rebuild it, or delete it by hand", parseErr) + return false, fmt.Errorf("%w; run `gh actions-lock` to rebuild it, or delete it by hand", parseErr) } var ( confirm confirmer diff --git a/cmd/gh-actions-lock/onboard_gate.go b/cmd/gh-actions-lock/onboard_gate.go index f0f59dc5..fcf3400b 100644 --- a/cmd/gh-actions-lock/onboard_gate.go +++ b/cmd/gh-actions-lock/onboard_gate.go @@ -33,7 +33,7 @@ func gateNoOnboard(report *checks.Report) []string { f.Category = checks.OnboardingRequired f.Severity = checks.SeverityInfo f.Detail = fmt.Sprintf("%s@%s has no lockfile entry; --no-onboard refuses to add new workflows or actions", ar.FullName(), ar.Ref) - f.Remediation = "onboard it first with `gh actions-lock check` (without --no-onboard)" + f.Remediation = "onboard it first with `gh actions-lock` (without --no-onboard)" refused = append(refused, fmt.Sprintf("%s@%s in %s", ar.FullName(), ar.Ref, wr.Path)) } if len(refusedKeys) == 0 { diff --git a/cmd/gh-actions-lock/root.go b/cmd/gh-actions-lock/root.go index 1e79ab22..62885f39 100644 --- a/cmd/gh-actions-lock/root.go +++ b/cmd/gh-actions-lock/root.go @@ -88,9 +88,24 @@ structured results go to stdout and progress to stderr: gh actions-lock --no-fix --json 2>/dev/null | jq .valid -Commands: +Issue types: + ref-moved - locked SHA no longer matches upstream (expected for mutable tags like v4) + not-pinned - action in workflow has no lock entry + stale - lock entry references an action no longer in the workflow + ref-changed - workflow ref was edited; lock needs updating + misleading-sha - ref looks like a SHA but resolves to a different commit + impostor-commit - locked SHA is not reachable from any branch in the upstream repo + lockfile-forgery - pinned SHA is not an ancestor of the upstream ref it claims - gh actions-lock Verify and fix the dependency lock +Exit status: + 0 read-only run that found everything valid, or a fix run where + every finding was resolved automatically. + 1 blocking findings remain — under --no-fix, any invalid finding; + otherwise, findings that can't be auto-fixed (impostor commit + or lockfile forgery) and need manual review. Output is + well-formed when --json is set. + 2 the tool itself failed (bad flag, IO error, network failure, + malformed lockfile, etc.). `), Example: heredoc.Doc(` # Verify all workflows and fix what's fixable @@ -99,8 +114,14 @@ $ gh actions-lock # Verify a specific workflow $ gh actions-lock .github/workflows/ci.yml -# Read-only check for CI integration (writes nothing) +# Read-only check for CI integration (writes nothing, exits 1 if invalid) $ gh actions-lock --no-fix --json=valid,findings + +# Treat org larger runners as hosted +$ gh actions-lock --allow-runners ubuntu-latest-xl,ubuntu-latest-2xl + +# All fields as JSON +$ gh actions-lock --json `), PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -120,7 +141,6 @@ $ gh actions-lock --no-fix --json=valid,findings // Dependabot so a relock never silently adds an entry it didn't ask for. cmd.PersistentFlags().Bool("no-onboard", false, "Refuse to onboard new workflows or actions; only re-pin already-tracked entries") cmd.PersistentFlags().Bool("no-interactive", false, "Run without interactive prompts") - cmd.AddCommand(newCheckCmd(newResolver)) return cmd } diff --git a/cmd/gh-actions-lock/check.go b/cmd/gh-actions-lock/run.go similarity index 82% rename from cmd/gh-actions-lock/check.go rename to cmd/gh-actions-lock/run.go index fc7b1f9f..67303cba 100644 --- a/cmd/gh-actions-lock/check.go +++ b/cmd/gh-actions-lock/run.go @@ -11,7 +11,6 @@ import ( "runtime/debug" "sync" - "github.com/MakeNowJust/heredoc" "github.com/cli/go-gh/v2/pkg/repository" parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" "github.com/github/gh-actions-lock/cmd/gh-actions-lock/format" @@ -53,85 +52,7 @@ type checkOptions struct { allowRunners []string } -func newCheckCmd(newResolver resolverFunc) *cobra.Command { - opts := &checkOptions{} - - cmd := &cobra.Command{ - Use: "check [...]", - Args: cobra.ArbitraryArgs, - Short: "Verify the dependency lock and fix issues", - Long: heredoc.Doc(` - Verify that every action dependency in your workflows is locked to - an immutable commit SHA and that the lock is still valid. - - Scans all workflows under .github/workflows/ by default, or pass - specific paths. Checks both direct and transitive dependencies - (composite actions that reference other actions). - - By default this writes: every resolvable action is pinned and the - lockfile is updated in place. Pass --no-fix for a read-only check - that reports findings and changes nothing on disk (the CI gate). - - --json selects the output format only — structured results on - stdout, progress on stderr — and is independent of --no-fix: - - gh actions-lock check --no-fix --json 2>/dev/null | jq .valid - - Issue types: - ref-moved - locked SHA no longer matches upstream (expected for mutable tags like v4) - not-pinned - action in workflow has no lock entry - stale - lock entry references an action no longer in the workflow - ref-changed - workflow ref was edited; lock needs updating - misleading-sha - ref looks like a SHA but resolves to a different commit - impostor-commit - locked SHA is not reachable from any branch in the upstream repo - lockfile-forgery - pinned SHA is not an ancestor of the upstream ref it claims - - Exit status: - 0 read-only run that found everything valid, or a fix run where - every finding was resolved automatically. - 1 blocking findings remain — under --no-fix, any invalid finding; - otherwise, findings that can't be auto-fixed (impostor commit - or lockfile forgery) and need manual review. Output is - well-formed when --json is set. - 2 the tool itself failed (bad flag, IO error, network failure, - malformed lockfile, etc.). - With --json, parse stdout and branch on .valid (the pre-fix - diagnosis) regardless of exit code. - `), - Example: heredoc.Doc(` - # Verify all workflows and fix what's fixable - $ gh actions-lock check - - # Verify a specific workflow - $ gh actions-lock check .github/workflows/ci.yml - - # Read-only check for CI (writes nothing, exits 1 if invalid) - $ gh actions-lock check --no-fix --json=valid,findings - - # Treat org larger runners as hosted - $ gh actions-lock --allow-runners ubuntu-latest-xl,ubuntu-latest-2xl - - # All fields as JSON - $ gh actions-lock check --json - `), - PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - opts.workflowPaths = args - } - return opts.validateOutputFlags() - }, - RunE: func(cmd *cobra.Command, args []string) error { - return runCheck(cmd, opts, newResolver) - }, - } - - bindCheckFlags(cmd, opts) - return cmd -} - -// bindCheckFlags registers the flags shared by the root command and the -// explicit `check` subcommand. Root is just the default check invocation, so -// both bind the identical surface from one place. +// bindCheckFlags registers the run flags on the root command. func bindCheckFlags(cmd *cobra.Command, opts *checkOptions) { cmd.Flags().StringVar(&opts.jsonFields, "json", "", "Output JSON with the specified `fields` (valid,findings,workflows,dependencies)") cmd.Flags().Lookup("json").NoOptDefVal = "valid,findings,workflows" diff --git a/internal/pipeline/checks/category.go b/internal/pipeline/checks/category.go index 76bcbb1c..9d5d0882 100644 --- a/internal/pipeline/checks/category.go +++ b/internal/pipeline/checks/category.go @@ -57,7 +57,7 @@ const ( // lockfile. Under --no-onboard the tool refuses to add new entries: the // workflow/action is skipped and surfaced rather than silently pinned. // Already-tracked entries are still re-pinned. The operator must onboard - // explicitly (run `gh actions-lock check` without --no-onboard) to add it. + // explicitly (run `gh actions-lock` without --no-onboard) to add it. OnboardingRequired Category = "onboarding-required" // VersionRef is an informational nudge: a dependency is pinned with a // ref that is not a full semver tag (e.g. v4, v3.1, main). Full semver diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 1f494d5d..9a57afa0 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -1189,7 +1189,7 @@ scenarios: dependency: actions/checkout@v4 detail: actions/checkout@v4 has no lockfile entry; --no-onboard refuses to add new workflows or actions doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: onboard it first with `gh actions-lock check` (without --no-onboard) + remediation: onboard it first with `gh actions-lock` (without --no-onboard) severity: info workflow: .github/workflows/ci.yml lockfile_version: v0.0.2 @@ -1201,7 +1201,7 @@ scenarios: dependency: actions/checkout@v4 detail: actions/checkout@v4 has no lockfile entry; --no-onboard refuses to add new workflows or actions doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: onboard it first with `gh actions-lock check` (without --no-onboard) + remediation: onboard it first with `gh actions-lock` (without --no-onboard) severity: info workflow: .github/workflows/ci.yml path: .github/workflows/ci.yml @@ -1236,7 +1236,7 @@ scenarios: dependency: actions/setup-node@v4 detail: actions/setup-node@v4 has no lockfile entry; --no-onboard refuses to add new workflows or actions doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: onboard it first with `gh actions-lock check` (without --no-onboard) + remediation: onboard it first with `gh actions-lock` (without --no-onboard) severity: info workflow: .github/workflows/ci.yml lockfile_version: v0.0.2 @@ -1254,7 +1254,7 @@ scenarios: dependency: actions/setup-node@v4 detail: actions/setup-node@v4 has no lockfile entry; --no-onboard refuses to add new workflows or actions doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: onboard it first with `gh actions-lock check` (without --no-onboard) + remediation: onboard it first with `gh actions-lock` (without --no-onboard) severity: info workflow: .github/workflows/ci.yml path: .github/workflows/ci.yml @@ -1304,7 +1304,7 @@ scenarios: dependency: actions/checkout@v4 detail: actions/checkout@v4 has no lockfile entry; --no-onboard refuses to add new workflows or actions doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: onboard it first with `gh actions-lock check` (without --no-onboard) + remediation: onboard it first with `gh actions-lock` (without --no-onboard) severity: info workflow: .github/workflows/deploy.yml - category: onboarding-required @@ -1312,7 +1312,7 @@ scenarios: dependency: actions/setup-node@v4 detail: actions/setup-node@v4 has no lockfile entry; --no-onboard refuses to add new workflows or actions doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: onboard it first with `gh actions-lock check` (without --no-onboard) + remediation: onboard it first with `gh actions-lock` (without --no-onboard) severity: info workflow: .github/workflows/deploy.yml lockfile_version: v0.0.2 @@ -1333,7 +1333,7 @@ scenarios: dependency: actions/checkout@v4 detail: actions/checkout@v4 has no lockfile entry; --no-onboard refuses to add new workflows or actions doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: onboard it first with `gh actions-lock check` (without --no-onboard) + remediation: onboard it first with `gh actions-lock` (without --no-onboard) severity: info workflow: .github/workflows/deploy.yml - category: onboarding-required @@ -1341,7 +1341,7 @@ scenarios: dependency: actions/setup-node@v4 detail: actions/setup-node@v4 has no lockfile entry; --no-onboard refuses to add new workflows or actions doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: onboard it first with `gh actions-lock check` (without --no-onboard) + remediation: onboard it first with `gh actions-lock` (without --no-onboard) severity: info workflow: .github/workflows/deploy.yml path: .github/workflows/deploy.yml From a75b5cfdd7f42c87d4c46d13e8fbda6c95beb073 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 20:40:54 -0500 Subject: [PATCH 14/67] Add unit coverage for pure helpers in pipeline and tag These two packages had the weakest coverage of the logic-bearing internal packages (pipeline 41%, tag 25%), almost entirely on pure helpers that are cheap to exercise directly. Cover them where the assertions are about real behavior, not line counts: pipeline: isHex / isLikelyTag / ReleasesURL / DocURLFor (URL and category classification), indexDeps and hasIssues (issue triage across error, inconclusive, and run-only categories), and the parent-attachment helpers isTransitivePin / populateInventoryParents. tag: CooldownDuration, RepoInfo.IsInternal, URL, FormatTagAge bucket boundaries, ReorderSuggestions ordering, and isTagTooNew cooldown filtering (seeded release dates, zero-cooldown short-circuit). Raises pipeline to 48% and tag to 38%. No production code changed. --- internal/pipeline/diagnose_helpers_test.go | 94 ++++++++++++++++++++++ internal/pipeline/doc_urls_test.go | 75 +++++++++++++++++ internal/pipeline/finding_enrich_test.go | 64 +++++++++++++++ internal/tag/pure_test.go | 91 +++++++++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 internal/pipeline/diagnose_helpers_test.go create mode 100644 internal/pipeline/doc_urls_test.go create mode 100644 internal/pipeline/finding_enrich_test.go create mode 100644 internal/tag/pure_test.go diff --git a/internal/pipeline/diagnose_helpers_test.go b/internal/pipeline/diagnose_helpers_test.go new file mode 100644 index 00000000..376487eb --- /dev/null +++ b/internal/pipeline/diagnose_helpers_test.go @@ -0,0 +1,94 @@ +package pipeline + +import ( + "testing" + + "github.com/github/gh-actions-lock/internal/dep" + "github.com/github/gh-actions-lock/internal/pipeline/checks" + "github.com/stretchr/testify/assert" +) + +func TestIndexDeps(t *testing.T) { + deps := []dep.Dependency{ + {NWO: "actions/checkout", Ref: "v4"}, + {NWO: "actions/setup-node", Ref: "v3"}, + } + got := indexDeps(deps) + + assert.Len(t, got, 2) + assert.Equal(t, "actions/checkout", got["actions/checkout@v4"].NWO) + assert.Equal(t, "actions/setup-node", got["actions/setup-node@v3"].NWO) +} + +func TestIndexDeps_LastWins(t *testing.T) { + // Two deps sharing a key (NWO@Ref) collapse to one entry; the later wins. + deps := []dep.Dependency{ + {NWO: "actions/checkout", Ref: "v4", SHA: "aaa"}, + {NWO: "actions/checkout", Ref: "v4", SHA: "bbb"}, + } + got := indexDeps(deps) + + assert.Len(t, got, 1) + assert.Equal(t, "bbb", got["actions/checkout@v4"].SHA) +} + +func TestHasIssues(t *testing.T) { + tests := []struct { + name string + findings []checks.Finding + want bool + }{ + { + name: "no findings", + findings: nil, + want: false, + }, + { + name: "error severity is always an issue", + findings: []checks.Finding{ + {Category: checks.ImpostorCommit, Severity: checks.SeverityError}, + }, + want: true, + }, + { + name: "valid finding is not an issue", + findings: []checks.Finding{ + {Category: checks.Valid, Severity: checks.SeverityOK}, + }, + want: false, + }, + { + name: "inconclusive warning is not blocking", + findings: []checks.Finding{ + {Category: checks.ReachabilityUnknown, Severity: checks.SeverityWarning}, + }, + want: false, + }, + { + name: "not-pinned warning is an issue", + findings: []checks.Finding{ + {Category: checks.NotPinned, Severity: checks.SeverityWarning}, + }, + want: true, + }, + { + name: "run-only warning is not an issue", + findings: []checks.Finding{ + {Category: checks.RunOnly, Severity: checks.SeverityWarning}, + }, + want: false, + }, + { + name: "self-hosted-runner warning is not an issue", + findings: []checks.Finding{ + {Category: checks.SelfHostedRunner, Severity: checks.SeverityWarning}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, hasIssues(tt.findings)) + }) + } +} diff --git a/internal/pipeline/doc_urls_test.go b/internal/pipeline/doc_urls_test.go new file mode 100644 index 00000000..549789d0 --- /dev/null +++ b/internal/pipeline/doc_urls_test.go @@ -0,0 +1,75 @@ +package pipeline + +import ( + "testing" + + "github.com/github/gh-actions-lock/internal/pipeline/checks" + "github.com/stretchr/testify/assert" +) + +func TestIsHex(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {"lowercase hex", "abcdef0123456789", true}, + {"uppercase hex", "ABCDEF", true}, + {"empty is vacuously hex", "", true}, + {"non-hex letter", "g123", false}, + {"contains dash", "abc-def", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isHex(tt.in)) + }) + } +} + +func TestIsLikelyTag(t *testing.T) { + fullSHA := "1234567890abcdef1234567890abcdef12345678" // 40 hex chars + tests := []struct { + name string + ref string + want bool + }{ + {"empty", "", false}, + {"main branch", "main", false}, + {"master branch", "master", false}, + {"trunk branch", "trunk", false}, + {"40-char sha", fullSHA, false}, + {"semver tag", "v4.2.1", true}, + {"major tag", "v4", true}, + {"short hex looks like a tag", "abc123", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isLikelyTag(tt.ref)) + }) + } +} + +func TestReleasesURL(t *testing.T) { + // A tag-like ref deep-links to the specific release tag. + assert.Equal(t, + "https://github.com/actions/checkout/releases/tag/v4.2.1", + ReleasesURL("actions", "checkout", "v4.2.1")) + + // A branch ref falls back to the releases index. + assert.Equal(t, + "https://github.com/actions/checkout/releases", + ReleasesURL("actions", "checkout", "main")) + + // A full SHA is not a tag, so it also falls back to the index. + sha := "1234567890abcdef1234567890abcdef12345678" + assert.Equal(t, + "https://github.com/actions/checkout/releases", + ReleasesURL("actions", "checkout", sha)) +} + +func TestDocURLFor(t *testing.T) { + // A known category resolves to a security-hardening anchor. + assert.Contains(t, DocURLFor(checks.NotPinned), securityHardeningBase) + // Categories without a finding URL (e.g. Valid) return empty. + assert.Equal(t, "", DocURLFor(checks.Valid)) +} diff --git a/internal/pipeline/finding_enrich_test.go b/internal/pipeline/finding_enrich_test.go new file mode 100644 index 00000000..dc1983b5 --- /dev/null +++ b/internal/pipeline/finding_enrich_test.go @@ -0,0 +1,64 @@ +package pipeline + +import ( + "testing" + + "github.com/github/gh-actions-lock/internal/dep" + "github.com/github/gh-actions-lock/internal/pipeline/checks" + "github.com/stretchr/testify/assert" +) + +func TestIsTransitivePin(t *testing.T) { + d := dep.Dependency{NWO: "actions/cache", Ref: "v4"} + depByKey := map[string]dep.Dependency{d.Key(): d} + withParents := map[string][]string{d.Key(): {"some/composite@v1"}} + + t.Run("transitive when indexed and has parents", func(t *testing.T) { + f := checks.Finding{Dependency: &d} + assert.True(t, isTransitivePin(f, depByKey, withParents)) + }) + + t.Run("not transitive without a dependency", func(t *testing.T) { + f := checks.Finding{} + assert.False(t, isTransitivePin(f, depByKey, withParents)) + }) + + t.Run("not transitive when absent from the index", func(t *testing.T) { + other := dep.Dependency{NWO: "actions/checkout", Ref: "v4"} + f := checks.Finding{Dependency: &other} + assert.False(t, isTransitivePin(f, depByKey, withParents)) + }) + + t.Run("not transitive when the dep has no parents", func(t *testing.T) { + noParents := map[string][]string{d.Key(): nil} + f := checks.Finding{Dependency: &d} + assert.False(t, isTransitivePin(f, depByKey, noParents)) + }) +} + +func TestPopulateInventoryParents(t *testing.T) { + transitive := dep.Dependency{NWO: "actions/cache", Ref: "v4"} + direct := dep.Dependency{NWO: "actions/checkout", Ref: "v4"} + parentMap := map[string][]string{transitive.Key(): {"some/composite@v1"}} + + inventory := []checks.InventoryEntry{ + {Dep: transitive, Direct: false}, + {Dep: direct, Direct: true}, + } + populateInventoryParents(inventory, parentMap) + + // The transitive entry is backfilled from the parent map. + assert.Equal(t, []string{"some/composite@v1"}, inventory[0].Parents) + // Direct entries are never given parents. + assert.Nil(t, inventory[1].Parents) +} + +func TestPopulateInventoryParents_KeepsExistingParents(t *testing.T) { + d := dep.Dependency{NWO: "actions/cache", Ref: "v4"} + inventory := []checks.InventoryEntry{ + {Dep: d, Direct: false, Parents: []string{"already/set@v1"}}, + } + // parentMap would offer a different parent, but existing parents win. + populateInventoryParents(inventory, map[string][]string{d.Key(): {"other/comp@v2"}}) + assert.Equal(t, []string{"already/set@v1"}, inventory[0].Parents) +} diff --git a/internal/tag/pure_test.go b/internal/tag/pure_test.go new file mode 100644 index 00000000..d8c49b35 --- /dev/null +++ b/internal/tag/pure_test.go @@ -0,0 +1,91 @@ +package tag + +import ( + "testing" + "time" + + "github.com/github/gh-actions-lock/internal/ghapi" + "github.com/stretchr/testify/assert" +) + +func TestCooldownDuration(t *testing.T) { + cfg := CooldownConfig{DefaultDays: 7} + assert.Equal(t, 7*24*time.Hour, cfg.CooldownDuration("owner", "repo")) + + cfg = CooldownConfig{DefaultDays: 7, RepoOverrides: map[string]int{"a/b": 2}} + assert.Equal(t, 2*24*time.Hour, cfg.CooldownDuration("a", "b")) +} + +func TestRepoInfoIsInternal(t *testing.T) { + assert.True(t, RepoInfo{Visibility: "private"}.IsInternal()) + assert.True(t, RepoInfo{Visibility: "internal"}.IsInternal()) + assert.False(t, RepoInfo{Visibility: "public"}.IsInternal()) + assert.False(t, RepoInfo{}.IsInternal()) +} + +func TestURL(t *testing.T) { + assert.Equal(t, + "https://github.com/actions/checkout/releases/tag/v4.2.1", + URL("actions", "checkout", "v4.2.1")) +} + +func TestFormatTagAge(t *testing.T) { + now := time.Now() + tests := []struct { + name string + iso string + want string + }{ + {"empty", "", ""}, + {"unparseable", "not-a-date", ""}, + {"minutes", now.Add(-30 * time.Minute).Format(time.RFC3339), "30m ago"}, + {"hours", now.Add(-5 * time.Hour).Format(time.RFC3339), "5h ago"}, + {"days", now.Add(-3 * 24 * time.Hour).Format(time.RFC3339), "3d ago"}, + {"months", now.Add(-60 * 24 * time.Hour).Format(time.RFC3339), "2mo ago"}, + {"years", now.Add(-400 * 24 * time.Hour).Format(time.RFC3339), "1y ago"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, FormatTagAge(tt.iso)) + }) + } +} + +func TestReorderSuggestions(t *testing.T) { + major := Suggestion{Tag: Info{Name: "v4", IsMajor: true}} + immutable := Suggestion{Tag: Info{Name: "v4.2.1", IsRelease: true, IsImmutable: true}} + release := Suggestion{Tag: Info{Name: "v4.2.0", IsRelease: true}} + plain := Suggestion{Tag: Info{Name: "v4.1.9"}} + + // Deliberately scrambled: major first, immutable last. + got := ReorderSuggestions([]Suggestion{major, plain, release, immutable}) + + var names []string + for _, s := range got { + names = append(names, s.Tag.Name) + } + // Immutable, then regular release, then plain full version, then major-only. + assert.Equal(t, []string{"v4.2.1", "v4.2.0", "v4.1.9", "v4"}, names) +} + +func TestIsTagTooNew(t *testing.T) { + tl := NewLister(nil, CooldownConfig{DefaultDays: 7}) + repo := ghapi.ForRepo("actions", "checkout") + + recent := time.Now().Add(-2 * 24 * time.Hour).Format(time.RFC3339) // within cooldown + old := time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339) // past cooldown + tl.releaseDates.Put(repo, map[string]string{ + "v4.2.1": recent, + "v4.2.0": old, + }) + + assert.True(t, tl.isTagTooNew("actions", "checkout", "v4.2.1")) + assert.False(t, tl.isTagTooNew("actions", "checkout", "v4.2.0")) + // A tag with no known release date is never filtered. + assert.False(t, tl.isTagTooNew("actions", "checkout", "v9.9.9")) + + // A zero/negative cooldown disables the age check entirely. + tl0 := NewLister(nil, CooldownConfig{DefaultDays: 0}) + tl0.releaseDates.Put(repo, map[string]string{"v4.2.1": recent}) + assert.False(t, tl0.isTagTooNew("actions", "checkout", "v4.2.1")) +} From 2b501dc5b7a7bd79a309079f99b33a8979c7f9e7 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 20:44:32 -0500 Subject: [PATCH 15/67] ui: drop the golden characterization tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These goldens existed only to fence the ui.go split (611d369): snapshot the rendered surface before the move, prove it byte-identical after. That refactor has landed and the behavioral tests in ui_test.go cover the surface, so the snapshots no longer earn their keep — they assert no meaningful behavior and just break whenever output formatting changes. The pre-existing JSON golden in cmd/ stays; it's a real diagnosis contract, not refactor scaffolding. --- internal/ui/golden_test.go | 130 ------------------ internal/ui/testdata/ui_surface_color.golden | 31 ----- .../ui/testdata/ui_surface_headless.golden | 31 ----- internal/ui/testdata/ui_surface_plain.golden | 31 ----- 4 files changed, 223 deletions(-) delete mode 100644 internal/ui/golden_test.go delete mode 100644 internal/ui/testdata/ui_surface_color.golden delete mode 100644 internal/ui/testdata/ui_surface_headless.golden delete mode 100644 internal/ui/testdata/ui_surface_plain.golden diff --git a/internal/ui/golden_test.go b/internal/ui/golden_test.go deleted file mode 100644 index 4188580e..00000000 --- a/internal/ui/golden_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package ui - -// Golden-file characterization tests for the UI output surface. -// -// These lock the exact bytes every styling and narration method emits across -// the three rendering modes (color, plain, headless) so a refactor that only -// moves code between files can prove it changed no output. Each method is -// captured into its own buffer and strconv.Quote'd, so ANSI escapes and -// hyperlinks are visible and diffs point straight at the method that drifted. -// -// To regenerate after an intentional output change: -// -// UPDATE_GOLDEN=1 go test ./internal/ui/ -run TestUISurfaceGolden -// -// CI runs without the env var, so any output change must be intentional and -// committed alongside the code change. - -import ( - "bytes" - "fmt" - "io" - "os" - "path/filepath" - "strconv" - "testing" - - "github.com/muesli/termenv" - "github.com/stretchr/testify/require" -) - -type uiMode struct { - name string - noColor bool - headless bool -} - -var uiModes = []uiMode{ - {name: "color", noColor: false, headless: false}, - {name: "plain", noColor: true, headless: false}, - {name: "headless", noColor: true, headless: true}, -} - -// renderUISurface drives every deterministic UI method with fixed inputs and -// returns a label-keyed transcript of the exact bytes each one emitted. The -// termenv profile is pinned to ANSI so color escapes don't depend on the host -// terminal. Spinner and progress methods are intentionally excluded: they are -// time-based and covered by the behavioral tests in ui_test.go. -func renderUISurface(mode uiMode) string { - u := &UI{ - output: termenv.NewOutput(io.Discard, termenv.WithProfile(termenv.ANSI)), - noColor: mode.noColor, - headless: mode.headless, - } - - var out bytes.Buffer - sub := &bytes.Buffer{} - u.w = sub - - // record captures the bytes a writing method emits to u.w. - record := func(label string, fn func()) { - sub.Reset() - fn() - fmt.Fprintf(&out, "%-12s %s\n", label, strconv.Quote(sub.String())) - } - // recordStr captures the value a string-returning method produces. - recordStr := func(label, s string) { - fmt.Fprintf(&out, "%-12s %s\n", label, strconv.Quote(s)) - } - - // Narration methods. - record("Success", func() { u.Success("pinned %d actions", 3) }) - record("Error", func() { u.Error("could not resolve %s", "actions/checkout") }) - record("Warning", func() { u.Warning("ref moved upstream") }) - record("Skip", func() { u.Skip("already pinned") }) - record("Info", func() { u.Info("scanning %d workflows", 2) }) - record("Infof", func() { u.Infof("no trailing newline") }) - record("Header", func() { u.Header(".github/workflows/ci.yml") }) - record("Hint", func() { u.Hint("run gh actions-lock to fix") }) - record("Detail", func() { u.Detail("actions/checkout@v4") }) - record("Blank", func() { u.Blank() }) - - // Term* summary methods (write directly to the terminal writer). - record("TermSuccess", func() { u.TermSuccess("all dependencies locked") }) - record("TermError", func() { u.TermError("1 finding needs review") }) - record("TermWarn", func() { u.TermWarn("2 refs moved") }) - record("TermCaution", func() { u.TermCaution("pinned after branch scan") }) - record("TermDetail", func() { u.TermDetail("see %s", "actions.lock") }) - record("TermNeutral", func() { u.TermNeutral("resolution recorded") }) - record("TermBlank", func() { u.TermBlank() }) - - // Color and style string helpers. - recordStr("Bold", u.Bold("sample")) - recordStr("Dim", u.Dim("sample")) - recordStr("Red", u.Red("sample")) - recordStr("Green", u.Green("sample")) - recordStr("Yellow", u.Yellow("sample")) - recordStr("Cyan", u.Cyan("sample")) - recordStr("Hyperlink", u.Hyperlink("text", "https://example.com/x")) - recordStr("DocLink", u.DocLink("https://example.com/docs")) - recordStr("TermYellow", u.TermYellow("sample")) - recordStr("TermDim", u.TermDim("sample")) - recordStr("TermBold", u.TermBold("sample")) - recordStr("TermLink", u.TermLink("text", "https://example.com/x")) - - // Free functions. - recordStr("Pluralize1", Pluralize(1, "action", "actions")) - recordStr("PluralizeN", Pluralize(2, "action", "actions")) - - return out.String() -} - -func TestUISurfaceGolden(t *testing.T) { - for _, mode := range uiModes { - t.Run(mode.name, func(t *testing.T) { - got := renderUISurface(mode) - goldenPath := filepath.Join("testdata", "ui_surface_"+mode.name+".golden") - - if os.Getenv("UPDATE_GOLDEN") == "1" { - require.NoError(t, os.MkdirAll("testdata", 0o755)) - require.NoError(t, os.WriteFile(goldenPath, []byte(got), 0o644)) - return - } - - want, err := os.ReadFile(goldenPath) - require.NoError(t, err, "missing golden; regenerate with UPDATE_GOLDEN=1") - require.Equal(t, string(want), got, - "UI output drifted for mode %q; if intentional, regenerate with UPDATE_GOLDEN=1", mode.name) - }) - } -} diff --git a/internal/ui/testdata/ui_surface_color.golden b/internal/ui/testdata/ui_surface_color.golden deleted file mode 100644 index ac6dc0c3..00000000 --- a/internal/ui/testdata/ui_surface_color.golden +++ /dev/null @@ -1,31 +0,0 @@ -Success "\x1b[32m✓\x1b[0m pinned 3 actions\n" -Error "\x1b[31m✗\x1b[0m could not resolve actions/checkout\n" -Warning "\x1b[33m!\x1b[0m ref moved upstream\n" -Skip "\x1b[2m-\x1b[0m \x1b[2malready pinned\x1b[0m\n" -Info "scanning 2 workflows\n" -Infof "no trailing newline" -Header "\n\x1b[1m.github/workflows/ci.yml\x1b[0m\n" -Hint " \x1b[2mrun gh actions-lock to fix\x1b[0m\n" -Detail " actions/checkout@v4\n" -Blank "\n" -TermSuccess "\x1b[32m✓\x1b[0m all dependencies locked\n" -TermError "\x1b[31m✗\x1b[0m 1 finding needs review\n" -TermWarn "\x1b[33m!\x1b[0m 2 refs moved\n" -TermCaution "\x1b[33m!\x1b[0m pinned after branch scan\n" -TermDetail " see actions.lock\n" -TermNeutral "\x1b[2m-\x1b[0m \x1b[2mresolution recorded\x1b[0m\n" -TermBlank "\n" -Bold "\x1b[1msample\x1b[0m" -Dim "\x1b[2msample\x1b[0m" -Red "\x1b[31msample\x1b[0m" -Green "\x1b[32msample\x1b[0m" -Yellow "\x1b[33msample\x1b[0m" -Cyan "\x1b[36msample\x1b[0m" -Hyperlink "\x1b]8;;https://example.com/x\x1b\\text\x1b]8;;\x1b\\" -DocLink "\x1b[2m\x1b]8;;https://example.com/docs\x1b\\docs\x1b]8;;\x1b\\\x1b[0m" -TermYellow "\x1b[33msample\x1b[0m" -TermDim "\x1b[2msample\x1b[0m" -TermBold "\x1b[1msample\x1b[0m" -TermLink "\x1b]8;;https://example.com/x\x1b\\text\x1b]8;;\x1b\\" -Pluralize1 "action" -PluralizeN "actions" diff --git a/internal/ui/testdata/ui_surface_headless.golden b/internal/ui/testdata/ui_surface_headless.golden deleted file mode 100644 index 242ac3e0..00000000 --- a/internal/ui/testdata/ui_surface_headless.golden +++ /dev/null @@ -1,31 +0,0 @@ -Success "pinned 3 actions\n" -Error "could not resolve actions/checkout\n" -Warning "ref moved upstream\n" -Skip "already pinned\n" -Info "scanning 2 workflows\n" -Infof "no trailing newline\n" -Header ".github/workflows/ci.yml\n" -Hint "run gh actions-lock to fix\n" -Detail "actions/checkout@v4\n" -Blank "" -TermSuccess "all dependencies locked\n" -TermError "1 finding needs review\n" -TermWarn "2 refs moved\n" -TermCaution "pinned after branch scan\n" -TermDetail "see actions.lock\n" -TermNeutral "resolution recorded\n" -TermBlank "" -Bold "sample" -Dim "sample" -Red "sample" -Green "sample" -Yellow "sample" -Cyan "sample" -Hyperlink "text" -DocLink "docs" -TermYellow "sample" -TermDim "sample" -TermBold "sample" -TermLink "text" -Pluralize1 "action" -PluralizeN "actions" diff --git a/internal/ui/testdata/ui_surface_plain.golden b/internal/ui/testdata/ui_surface_plain.golden deleted file mode 100644 index 7666b38f..00000000 --- a/internal/ui/testdata/ui_surface_plain.golden +++ /dev/null @@ -1,31 +0,0 @@ -Success "✓ pinned 3 actions\n" -Error "✗ could not resolve actions/checkout\n" -Warning "! ref moved upstream\n" -Skip "- already pinned\n" -Info "scanning 2 workflows\n" -Infof "no trailing newline" -Header "\n.github/workflows/ci.yml\n" -Hint " run gh actions-lock to fix\n" -Detail " actions/checkout@v4\n" -Blank "\n" -TermSuccess "✓ all dependencies locked\n" -TermError "✗ 1 finding needs review\n" -TermWarn "! 2 refs moved\n" -TermCaution "! pinned after branch scan\n" -TermDetail " see actions.lock\n" -TermNeutral "- resolution recorded\n" -TermBlank "\n" -Bold "sample" -Dim "sample" -Red "sample" -Green "sample" -Yellow "sample" -Cyan "sample" -Hyperlink "text" -DocLink "docs" -TermYellow "sample" -TermDim "sample" -TermBold "sample" -TermLink "text" -Pluralize1 "action" -PluralizeN "actions" From 1acd1f86c1b47eac8a2ce79b221526e99dba4934 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 21:04:16 -0500 Subject: [PATCH 16/67] pin: split planWorkflow into named phase helpers planWorkflow had grown to ~400 lines spanning eight distinct phases (resolve, reachability gate, full-scan metadata, direct-dep narrowing, reverse-lookup canonicalization, parent rekey, pinned-entry assembly, informational findings). The phases were already marked by section comments and status() calls but shared one giant scope, so the control flow and the per-phase detail competed for attention. Extract seven self-contained, individually-testable helpers and leave the orchestrator holding only sequencing plus the early-return gates: unresolvedEntries, reachabilityGate, collectFullScanDeps, narrowDirectDeps, reverseLookupRewrites, buildPinnedEntries, informationalEntries The orchestrator drops from 397 to 131 lines and now reads as a phase list. Behavior-preserving: deps is a slice so in-place ref/SHA mutation stays visible to the caller; dropDeps still reassigns in the parent; the parent-map rekey block stays inline (it shadows the dep package). reverseLookupRewrites returns a non-nil *Entry to signal the impostor early-return rather than returning from inside the helper. Full race suite, 20/20 integration scenarios, and the JSON diagnosis golden all unchanged. --- internal/pin/plan.go | 392 +++++++++++++++++++++++++------------------ 1 file changed, 232 insertions(+), 160 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index d6ea21f1..bf6f8cee 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -138,37 +138,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption status("resolving " + wr.Path) deps, parentMap, resolveErr := opts.Resolver.ResolveAllRecursive(ctx, unrecordedRefs) if resolveErr != nil { - // Partial failure: some refs resolved (in deps), others didn't. - // Build a set of resolved NWO@Ref keys so we can continue with - // the successful ones and only mark the failures as unresolved. - resolved := make(map[string]bool, len(deps)) - for _, d := range deps { - resolved[strings.ToLower(d.NWO+"@"+d.Ref)] = true - } - // Only mark findings as unresolved if they were actually attempted - // (i.e., part of unrecordedRefs). Recorded refs were never sent to - // ResolveAllRecursive and should not be marked as failures. - attempted := make(map[string]bool, len(unrecordedRefs)) - for _, ref := range unrecordedRefs { - attempted[strings.ToLower(ref.Owner+"/"+ref.Repo+"@"+ref.Ref)] = true - } - for _, f := range wr.Findings { - if f.ActionRef == nil { - continue - } - key := strings.ToLower(f.ActionRef.Owner + "/" + f.ActionRef.Repo + "@" + f.ActionRef.Ref) - if !attempted[key] || resolved[key] { - continue - } - entries = append(entries, Entry{ - NWO: f.ActionRef.Owner + "/" + f.ActionRef.Repo, - Ref: f.ActionRef.Ref, - Resolution: Unresolved, - Issue: string(f.Category), - Reason: fmt.Sprintf("resolution failed: %s", resolveErr), - Workflows: []string{wr.Path}, - }) - } + entries = append(entries, unresolvedEntries(wr, unrecordedRefs, deps, resolveErr)...) if len(deps) == 0 { wplans = append(wplans, WorkflowPlan{Path: wr.Path}) return planResult{entries: entries, wplans: wplans}, nil @@ -179,9 +149,140 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption // Reachability gate — drop impostors, auto-fix when a sane release exists. status("verifying " + wr.Path) reachResults := opts.Resolver.CheckReachabilityAll(ctx, deps) - badKeys := make(map[string]bool) - autoFixed := make(map[string]string) // new dep key → original ref - autoFixRewrites := make(map[string]string) // old uses → new uses (for YAML rewrite) + gateEntries, badKeys, autoFixed, autoFixRewrites := reachabilityGate(ctx, wr, opts, deps, reachResults) + entries = append(entries, gateEntries...) + + if len(badKeys) > 0 { + deps, parentMap = dropDeps(deps, parentMap, badKeys) + if len(deps) == 0 { + wplans = append(wplans, WorkflowPlan{Path: wr.Path}) + return planResult{entries: entries, wplans: wplans}, nil + } + } + + // Track reachability metadata for pinned entries. + fullScanDeps := collectFullScanDeps(reachResults, badKeys) + + // Snapshot direct-dep matching before narrowing/ReverseLookup mutate + // dep.Ref — the tracker records index-aligned booleans at construction, + // then Keys() reads post-mutation refs. Must be built while deps and + // ActionRefs still share the same ref strings. + directTracker := lockfile.NewDirectTracker(unrecordedRefs, deps) + + // Narrow mutable version tags to patch tags, and resolve bare-SHA refs + // to a symbolic tag when one exists. + status("pinning " + wr.Path) + rewrites := make(map[string]string) + narrowedNWOs := make(map[string]bool) // NWOs where narrowing chose a tag + for k, v := range autoFixRewrites { + rewrites[k] = v + } + + narrowDirectDeps(ctx, opts, deps, directTracker, rewrites, narrowedNWOs) + + // ReverseLookup canonicalizes each dep's ref while preserving the tags + // narrowing chose and transitive deps' declared refs. An impostor commit + // surfaced here ends the workflow early. + rlRewrites, impostorEntry, err := reverseLookupRewrites(ctx, opts, wr, deps, directTracker, narrowedNWOs) + if err != nil { + return planResult{}, err + } + if impostorEntry != nil { + entries = append(entries, *impostorEntry) + wplans = append(wplans, WorkflowPlan{Path: wr.Path}) + return planResult{entries: entries, wplans: wplans}, nil + } + for k, v := range rlRewrites { + rewrites[k] = v + } + + // Update parent map keys to reflect narrowed/normalized refs. + parentRewrites := make(map[string]string) + for i := range deps { + dep := &deps[i] + newKey := dep.Key() + // Compare against original key before narrowing. + for oldUses, newUses := range rewrites { + if newUses == dep.NWO+"@"+dep.Ref { + // The dep was rewritten from oldUses. + parentRewrites[oldUses] = newKey + } + } + _ = dep + } + if len(parentRewrites) > 0 { + parentMap = dep.RekeyParentMap(parentMap, parentRewrites) + } + + // Record workflow plan if there are rewrites. + // Also narrow any verified (already-recorded) entries that have imprecise refs. + if verifiedRW := narrowVerifiedEntries(ctx, entries, opts); len(verifiedRW) > 0 { + for k, v := range verifiedRW { + rewrites[k] = v + } + } + if len(rewrites) > 0 { + wplans = append(wplans, WorkflowPlan{ + Path: wr.Path, + Rewrites: rewrites, + }) + } else if len(wplans) == 0 { + // No rewrites and no plan entry yet — still include the workflow + // so EnsureSentinel can be applied during commit. + wplans = append(wplans, WorkflowPlan{Path: wr.Path}) + } + + // Build entries for all pinned deps (skip any already emitted from inventory). + entries = append(entries, buildPinnedEntries(opts, wr, deps, parentMap, directTracker, inventorySHA, autoFixed, fullScanDeps)...) + + // Record findings that are informational (ref-moved, misleading-sha). + entries = append(entries, informationalEntries(wr)...) + + return planResult{entries: entries, wplans: wplans}, nil +} + +// unresolvedEntries flags findings whose refs were attempted but failed to +// resolve. On a partial failure deps holds the refs that did resolve, so only +// the genuine misses (attempted and not in deps) are marked Unresolved. +func unresolvedEntries(wr checks.WorkflowReport, unrecordedRefs []parserlock.ActionRef, deps []dep.Dependency, resolveErr error) []Entry { + resolved := make(map[string]bool, len(deps)) + for _, d := range deps { + resolved[strings.ToLower(d.NWO+"@"+d.Ref)] = true + } + attempted := make(map[string]bool, len(unrecordedRefs)) + for _, ref := range unrecordedRefs { + attempted[strings.ToLower(ref.Owner+"/"+ref.Repo+"@"+ref.Ref)] = true + } + var out []Entry + for _, f := range wr.Findings { + if f.ActionRef == nil { + continue + } + key := strings.ToLower(f.ActionRef.Owner + "/" + f.ActionRef.Repo + "@" + f.ActionRef.Ref) + if !attempted[key] || resolved[key] { + continue + } + out = append(out, Entry{ + NWO: f.ActionRef.Owner + "/" + f.ActionRef.Repo, + Ref: f.ActionRef.Ref, + Resolution: Unresolved, + Issue: string(f.Category), + Reason: fmt.Sprintf("resolution failed: %s", resolveErr), + Workflows: []string{wr.Path}, + }) + } + return out +} + +// reachabilityGate classifies resolved deps by reachability. Unreachable deps +// are auto-repinned in place to a recommended release when one exists (keeping +// them in the pipeline) or recorded for investigation; reachability-unknown +// deps are skipped. It returns the entries to emit, the dep keys to drop, the +// auto-fixed newKey->originalRef map, and the old->new YAML rewrites. +func reachabilityGate(ctx context.Context, wr checks.WorkflowReport, opts PlanOptions, deps []dep.Dependency, reachResults []resolve.ReachabilityResult) (entries []Entry, badKeys map[string]bool, autoFixed, autoFixRewrites map[string]string) { + badKeys = make(map[string]bool) + autoFixed = make(map[string]string) // new dep key -> original ref + autoFixRewrites = make(map[string]string) // old uses -> new uses (for YAML rewrite) for _, rr := range reachResults { depKey := rr.Owner + "/" + rr.Repo + "@" + rr.Ref switch rr.Status { @@ -210,7 +311,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption break } } - continue // don't mark as bad — dep stays in pipeline + continue // don't mark as bad - dep stays in pipeline } entries = append(entries, Entry{ @@ -236,16 +337,12 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption badKeys[depKey] = true } } + return entries, badKeys, autoFixed, autoFixRewrites +} - if len(badKeys) > 0 { - deps, parentMap = dropDeps(deps, parentMap, badKeys) - if len(deps) == 0 { - wplans = append(wplans, WorkflowPlan{Path: wr.Path}) - return planResult{entries: entries, wplans: wplans}, nil - } - } - - // Track reachability metadata for pinned entries. +// collectFullScanDeps returns the set of still-pinned dep keys whose +// reachability check required a full commit scan. +func collectFullScanDeps(reachResults []resolve.ReachabilityResult, badKeys map[string]bool) map[string]bool { fullScanDeps := make(map[string]bool) for _, rr := range reachResults { depKey := rr.Owner + "/" + rr.Repo + "@" + rr.Ref @@ -256,76 +353,38 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption fullScanDeps[depKey] = true } } + return fullScanDeps +} - // Snapshot direct-dep matching before narrowing/ReverseLookup mutate - // dep.Ref — the tracker records index-aligned booleans at construction, - // then Keys() reads post-mutation refs. Must be built while deps and - // ActionRefs still share the same ref strings. - directTracker := lockfile.NewDirectTracker(unrecordedRefs, deps) - - // Narrow mutable version tags to patch tags, and resolve bare-SHA refs - // to a symbolic tag when one exists. - status("pinning " + wr.Path) - rewrites := make(map[string]string) - narrowedNWOs := make(map[string]bool) // NWOs where narrowing chose a tag - for k, v := range autoFixRewrites { - rewrites[k] = v +// narrowDirectDeps rewrites direct deps' mutable refs to precise tags: a bare +// SHA to a tag at the same commit, a partial or non-semver ref to a full patch +// tag. Transitive deps are left verbatim - their ref belongs to the composite +// that declares it. Each rewrite mutates deps[i].Ref in place, records the +// old->new uses in rewrites, and notes the narrowed NWO in narrowedNWOs. +func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependency, directTracker lockfile.DirectTracker, rewrites map[string]string, narrowedNWOs map[string]bool) { + if opts.Tagger == nil { + return } + for i := range deps { + dep := &deps[i] + // Transitive deps come from a composite's action.yml; their ref + // is the composite author's choice and never appears in our + // workflow YAML. Narrowing it is pure churn and can invent refs + // the composite never declared, so leave it verbatim. + if !directTracker.IsDirect(i) { + continue + } + owner, repo := dep.OwnerRepo() + if owner == "" { + continue + } - if opts.Tagger != nil { - for i := range deps { - dep := &deps[i] - // Transitive deps come from a composite's action.yml; their ref - // is the composite author's choice and never appears in our - // workflow YAML. Narrowing it is pure churn and can invent refs - // the composite never declared, so leave it verbatim. - if !directTracker.IsDirect(i) { - continue - } - owner, repo := dep.OwnerRepo() - if owner == "" { - continue - } - - // Bare-SHA refs: find a tag pointing at the same commit. - if parserlock.IsFullSha(dep.Ref) { - patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) - if err != nil { - continue - } - if patchTag == "" { - patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA) - if err != nil || patchTag == "" { - continue - } - } - oldUses := dep.NWO + "@" + dep.Ref - newUses := dep.NWO + "@" + patchTag - rewrites[oldUses] = newUses - dep.Ref = patchTag - narrowedNWOs[strings.ToLower(dep.NWO)] = true - continue - } - - // Narrow to a full semver patch tag when possible. Covers - // partial semver (v4, v3.1) and non-semver refs (main, master). - // Skip if --no-narrow or if the lockfile already recorded this - // dep without a full semver ref (respect prior precision choice). - nwoLower := strings.ToLower(dep.NWO) - if opts.NoNarrow || opts.prevImpreciseNWO[nwoLower] { - continue - } - sv, ok := parserlock.ParseSemVer(dep.Ref) - if ok && sv.IsFull() { - continue - } - + // Bare-SHA refs: find a tag pointing at the same commit. + if parserlock.IsFullSha(dep.Ref) { patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) if err != nil { continue } - // No exact tag match — if the repo publishes semver releases, - // walk back to the latest tag that's an ancestor of this SHA. if patchTag == "" { patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA) if err != nil || patchTag == "" { @@ -336,11 +395,51 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption newUses := dep.NWO + "@" + patchTag rewrites[oldUses] = newUses dep.Ref = patchTag - narrowedNWOs[nwoLower] = true + narrowedNWOs[strings.ToLower(dep.NWO)] = true + continue + } + + // Narrow to a full semver patch tag when possible. Covers + // partial semver (v4, v3.1) and non-semver refs (main, master). + // Skip if --no-narrow or if the lockfile already recorded this + // dep without a full semver ref (respect prior precision choice). + nwoLower := strings.ToLower(dep.NWO) + if opts.NoNarrow || opts.prevImpreciseNWO[nwoLower] { + continue + } + sv, ok := parserlock.ParseSemVer(dep.Ref) + if ok && sv.IsFull() { + continue + } + + patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) + if err != nil { + continue + } + // No exact tag match - if the repo publishes semver releases, + // walk back to the latest tag that's an ancestor of this SHA. + if patchTag == "" { + patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA) + if err != nil || patchTag == "" { + continue + } } + oldUses := dep.NWO + "@" + dep.Ref + newUses := dep.NWO + "@" + patchTag + rewrites[oldUses] = newUses + dep.Ref = patchTag + narrowedNWOs[nwoLower] = true } +} - // Save narrowed refs before ReverseLookup — it may overwrite dep.Ref +// reverseLookupRewrites canonicalizes each dep's ref via ReverseLookup +// (SHA -> containing tag/branch) while preserving the tags narrowing already +// chose and transitive deps' author-declared refs. It mutates deps' refs back +// to those preserved values and returns the rewrites to merge for the workflow +// YAML. If ReverseLookup surfaces an impostor commit it returns a non-nil entry +// and no error so the caller can end the workflow early. +func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, directTracker lockfile.DirectTracker, narrowedNWOs map[string]bool) (map[string]string, *Entry, error) { + // Save narrowed refs before ReverseLookup - it may overwrite dep.Ref // with a branch name, but we want to keep the semver tag narrowing chose. narrowedRefs := make(map[int]string) for i := range deps { @@ -353,7 +452,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption // Preserve transitive deps' declared refs across ReverseLookup. We still // want the tag/branch metadata it populates (the lockfile write requires // a branch), but the ref itself must stay exactly as the composite's - // action.yml declares it — we don't own it and must not rewrite it. + // action.yml declares it - we don't own it and must not rewrite it. transitiveRefs := make(map[int]string) for i := range deps { if !directTracker.IsDirect(i) { @@ -361,35 +460,34 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption } } - // ReverseLookup: SHA → containing tag/branch. Rewrites refs to canonical form. + // ReverseLookup: SHA -> containing tag/branch. Rewrites refs to canonical form. normRewrites, err := opts.Resolver.ReverseLookup(ctx, deps) if err != nil { var imp *resolve.ImpostorError if errors.As(err, &imp) { - entries = append(entries, Entry{ + return nil, &Entry{ NWO: imp.NWO, Ref: imp.Ref, Resolution: Investigate, Issue: string(checks.ImpostorCommit), Reason: imp.Error(), Workflows: []string{wr.Path}, - }) - wplans = append(wplans, WorkflowPlan{Path: wr.Path}) - return planResult{entries: entries, wplans: wplans}, nil + }, nil } - return planResult{}, fmt.Errorf("reverse lookup: %w", err) + return nil, nil, fmt.Errorf("reverse lookup: %w", err) } // Restore narrowed refs that ReverseLookup may have overwritten. for i, ref := range narrowedRefs { deps[i].Ref = ref } // Restore transitive deps' declared refs and suppress any rewrite - // ReverseLookup produced for them — keyed by the declared NWO@ref. + // ReverseLookup produced for them - keyed by the declared NWO@ref. transitiveRewriteKeys := make(map[string]bool, len(transitiveRefs)) for i, ref := range transitiveRefs { deps[i].Ref = ref transitiveRewriteKeys[deps[i].NWO+"@"+ref] = true } + rewrites := make(map[string]string) for k, v := range normRewrites { if transitiveRewriteKeys[k] { continue @@ -402,45 +500,15 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption } rewrites[k] = v } + return rewrites, nil, nil +} - // Update parent map keys to reflect narrowed/normalized refs. - parentRewrites := make(map[string]string) - for i := range deps { - dep := &deps[i] - newKey := dep.Key() - // Compare against original key before narrowing. - for oldUses, newUses := range rewrites { - if newUses == dep.NWO+"@"+dep.Ref { - // The dep was rewritten from oldUses. - parentRewrites[oldUses] = newKey - } - } - _ = dep - } - if len(parentRewrites) > 0 { - parentMap = dep.RekeyParentMap(parentMap, parentRewrites) - } - - // Record workflow plan if there are rewrites. - // Also narrow any verified (already-recorded) entries that have imprecise refs. - if verifiedRW := narrowVerifiedEntries(ctx, entries, opts); len(verifiedRW) > 0 { - for k, v := range verifiedRW { - rewrites[k] = v - } - } - if len(rewrites) > 0 { - wplans = append(wplans, WorkflowPlan{ - Path: wr.Path, - Rewrites: rewrites, - }) - } else if len(wplans) == 0 { - // No rewrites and no plan entry yet — still include the workflow - // so EnsureSentinel can be applied during commit. - wplans = append(wplans, WorkflowPlan{Path: wr.Path}) - } - +// buildPinnedEntries emits an entry for every resolved dep, marking it Verified +// when the lockfile already records the same SHA and Pinned otherwise. Deps +// already emitted from inventory (by NWO:SHA) are skipped. +func buildPinnedEntries(opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, parentMap dep.ParentMap, directTracker lockfile.DirectTracker, inventorySHA map[string]bool, autoFixed map[string]string, fullScanDeps map[string]bool) []Entry { // Load existing lockfile state so re-runs are noops for unchanged deps. - existingSHA := make(map[string]string) // NWO@Ref → SHA + existingSHA := make(map[string]string) // NWO@Ref -> SHA if opts.Store != nil { wfKey := workflowfile.KeyFromPath(wr.Path) if existing, err := opts.Store.Get(wfKey); err == nil { @@ -450,8 +518,8 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption } } - // Build entries for all pinned deps (skip any already emitted from inventory). directKeys := directTracker.Keys(deps) + var out []Entry for _, dep := range deps { nwoSHA := strings.ToLower(dep.NWO) + ":" + strings.ToLower(dep.SHA) if inventorySHA[nwoSHA] { @@ -480,10 +548,15 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption entry.Direct = true // auto-fixed deps are always direct uses entry.Resolution = Pinned // auto-fix is always a new pin } - entries = append(entries, entry) + out = append(out, entry) } + return out +} - // Record findings that are informational (ref-moved, misleading-sha). +// informationalEntries records ref-moved and misleading-sha findings as +// Investigate entries. +func informationalEntries(wr checks.WorkflowReport) []Entry { + var out []Entry for _, f := range wr.Findings { switch f.Category { case checks.MisleadingSHA, checks.RefMoved: @@ -493,7 +566,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption nwo = f.ActionRef.Owner + "/" + f.ActionRef.Repo ref = f.ActionRef.Ref } - entries = append(entries, Entry{ + out = append(out, Entry{ NWO: nwo, Ref: ref, ObservedSHA: f.ObservedSHA, @@ -504,8 +577,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption }) } } - - return planResult{entries: entries, wplans: wplans}, nil + return out } func findFinding(findings []checks.Finding, nwo, ref string) *checks.Finding { From 8ded18199d34a8d159555214ac25a44dfde519e0 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 18 Jun 2026 21:09:39 -0500 Subject: [PATCH 17/67] pipeline: split diagnoseOneParsed into precheck and sweep helpers diagnoseOneParsed was ~220 lines mixing two distinct phases: a run of mutually-exclusive terminal preconditions (load error, local-path actions, non-hosted runner, no refs, unreadable deps) that each emit one finding and bail, followed by the happy-path diagnosis (resolve, inventory, three reachability sweeps, RunChecks, finding assembly). Extract the guards into precheckWorkflow, returning (report, handled) so the orchestrator keeps its early-out visible, and lift the three reachability passes into reachabilitySweeps. The main function drops to ~80 lines and now reads as: precheck gate -> resolve -> inventory -> sweeps -> checks -> assembly. Behavior-preserving: precheckWorkflow seeds ActionRefs/ParseWarnings exactly as before; reachabilitySweeps returns the three result sets separately so their (NWO, Ref, SHA) keys stay unmixed downstream. Full race suite, 20/20 integration scenarios, and the JSON golden unchanged. --- internal/pipeline/diagnose.go | 187 +++++++++++++++++++--------------- 1 file changed, 104 insertions(+), 83 deletions(-) diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index 5a55812c..0ff51227 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -48,6 +48,95 @@ func DiagnoseParsed(ctx context.Context, parsed []checks.ParsedWorkflow, r *reso } func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve.Resolver, store *lockfile.State, pool *pinpool.Pool) checks.WorkflowReport { + wr, done := precheckWorkflow(pw, store) + if done { + return wr + } + wr.Deps = pw.ExistingDeps + + directNWOs := make(map[ghapi.Repo]bool, len(pw.Refs)) + for _, ref := range pw.Refs { + directNWOs[ghapi.ForRepo(ref.Owner, ref.Repo)] = true + } + + // Resolve live state: hits cache when ParseAll's caller pre-warmed the + // resolver. Failure degrades to structural-only checks for any refs that + // couldn't be resolved — partial results are kept. + var liveDeps []dep.Dependency + var resolvedParents dep.ParentMap + if r != nil { + var resolveErr error + liveDeps, resolvedParents, resolveErr = r.ResolveAllRecursive(ctx, pw.Refs) + if resolveErr != nil { + // Low: we're surfacing the resolver failure itself, not a + // verdict about any specific dependency. + wr.Findings = append(wr.Findings, checks.Finding{ + WorkflowPath: pw.Path, + Category: checks.ReachabilityUnknown, + Severity: checks.SeverityWarning, + Confidence: checks.ConfidenceLow, + Detail: fmt.Sprintf("could not re-resolve actions: %s", resolveErr), + }) + } + } + + for _, dep := range pw.ExistingDeps { + owner, repo := dep.OwnerRepo() + wr.Inventory = append(wr.Inventory, checks.InventoryEntry{ + Dep: dep, + File: pw.Path, + Direct: directNWOs[ghapi.ForRepo(owner, repo)], + }) + } + parentMap := map[string][]string{} + if r != nil { + parentMap = resolvedParents + populateInventoryParents(wr.Inventory, parentMap) + } + + reach, liveMovedReach, liveDirectReach := reachabilitySweeps(ctx, pw, r, liveDeps) + var checkR checks.CheckResolver + if r != nil && liveDeps != nil { + checkR = checks.NewPrewarmedResolver(r, liveDeps, reach, liveMovedReach, liveDirectReach) + } + rawFindings := checks.RunChecks(ctx, pw, store.File(), checkR) + + depByKey := indexDeps(pw.ExistingDeps) + for _, f := range rawFindings { + if f.Category == checks.Stale && isTransitivePin(f, depByKey, parentMap) { + continue + } + attachParent(&f, depByKey, directNWOs, parentMap) + f.DocURL = DocURLFor(f.Category) + wr.Findings = append(wr.Findings, f) + } + + if len(reach) > 0 { + wr.Findings = append(wr.Findings, reachabilityComplementFindings(pw.Path, reach, pw.ExistingDeps, directNWOs, parentMap, wr.Findings)...) + } + if len(liveDirectReach) > 0 { + wr.Findings = append(wr.Findings, liveReachImpostorFindings(pw.Path, liveDirectReach, liveDeps, directNWOs, parentMap, wr.Findings)...) + } + + if !hasIssues(wr.Findings) { + wr.Findings = append(wr.Findings, checks.Finding{ + WorkflowPath: pw.Path, + Category: checks.Valid, + Severity: checks.SeverityOK, + Confidence: checks.ConfidenceHigh, + Detail: "all dependencies pinned and verified", + }) + } + + return wr +} + +// precheckWorkflow handles the terminal preconditions that stop a workflow +// from being diagnosed normally: a load error, local-path actions, a +// non-hosted runner, no action refs, or an unreadable dependencies block. It +// returns the report plus true when one fired; otherwise it returns a report +// seeded with ActionRefs/ParseWarnings and false. +func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.WorkflowReport, bool) { wr := checks.WorkflowReport{Path: pw.Path} if pw.LoadErr != nil { @@ -60,7 +149,7 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve Detail: fmt.Sprintf("failed to load workflow: %s", pw.LoadErr), DocURL: DocURLFor(checks.NotPinned), }) - return wr + return wr, true } wr.ActionRefs = pw.Refs @@ -86,7 +175,7 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve Detail: "workflow uses local path actions; lockfile onboarding is not supported", }) } - return wr + return wr, true } if pw.NonHostedRunner { @@ -109,7 +198,7 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve Confidence: checks.ConfidenceHigh, Detail: fmt.Sprintf("runs-on uses expressions [%s] that can't be resolved statically", strings.Join(exprLabels, ", ")), }) - return wr + return wr, true } // Otherwise report as self-hosted (include only literal labels in detail). @@ -133,7 +222,7 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve Detail: fmt.Sprintf("uses non-hosted runner labels [%s]; lockfile onboarding is not supported", labelList), }) } - return wr + return wr, true } if len(pw.Refs) == 0 { @@ -144,7 +233,7 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve Confidence: checks.ConfidenceHigh, Detail: "no action references found", }) - return wr + return wr, true } if pw.DepsErr != nil { @@ -157,51 +246,18 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve Remediation: "fix or regenerate the dependencies: section with `gh actions-lock`", DocURL: DocURLFor(checks.NotPinned), }) - return wr - } - wr.Deps = pw.ExistingDeps - - directNWOs := make(map[ghapi.Repo]bool, len(pw.Refs)) - for _, ref := range pw.Refs { - directNWOs[ghapi.ForRepo(ref.Owner, ref.Repo)] = true - } - - // Resolve live state: hits cache when ParseAll's caller pre-warmed the - // resolver. Failure degrades to structural-only checks for any refs that - // couldn't be resolved — partial results are kept. - var liveDeps []dep.Dependency - var resolvedParents dep.ParentMap - if r != nil { - var resolveErr error - liveDeps, resolvedParents, resolveErr = r.ResolveAllRecursive(ctx, pw.Refs) - if resolveErr != nil { - // Low: we're surfacing the resolver failure itself, not a - // verdict about any specific dependency. - wr.Findings = append(wr.Findings, checks.Finding{ - WorkflowPath: pw.Path, - Category: checks.ReachabilityUnknown, - Severity: checks.SeverityWarning, - Confidence: checks.ConfidenceLow, - Detail: fmt.Sprintf("could not re-resolve actions: %s", resolveErr), - }) - } + return wr, true } - for _, dep := range pw.ExistingDeps { - owner, repo := dep.OwnerRepo() - wr.Inventory = append(wr.Inventory, checks.InventoryEntry{ - Dep: dep, - File: pw.Path, - Direct: directNWOs[ghapi.ForRepo(owner, repo)], - }) - } - parentMap := map[string][]string{} - if r != nil { - parentMap = resolvedParents - populateInventoryParents(wr.Inventory, parentMap) - } + return wr, false +} - var reach []resolve.ReachabilityResult +// reachabilitySweeps runs the three independent reachability passes over a +// workflow's deps: the locked-SHA sweep (split into trusted vs needs-check), +// the tag-moved live-SHA sweep, and the pin-time parity sweep for live SHAs +// the other two miss. Each pass returns its own result set so their +// (NWO, Ref, SHA) keys stay unmixed downstream. +func reachabilitySweeps(ctx context.Context, pw checks.ParsedWorkflow, r *resolve.Resolver, liveDeps []dep.Dependency) (reach, liveMovedReach, liveDirectReach []resolve.ReachabilityResult) { if r != nil && len(pw.ExistingDeps) > 0 { toCheck, trusted := partitionReachByLive(pw.ExistingDeps, liveDeps, pw.SkipReachWhenUnchanged) reach = trusted @@ -216,7 +272,6 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve // (NWO, Ref, SHA) keys don't shadow the lockfile sweep — they // share NWO@Ref dep keys, which would confuse // reachabilityComplementFindings if mixed into `reach`. - var liveMovedReach []resolve.ReachabilityResult if r != nil && len(liveDeps) > 0 && len(pw.ExistingDeps) > 0 { if moved := liveMovedDeps(pw.ExistingDeps, liveDeps); len(moved) > 0 { liveMovedReach = r.CheckReachabilityAll(ctx, moved) @@ -228,46 +283,12 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve // transitive composite live dep that isn't in the lockfile yet. With // this in place, applyPin's reach-loop Unreachable branch becomes a // fail-loud invariant rather than a primary detection path. - var liveDirectReach []resolve.ReachabilityResult if r != nil && len(liveDeps) > 0 { if extra := liveDirectReachDeps(pw, liveDeps); len(extra) > 0 { liveDirectReach = r.CheckReachabilityAll(ctx, extra) } } - var checkR checks.CheckResolver - if r != nil && liveDeps != nil { - checkR = checks.NewPrewarmedResolver(r, liveDeps, reach, liveMovedReach, liveDirectReach) - } - rawFindings := checks.RunChecks(ctx, pw, store.File(), checkR) - - depByKey := indexDeps(pw.ExistingDeps) - for _, f := range rawFindings { - if f.Category == checks.Stale && isTransitivePin(f, depByKey, parentMap) { - continue - } - attachParent(&f, depByKey, directNWOs, parentMap) - f.DocURL = DocURLFor(f.Category) - wr.Findings = append(wr.Findings, f) - } - - if len(reach) > 0 { - wr.Findings = append(wr.Findings, reachabilityComplementFindings(pw.Path, reach, pw.ExistingDeps, directNWOs, parentMap, wr.Findings)...) - } - if len(liveDirectReach) > 0 { - wr.Findings = append(wr.Findings, liveReachImpostorFindings(pw.Path, liveDirectReach, liveDeps, directNWOs, parentMap, wr.Findings)...) - } - - if !hasIssues(wr.Findings) { - wr.Findings = append(wr.Findings, checks.Finding{ - WorkflowPath: pw.Path, - Category: checks.Valid, - Severity: checks.SeverityOK, - Confidence: checks.ConfidenceHigh, - Detail: "all dependencies pinned and verified", - }) - } - - return wr + return reach, liveMovedReach, liveDirectReach } func indexDeps(deps []dep.Dependency) map[string]dep.Dependency { From 253244dfeedb2e7ce43e4b2251060f081b459029 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 19 Jun 2026 09:28:25 -0500 Subject: [PATCH 18/67] Trim verbose --help text and overlong doc comments Two review comments asked to drop the Issue types and Exit status blocks from the root command's --help; they restated information the per-finding output and documented exit codes already convey. Removing them keeps the help focused on usage and the --json example. Also trims internal doc comments in plan.go and diagnose.go to three lines or less. Each was fact-checked against its function body first; the shorter versions keep the non-obvious "why" and drop the restated mechanics. --- cmd/gh-actions-lock/root.go | 19 ------------------- internal/pin/plan.go | 25 +++++++++---------------- internal/pipeline/diagnose.go | 23 +++++++++-------------- 3 files changed, 18 insertions(+), 49 deletions(-) diff --git a/cmd/gh-actions-lock/root.go b/cmd/gh-actions-lock/root.go index 62885f39..f21347e4 100644 --- a/cmd/gh-actions-lock/root.go +++ b/cmd/gh-actions-lock/root.go @@ -87,25 +87,6 @@ lockfile. Pass --no-fix for a read-only check that writes nothing. structured results go to stdout and progress to stderr: gh actions-lock --no-fix --json 2>/dev/null | jq .valid - -Issue types: - ref-moved - locked SHA no longer matches upstream (expected for mutable tags like v4) - not-pinned - action in workflow has no lock entry - stale - lock entry references an action no longer in the workflow - ref-changed - workflow ref was edited; lock needs updating - misleading-sha - ref looks like a SHA but resolves to a different commit - impostor-commit - locked SHA is not reachable from any branch in the upstream repo - lockfile-forgery - pinned SHA is not an ancestor of the upstream ref it claims - -Exit status: - 0 read-only run that found everything valid, or a fix run where - every finding was resolved automatically. - 1 blocking findings remain — under --no-fix, any invalid finding; - otherwise, findings that can't be auto-fixed (impostor commit - or lockfile forgery) and need manual review. Output is - well-formed when --json is set. - 2 the tool itself failed (bad flag, IO error, network failure, - malformed lockfile, etc.). `), Example: heredoc.Doc(` # Verify all workflows and fix what's fixable diff --git a/internal/pin/plan.go b/internal/pin/plan.go index bf6f8cee..583c6c79 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -274,11 +274,9 @@ func unresolvedEntries(wr checks.WorkflowReport, unrecordedRefs []parserlock.Act return out } -// reachabilityGate classifies resolved deps by reachability. Unreachable deps -// are auto-repinned in place to a recommended release when one exists (keeping -// them in the pipeline) or recorded for investigation; reachability-unknown -// deps are skipped. It returns the entries to emit, the dep keys to drop, the -// auto-fixed newKey->originalRef map, and the old->new YAML rewrites. +// reachabilityGate classifies resolved deps: unreachable deps are auto-repinned +// to a recommended release when one exists, else dropped for investigation; +// reachability-unknown deps are dropped. badKeys lists the dropped dep keys. func reachabilityGate(ctx context.Context, wr checks.WorkflowReport, opts PlanOptions, deps []dep.Dependency, reachResults []resolve.ReachabilityResult) (entries []Entry, badKeys map[string]bool, autoFixed, autoFixRewrites map[string]string) { badKeys = make(map[string]bool) autoFixed = make(map[string]string) // new dep key -> original ref @@ -356,11 +354,9 @@ func collectFullScanDeps(reachResults []resolve.ReachabilityResult, badKeys map[ return fullScanDeps } -// narrowDirectDeps rewrites direct deps' mutable refs to precise tags: a bare -// SHA to a tag at the same commit, a partial or non-semver ref to a full patch -// tag. Transitive deps are left verbatim - their ref belongs to the composite -// that declares it. Each rewrite mutates deps[i].Ref in place, records the -// old->new uses in rewrites, and notes the narrowed NWO in narrowedNWOs. +// narrowDirectDeps rewrites direct deps' mutable refs to precise tags (bare SHA +// or partial/non-semver ref -> full patch tag), leaving transitive deps verbatim. +// Each rewrite mutates deps[i].Ref and records the old->new uses and narrowed NWO. func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependency, directTracker lockfile.DirectTracker, rewrites map[string]string, narrowedNWOs map[string]bool) { if opts.Tagger == nil { return @@ -432,12 +428,9 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen } } -// reverseLookupRewrites canonicalizes each dep's ref via ReverseLookup -// (SHA -> containing tag/branch) while preserving the tags narrowing already -// chose and transitive deps' author-declared refs. It mutates deps' refs back -// to those preserved values and returns the rewrites to merge for the workflow -// YAML. If ReverseLookup surfaces an impostor commit it returns a non-nil entry -// and no error so the caller can end the workflow early. +// reverseLookupRewrites canonicalizes dep refs via ReverseLookup (SHA -> tag/ +// branch), restoring refs that narrowing or a transitive dep already fixed. A +// non-nil *Entry signals an impostor commit (err stays nil) so the caller bails. func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, directTracker lockfile.DirectTracker, narrowedNWOs map[string]bool) (map[string]string, *Entry, error) { // Save narrowed refs before ReverseLookup - it may overwrite dep.Ref // with a branch name, but we want to keep the semver tag narrowing chose. diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index 0ff51227..8695444e 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -16,10 +16,9 @@ import ( "github.com/github/gh-actions-lock/internal/workflowfile" ) -// DiagnoseParsed runs the engine diagnostics for each pre-parsed workflow. -// Assumes the resolver caches have already been warmed (calls into the -// resolver will hit cache and stay silent). Returns a checks.Report aggregating per- -// workflow findings in input order. +// DiagnoseParsed runs engine diagnostics for each pre-parsed workflow, assuming +// the resolver caches are warm (calls hit cache and stay silent). Returns a +// checks.Report aggregating per-workflow findings in input order. func DiagnoseParsed(ctx context.Context, parsed []checks.ParsedWorkflow, r *resolve.Resolver, store *lockfile.State, pool *pinpool.Pool) *checks.Report { type indexedPW struct { idx int @@ -131,11 +130,9 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve return wr } -// precheckWorkflow handles the terminal preconditions that stop a workflow -// from being diagnosed normally: a load error, local-path actions, a -// non-hosted runner, no action refs, or an unreadable dependencies block. It -// returns the report plus true when one fired; otherwise it returns a report -// seeded with ActionRefs/ParseWarnings and false. +// precheckWorkflow handles terminal preconditions (load error, local-path +// actions, non-hosted runner, no refs, unreadable deps). It returns true when +// one fired; otherwise the report is seeded with ActionRefs/ParseWarnings. func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.WorkflowReport, bool) { wr := checks.WorkflowReport{Path: pw.Path} @@ -252,11 +249,9 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W return wr, false } -// reachabilitySweeps runs the three independent reachability passes over a -// workflow's deps: the locked-SHA sweep (split into trusted vs needs-check), -// the tag-moved live-SHA sweep, and the pin-time parity sweep for live SHAs -// the other two miss. Each pass returns its own result set so their -// (NWO, Ref, SHA) keys stay unmixed downstream. +// reachabilitySweeps runs three independent reachability passes: the locked-SHA +// sweep, the tag-moved live-SHA sweep, and the pin-time parity sweep. Each pass +// returns its own set so their (NWO, Ref, SHA) keys stay unmixed downstream. func reachabilitySweeps(ctx context.Context, pw checks.ParsedWorkflow, r *resolve.Resolver, liveDeps []dep.Dependency) (reach, liveMovedReach, liveDirectReach []resolve.ReachabilityResult) { if r != nil && len(pw.ExistingDeps) > 0 { toCheck, trusted := partitionReachByLive(pw.ExistingDeps, liveDeps, pw.SkipReachWhenUnchanged) From b8d62ce9eb607a691a560e83491b35323d31202d Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 07:57:49 -0500 Subject: [PATCH 19/67] integration: add output_contains assertions to error scenarios Scenarios that only checked exit code now also verify the error message contains the expected keywords: - api_rate_limit_429: 'could not be resolved', '429' - api_server_error_500: 'could not be resolved', '500' - onboarded_future_version: 'upgrade' - no_workflows: 'no workflow' - onboarded_no_fix_corrupt: 'corrupt' - onboarded_no_interactive_corrupt: 'corrupt' - dbot_corrupt_lockfile_ci: 'corrupt' --- test/scenarios/catalog.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 9a57afa0..dd6bc4df 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -237,6 +237,9 @@ scenarios: actions: ["actions/checkout@v4"] expect: exit: 1 + output_contains: + - "could not be resolved" + - "429" - name: api_server_error_500 category: api_errors @@ -250,6 +253,9 @@ scenarios: actions: ["actions/checkout@v4"] expect: exit: 1 + output_contains: + - "could not be resolved" + - "500" - name: api_unauthorized_401 category: api_errors @@ -312,6 +318,8 @@ scenarios: lockfile_template: future_version expect: exit: 2 + output_contains: + - "upgrade" # ╔═════════════════════════════════════════════════════════════════════════╗ # ║═══════════════════════════ workflow_parsing ════════════════════════════║ @@ -325,6 +333,8 @@ scenarios: workflows: {} expect: exit: 2 + output_contains: + - "no workflow" - name: no_workflows_summary category: workflow_parsing @@ -1089,6 +1099,8 @@ scenarios: lockfile: "{{{{invalid yaml content not parseable at all}}}}" expect: exit: 2 + output_contains: + - "corrupt" - name: onboarded_no_interactive_corrupt category: lockfile @@ -1103,6 +1115,8 @@ scenarios: lockfile: "{{{{invalid yaml content not parseable at all}}}}" expect: exit: 2 + output_contains: + - "corrupt" # ╔═════════════════════════════════════════════════════════════════════════╗ # ║══════════════════════════════ dependabot ═══════════════════════════════║ @@ -1272,6 +1286,8 @@ scenarios: lockfile: "{{{{invalid yaml content not parseable at all}}}}" expect: exit: 2 + output_contains: + - "corrupt" - name: dbot_multi_workflow category: dependabot From cf573bda3683505c89a3a3b365168b67f934b5e7 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 08:21:27 -0500 Subject: [PATCH 20/67] integration: add lockfile_deps_cover_direct to all lockfile-producing scenarios 29 additional scenarios now assert that every action ref listed in the lockfile's workflows: section exists as a key in dependencies:. This catches structural regressions where the lockfile is written but deps are incomplete. Also fixes lockfile_contains assertions from the cherry-picked sticky precision commit to use v0.0.2 key format ('owner/repo@ref': instead of owner/repo@ref:sha1-hex), and fixes output_contains for corrupt lockfile scenarios ('unreadable' not 'corrupt'). Excludes 6 scenarios that intentionally skip lockfile creation (run-only, local-action-only, non-hosted-runner workflows). --- test/scenarios/catalog.yml | 41 ++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index dd6bc4df..a293a4cf 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -63,6 +63,7 @@ scenarios: actions: ["actions/checkout@v4"] expect: exit: 0 + lockfile_deps_cover_direct: true - name: already_pinned_all_valid category: happy_path @@ -77,7 +78,8 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 - lockfile_contains: ["actions/checkout@v4:sha1-"] + lockfile_deps_cover_direct: true + lockfile_contains: ["'actions/checkout@v4':"] lockfile_comment_excludes: 'actions/checkout@v4\.\d' - name: multi_action_happy @@ -94,6 +96,7 @@ scenarios: - "actions/cache@v4" expect: exit: 0 + lockfile_deps_cover_direct: true - name: composite_action_transitive category: happy_path @@ -305,6 +308,7 @@ scenarios: response: "y" expect: exit: 0 + lockfile_deps_cover_direct: true - name: onboarded_future_version category: lockfile @@ -382,6 +386,7 @@ scenarios: - uses: actions/checkout@v4 expect: exit: 0 + lockfile_deps_cover_direct: true - name: local_action_skipped category: workflow_parsing @@ -436,6 +441,7 @@ scenarios: - "actions/cache/save@v4" expect: exit: 0 + lockfile_deps_cover_direct: true - name: duplicate_action_refs category: workflow_parsing @@ -456,6 +462,7 @@ scenarios: - uses: actions/checkout@v4 expect: exit: 0 + lockfile_deps_cover_direct: true # ╔═════════════════════════════════════════════════════════════════════════╗ # ║═════════════════════════════ output_modes ══════════════════════════════║ @@ -516,6 +523,7 @@ scenarios: actions: ["actions/checkout@v4"] expect: exit: 0 + lockfile_deps_cover_direct: true - name: rescan_inconclusive_fails category: output_modes @@ -566,6 +574,7 @@ scenarios: actions: ["actions/checkout@v4", "actions/setup-node@v4"] expect: exit: 0 + lockfile_deps_cover_direct: true - name: many_workflows category: multi_workflow @@ -590,6 +599,7 @@ scenarios: actions: ["actions/checkout@v4", "actions/create-release@v1"] expect: exit: 0 + lockfile_deps_cover_direct: true - name: transitive_closure_cross_repo category: multi_workflow @@ -668,6 +678,7 @@ scenarios: actions: ["actions/checkout@v4"] expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_matches: 'v4\.\d+\.\d+' - name: fresh_full_tag_unchanged @@ -681,6 +692,7 @@ scenarios: actions: ["actions/checkout@v4.2.0"] expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_matches: 'v4\.2\.0' - name: fresh_branch_ref_narrows @@ -694,6 +706,7 @@ scenarios: actions: ["actions/checkout@main"] expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_matches: 'v\d+\.\d+\.\d+' - name: fresh_branch_ref_no_narrow @@ -708,6 +721,7 @@ scenarios: actions: ["actions/checkout@main"] expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_matches: 'main' - name: onboarded_branch_ref_narrows @@ -722,6 +736,7 @@ scenarios: lockfile_template: pinned_checkout_main expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_matches: 'v\d+\.\d+\.\d+' - name: local_action_only @@ -831,6 +846,7 @@ scenarios: actions: ["actions/checkout@v4"] expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_excludes: 'v4\.\d+\.\d+' - name: fresh_no_narrow_full_tag @@ -845,6 +861,7 @@ scenarios: actions: ["actions/checkout@v4.2.0"] expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_matches: 'v4\.2\.0' - name: onboarded_sticky_imprecise @@ -859,6 +876,7 @@ scenarios: lockfile_template: pinned_checkout_v4_imprecise expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_excludes: 'v4\.\d+\.\d+' - name: onboarded_full_tag_unchanged @@ -873,6 +891,7 @@ scenarios: lockfile_template: pinned_checkout_full expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_matches: 'v4\.\d+\.\d+' - name: onboarded_no_narrow_keeps_major @@ -888,6 +907,7 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_comment_excludes: 'v4\.\d+\.\d+' output_excludes: ["pinned without a full semver tag"] @@ -903,6 +923,7 @@ scenarios: actions: ["actions/checkout@v4"] expect: exit: 0 + lockfile_deps_cover_direct: true output_excludes: ["Consider using full semver"] - name: fresh_no_fix_no_narrow_json_version_ref @@ -955,6 +976,7 @@ scenarios: actions: ["cli/gh-extension-precompile@v2.1.0"] expect: exit: 0 + lockfile_deps_cover_direct: true output_excludes: ["pinned without a full semver tag"] - name: transitive_provenance_ref_not_narrowed @@ -968,6 +990,7 @@ scenarios: actions: ["cli/gh-extension-precompile@v2.1.0"] expect: exit: 0 + lockfile_deps_cover_direct: true lockfile_contains: - "'actions/attest-build-provenance@v1':" - "ref: 'v1'" @@ -1004,7 +1027,8 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 - lockfile_contains: ["actions/checkout@v4:sha1-"] + lockfile_deps_cover_direct: true + lockfile_contains: ["'actions/checkout@v4':"] lockfile_comment_excludes: 'actions/checkout@v4\.\d' - name: onboarded_no_onboard_mixed @@ -1023,7 +1047,8 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 - lockfile_contains: ["actions/checkout@v4:sha1-"] + lockfile_deps_cover_direct: true + lockfile_contains: ["'actions/checkout@v4':"] lockfile_comment_excludes: 'actions/checkout@v4\.\d' - name: fresh_no_fix_no_onboard_json_findings @@ -1068,6 +1093,7 @@ scenarios: lockfile_template: pinned_checkout_v4_imprecise expect: exit: 0 + lockfile_deps_cover_direct: true - name: fresh_no_interactive_no_onboard_ci category: onboarding @@ -1100,7 +1126,7 @@ scenarios: expect: exit: 2 output_contains: - - "corrupt" + - "unreadable" - name: onboarded_no_interactive_corrupt category: lockfile @@ -1116,7 +1142,7 @@ scenarios: expect: exit: 2 output_contains: - - "corrupt" + - "unreadable" # ╔═════════════════════════════════════════════════════════════════════════╗ # ║══════════════════════════════ dependabot ═══════════════════════════════║ @@ -1142,6 +1168,7 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 + lockfile_deps_cover_direct: true stdout_is_json: true jq: - expr: ".valid" @@ -1287,7 +1314,7 @@ scenarios: expect: exit: 2 output_contains: - - "corrupt" + - "unreadable" - name: dbot_multi_workflow category: dependabot @@ -1375,6 +1402,7 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 + lockfile_deps_cover_direct: true stdout_is_json: true jq: - expr: '.findings[] | select(.category == "version-ref") | .category' @@ -1399,6 +1427,7 @@ scenarios: lockfile_template: pinned_checkout expect: exit: 0 + lockfile_deps_cover_direct: true stdout_is_json: true jq: - expr: '.valid' From 6b3b1b5088c5a13d75720322ad5f6dc0550f5048 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 09:47:31 -0500 Subject: [PATCH 21/67] lockfile: bump parser to 66dfeda (multi-version parsing) Parser now accepts both v0.0.1 and v0.0.2 lockfiles, normalizing to the latest File struct. Adds ParseWithPolicy for server-side version gating and SchemaForVersion for embedded JSON schema lookup. CLI continues writing v0.0.2 format. --- go.mod | 2 +- go.sum | 12 ++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 59f6cc1f..b0c475c9 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.4-0.20260623005021-eff8f62231d4 +require github.com/github/actions-lockfile/go v0.0.4-0.20260623135211-66dfeda9f4d3 require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index 8c5262f1..07a162ea 100644 --- a/go.sum +++ b/go.sum @@ -33,16 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/github/actions-lockfile/go v0.0.3 h1:pvvbEnsmKtBiiUJ2I5n35W3Uei3EvmnZe1PKJ3q4YrY= -github.com/github/actions-lockfile/go v0.0.3/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= -github.com/github/actions-lockfile/go v0.0.4-0.20260622225325-1313b0aabde9 h1:kumVX2Ngu40DNtmRb670KkxEn27WF5qNqyIRI0ezt24= -github.com/github/actions-lockfile/go v0.0.4-0.20260622225325-1313b0aabde9/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= -github.com/github/actions-lockfile/go v0.0.4-0.20260622233836-15801014a086 h1:WEJR8Y8yJ65p0lAEQwyFSDdA3lqZTPNPQ5Y8HMNXe6E= -github.com/github/actions-lockfile/go v0.0.4-0.20260622233836-15801014a086/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= -github.com/github/actions-lockfile/go v0.0.4-0.20260622233938-e9a0b36d29c8 h1:sScZ+WDdC6IucVpiWjcpYosvUgTCq2Hmgq5rWCD9ePc= -github.com/github/actions-lockfile/go v0.0.4-0.20260622233938-e9a0b36d29c8/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= -github.com/github/actions-lockfile/go v0.0.4-0.20260623005021-eff8f62231d4 h1:lN99X1026osmkE+nYZWDkHAII4Ofm7fAj3lCbk6ai7o= -github.com/github/actions-lockfile/go v0.0.4-0.20260623005021-eff8f62231d4/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623135211-66dfeda9f4d3 h1:r3azcDdw3X5GUnpTdZHzAy28/rMAL+hESYic0+/Y+X4= +github.com/github/actions-lockfile/go v0.0.4-0.20260623135211-66dfeda9f4d3/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= From 135d9ba3e1991f61895ce80b899fdf43132c3385 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 10:21:28 -0500 Subject: [PATCH 22/67] lockfile: bump parser to 1ee4c25 (cleanup) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b0c475c9..7053fb4c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.4-0.20260623135211-66dfeda9f4d3 +require github.com/github/actions-lockfile/go v0.0.4-0.20260623152044-1ee4c251236e require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index 07a162ea..d1edcd20 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/github/actions-lockfile/go v0.0.4-0.20260623135211-66dfeda9f4d3 h1:r3azcDdw3X5GUnpTdZHzAy28/rMAL+hESYic0+/Y+X4= -github.com/github/actions-lockfile/go v0.0.4-0.20260623135211-66dfeda9f4d3/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623152044-1ee4c251236e h1:SuM7ss7lrITSjsgZaYVOw0nsFx/wB6a7tSsEWHE+aMs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623152044-1ee4c251236e/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= From 4f6181a9b1f3b723191d6bf9ea334e0006b61449 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 10:57:51 -0500 Subject: [PATCH 23/67] drop impostor-commit reachability check entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the impostor-commit detection infrastructure (branch containment checks via REST API). We already validate that actions resolve to a ref in refs/heads or refs/tags — we don't need to assert branch presence as a hard requirement. Key changes: - Delete reachability.go, errors.go, impostor.go and their tests - Delete reach_findings.go, reach_partition.go - Remove ImpostorCommit category constant - Remove CheckReachability from CheckResolver interface - Remove reachability pre-warm phase from pipeline - Remove reachabilityGate from pin/plan.go - Remove WithCheckReachabilityFunc resolver option - Remove all reachability cache infrastructure from resolver - Add orphaned-commit check in ReverseLookup: if DiscoverContaining finds neither tag nor branch for a commit, error out (blocking) - Retain Investigate entries in retainUnresolvablePins (alongside Unresolved) - Clean up stale impostor comments throughout --- cmd/gh-actions-lock/check_json_golden_test.go | 2 +- cmd/gh-actions-lock/command_test.go | 220 +-------- cmd/gh-actions-lock/format/terminal.go | 11 +- cmd/gh-actions-lock/format/terminal_test.go | 28 +- cmd/gh-actions-lock/pin_summary.go | 39 +- cmd/gh-actions-lock/pin_summary_test.go | 44 +- cmd/gh-actions-lock/run.go | 5 +- internal/ghapi/cachekey.go | 23 - internal/ghapi/cachekey_test.go | 11 - internal/pin/commit.go | 7 +- internal/pin/plan.go | 181 +------ internal/pin/plan_test.go | 136 +----- internal/pin/retain_impostor_test.go | 3 +- internal/pipeline/checks/category.go | 4 - internal/pipeline/checks/category_test.go | 3 +- internal/pipeline/checks/finding.go | 2 +- internal/pipeline/checks/impostor.go | 139 ------ internal/pipeline/checks/impostor_test.go | 156 ------ internal/pipeline/checks/misleading.go | 67 --- internal/pipeline/checks/parsed.go | 13 +- internal/pipeline/checks/resolver.go | 37 +- internal/pipeline/checks/run.go | 20 +- internal/pipeline/checks/run_test.go | 233 +-------- internal/pipeline/diagnose.go | 47 +- internal/pipeline/diagnose_helpers_test.go | 2 +- internal/pipeline/doc_urls.go | 10 +- internal/pipeline/impostor_parity_test.go | 298 ------------ internal/pipeline/parse.go | 5 +- internal/pipeline/reach_findings.go | 171 ------- internal/pipeline/reach_findings_test.go | 129 ----- internal/pipeline/reach_partition.go | 270 ---------- internal/pipeline/resolver_test.go | 102 ---- internal/pipeline/run.go | 77 +-- internal/resolve/ancestry.go | 18 - internal/resolve/cacheentry.go | 8 - internal/resolve/cacheentry_test.go | 10 - internal/resolve/discover_test.go | 18 +- internal/resolve/errors.go | 20 - internal/resolve/errors_test.go | 29 -- internal/resolve/reachability.go | 460 ------------------ internal/resolve/reachability_test.go | 301 ------------ internal/resolve/resolver.go | 49 +- internal/resolve/resolver_test.go | 8 - internal/resolve/reverse_lookup.go | 20 +- 44 files changed, 119 insertions(+), 3317 deletions(-) delete mode 100644 internal/pipeline/checks/impostor.go delete mode 100644 internal/pipeline/checks/impostor_test.go delete mode 100644 internal/pipeline/impostor_parity_test.go delete mode 100644 internal/pipeline/reach_findings.go delete mode 100644 internal/pipeline/reach_findings_test.go delete mode 100644 internal/pipeline/reach_partition.go delete mode 100644 internal/pipeline/resolver_test.go delete mode 100644 internal/resolve/errors.go delete mode 100644 internal/resolve/errors_test.go delete mode 100644 internal/resolve/reachability.go delete mode 100644 internal/resolve/reachability_test.go diff --git a/cmd/gh-actions-lock/check_json_golden_test.go b/cmd/gh-actions-lock/check_json_golden_test.go index 7d5ac602..996827b3 100644 --- a/cmd/gh-actions-lock/check_json_golden_test.go +++ b/cmd/gh-actions-lock/check_json_golden_test.go @@ -91,7 +91,7 @@ func TestCheckCommand_JSONGolden(t *testing.T) { copyFixtureTree(t, srcDir, dir) t.Chdir(dir) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, // The fixture's lockfile addresses the workflow as // .github/workflows/ci.yml, so we run check on that exact path. "--rescan", "--no-fix", "--json=valid,findings,workflows,dependencies", diff --git a/cmd/gh-actions-lock/command_test.go b/cmd/gh-actions-lock/command_test.go index 8c051f60..2ed1d957 100644 --- a/cmd/gh-actions-lock/command_test.go +++ b/cmd/gh-actions-lock/command_test.go @@ -1,7 +1,6 @@ package main import ( - "context" "encoding/json" "io" "net/http" @@ -54,7 +53,7 @@ jobs: "actions/setup-go@v6=sha1-4a3601121dd01d1626a1e23e37211e3254c1c06c", ) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err) @@ -70,27 +69,6 @@ jobs: const nodeActionYAML = "name: Test Action\nruns:\n using: node20\n" -// reachableFunc returns a checkReachFn that reports all commits as reachable. -func reachableFunc() func(context.Context, string, string, string, string) (resolve.ReachabilityStatus, string) { - return func(_ context.Context, owner, repo, sha, ref string) (resolve.ReachabilityStatus, string) { - return resolve.Reachable, "ancestor of " + ref - } -} - -// unreachableFunc returns a checkReachFn that reports all commits as unreachable. -func unreachableFunc() func(context.Context, string, string, string, string) (resolve.ReachabilityStatus, string) { - return func(_ context.Context, owner, repo, sha, ref string) (resolve.ReachabilityStatus, string) { - return resolve.Unreachable, "commit is not an ancestor of " + ref - } -} - -// unknownReachFunc returns a checkReachFn that reports unknown (clone failure). -func unknownReachFunc() func(context.Context, string, string, string, string) (resolve.ReachabilityStatus, string) { - return func(_ context.Context, owner, repo, sha, ref string) (resolve.ReachabilityStatus, string) { - return resolve.ReachabilityUnknown, "clone failed" - } -} - func testRepoResponse(nameWithOwner, oid, actionYAML string) map[string]any { return map[string]any{ "nameWithOwner": nameWithOwner, @@ -188,10 +166,6 @@ func readTempLockfilePins(t *testing.T) string { } func runCommandWithHTTP(t *testing.T, rt http.RoundTripper, args ...string) (string, string, error) { - return runCommandWithHTTPAndReach(t, rt, nil, args...) -} - -func runCommandWithHTTPAndReach(t *testing.T, rt http.RoundTripper, reachFn func(context.Context, string, string, string, string) (resolve.ReachabilityStatus, string), args ...string) (string, string, error) { t.Helper() stdoutR, stdoutW, err := os.Pipe() @@ -200,11 +174,7 @@ func runCommandWithHTTPAndReach(t *testing.T, rt http.RoundTripper, reachFn func require.NoError(t, err) newResolver := func(hostname string, pool *pinpool.Pool) (*resolve.Resolver, error) { - var opts []resolve.Option - if reachFn != nil { - opts = append(opts, resolve.WithCheckReachabilityFunc(reachFn)) - } - return resolve.New(hostname, pool, append(opts, resolve.WithTransport(rt))...) + return resolve.New(hostname, pool, resolve.WithTransport(rt)) } cmd := newRootCmd(newResolver) @@ -245,168 +215,6 @@ func runCommandWithHTTPAndReach(t *testing.T, rt http.RoundTripper, reachFn func // commit. The malicious commit is NOT reachable from the legitimate tag. // TestCheck_TamperedAndUnreachable verifies that when a pinned SHA differs // from live resolution AND the old SHA is unreachable, both errors are reported. -func TestCheck_TamperedAndUnreachable(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - pinnedSHA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - liveSHA := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - - reg.Register( - httpmock.GraphQLForRepo("example", "action"), - httpmock.JSONResponse(map[string]any{ - "data": map[string]any{ - "a0": testRepoResponse("example/action", liveSHA, nodeActionYAML), - }, - }), - ) - - workflowPath := writeTempWorkflow(t, ` -name: ci -on: push -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: example/action@v1 -`, - "example/action@v1=sha1-" + pinnedSHA, - ) - - stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), - "--rescan", "--no-fix", "--json=valid,findings", workflowPath, - ) - require.ErrorIs(t, err, errSilent, "JSON mode should exit non-zero when findings are invalid") - - var payload struct { - Valid bool `json:"valid"` - Findings []format.Finding `json:"findings"` - } - require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) - assert.False(t, payload.Valid) - - categories := map[string]bool{} - for _, f := range payload.Findings { - categories[f.Category] = true - } - // SHA changed but Compare API is not mocked → ancestry returns - // Unknown, so the SHA mismatch surfaces as ancestry-unknown - // rather than ref-moved or lockfile-forgery. - assert.True(t, categories["ancestry-unknown"], "should detect SHA changed (ancestry inconclusive): %+v", payload.Findings) - assert.True(t, categories["impostor-commit"], "should detect unreachable commit: %+v", payload.Findings) -} - -// TestCheck_UnreachableOnly verifies that when a pinned SHA matches live -// resolution but is not reachable from the ref, an impostor-commit error is reported. -func TestCheck_UnreachableOnly(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - sha := "cccccccccccccccccccccccccccccccccccccccc" - - reg.Register( - httpmock.GraphQLForRepo("example", "action"), - httpmock.JSONResponse(map[string]any{ - "data": map[string]any{ - "a0": testRepoResponse("example/action", sha, nodeActionYAML), - }, - }), - ) - - workflowPath := writeTempWorkflow(t, ` -name: ci -on: push -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: example/action@v1 -`, - "example/action@v1", - ) - - stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), - "--rescan", "--no-fix", "--json=valid,findings", workflowPath, - ) - require.ErrorIs(t, err, errSilent, "JSON mode should exit non-zero when findings are invalid") - - var payload struct { - Valid bool `json:"valid"` - Findings []format.Finding `json:"findings"` - } - require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) - assert.False(t, payload.Valid) - - hasUnreachable := false - for _, f := range payload.Findings { - if f.Category == "impostor-commit" { - hasUnreachable = true - } - } - assert.True(t, hasUnreachable, "should detect unreachable commit: %+v", payload.Findings) -} - -// TestCheck_ReachabilityUnknown verifies that when the reachability check -// cannot complete, validation passes with a warning. -func TestCheck_ReachabilityUnknown(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - sha := "dddddddddddddddddddddddddddddddddddddddd" - - reg.Register( - httpmock.GraphQLForRepo("example", "action"), - httpmock.JSONResponse(map[string]any{ - "data": map[string]any{ - "a0": testRepoResponse("example/action", sha, nodeActionYAML), - }, - }), - ) - - workflowPath := writeTempWorkflow(t, ` -name: ci -on: push -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: example/action@v1 -`, - "example/action@v1", - ) - - stdout, _, err := runCommandWithHTTPAndReach(t, reg, unknownReachFunc(), - "--rescan", "--no-fix", "--json=valid,findings", workflowPath, - ) - require.NoError(t, err, "unknown reachability should not fail the check") - - var payload struct { - Valid bool `json:"valid"` - Findings []format.Finding `json:"findings"` - } - require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) - assert.True(t, payload.Valid, "valid should be true when reachability is unknown") - - // Reachability unknown surfaces as CategoryReachabilityUnknown + - // SeverityWarning so consumers (Dependabot FindingMapper) don't - // see CategoryValid for a scan that didn't actually verify. - hasWarning := false - sawValid := false - for _, f := range payload.Findings { - if f.Severity == "warning" && strings.Contains(f.Detail, "clone failed") { - hasWarning = true - if f.Category != "reachability-unknown" { - t.Errorf("category = %q, want %q (must not regress to valid+warning)", f.Category, "reachability-unknown") - } - } - if f.Category == "valid" { - sawValid = true - } - } - assert.True(t, hasWarning, "should have a reachability warning: %+v", payload.Findings) - assert.False(t, sawValid, "CategoryValid must not appear for an unverified scan: %+v", payload.Findings) -} - // TestCheck_Reachable verifies the happy path: pinned SHA matches live // resolution and is reachable — validation passes with no errors or warnings. func TestCheck_Reachable(t *testing.T) { @@ -435,7 +243,7 @@ jobs: "example/action@v1=sha1-"+sha, ) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err) @@ -501,7 +309,7 @@ jobs: "example/action@v1=sha1-" + pinnedSHA, ) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.ErrorIs(t, err, errSilent, "JSON mode should exit non-zero for forgery findings") @@ -563,7 +371,7 @@ jobs: "example/action@v1=sha1-" + pinnedSHA, ) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err, "ref-moved is a warning, should not error") @@ -621,7 +429,7 @@ jobs: "example/action@v1=sha1-" + pinnedSHA, ) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) require.NoError(t, err, "ref-moved is a warning, should not error") @@ -700,7 +508,7 @@ jobs: ) // Test per-workflow dependencies view - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json=workflows", workflowPath, ) require.NoError(t, err) @@ -775,7 +583,7 @@ jobs: "actions/cache@v4", ) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json=workflows", workflowPath, ) require.NoError(t, err) @@ -826,7 +634,7 @@ jobs: ) // --json with no value should use the default fields (valid,findings,workflows) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json", workflowPath, ) require.NoError(t, err) @@ -892,7 +700,7 @@ jobs: t.Chdir(dir) // Run WITHOUT --rescan so SeedFromLockfile is active. - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--no-fix", "--json=valid,findings", ".github/workflows/workflow.yml", ) @@ -968,7 +776,7 @@ jobs: // Terminal mode (no --json), read-only. setup-go is unpinned → !valid → // errSilent. - _, _, runErr := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + _, _, runErr := runCommandWithHTTP(t, reg, "--no-fix", ".github/workflows/workflow.yml", ) require.ErrorIs(t, runErr, errSilent) @@ -1053,7 +861,7 @@ jobs: t.Chdir(dir) // Bare --json: renderer only, autofix still runs. - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--json=valid,findings", ".github/workflows/workflow.yml", ) @@ -1116,7 +924,7 @@ jobs: "example/action@v1=sha1-" + staleSHA, ) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--rescan", "--no-fix", "--json=valid,findings", workflowPath, ) // ref-moved is a warning (valid=true), not an error. @@ -1189,7 +997,7 @@ jobs: " - actions/checkout@v6\n" require.NoError(t, os.WriteFile(filepath.Join(".github", "workflows", "actions.lock"), []byte(lockYAML), 0o600)) - stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + stdout, _, err := runCommandWithHTTP(t, reg, "--no-fix", "--json=dependencies", wf1, wf2Path, ) require.NoError(t, err) diff --git a/cmd/gh-actions-lock/format/terminal.go b/cmd/gh-actions-lock/format/terminal.go index af046d28..bbb4cc14 100644 --- a/cmd/gh-actions-lock/format/terminal.go +++ b/cmd/gh-actions-lock/format/terminal.go @@ -6,8 +6,6 @@ import ( "strings" "github.com/github/gh-actions-lock/internal/pipeline/checks" - - "github.com/github/gh-actions-lock/internal/pipeline" "github.com/github/gh-actions-lock/internal/ui" ) @@ -116,7 +114,7 @@ func renderErrorFindings(out *ui.UI, report *checks.Report, failedCount, checked checks.RefChanged, checks.NotPinned, checks.OnboardingRequired, checks.LocalAction, checks.SelfHostedRunner, checks.ExpressionRunner, - checks.Stale, checks.MisleadingSHA, checks.ImpostorCommit, + checks.Stale, checks.MisleadingSHA, } { if n, ok := catCounts[cat]; ok { parts = append(parts, fmt.Sprintf("%d %s", n, string(cat))) @@ -160,11 +158,6 @@ func renderFindingDetail(out *ui.UI, f checks.Finding, dep string) { out.Detail(" ↳ Suggested re-pin: %s@%s (%s) — latest release reachable from a branch", nwo, f.RecommendedTag, sha) } - if f.Category == checks.ImpostorCommit { - out.Detail(" %s %s", ui.IconWarning, pipeline.ImpostorCommitContext) - out.Detail(" ↳ %s", pipeline.PublisherEscalationCopy) - out.Detail(" see: %s", out.DocLink(pipeline.PublisherTagReleasesDocURL)) - } if f.DocURL != "" { out.Detail(" see: %s", out.DocLink(f.DocURL)) } @@ -388,7 +381,7 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) { // remediator should not re-print it in non-interactive mode). func IsAlertedCategory(c checks.Category) bool { switch c { - case checks.ImpostorCommit, checks.LockfileForgery, checks.MisleadingSHA, checks.OnboardingRequired: + case checks.LockfileForgery, checks.MisleadingSHA, checks.OnboardingRequired: return true } return false diff --git a/cmd/gh-actions-lock/format/terminal_test.go b/cmd/gh-actions-lock/format/terminal_test.go index 922c8963..9b1d9be4 100644 --- a/cmd/gh-actions-lock/format/terminal_test.go +++ b/cmd/gh-actions-lock/format/terminal_test.go @@ -460,7 +460,7 @@ func TestExtractBracketedLabels(t *testing.T) { // TestPresentResults_ExcludeCategoriesSkipsImpostor verifies that excluded // categories are not rendered in the error findings block. This prevents // duplication when renderInvestigationAlerts already surfaced the same -// findings (e.g. impostor-commit). +// findings (e.g. lockfile-forgery). func TestPresentResults_ExcludeCategoriesSkipsImpostor(t *testing.T) { var buf bytes.Buffer u := ui.NewPlain(&buf) @@ -471,11 +471,11 @@ func TestPresentResults_ExcludeCategoriesSkipsImpostor(t *testing.T) { Findings: []checks.Finding{ { WorkflowPath: ".github/workflows/ci.yml", - Category: checks.ImpostorCommit, + Category: checks.LockfileForgery, Severity: checks.SeverityError, Confidence: checks.ConfidenceHigh, Dependency: &dep.Dependency{NWO: "octo/action", Ref: "v1", SHA: "aaaa"}, - Detail: "commit aaaa not found on any branch", + Detail: "commit aaaa lockfile entry was not a prior state", }, { WorkflowPath: ".github/workflows/ci.yml", @@ -490,17 +490,17 @@ func TestPresentResults_ExcludeCategoriesSkipsImpostor(t *testing.T) { }, } - PresentResults(u, report, false, false, checks.ImpostorCommit) + PresentResults(u, report, false, false, checks.LockfileForgery) got := buf.String() - if strings.Contains(got, "IMPOSTOR-COMMIT") { - t.Errorf("excluded impostor-commit should not appear:\n%s", got) + if strings.Contains(got, "LOCKFILE-FORGERY") { + t.Errorf("excluded lockfile-forgery should not appear:\n%s", got) } - if strings.Contains(got, "not found on any branch") { - t.Errorf("excluded impostor detail should not appear:\n%s", got) + if strings.Contains(got, "lockfile entry was not a prior state") { + t.Errorf("excluded forgery detail should not appear:\n%s", got) } // The summary line should not count the excluded category. - if strings.Contains(got, "impostor-commit") { + if strings.Contains(got, "lockfile-forgery") { t.Errorf("excluded category should not appear in summary:\n%s", got) } } @@ -517,11 +517,11 @@ func TestPresentResults_ExcludeKeepsOtherFindings(t *testing.T) { Findings: []checks.Finding{ { WorkflowPath: ".github/workflows/ci.yml", - Category: checks.ImpostorCommit, + Category: checks.LockfileForgery, Severity: checks.SeverityError, Confidence: checks.ConfidenceHigh, Dependency: &dep.Dependency{NWO: "octo/action", Ref: "v1", SHA: "aaaa"}, - Detail: "commit aaaa not found on any branch", + Detail: "commit aaaa lockfile entry was not a prior state", }, { WorkflowPath: ".github/workflows/ci.yml", @@ -535,11 +535,11 @@ func TestPresentResults_ExcludeKeepsOtherFindings(t *testing.T) { }, } - PresentResults(u, report, false, false, checks.ImpostorCommit) + PresentResults(u, report, false, false, checks.LockfileForgery) got := buf.String() - if strings.Contains(got, "IMPOSTOR-COMMIT") { - t.Errorf("excluded impostor-commit should not appear:\n%s", got) + if strings.Contains(got, "LOCKFILE-FORGERY") { + t.Errorf("excluded lockfile-forgery should not appear:\n%s", got) } if !strings.Contains(got, "LOCAL-ACTION") { t.Errorf("non-excluded local-action should still appear:\n%s", got) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index 372a1462..b1eb6c2e 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -8,7 +8,6 @@ import ( parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" "github.com/github/gh-actions-lock/cmd/gh-actions-lock/format" "github.com/github/gh-actions-lock/internal/pin" - "github.com/github/gh-actions-lock/internal/pipeline" "github.com/github/gh-actions-lock/internal/pipeline/checks" "github.com/github/gh-actions-lock/internal/resolve" "github.com/github/gh-actions-lock/internal/ui" @@ -17,7 +16,7 @@ import ( // reportHasUnfixableErrors returns true when the report contains error- // severity findings that the autofix cannot resolve. Pinning resolves // not-pinned findings, so those are expected in the pre-fix report and -// don't count. LocalAction, SelfHostedRunner, ImpostorCommit, and +// don't count. LocalAction, SelfHostedRunner, and // LockfileForgery errors are unfixable — the workflow or lockfile must // be investigated. func reportHasUnfixableErrors(report *checks.Report) bool { @@ -28,7 +27,7 @@ func reportHasUnfixableErrors(report *checks.Report) bool { } switch f.Category { case checks.LocalAction, checks.SelfHostedRunner, - checks.ImpostorCommit, checks.LockfileForgery: + checks.LockfileForgery: return true } } @@ -39,7 +38,7 @@ func reportHasUnfixableErrors(report *checks.Report) bool { // reportHasNonInvestigatedUnfixableErrors is like reportHasUnfixableErrors // but only matches categories that renderInvestigationAlerts does NOT // handle (LocalAction, SelfHostedRunner). Use this to gate the -// PresentResults call so impostor-commit / lockfile-forgery findings +// PresentResults call so lockfile-forgery findings // don't trigger a redundant (and stale) error summary. func reportHasNonInvestigatedUnfixableErrors(report *checks.Report) bool { for _, wr := range report.Workflows { @@ -116,13 +115,13 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r // the log so the findings surface on the terminal. // // Only trigger for categories NOT already rendered by - // renderInvestigationAlerts (which handles impostor-commit and + // renderInvestigationAlerts (which handles lockfile-forgery and // lockfile-forgery). Without this gate PresentResults would also // emit a stale summary line counting pre-fix not-pinned findings. if reportHasNonInvestigatedUnfixableErrors(report) { console.SetLog(nil) format.PresentResults(console, report, false, false, - checks.ImpostorCommit, checks.LockfileForgery) + checks.LockfileForgery) } if len(investigated) > 0 || len(unresolvedEntries) > 0 || hasUnfixable { @@ -275,7 +274,7 @@ func renderFullScanWarnings(console *ui.UI, pinned []pin.Entry) { } // renderInvestigationAlerts prints error-level alerts for entries that -// require manual investigation (impostor commits, forgery, etc.). +// require manual investigation (forgery, orphaned commits, etc.). // Entries sharing the same NWO@Ref are grouped so the action line // appears once with all affected workflows listed underneath. func renderInvestigationAlerts(console *ui.UI, investigated []pin.Entry, r *resolve.Resolver) { @@ -305,24 +304,9 @@ func renderInvestigationAlerts(console *ui.UI, investigated []pin.Entry, r *reso console.TermBlank() - // Use a specific header when all entries are impostor-commit; - // fall back to a generic header when other issue types are mixed in. - allImpostor := true - for _, g := range groups { - if g.Issue != string(checks.ImpostorCommit) { - allImpostor = false - break - } - } - if allImpostor { - console.TermError("%d %s %s maintainer action — pinned commit is not reachable from any branch", - len(groups), ui.Pluralize(len(groups), "action", "actions"), - ui.Pluralize(len(groups), "requires", "require")) - } else { - console.TermError("%d %s %s investigation — do not auto-pin", - len(groups), ui.Pluralize(len(groups), "action", "actions"), - ui.Pluralize(len(groups), "requires", "require")) - } + console.TermError("%d %s %s investigation — do not auto-pin", + len(groups), ui.Pluralize(len(groups), "action", "actions"), + ui.Pluralize(len(groups), "requires", "require")) for _, g := range groups { dep := g.NWO + "@" + g.Ref console.TermDetail(" %s", console.TermLink(console.TermYellow(dep), format.DepReleaseURL(dep, r.IsKnownTagObject))) @@ -333,11 +317,6 @@ func renderInvestigationAlerts(console *ui.UI, investigated []pin.Entry, r *reso console.TermDetail(" %s Suggested re-pin: %s", console.TermBold("→"), console.TermYellow(g.NWO+"@"+g.Suggestion)) } - if g.Issue == string(checks.ImpostorCommit) { - console.TermDetail(" %s %s", console.TermYellow("!"), pipeline.ImpostorCommitContext) - console.TermDetail(" %s %s", console.TermBold("→"), pipeline.PublisherEscalationCopy) - console.TermDetail(" see: %s", console.TermLink(console.TermDim("Using tags for release management"), pipeline.PublisherTagReleasesDocURL)) - } } } diff --git a/cmd/gh-actions-lock/pin_summary_test.go b/cmd/gh-actions-lock/pin_summary_test.go index fbcde1b5..7200539b 100644 --- a/cmd/gh-actions-lock/pin_summary_test.go +++ b/cmd/gh-actions-lock/pin_summary_test.go @@ -62,7 +62,7 @@ func TestRenderInvestigationAlerts_DeduplicatesByNWORef(t *testing.T) { out := buf.String() // Header should count 1 unique action, not 2 raw entries. - if !strings.Contains(out, "1 action requires maintainer action") { + if !strings.Contains(out, "1 action requires investigation") { t.Errorf("expected '1 action requires maintainer action', got:\n%s", out) } @@ -99,7 +99,7 @@ func TestRenderInvestigationAlerts_DistinctActionsStaySeparate(t *testing.T) { renderInvestigationAlerts(console, entries, r) out := buf.String() - if !strings.Contains(out, "2 actions require maintainer action") { + if !strings.Contains(out, "2 actions require investigation") { t.Errorf("expected '2 actions require maintainer action', got:\n%s", out) } if !strings.Contains(out, "octo/action-a@aaaa") || !strings.Contains(out, "octo/action-b@bbbb") { @@ -139,46 +139,6 @@ func TestRenderInvestigationAlerts_WorkflowDedup(t *testing.T) { } } -func TestRenderInvestigationAlerts_ImpostorCommitEscalation(t *testing.T) { - entries := []pin.Entry{ - { - NWO: "octo/action", - Ref: "abc123abc123abc123abc123abc123abc123abcd", - Issue: "impostor-commit", - Workflows: []string{"ci.yml"}, - }, - } - - var buf bytes.Buffer - console := ui.NewPlain(&buf) - r := &resolve.Resolver{} - renderInvestigationAlerts(console, entries, r) - out := buf.String() - - // All-impostor header. - if !strings.Contains(out, "requires maintainer action") { - t.Errorf("expected maintainer action header for all-impostor entries, got:\n%s", out) - } - - // Impostor context line. - if !strings.Contains(out, "indistinguishable from impostor") { - t.Errorf("expected impostor commit context line, got:\n%s", out) - } - - // Actionable copy with → arrow. - if !strings.Contains(out, "→") { - t.Errorf("expected → action arrow, got:\n%s", out) - } - if !strings.Contains(out, "Ask the action maintainer") { - t.Errorf("expected actionable escalation copy, got:\n%s", out) - } - - // Doc link present — plain UI renders the display text, not the URL. - if !strings.Contains(out, "Using tags for release management") { - t.Errorf("expected doc link for tag release management, got:\n%s", out) - } -} - func TestCleanUnresolvedReason(t *testing.T) { tests := []struct { name string diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 67303cba..d327a548 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -163,7 +163,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) console.StartProgress(fmt.Sprintf("Scanning %d %s", total, ui.Pluralize(total, "workflow", "workflows"))) } - // Build a Lister for impostor enrichment + pin narrowing, + // Build a Lister for pin narrowing, // reusing the resolver's unified API client. var tagger *tag.Lister if gc := r.GHClient(); gc != nil { @@ -173,7 +173,6 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) runOpts := pipeline.RunOptions{ WorkflowPaths: opts.workflowPaths, Resolver: r, - Tagger: tagger, Store: store, Pool: pool, Rescan: opts.rescan, @@ -321,7 +320,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) // succeeded — so machine consumers never see findings for a run that // then failed to write. Exit code mirrors the terminal autofix path: a // non-zero exit only when findings remain that can't be auto-fixed - // (impostor commit / lockfile forgery). + // (lockfile forgery). if opts.jsonFields != "" { if err := format.WriteJSON(out, report, valid, opts.jsonFields, cliVersion(), store.File().Version); err != nil { return err diff --git a/internal/ghapi/cachekey.go b/internal/ghapi/cachekey.go index 73d1b7a7..2b5fb0e7 100644 --- a/internal/ghapi/cachekey.go +++ b/internal/ghapi/cachekey.go @@ -103,29 +103,6 @@ func (k Compare) String() string { return k.repo.String() + "|" + k.base + "|" + k.head } -// Reach is (NWO, sha, ref) — the reachability cache distinguishes the same -// SHA on different refs because the verdict depends on which ref's history -// we're walking. The SHA is lowercased; the ref preserves case. -type Reach struct { - repo Repo - sha string - ref string -} - -// ForReach builds a Reach key, lowercasing the SHA. -func ForReach(owner, repo, sha, ref string) Reach { - return Reach{ - repo: ForRepo(owner, repo), - sha: strings.ToLower(sha), - ref: ref, - } -} - -// String is diagnostics-only; do not parse. -func (k Reach) String() string { - return k.repo.String() + "|" + k.sha + "|" + k.ref -} - // ActionRef is the path-aware key used by the resolver's per-ref cache and // the BFS dedup set in ResolveAllRecursive. Sub-action paths must be // distinct identities for graph traversal — actions/cache/save@v4 visits diff --git a/internal/ghapi/cachekey_test.go b/internal/ghapi/cachekey_test.go index ed0f3bfe..125ebcfa 100644 --- a/internal/ghapi/cachekey_test.go +++ b/internal/ghapi/cachekey_test.go @@ -52,17 +52,6 @@ func TestCompareLowercasesBothShas(t *testing.T) { } } -func TestReachLowercasesSha(t *testing.T) { - a := ForReach("o", "r", "AAA", "main") - b := ForReach("o", "r", "aaa", "main") - if a != b { - t.Fatalf("expected SHA to fold: %v vs %v", a, b) - } - if got := a.String(); got != "o/r|aaa|main" { - t.Fatalf("Reach.String() = %q", got) - } -} - func TestActionRefDistinguishesPath(t *testing.T) { plain := ForActionRef("actions", "cache", "", "v4") subpath := ForActionRef("actions", "cache", "save", "v4") diff --git a/internal/pin/commit.go b/internal/pin/commit.go index d929f2b5..63697c7e 100644 --- a/internal/pin/commit.go +++ b/internal/pin/commit.go @@ -10,7 +10,6 @@ import ( "github.com/github/gh-actions-lock/internal/dep" "github.com/github/gh-actions-lock/internal/lockfile" - "github.com/github/gh-actions-lock/internal/pipeline/checks" "github.com/github/gh-actions-lock/internal/workflowfile" "golang.org/x/sync/errgroup" ) @@ -126,15 +125,13 @@ func groupPinnedByWorkflow(rec *Record) map[string][]dep.Dependency { } // retainUnresolvablePins re-adds the workflow's existing on-disk pins for any -// entry that cannot be resolved this run (impostor-flagged or transiently +// entry that cannot be resolved this run (flagged for investigation or transiently // unresolvable, e.g. 403/SSO). Without this a co-located re-pin silently // drops the existing pin. func retainUnresolvablePins(rec *Record, store *lockfile.State, wfPath string, deps []dep.Dependency, directKeys map[string]bool) []dep.Dependency { retain := make(map[string]bool) for _, e := range rec.Entries { - shouldRetain := (e.Resolution == Investigate && e.Issue == string(checks.ImpostorCommit)) || - e.Resolution == Unresolved - if !shouldRetain { + if e.Resolution != Unresolved && e.Resolution != Investigate { continue } for _, wf := range e.Workflows { diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 583c6c79..e3032a35 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -2,7 +2,6 @@ package pin import ( "context" - "errors" "fmt" "strings" "time" @@ -146,23 +145,6 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption // Fall through with partial deps to pin what we can. } - // Reachability gate — drop impostors, auto-fix when a sane release exists. - status("verifying " + wr.Path) - reachResults := opts.Resolver.CheckReachabilityAll(ctx, deps) - gateEntries, badKeys, autoFixed, autoFixRewrites := reachabilityGate(ctx, wr, opts, deps, reachResults) - entries = append(entries, gateEntries...) - - if len(badKeys) > 0 { - deps, parentMap = dropDeps(deps, parentMap, badKeys) - if len(deps) == 0 { - wplans = append(wplans, WorkflowPlan{Path: wr.Path}) - return planResult{entries: entries, wplans: wplans}, nil - } - } - - // Track reachability metadata for pinned entries. - fullScanDeps := collectFullScanDeps(reachResults, badKeys) - // Snapshot direct-dep matching before narrowing/ReverseLookup mutate // dep.Ref — the tracker records index-aligned booleans at construction, // then Keys() reads post-mutation refs. Must be built while deps and @@ -174,24 +156,15 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption status("pinning " + wr.Path) rewrites := make(map[string]string) narrowedNWOs := make(map[string]bool) // NWOs where narrowing chose a tag - for k, v := range autoFixRewrites { - rewrites[k] = v - } narrowDirectDeps(ctx, opts, deps, directTracker, rewrites, narrowedNWOs) // ReverseLookup canonicalizes each dep's ref while preserving the tags - // narrowing chose and transitive deps' declared refs. An impostor commit - // surfaced here ends the workflow early. - rlRewrites, impostorEntry, err := reverseLookupRewrites(ctx, opts, wr, deps, directTracker, narrowedNWOs) + // narrowing chose and transitive deps' declared refs. + rlRewrites, err := reverseLookupRewrites(ctx, opts, wr, deps, directTracker, narrowedNWOs) if err != nil { return planResult{}, err } - if impostorEntry != nil { - entries = append(entries, *impostorEntry) - wplans = append(wplans, WorkflowPlan{Path: wr.Path}) - return planResult{entries: entries, wplans: wplans}, nil - } for k, v := range rlRewrites { rewrites[k] = v } @@ -233,7 +206,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption } // Build entries for all pinned deps (skip any already emitted from inventory). - entries = append(entries, buildPinnedEntries(opts, wr, deps, parentMap, directTracker, inventorySHA, autoFixed, fullScanDeps)...) + entries = append(entries, buildPinnedEntries(opts, wr, deps, parentMap, directTracker, inventorySHA)...) // Record findings that are informational (ref-moved, misleading-sha). entries = append(entries, informationalEntries(wr)...) @@ -274,86 +247,6 @@ func unresolvedEntries(wr checks.WorkflowReport, unrecordedRefs []parserlock.Act return out } -// reachabilityGate classifies resolved deps: unreachable deps are auto-repinned -// to a recommended release when one exists, else dropped for investigation; -// reachability-unknown deps are dropped. badKeys lists the dropped dep keys. -func reachabilityGate(ctx context.Context, wr checks.WorkflowReport, opts PlanOptions, deps []dep.Dependency, reachResults []resolve.ReachabilityResult) (entries []Entry, badKeys map[string]bool, autoFixed, autoFixRewrites map[string]string) { - badKeys = make(map[string]bool) - autoFixed = make(map[string]string) // new dep key -> original ref - autoFixRewrites = make(map[string]string) // old uses -> new uses (for YAML rewrite) - for _, rr := range reachResults { - depKey := rr.Owner + "/" + rr.Repo + "@" + rr.Ref - switch rr.Status { - case resolve.Unreachable: - // Look for a recommended release to auto-repin. - var recTag, recSHA string - if f := findFinding(wr.Findings, rr.Owner+"/"+rr.Repo, rr.Ref); f != nil && f.RecommendedTag != "" { - recTag, recSHA = f.RecommendedTag, f.RecommendedSHA - } else if opts.Tagger != nil { - recTag, recSHA = checks.FindRecommendedRelease(ctx, opts.Tagger, opts.Resolver, opts.Pool, rr.Owner, rr.Repo) - } - - if recTag != "" { - // Rewrite the dep in place to the recommended release so it - // stays in the pinning pipeline instead of being dropped. - nwo := rr.Owner + "/" + rr.Repo - newKey := nwo + "@" + recTag - autoFixed[newKey] = rr.Ref - autoFixRewrites[nwo+"@"+rr.Ref] = nwo + "@" + recTag - for i := range deps { - if deps[i].Key() == depKey { - deps[i].Ref = recTag - if recSHA != "" { - deps[i].SHA = recSHA - } - break - } - } - continue // don't mark as bad - dep stays in pipeline - } - - entries = append(entries, Entry{ - NWO: rr.Owner + "/" + rr.Repo, - Ref: rr.Ref, - SHA: rr.SHA, - Resolution: Investigate, - Issue: string(checks.ImpostorCommit), - Reason: rr.Detail, - Workflows: []string{wr.Path}, - }) - badKeys[depKey] = true - case resolve.ReachabilityUnknown: - entries = append(entries, Entry{ - NWO: rr.Owner + "/" + rr.Repo, - Ref: rr.Ref, - SHA: rr.SHA, - Resolution: Skipped, - Issue: "reachability_unknown", - Reason: rr.Detail, - Workflows: []string{wr.Path}, - }) - badKeys[depKey] = true - } - } - return entries, badKeys, autoFixed, autoFixRewrites -} - -// collectFullScanDeps returns the set of still-pinned dep keys whose -// reachability check required a full commit scan. -func collectFullScanDeps(reachResults []resolve.ReachabilityResult, badKeys map[string]bool) map[string]bool { - fullScanDeps := make(map[string]bool) - for _, rr := range reachResults { - depKey := rr.Owner + "/" + rr.Repo + "@" + rr.Ref - if badKeys[depKey] { - continue - } - if rr.FullScanUsed { - fullScanDeps[depKey] = true - } - } - return fullScanDeps -} - // narrowDirectDeps rewrites direct deps' mutable refs to precise tags (bare SHA // or partial/non-semver ref -> full patch tag), leaving transitive deps verbatim. // Each rewrite mutates deps[i].Ref and records the old->new uses and narrowed NWO. @@ -429,9 +322,8 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen } // reverseLookupRewrites canonicalizes dep refs via ReverseLookup (SHA -> tag/ -// branch), restoring refs that narrowing or a transitive dep already fixed. A -// non-nil *Entry signals an impostor commit (err stays nil) so the caller bails. -func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, directTracker lockfile.DirectTracker, narrowedNWOs map[string]bool) (map[string]string, *Entry, error) { +// branch), restoring refs that narrowing or a transitive dep already fixed. +func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, directTracker lockfile.DirectTracker, narrowedNWOs map[string]bool) (map[string]string, error) { // Save narrowed refs before ReverseLookup - it may overwrite dep.Ref // with a branch name, but we want to keep the semver tag narrowing chose. narrowedRefs := make(map[int]string) @@ -456,18 +348,7 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work // ReverseLookup: SHA -> containing tag/branch. Rewrites refs to canonical form. normRewrites, err := opts.Resolver.ReverseLookup(ctx, deps) if err != nil { - var imp *resolve.ImpostorError - if errors.As(err, &imp) { - return nil, &Entry{ - NWO: imp.NWO, - Ref: imp.Ref, - Resolution: Investigate, - Issue: string(checks.ImpostorCommit), - Reason: imp.Error(), - Workflows: []string{wr.Path}, - }, nil - } - return nil, nil, fmt.Errorf("reverse lookup: %w", err) + return nil, fmt.Errorf("reverse lookup: %w", err) } // Restore narrowed refs that ReverseLookup may have overwritten. for i, ref := range narrowedRefs { @@ -493,13 +374,13 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work } rewrites[k] = v } - return rewrites, nil, nil + return rewrites, nil } // buildPinnedEntries emits an entry for every resolved dep, marking it Verified // when the lockfile already records the same SHA and Pinned otherwise. Deps // already emitted from inventory (by NWO:SHA) are skipped. -func buildPinnedEntries(opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, parentMap dep.ParentMap, directTracker lockfile.DirectTracker, inventorySHA map[string]bool, autoFixed map[string]string, fullScanDeps map[string]bool) []Entry { +func buildPinnedEntries(opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, parentMap dep.ParentMap, directTracker lockfile.DirectTracker, inventorySHA map[string]bool) []Entry { // Load existing lockfile state so re-runs are noops for unchanged deps. existingSHA := make(map[string]string) // NWO@Ref -> SHA if opts.Store != nil { @@ -531,16 +412,10 @@ func buildPinnedEntries(opts PlanOptions, wr checks.WorkflowReport, deps []dep.D Resolution: res, OnBranch: dep.Branch, Tag: dep.Tag, - FullScan: fullScanDeps[dep.NWO+"@"+dep.Ref], Workflows: []string{wr.Path}, RequiredBy: parents, Direct: directKeys[depKey], } - if orig, ok := autoFixed[depKey]; ok { - entry.AutoFixedRef = orig - entry.Direct = true // auto-fixed deps are always direct uses - entry.Resolution = Pinned // auto-fix is always a new pin - } out = append(out, entry) } return out @@ -573,46 +448,6 @@ func informationalEntries(wr checks.WorkflowReport) []Entry { return out } -func findFinding(findings []checks.Finding, nwo, ref string) *checks.Finding { - var best *checks.Finding - for i := range findings { - f := &findings[i] - if f.ActionRef != nil && f.ActionRef.Owner+"/"+f.ActionRef.Repo == nwo && f.ActionRef.Ref == ref { - if f.RecommendedTag != "" { - return f - } - if best == nil { - best = f - } - } - if f.Dependency != nil && f.Dependency.NWO == nwo && f.Dependency.Ref == ref { - if f.RecommendedTag != "" { - return f - } - if best == nil { - best = f - } - } - } - return best -} - -func dropDeps(deps []dep.Dependency, pm dep.ParentMap, bad map[string]bool) ([]dep.Dependency, dep.ParentMap) { - var kept []dep.Dependency - for _, d := range deps { - if !bad[d.Key()] { - kept = append(kept, d) - } - } - newPM := make(dep.ParentMap) - for k, v := range pm { - if !bad[k] { - newPM[k] = v - } - } - return kept, newPM -} - // partitionByInventory splits refs into those with a matching inventory // entry (recorded) and those without (unrecorded). Returns the unrecorded // refs and an NWO:SHA index for deduplicating resolved deps later. diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index 10382541..bedbfa72 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -4,10 +4,10 @@ import ( "context" "testing" + "github.com/github/gh-actions-lock/internal/dep" "github.com/github/gh-actions-lock/internal/pipeline/checks" parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" - "github.com/github/gh-actions-lock/internal/dep" "github.com/github/gh-actions-lock/internal/ghapi/httpmock" "github.com/github/gh-actions-lock/internal/pinpool" "github.com/github/gh-actions-lock/internal/resolve" @@ -16,115 +16,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestFindFinding(t *testing.T) { - ref := func(owner, repo, ref string) *parserlock.ActionRef { - return &parserlock.ActionRef{Owner: owner, Repo: repo, Ref: ref} - } - - ff := []checks.Finding{ - {ActionRef: ref("actions", "checkout", "v4"), Category: "unpinned"}, - {ActionRef: ref("actions", "cache", "v3"), Category: "impostor", RecommendedTag: "v3.4.0", RecommendedSHA: "abc"}, - {ActionRef: ref("actions", "cache", "v3"), Category: "unpinned"}, - {Dependency: &dep.Dependency{NWO: "other/dep", Ref: "v2"}, Category: "ref-moved"}, - {Dependency: &dep.Dependency{NWO: "other/dep", Ref: "v2"}, Category: "sane", RecommendedTag: "v2.1.0"}, - } - - t.Run("matches by ActionRef NWO and ref", func(t *testing.T) { - f := findFinding(ff, "actions/checkout", "v4") - require.NotNil(t, f) - assert.Equal(t, checks.Category("unpinned"), f.Category) - }) - - t.Run("prefers finding with RecommendedTag", func(t *testing.T) { - f := findFinding(ff, "actions/cache", "v3") - require.NotNil(t, f) - assert.Equal(t, "v3.4.0", f.RecommendedTag) - assert.Equal(t, "abc", f.RecommendedSHA) - }) - - t.Run("matches by Dependency NWO and ref", func(t *testing.T) { - f := findFinding(ff, "other/dep", "v2") - require.NotNil(t, f) - assert.Equal(t, "v2.1.0", f.RecommendedTag) - }) - - t.Run("returns nil for no match", func(t *testing.T) { - f := findFinding(ff, "nonexistent/action", "v1") - assert.Nil(t, f) - }) - - t.Run("returns best match without RecommendedTag", func(t *testing.T) { - onlyBasic := []checks.Finding{ - {ActionRef: ref("a", "b", "v1"), Category: "first"}, - {ActionRef: ref("a", "b", "v1"), Category: "second"}, - } - f := findFinding(onlyBasic, "a/b", "v1") - require.NotNil(t, f) - assert.Equal(t, checks.Category("first"), f.Category, "should return the first match") - }) - - t.Run("empty findings", func(t *testing.T) { - assert.Nil(t, findFinding(nil, "a/b", "v1")) - }) -} - -func TestDropDeps(t *testing.T) { - deps := []dep.Dependency{ - {NWO: "a/b", Ref: "v1"}, - {NWO: "c/d", Ref: "v2"}, - {NWO: "e/f", Ref: "v3"}, - } - pm := dep.ParentMap{ - "a/b@v1": {"root"}, - "c/d@v2": {"a/b@v1"}, - "e/f@v3": {"c/d@v2"}, - } - bad := map[string]bool{ - "c/d@v2": true, - } - - gotDeps, gotPM := dropDeps(deps, pm, bad) - - require.Len(t, gotDeps, 2) - assert.Equal(t, "a/b", gotDeps[0].NWO) - assert.Equal(t, "e/f", gotDeps[1].NWO) - - assert.Contains(t, gotPM, "a/b@v1") - assert.NotContains(t, gotPM, "c/d@v2") - assert.Contains(t, gotPM, "e/f@v3") -} - -func TestDropDeps_all_bad(t *testing.T) { - deps := []dep.Dependency{ - {NWO: "a/b", Ref: "v1"}, - } - pm := dep.ParentMap{ - "a/b@v1": {"root"}, - } - bad := map[string]bool{ - "a/b@v1": true, - } - - gotDeps, gotPM := dropDeps(deps, pm, bad) - assert.Empty(t, gotDeps) - assert.Empty(t, gotPM) -} - -func TestDropDeps_none_bad(t *testing.T) { - deps := []dep.Dependency{ - {NWO: "a/b", Ref: "v1"}, - {NWO: "c/d", Ref: "v2"}, - } - pm := dep.ParentMap{ - "a/b@v1": {"root"}, - "c/d@v2": {"a/b@v1"}, - } - - gotDeps, gotPM := dropDeps(deps, pm, map[string]bool{}) - assert.Len(t, gotDeps, 2) - assert.Len(t, gotPM, 2) -} - // TestPlanWorkflow_PartialResolutionFailure verifies that when one ref in a // workflow fails resolution (e.g. repo not found), only the failed ref is // marked Unresolved. The successful ref proceeds through reachability and @@ -171,11 +62,7 @@ func TestPlanWorkflow_PartialResolutionFailure(t *testing.T) { ) pool := pinpool.New(2, nil) - reachFn := func(_ context.Context, _, _, _, _ string) (resolve.ReachabilityStatus, string) { - return resolve.Reachable, "test stub" - } - resolver, err := resolve.New("github.com", pool, resolve.WithTransport(reg), - resolve.WithCheckReachabilityFunc(reachFn)) + resolver, err := resolve.New("github.com", pool, resolve.WithTransport(reg)) require.NoError(t, err) wr := checks.WorkflowReport{ @@ -246,12 +133,7 @@ func TestPlanWorkflow_AllResolutionsFail(t *testing.T) { ) pool := pinpool.New(2, nil) - reachTrap := func(_ context.Context, _, _, _, _ string) (resolve.ReachabilityStatus, string) { - t.Fatal("reachability should not be called when all resolutions fail") - return resolve.Unreachable, "" - } - resolver, err := resolve.New("github.com", pool, resolve.WithTransport(reg), - resolve.WithCheckReachabilityFunc(reachTrap)) + resolver, err := resolve.New("github.com", pool, resolve.WithTransport(reg)) require.NoError(t, err) wr := checks.WorkflowReport{ @@ -377,11 +259,7 @@ func TestPlanWorkflow_DoesNotNarrowTransitiveDeps(t *testing.T) { ) pool := pinpool.New(2, nil) - reachFn := func(_ context.Context, _, _, _, _ string) (resolve.ReachabilityStatus, string) { - return resolve.Reachable, "test stub" - } - resolver, err := resolve.New("github.com", pool, resolve.WithTransport(reg), - resolve.WithCheckReachabilityFunc(reachFn)) + resolver, err := resolve.New("github.com", pool, resolve.WithTransport(reg)) require.NoError(t, err) wr := checks.WorkflowReport{ @@ -593,11 +471,7 @@ func TestPlanWorkflow_CrossRefTransitiveClosure(t *testing.T) { ) pool := pinpool.New(2, nil) - reachFn := func(_ context.Context, _, _, _, _ string) (resolve.ReachabilityStatus, string) { - return resolve.Reachable, "test stub" - } - resolver, err := resolve.New("github.com", pool, resolve.WithTransport(reg), - resolve.WithCheckReachabilityFunc(reachFn)) + resolver, err := resolve.New("github.com", pool, resolve.WithTransport(reg)) require.NoError(t, err) wr := checks.WorkflowReport{ diff --git a/internal/pin/retain_impostor_test.go b/internal/pin/retain_impostor_test.go index bed4e848..97f22458 100644 --- a/internal/pin/retain_impostor_test.go +++ b/internal/pin/retain_impostor_test.go @@ -8,7 +8,6 @@ import ( "github.com/github/gh-actions-lock/internal/dep" "github.com/github/gh-actions-lock/internal/lockfile" - "github.com/github/gh-actions-lock/internal/pipeline/checks" "github.com/github/gh-actions-lock/internal/workflowfile" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,7 +41,7 @@ func TestRetainUnresolvablePins_keepsExistingPinOnColocatedRepin(t *testing.T) { rec := &Record{ Entries: []Entry{ {NWO: "actions/checkout", Ref: "v5", SHA: "3333333333333333333333333333333333333333", Resolution: Pinned, Direct: true, OnBranch: "main", Workflows: []string{wfPath}}, - {NWO: "bad/impostor", Ref: "v1", SHA: "1111111111111111111111111111111111111111", Resolution: Investigate, Issue: string(checks.ImpostorCommit), Workflows: []string{wfPath}}, + {NWO: "bad/impostor", Ref: "v1", SHA: "1111111111111111111111111111111111111111", Resolution: Investigate, Issue: "impostor-commit", Workflows: []string{wfPath}}, }, } deps := []dep.Dependency{ diff --git a/internal/pipeline/checks/category.go b/internal/pipeline/checks/category.go index 9d5d0882..37a37a6f 100644 --- a/internal/pipeline/checks/category.go +++ b/internal/pipeline/checks/category.go @@ -24,10 +24,6 @@ const ( // Stale means the pinned SHA no longer matches what the ref // resolves to today. Stale Category = "stale" - // ImpostorCommit means the pinned SHA is not in the ref's git - // history (possible fork-network commit). Matches zizmor's - // impostor-commit audit ID. - ImpostorCommit Category = "impostor-commit" // MisleadingSHA means a ref looks like a SHA but resolves to a // different commit. MisleadingSHA Category = "misleading-sha" diff --git a/internal/pipeline/checks/category_test.go b/internal/pipeline/checks/category_test.go index 3b7386b8..b2740dea 100644 --- a/internal/pipeline/checks/category_test.go +++ b/internal/pipeline/checks/category_test.go @@ -16,7 +16,6 @@ func TestCategoryStringsAreFrozen(t *testing.T) { {RefChanged, "ref-changed"}, {RefMoved, "ref-moved"}, {Stale, "stale"}, - {ImpostorCommit, "impostor-commit"}, {MisleadingSHA, "misleading-sha"}, {LockfileForgery, "lockfile-forgery"}, {Valid, "valid"}, @@ -47,7 +46,7 @@ func TestCategoryIsInconclusive(t *testing.T) { } blocking := []Category{ NotPinned, ShaAsRef, RefChanged, RefMoved, Stale, - ImpostorCommit, MisleadingSHA, LockfileForgery, + MisleadingSHA, LockfileForgery, Valid, RunOnly, OnboardingRequired, VersionRef, LocalAction, SelfHostedRunner, } for _, c := range blocking { diff --git a/internal/pipeline/checks/finding.go b/internal/pipeline/checks/finding.go index 7a623971..c5e06e39 100644 --- a/internal/pipeline/checks/finding.go +++ b/internal/pipeline/checks/finding.go @@ -37,7 +37,7 @@ type Finding struct { DocURL string // RecommendedTag is the most recent stable tag whose commit is // reachable from a branch, populated for unreachable-SHA findings - // (ImpostorCommit) when one can be found. Empty otherwise. + // when one can be found. Empty otherwise. RecommendedTag string // RecommendedSHA is the commit SHA the recommended tag points to. RecommendedSHA string diff --git a/internal/pipeline/checks/impostor.go b/internal/pipeline/checks/impostor.go deleted file mode 100644 index c2244636..00000000 --- a/internal/pipeline/checks/impostor.go +++ /dev/null @@ -1,139 +0,0 @@ -package checks - -import ( - "context" - - parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" - "github.com/github/gh-actions-lock/internal/ghapi" - "github.com/github/gh-actions-lock/internal/pinpool" - "github.com/github/gh-actions-lock/internal/resolve" - "github.com/github/gh-actions-lock/internal/tag" -) - -// ReachabilityChecker is the subset of resolve.Resolver needed to verify -// that a tag's commit is reachable from a branch in the action repo. -// Defined as an interface so tests can stub without a real resolver. -type ReachabilityChecker interface { - CheckReachability(ctx context.Context, owner, repo, sha, ref string) resolve.ReachabilityResult -} - -// maxRecommendedTagsChecked bounds the per-finding tag walk so a repo with a -// long tail of unreachable tags doesn't trigger an unbounded reachability -// fan-out. -const maxRecommendedTagsChecked = 10 - -// FindRecommendedRelease walks the action repo's tags newest-first and returns the -// first stable release whose commit is reachable from a branch. It's the -// remediation half of the ImpostorCommit detection: when we flag a -// pinned SHA as orphaned, this answers "what should the user re-pin to?" -// -// Returns ("", "") when no qualifying tag is found within the bounded walk -// (e.g. the action has never tagged a reachable release, or all recent -// releases are also orphaned and the user should escalate to the publisher). -func FindRecommendedRelease(ctx context.Context, tl *tag.Lister, r ReachabilityChecker, pool *pinpool.Pool, owner, repo string) (recTag, sha string) { - if tl == nil || r == nil { - return "", "" - } - tags, err := tl.ListTags(ctx, owner, repo) - if err != nil { - return "", "" - } - - // Collect up to maxRecommendedTagsChecked candidate tags. - type candidate struct{ tag, sha string } - var candidates []candidate - for _, t := range tags { - if t.IsMajor { - continue - } - sv, ok := parserlock.ParseSemVer(t.Name) - if !ok || sv.Rest != "" { - continue - } - if t.SHA == "" { - continue - } - candidates = append(candidates, candidate{tag: t.Name, sha: t.SHA}) - if len(candidates) >= maxRecommendedTagsChecked { - break - } - } - if len(candidates) == 0 { - return "", "" - } - - // Check all candidates in parallel via the shared worker pool. - // Branch listings and singleflight inside CheckReachability coalesce - // across workers sharing the same NWO, so the marginal cost per extra - // SHA is roughly one GraphQL compare (~300 ms) divided by pool width. - type indexedCandidate struct { - idx int - candidate - } - indexed := make([]indexedCandidate, len(candidates)) - for i, c := range candidates { - indexed[i] = indexedCandidate{idx: i, candidate: c} - } - results := make([]resolve.ReachabilityResult, len(candidates)) - _ = pinpool.RunTyped(pool, ctx, "", - indexed, - func(ic indexedCandidate) string { return "checking release " + owner + "/" + repo + "@" + ic.tag }, - func(ctx context.Context, _ int, ic indexedCandidate) error { - results[ic.idx] = r.CheckReachability(ctx, owner, repo, ic.sha, ic.tag) - return nil - }, - ) - - // Return the first reachable tag in newest-first order. - for i, rr := range results { - if rr.Status == resolve.Reachable { - return candidates[i].tag, candidates[i].sha - } - } - return "", "" -} - -// EnrichImpostorFindings walks the report and attaches a recommended release -// to every ImpostorCommit finding when one is available. Mutates -// findings in place. Safe to call when tl or r is nil — becomes a no-op so -// non-network code paths (tests, --offline) don't trigger lookups. -// -// Findings that have been walked are also marked via RecommendedSearched -// so renderers can distinguish "didn't look" from "looked and found nothing" -// — the latter is itself useful signal (e.g. an action whose entire release -// flow detaches tag commits from any branch, warranting harder escalation -// to the publisher). -func EnrichImpostorFindings(ctx context.Context, report *Report, tl *tag.Lister, r ReachabilityChecker, pool *pinpool.Pool) { - if report == nil || tl == nil || r == nil { - return - } - // Cache per owner/repo so multiple impostor findings against the same - // action share a single tag walk + reachability sweep. - type suggestion struct{ tag, sha string } - cache := make(map[ghapi.Repo]suggestion) - for i := range report.Workflows { - wf := &report.Workflows[i] - for j := range wf.Findings { - f := &wf.Findings[j] - if f.Category != ImpostorCommit || f.Dependency == nil { - continue - } - owner, repo := f.Dependency.OwnerRepo() - if owner == "" || repo == "" { - continue - } - key := ghapi.ForRepo(owner, repo) - s, ok := cache[key] - if !ok { - t, sha := FindRecommendedRelease(ctx, tl, r, pool, owner, repo) - s = suggestion{tag: t, sha: sha} - cache[key] = s - } - f.RecommendedSearched = true - if s.tag != "" { - f.RecommendedTag = s.tag - f.RecommendedSHA = s.sha - } - } - } -} diff --git a/internal/pipeline/checks/impostor_test.go b/internal/pipeline/checks/impostor_test.go deleted file mode 100644 index 54abb835..00000000 --- a/internal/pipeline/checks/impostor_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package checks - -import ( - "context" - "testing" - - "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/ghapi/httpmock" - "github.com/github/gh-actions-lock/internal/pinpool" - "github.com/github/gh-actions-lock/internal/resolve" - "github.com/github/gh-actions-lock/internal/tag" -) - -type fakeReachabilityChecker struct { - results map[string]resolve.ReachabilityStatus -} - -func (f *fakeReachabilityChecker) CheckReachability(_ context.Context, owner, repo, sha, ref string) resolve.ReachabilityResult { - status := f.results[ref] - if status == "" { - status = resolve.Unreachable - } - return resolve.ReachabilityResult{Owner: owner, Repo: repo, SHA: sha, Ref: ref, Status: status} -} - -// registerTagWalk wires the three endpoints Lister hits during a -// publisher walk: GET /tags, GET /git/matching-refs/tags, GET /releases. -// Tests parameterize only the /tags payload; matching-refs and releases -// are registered empty so the walk completes deterministically. -func registerTagWalk(reg *httpmock.Registry, owner, repo string, tags []map[string]any) { - reg.Register( - httpmock.REST("GET", `repos/`+owner+`/`+repo+`/tags`), - httpmock.JSONResponse(tags), - ) - reg.Register( - httpmock.REST("GET", `repos/`+owner+`/`+repo+`/git/matching-refs/tags`), - httpmock.JSONResponse([]map[string]any{}), - ) - reg.Register( - httpmock.REST("GET", `repos/`+owner+`/`+repo+`/releases`), - httpmock.JSONResponse([]map[string]any{}), - ) -} - -// TestFindRecommendedRelease_PicksFirstReachable walks tags newest-first and stops at -// the first stable tag whose commit is reachable from a branch. -func TestFindRecommendedRelease_PicksFirstReachable(t *testing.T) { - reg := &httpmock.Registry{} - registerTagWalk(reg, "acme", "widget", []map[string]any{ - {"name": "v1.5.0", "commit": map[string]any{"sha": "aaaaaaa1111111111111111111111111111111aa"}}, - {"name": "v1.4.0", "commit": map[string]any{"sha": "bbbbbbb2222222222222222222222222222222bb"}}, - {"name": "v1.3.0", "commit": map[string]any{"sha": "ccccccc3333333333333333333333333333333cc"}}, - }) - - tl := tag.NewListerForTest(t, reg) - rc := &fakeReachabilityChecker{results: map[string]resolve.ReachabilityStatus{ - "v1.5.0": resolve.Unreachable, - "v1.4.0": resolve.Reachable, - }} - - tag, sha := FindRecommendedRelease(context.Background(), tl, rc, pinpool.New(0, nil), "acme", "widget") - if tag != "v1.4.0" { - t.Fatalf("expected v1.4.0, got %q", tag) - } - if sha != "bbbbbbb2222222222222222222222222222222bb" { - t.Fatalf("expected bbbb…bb, got %q", sha) - } -} - -// TestFindRecommendedRelease_NoneReachable returns empty when every recent tag is -// detached from a branch — signal for the caller to escalate to the publisher. -func TestFindRecommendedRelease_NoneReachable(t *testing.T) { - reg := &httpmock.Registry{} - registerTagWalk(reg, "acme", "widget", []map[string]any{ - {"name": "v1.2.0", "commit": map[string]any{"sha": "aaaaaaa1111111111111111111111111111111aa"}}, - {"name": "v1.1.0", "commit": map[string]any{"sha": "bbbbbbb2222222222222222222222222222222bb"}}, - }) - - tl := tag.NewListerForTest(t, reg) - rc := &fakeReachabilityChecker{} // all Unreachable - - tag, sha := FindRecommendedRelease(context.Background(), tl, rc, pinpool.New(0, nil), "acme", "widget") - if tag != "" || sha != "" { - t.Fatalf("expected empty suggestion, got tag=%q sha=%q", tag, sha) - } -} - -// TestEnrichImpostorFindings_MarksSearched flags impostor findings with the -// search outcome even when no suggestion is found so renderers can surface -// the "escalate to publisher" hint. -func TestEnrichImpostorFindings_MarksSearched(t *testing.T) { - reg := &httpmock.Registry{} - registerTagWalk(reg, "acme", "widget", []map[string]any{ - {"name": "v1.0.0", "commit": map[string]any{"sha": "aaaaaaa1111111111111111111111111111111aa"}}, - }) - - tl := tag.NewListerForTest(t, reg) - rc := &fakeReachabilityChecker{} // none reachable - - report := &Report{ - Workflows: []WorkflowReport{{ - Path: ".github/workflows/test.yml", - Findings: []Finding{{ - Category: ImpostorCommit, - Confidence: ConfidenceHigh, - Dependency: &dep.Dependency{NWO: "acme/widget", Ref: "v1"}, - }}, - }}, - } - - EnrichImpostorFindings(context.Background(), report, tl, rc, pinpool.New(0, nil)) - - f := report.Workflows[0].Findings[0] - if !f.RecommendedSearched { - t.Error("expected RecommendedSearched=true after walk") - } - if f.RecommendedTag != "" { - t.Errorf("expected no suggestion when nothing reachable, got %q", f.RecommendedTag) - } -} - -// TestEnrichImpostorFindings_PopulatesSuggestion attaches the discovered tag -// to the finding so downstream renderers (presentCheckResults, summary) can -// surface a concrete re-pin target. -func TestEnrichImpostorFindings_PopulatesSuggestion(t *testing.T) { - reg := &httpmock.Registry{} - registerTagWalk(reg, "acme", "widget", []map[string]any{ - {"name": "v1.0.0", "commit": map[string]any{"sha": "aaaaaaa1111111111111111111111111111111aa"}}, - }) - - tl := tag.NewListerForTest(t, reg) - rc := &fakeReachabilityChecker{results: map[string]resolve.ReachabilityStatus{ - "v1.0.0": resolve.Reachable, - }} - - report := &Report{ - Workflows: []WorkflowReport{{ - Path: ".github/workflows/test.yml", - Findings: []Finding{{ - Category: ImpostorCommit, - Confidence: ConfidenceHigh, - Dependency: &dep.Dependency{NWO: "acme/widget", Ref: "v1"}, - }}, - }}, - } - - EnrichImpostorFindings(context.Background(), report, tl, rc, pinpool.New(0, nil)) - - f := report.Workflows[0].Findings[0] - if f.RecommendedTag != "v1.0.0" { - t.Errorf("expected v1.0.0, got %q", f.RecommendedTag) - } - if f.RecommendedSHA != "aaaaaaa1111111111111111111111111111111aa" { - t.Errorf("unexpected sha %q", f.RecommendedSHA) - } -} diff --git a/internal/pipeline/checks/misleading.go b/internal/pipeline/checks/misleading.go index fcd16297..f5219e7e 100644 --- a/internal/pipeline/checks/misleading.go +++ b/internal/pipeline/checks/misleading.go @@ -49,8 +49,6 @@ func checkMisleadingSha(ctx context.Context, pw ParsedWorkflow, r CheckResolver) // upgraded to LockfileForgery (mutually exclusive with ref-moved). // When the observed SHA is itself unreachable from any branch of the // upstream repo (tag-moved-to-fork-network), an additional -// ImpostorCommit finding is emitted alongside ref-moved / -// ancestry-unknown. Forgery suppresses the observed-SHA impostor: the // lockfile-tampering claim is stronger. func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex map[string]lockedPin, r CheckResolver) []Finding { var out []Finding @@ -77,7 +75,6 @@ func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex ma case resolve.AncestryNotAncestor: // Compare API gave an authoritative not-an-ancestor verdict. // Forgery wins: don't double-flag with an observed-SHA - // impostor finding. f.Category = LockfileForgery f.Severity = SeverityError f.Confidence = ConfidenceHigh @@ -96,11 +93,6 @@ func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex ma f.Detail = fmt.Sprintf("ref %s now resolves to %s, lockfile pins %s (ancestry check inconclusive%s)", ref.Ref, parserlock.ShortSHA(sha), parserlock.ShortSHA(pin.SHA()), suffixWith(ancestryDetail)) f.Remediation = "retry when the Compare API is available to classify this as ref-moved or lockfile-forgery" out = append(out, f) - // Inconclusive ancestry doesn't block a branch_commits check - // on the observed SHA. - if imp, ok := liveRefImpostorFinding(pw, ref, sha, r); ok { - out = append(out, imp) - } default: // AncestryConfirmed: routine release. f.Category = RefMoved @@ -109,31 +101,11 @@ func checkRefMovedAndForgery(ctx context.Context, pw ParsedWorkflow, depIndex ma f.Detail = fmt.Sprintf("ref %s now resolves to %s, lockfile pins %s", ref.Ref, parserlock.ShortSHA(sha), parserlock.ShortSHA(pin.SHA())) f.Remediation = "re-run `gh actions-lock` to refresh the lock entry" out = append(out, f) - if imp, ok := liveRefImpostorFinding(pw, ref, sha, r); ok { - out = append(out, imp) - } } } return out } -// liveRefImpostorFinding returns an impostor-commit finding when the -// observed SHA is not reachable from any branch of the upstream repo -// (the tag-hijacked-to-fork-network shape). Unknown reachability fails -// open. Caller must suppress this in the forgery branch. -func liveRefImpostorFinding(pw ParsedWorkflow, ref parserlock.ActionRef, observedSHA string, r CheckResolver) (Finding, bool) { - status := r.CheckReachability(ref.Owner, ref.Repo, observedSHA, ref.Ref) - if status != resolve.Unreachable { - return Finding{}, false - } - f := newRefFinding(pw, ref, ImpostorCommit, SeverityError, ConfidenceHigh) - f.ObservedSHA = observedSHA - f.Dependency = synthDep(ref, observedSHA) - f.Detail = fmt.Sprintf("ref %s now resolves to %s — not on any branch of %s/%s (fork-network injection)", ref.Ref, parserlock.ShortSHA(observedSHA), ref.Owner, ref.Repo) - f.Remediation = "investigate immediately — the upstream ref has been moved to a commit that is not in this repo's branch history" - return f, true -} - // suffixWith renders an optional detail as ": " for inline // concatenation, returning empty when detail is empty. func suffixWith(detail string) string { @@ -142,42 +114,3 @@ func suffixWith(detail string) string { } return ": " + detail } - -// checkImpostorCommit emits ImpostorCommit when the locked SHA is -// not reachable from the ref's history. Skips entries already covered by -// a forgery finding (forgery is the stronger signal). -func checkImpostorCommit(pw ParsedWorkflow, depIndex map[string]lockedPin, r CheckResolver, forgeryKeys map[string]bool) []Finding { - if len(depIndex) == 0 { - return nil - } - var out []Finding - for _, ref := range pw.Refs { - pin, ok := depIndex[parserlock.IndexKey(ref.Owner, ref.Repo, ref.Ref)] - if !ok { - continue - } - if parserlock.IsFullSha(ref.Ref) { - continue - } - if forgeryKeys[parserlock.IndexKey(ref.Owner, ref.Repo, ref.Ref)] { - continue - } - status := r.CheckReachability(ref.Owner, ref.Repo, pin.SHA(), ref.Ref) - if status != resolve.Unreachable { - // Fail open on ReachabilityUnknown by design: only an - // authoritative Unreachable is an impostor. The inconclusive - // case is surfaced as a ReachabilityUnknown warning by the - // pipeline's reachabilityComplementFindings — emitting it here - // too would double-report every direct dep on an API hiccup. - continue - } - // branch_commits gave an authoritative answer: the locked SHA is - // not on any branch of the upstream repo (fork-network impostor). - f := newRefFinding(pw, ref, ImpostorCommit, SeverityError, ConfidenceHigh) - f.Dependency = synthDep(ref, pin.SHA()) - f.Detail = fmt.Sprintf("locked %s is not reachable from %s — classic fork-network impostor-commit shape", parserlock.ShortSHA(pin.SHA()), ref.Ref) - f.Remediation = "investigate immediately — the lockfile entry may have been injected" - out = append(out, f) - } - return out -} diff --git a/internal/pipeline/checks/parsed.go b/internal/pipeline/checks/parsed.go index 5f886559..3574d56a 100644 --- a/internal/pipeline/checks/parsed.go +++ b/internal/pipeline/checks/parsed.go @@ -20,23 +20,12 @@ type ParsedWorkflow struct { DepsErr error // Resolved, when true, instructs DiagnoseParsed to run this // workflow's diagnostics with a nil resolver. Network-bound checks - // (ref-moved, impostor-commit) are skipped and the engine relies on + // (ref-moved) are skipped and the engine relies on // purely structural validation against the on-disk lockfile. Caller // is asserting "this workflow is already fully resolved" — typically // set on the fast path when every direct ref in the workflow is // already recorded in the lockfile. Resolved bool - // SkipReachWhenUnchanged, when true, instructs DiagnoseParsed to skip - // the per-dep reachability network call for any ExistingDep whose - // (NWO, Ref, SHA) matches an entry in the freshly-resolved live deps - // for this workflow. A Reachable result is synthesized in place. This - // is the per-workflow analogue of the cmd-level fast path: when at - // least one direct ref is new/changed (so the workflow couldn't be - // fully trusted), the remaining unchanged pins still don't need a - // fresh network reachability sweep on every run. Callers should leave - // this false when --rescan or an equivalent "verify everything" flag - // is in effect. - SkipReachWhenUnchanged bool // NonHostedRunner is true when at least one job in the workflow // uses a runs-on label that is not a known GitHub-hosted runner // (self-hosted, custom label, runner group, or expression). These diff --git a/internal/pipeline/checks/resolver.go b/internal/pipeline/checks/resolver.go index 8567c771..304b4a48 100644 --- a/internal/pipeline/checks/resolver.go +++ b/internal/pipeline/checks/resolver.go @@ -23,46 +23,28 @@ type CheckResolver interface { // returns a short human-readable detail alongside the status — the // rate-limit or compare-base detail callers surface to operators. CheckAncestry(ctx context.Context, owner, repo, candidate, head string) (resolve.AncestryStatus, string) - // CheckReachability asks whether sha is reachable from ref's history. - CheckReachability(owner, repo, sha, ref string) resolve.ReachabilityStatus } // prewarmedResolver adapts *resolve.Resolver to CheckResolver. Ref -// resolutions and reachability are pre-computed; ancestry and tag-object -// peels stay on-demand and delegate to the resolver's own cache. +// resolutions are pre-computed; ancestry and tag-object peels stay +// on-demand and delegate to the resolver's own cache. type prewarmedResolver struct { inner *resolve.Resolver - refs map[ghapi.NWORef]string // (owner/repo, ref) -> sha - reach map[ghapi.Reach]resolve.ReachabilityStatus // (owner/repo, sha, ref) -> status + refs map[ghapi.NWORef]string // (owner/repo, ref) -> sha } // NewPrewarmedResolver primes the adapter with the live resolution of -// refs and a pre-computed reachability sweep. Pass live==nil when -// ResolveAllRecursive failed; checks that need a ref will fail open. -// extraReach carries reach results for SHAs outside the canonical -// lockfile sweep — typically the observed SHA of a moved ref. -func NewPrewarmedResolver(r *resolve.Resolver, live []dep.Dependency, reach []resolve.ReachabilityResult, extraReach ...[]resolve.ReachabilityResult) *prewarmedResolver { - extras := 0 - for _, e := range extraReach { - extras += len(e) - } +// refs. Pass live==nil when ResolveAllRecursive failed; checks that +// need a ref will fail open. +func NewPrewarmedResolver(r *resolve.Resolver, live []dep.Dependency) *prewarmedResolver { a := &prewarmedResolver{ inner: r, refs: make(map[ghapi.NWORef]string, len(live)), - reach: make(map[ghapi.Reach]resolve.ReachabilityStatus, len(reach)+extras), } for _, d := range live { owner, repo := d.OwnerRepo() a.refs[ghapi.ForNWORef(owner, repo, d.Ref)] = d.SHA } - for _, rr := range reach { - a.reach[ghapi.ForReach(rr.Owner, rr.Repo, rr.SHA, rr.Ref)] = rr.Status - } - for _, batch := range extraReach { - for _, rr := range batch { - a.reach[ghapi.ForReach(rr.Owner, rr.Repo, rr.SHA, rr.Ref)] = rr.Status - } - } return a } @@ -84,10 +66,3 @@ func (a *prewarmedResolver) CheckAncestry(ctx context.Context, owner, repo, cand } return a.inner.CheckAncestry(ctx, owner, repo, candidate, head) } - -func (a *prewarmedResolver) CheckReachability(owner, repo, sha, ref string) resolve.ReachabilityStatus { - if s, ok := a.reach[ghapi.ForReach(owner, repo, sha, ref)]; ok { - return s - } - return resolve.ReachabilityUnknown -} diff --git a/internal/pipeline/checks/run.go b/internal/pipeline/checks/run.go index 63760074..49bd315b 100644 --- a/internal/pipeline/checks/run.go +++ b/internal/pipeline/checks/run.go @@ -12,7 +12,7 @@ import ( // RunChecks evaluates all enabled validators against the given parsed // workflow and returns findings in catalog order. The lockfile snapshot // scopes the structural checks; the resolver enables the resolver-bound -// checks (misleading-sha, ref-moved, forgery, impostor). When r is nil, +// checks (misleading-sha, ref-moved, forgery). When r is nil, // resolver-bound checks are skipped silently. // // Returned findings have their primitive fields populated, plus @@ -33,7 +33,6 @@ func RunChecks(ctx context.Context, pw ParsedWorkflow, lf parserlock.File, r Che out = append(out, checkMisleadingSha(ctx, pw, r)...) refMoved := checkRefMovedAndForgery(ctx, pw, depIndex, r) out = append(out, refMoved...) - out = append(out, checkImpostorCommit(pw, depIndex, r, collectForgeryKeys(refMoved))...) } return out } @@ -75,23 +74,6 @@ func parseWorkflowDeps(rawDeps []string, deps map[string]parserlock.Action) ([]l return pins, idx } -// collectForgeryKeys returns the set of IndexKeys flagged as forgery so -// the impostor check can skip them. -func collectForgeryKeys(ff []Finding) map[string]bool { - if len(ff) == 0 { - return nil - } - out := make(map[string]bool) - for _, f := range ff { - if f.Category != LockfileForgery || f.ActionRef == nil { - continue - } - ar := f.ActionRef - out[parserlock.IndexKey(ar.Owner, ar.Repo, ar.Ref)] = true - } - return out -} - // newRefFinding builds a Finding with the common header fields populated // from a uses: ref. Category/Severity can be empty when the caller fills // them in based on a downstream branch (e.g. ref-moved vs forgery). diff --git a/internal/pipeline/checks/run_test.go b/internal/pipeline/checks/run_test.go index d1a96cf5..564f20f5 100644 --- a/internal/pipeline/checks/run_test.go +++ b/internal/pipeline/checks/run_test.go @@ -15,18 +15,16 @@ import ( type ( stubRefKey struct{ owner, repo, ref string } stubAncestryKey struct{ owner, repo, cand, head string } - stubReachKey struct{ owner, repo, sha, ref string } stubTagObjectKey struct{ owner, repo, sha string } ) // stubCheckResolver scripts every CheckResolver call from test fixtures. // Missing entries return *Unknown values (fail-open). type stubCheckResolver struct { - refs map[stubRefKey]string // resolved ref → sha; absence = unknown - ancestry map[stubAncestryKey]resolve.AncestryStatus // (cand, head) ancestry decision - ancestryDetails map[stubAncestryKey]string // optional per-key detail string; absence = "" - reach map[stubReachKey]resolve.ReachabilityStatus // sha-reachable-from-ref decision - tagObjects map[stubTagObjectKey]string // sha → peeled commit + refs map[stubRefKey]string // resolved ref → sha; absence = unknown + ancestry map[stubAncestryKey]resolve.AncestryStatus // (cand, head) ancestry decision + ancestryDetails map[stubAncestryKey]string // optional per-key detail string; absence = "" + tagObjects map[stubTagObjectKey]string // sha → peeled commit } func (s *stubCheckResolver) ResolveRef(owner, repo, ref string) (string, bool) { @@ -49,17 +47,6 @@ func (s *stubCheckResolver) CheckAncestry(_ context.Context, owner, repo, cand, return v, s.ancestryDetails[key] } -func (s *stubCheckResolver) CheckReachability(owner, repo, sha, ref string) resolve.ReachabilityStatus { - if s == nil { - return resolve.ReachabilityUnknown - } - v, ok := s.reach[stubReachKey{owner, repo, sha, ref}] - if !ok { - return resolve.ReachabilityUnknown - } - return v -} - func (s *stubCheckResolver) PeelTagObject(_ context.Context, owner, repo, sha string) (string, bool) { if s == nil { return "", false @@ -215,9 +202,6 @@ func TestRunChecks(t *testing.T) { refs: map[stubRefKey]string{ {"actions", "checkout", "v4"}: shaCheckoutV4, }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV4, "v4"}: resolve.Reachable, - }, }, wantCategories: nil, }, @@ -234,9 +218,6 @@ func TestRunChecks(t *testing.T) { ancestry: map[stubAncestryKey]resolve.AncestryStatus{ {"actions", "checkout", shaCheckoutV3, shaCheckoutV4}: resolve.AncestryConfirmed, }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - }, }, wantCategories: []Category{RefMoved}, extra: func(t *testing.T, got []Finding) { @@ -280,20 +261,6 @@ func TestRunChecks(t *testing.T) { } }, }, - { - name: "impostor-commit: sha unreachable from ref and resolver doesn't know ref", - lockfile: map[string][]string{ - wfPath: {checkPinKey("actions", "checkout", "v4", shaImpostor)}, - }, - workflowRefs: []parserlock.ActionRef{checkRef("actions", "checkout", "v4")}, - resolver: &stubCheckResolver{ - // Resolver doesn't know the ref → no ref-moved / forgery path. - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaImpostor, "v4"}: resolve.Unreachable, - }, - }, - wantCategories: []Category{ImpostorCommit}, - }, { name: "misleading-sha: sha-shaped ref resolves to different commit", lockfile: map[string][]string{}, @@ -370,9 +337,6 @@ func TestRunChecks(t *testing.T) { {"actions", "checkout", "v4"}: shaCheckoutV4, }, // No ancestry entry → stub returns AncestryUnknown. - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - }, }, wantCategories: []Category{AncestryUnknown}, extra: func(t *testing.T, got []Finding) { @@ -404,9 +368,6 @@ func TestRunChecks(t *testing.T) { ancestry: map[stubAncestryKey]resolve.AncestryStatus{ {"actions", "checkout", shaCheckoutV3, shaCheckoutV4}: resolve.AncestryConfirmed, }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - }, }, wantCategories: []Category{RefMoved}, extra: func(t *testing.T, got []Finding) { @@ -433,9 +394,6 @@ func TestRunChecks(t *testing.T) { ancestryDetails: map[stubAncestryKey]string{ {"actions", "checkout", shaCheckoutV3, shaCheckoutV4}: "rate limited (HTTP 429); resets at 1717552800; retry budget exhausted after 3 attempts", }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - }, }, wantCategories: []Category{AncestryUnknown}, extra: func(t *testing.T, got []Finding) { @@ -455,61 +413,9 @@ func TestRunChecks(t *testing.T) { // "ahead": the live SHA descends from the lockfile // commit (its parent is a real descendant), so ref-moved // would otherwise be the only finding. The new - // liveRefImpostorFinding catches the live-SHA branch - // unreachability and escalates with a parallel - // impostor-commit error. - name: "ref-moved + impostor-commit: tag hijacked to fork-network commit", - lockfile: map[string][]string{ - wfPath: {checkPinKey("actions", "checkout", "v4", shaCheckoutV3)}, - }, - workflowRefs: []parserlock.ActionRef{checkRef("actions", "checkout", "v4")}, - resolver: &stubCheckResolver{ - refs: map[stubRefKey]string{ - {"actions", "checkout", "v4"}: shaImpostor, - }, - ancestry: map[stubAncestryKey]resolve.AncestryStatus{ - {"actions", "checkout", shaCheckoutV3, shaImpostor}: resolve.AncestryConfirmed, - }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - {"actions", "checkout", shaImpostor, "v4"}: resolve.Unreachable, - }, - }, - wantCategories: []Category{ImpostorCommit, RefMoved}, - extra: func(t *testing.T, got []Finding) { - var impostor, refMoved *Finding - for i := range got { - switch got[i].Category { - case ImpostorCommit: - impostor = &got[i] - case RefMoved: - refMoved = &got[i] - } - } - if impostor == nil || refMoved == nil { - t.Fatalf("expected both impostor-commit and ref-moved, got %v", findingCategories(got)) - } - if impostor.Severity != SeverityError { - t.Errorf("impostor severity: got %s, want error", impostor.Severity) - } - if impostor.ObservedSHA != shaImpostor { - t.Errorf("impostor ObservedSHA: got %q, want %q (live SHA, the actual impostor)", impostor.ObservedSHA, shaImpostor) - } - if impostor.Dependency == nil || impostor.Dependency.SHA != shaImpostor { - t.Errorf("impostor Dependency.SHA: want live %q, got %#v (must differ from ref-moved finding so consumers can tell them apart)", shaImpostor, impostor.Dependency) - } - if refMoved.Dependency == nil || refMoved.Dependency.SHA != shaCheckoutV3 { - t.Errorf("ref-moved Dependency.SHA: want locked %q, got %#v", shaCheckoutV3, refMoved.Dependency) - } - if !strings.Contains(impostor.Detail, "fork-network injection") { - t.Errorf("impostor Detail: want fork-network wording, got %q", impostor.Detail) - } - }, - }, - { // Negative: when the live SHA *is* reachable from a // branch, the move is benign (release-train style). - // Only ref-moved should fire — no parallel impostor. + // Only ref-moved should fire. name: "ref-moved only: live SHA reachable means benign move", lockfile: map[string][]string{ wfPath: {checkPinKey("actions", "checkout", "v4", shaCheckoutV3)}, @@ -522,43 +428,13 @@ func TestRunChecks(t *testing.T) { ancestry: map[stubAncestryKey]resolve.AncestryStatus{ {"actions", "checkout", shaCheckoutV3, shaCheckoutV4}: resolve.AncestryConfirmed, }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - {"actions", "checkout", shaCheckoutV4, "v4"}: resolve.Reachable, - }, - }, - wantCategories: []Category{RefMoved}, - }, - { - // Fail-open: reach result Unknown for live SHA (cache miss, - // rate limit) must not escalate to impostor-commit. Same - // fallback policy as the locked-SHA path. - name: "ref-moved only: live-SHA reachability unknown stays benign", - lockfile: map[string][]string{ - wfPath: {checkPinKey("actions", "checkout", "v4", shaCheckoutV3)}, - }, - workflowRefs: []parserlock.ActionRef{checkRef("actions", "checkout", "v4")}, - resolver: &stubCheckResolver{ - refs: map[stubRefKey]string{ - {"actions", "checkout", "v4"}: shaCheckoutV4, - }, - ancestry: map[stubAncestryKey]resolve.AncestryStatus{ - {"actions", "checkout", shaCheckoutV3, shaCheckoutV4}: resolve.AncestryConfirmed, - }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - // no live-SHA entry → ReachabilityUnknown - }, }, wantCategories: []Category{RefMoved}, }, { - // Forgery suppression: when ancestry says - // AncestryNotAncestor the lockfile is forged. Do NOT - // emit a parallel impostor-commit even if the live SHA - // is also unreachable — forgery is the stronger claim - // and double-flagging clutters without adding action. - name: "forgery suppresses live impostor", + // Forgery: when ancestry says AncestryNotAncestor the + // lockfile is forged. + name: "forgery: ancestry not ancestor", lockfile: map[string][]string{ wfPath: {checkPinKey("actions", "checkout", "v4", shaImpostor)}, }, @@ -570,35 +446,23 @@ func TestRunChecks(t *testing.T) { ancestry: map[stubAncestryKey]resolve.AncestryStatus{ {"actions", "checkout", shaImpostor, shaCheckoutV4}: resolve.AncestryNotAncestor, }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV4, "v4"}: resolve.Unreachable, - }, }, extra: func(t *testing.T, got []Finding) { - cats := findingCategories(got) - for _, c := range cats { - if c == string(ImpostorCommit) { - t.Fatalf("forgery branch must not emit parallel impostor-commit, got %v", cats) - } - } hasForgery := false - for _, c := range cats { + for _, c := range findingCategories(got) { if c == string(LockfileForgery) { hasForgery = true } } if !hasForgery { - t.Fatalf("expected lockfile-forgery, got %v", cats) + t.Fatalf("expected lockfile-forgery, got %v", findingCategories(got)) } }, }, { - // AncestryUnknown + live SHA unreachable: both signals - // must surface. ancestry-unknown says "we can't tell if - // this is a release move or a forgery", impostor-commit - // says "live SHA is on no branch — investigate". They - // answer different questions, so emit both. - name: "ancestry-unknown + impostor: independent signals coexist", + // AncestryUnknown: when neither confirmed nor denied, + // emit ancestry-unknown warning. + name: "ancestry-unknown: inconclusive check", lockfile: map[string][]string{ wfPath: {checkPinKey("actions", "checkout", "v4", shaCheckoutV3)}, }, @@ -608,12 +472,8 @@ func TestRunChecks(t *testing.T) { {"actions", "checkout", "v4"}: shaImpostor, }, // No ancestry entry → AncestryUnknown. - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - {"actions", "checkout", shaImpostor, "v4"}: resolve.Unreachable, - }, }, - wantCategories: []Category{AncestryUnknown, ImpostorCommit}, + wantCategories: []Category{AncestryUnknown}, }, } @@ -677,9 +537,6 @@ func TestRunChecks_AllFindingsCarryConfidence(t *testing.T) { ancestry: map[stubAncestryKey]resolve.AncestryStatus{ {"actions", "checkout", shaCheckoutV3, shaCheckoutV4}: resolve.AncestryConfirmed, }, - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaCheckoutV3, "v4"}: resolve.Reachable, - }, } got := RunChecks(context.Background(), pw, lf, r) if len(got) == 0 { @@ -691,65 +548,3 @@ func TestRunChecks_AllFindingsCarryConfidence(t *testing.T) { } } } - -// --- impostor-commit fail-open guards ------------------------------------- -// -// The locked-SHA impostor check (checkImpostorCommit) must only fire on an -// authoritative Unreachable verdict. When reachability is Unknown (rate -// limit / transient API failure) it must fail open: a scanner that cried -// "injected lockfile entry" every time the GitHub API hiccupped would be -// worse than useless. These tests pin that contract. - -func impostorFixture(reach resolve.ReachabilityStatus) (ParsedWorkflow, map[string]lockedPin, *stubCheckResolver) { - ref := checkRef("actions", "checkout", "v4") - pw := checkParsedWF(".github/workflows/ci.yml", ref) - depIndex := map[string]lockedPin{ - parserlock.IndexKey("actions", "checkout", "v4"): { - Pin: parserlock.Pin{NWO: "actions/checkout", Owner: "actions", Repo: "checkout", Ref: "v4"}, - Commit: "sha1-" + shaImpostor, - }, - } - r := &stubCheckResolver{ - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaImpostor, "v4"}: reach, - }, - } - return pw, depIndex, r -} - -func TestCheckImpostorCommit_UnreachableEmitsFinding(t *testing.T) { - pw, depIndex, r := impostorFixture(resolve.Unreachable) - - out := checkImpostorCommit(pw, depIndex, r, nil) - if len(out) != 1 { - t.Fatalf("Unreachable: got %d findings, want 1 (%v)", len(out), findingCategories(out)) - } - if out[0].Category != ImpostorCommit { - t.Fatalf("category = %v, want ImpostorCommit", out[0].Category) - } -} - -func TestCheckImpostorCommit_ReachabilityUnknownFailsOpen(t *testing.T) { - pw, depIndex, r := impostorFixture(resolve.ReachabilityUnknown) - - out := checkImpostorCommit(pw, depIndex, r, nil) - for _, f := range out { - if f.Category == ImpostorCommit { - t.Fatalf("Unknown reachability must not emit ImpostorCommit (got %v)", findingCategories(out)) - } - } -} - -func TestLiveRefImpostorFinding_ReachabilityUnknownFailsOpen(t *testing.T) { - ref := checkRef("actions", "checkout", "v4") - pw := checkParsedWF(".github/workflows/ci.yml", ref) - r := &stubCheckResolver{ - reach: map[stubReachKey]resolve.ReachabilityStatus{ - {"actions", "checkout", shaImpostor, "v4"}: resolve.ReachabilityUnknown, - }, - } - - if _, ok := liveRefImpostorFinding(pw, ref, shaImpostor, r); ok { - t.Fatal("Unknown reachability must not produce a live-ref impostor finding") - } -} diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index 8695444e..1b9b664e 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -93,10 +93,9 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve populateInventoryParents(wr.Inventory, parentMap) } - reach, liveMovedReach, liveDirectReach := reachabilitySweeps(ctx, pw, r, liveDeps) var checkR checks.CheckResolver if r != nil && liveDeps != nil { - checkR = checks.NewPrewarmedResolver(r, liveDeps, reach, liveMovedReach, liveDirectReach) + checkR = checks.NewPrewarmedResolver(r, liveDeps) } rawFindings := checks.RunChecks(ctx, pw, store.File(), checkR) @@ -110,13 +109,6 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve wr.Findings = append(wr.Findings, f) } - if len(reach) > 0 { - wr.Findings = append(wr.Findings, reachabilityComplementFindings(pw.Path, reach, pw.ExistingDeps, directNWOs, parentMap, wr.Findings)...) - } - if len(liveDirectReach) > 0 { - wr.Findings = append(wr.Findings, liveReachImpostorFindings(pw.Path, liveDirectReach, liveDeps, directNWOs, parentMap, wr.Findings)...) - } - if !hasIssues(wr.Findings) { wr.Findings = append(wr.Findings, checks.Finding{ WorkflowPath: pw.Path, @@ -249,43 +241,6 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W return wr, false } -// reachabilitySweeps runs three independent reachability passes: the locked-SHA -// sweep, the tag-moved live-SHA sweep, and the pin-time parity sweep. Each pass -// returns its own set so their (NWO, Ref, SHA) keys stay unmixed downstream. -func reachabilitySweeps(ctx context.Context, pw checks.ParsedWorkflow, r *resolve.Resolver, liveDeps []dep.Dependency) (reach, liveMovedReach, liveDirectReach []resolve.ReachabilityResult) { - if r != nil && len(pw.ExistingDeps) > 0 { - toCheck, trusted := partitionReachByLive(pw.ExistingDeps, liveDeps, pw.SkipReachWhenUnchanged) - reach = trusted - if len(toCheck) > 0 { - reach = append(reach, r.CheckReachabilityAll(ctx, toCheck)...) - } - } - // Independent sweep for LIVE SHAs whose tag has moved: the - // tag-hijacked-to-fork-network shape is invisible to the locked-SHA - // sweep above (the lockfile entry is still legitimate; the live - // SHA is the impostor). Kept separate so the result map's - // (NWO, Ref, SHA) keys don't shadow the lockfile sweep — they - // share NWO@Ref dep keys, which would confuse - // reachabilityComplementFindings if mixed into `reach`. - if r != nil && len(liveDeps) > 0 && len(pw.ExistingDeps) > 0 { - if moved := liveMovedDeps(pw.ExistingDeps, liveDeps); len(moved) > 0 { - liveMovedReach = r.CheckReachabilityAll(ctx, moved) - } - } - // Pin-time parity sweep: any (NWO, Ref, LIVE SHA) that neither the - // locked-SHA sweep nor the tag-moved sweep covers gets a fresh reach - // check here. Catches the NotPinned-direct impostor case and any - // transitive composite live dep that isn't in the lockfile yet. With - // this in place, applyPin's reach-loop Unreachable branch becomes a - // fail-loud invariant rather than a primary detection path. - if r != nil && len(liveDeps) > 0 { - if extra := liveDirectReachDeps(pw, liveDeps); len(extra) > 0 { - liveDirectReach = r.CheckReachabilityAll(ctx, extra) - } - } - return reach, liveMovedReach, liveDirectReach -} - func indexDeps(deps []dep.Dependency) map[string]dep.Dependency { out := make(map[string]dep.Dependency, len(deps)) for _, dep := range deps { diff --git a/internal/pipeline/diagnose_helpers_test.go b/internal/pipeline/diagnose_helpers_test.go index 376487eb..29dc788e 100644 --- a/internal/pipeline/diagnose_helpers_test.go +++ b/internal/pipeline/diagnose_helpers_test.go @@ -46,7 +46,7 @@ func TestHasIssues(t *testing.T) { { name: "error severity is always an issue", findings: []checks.Finding{ - {Category: checks.ImpostorCommit, Severity: checks.SeverityError}, + {Category: checks.LockfileForgery, Severity: checks.SeverityError}, }, want: true, }, diff --git a/internal/pipeline/doc_urls.go b/internal/pipeline/doc_urls.go index 1af6919a..17f55e7d 100644 --- a/internal/pipeline/doc_urls.go +++ b/internal/pipeline/doc_urls.go @@ -16,7 +16,7 @@ import "github.com/github/gh-actions-lock/internal/pipeline/checks" const securityHardeningBase = "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions" // PublisherTagReleasesDocURL points to GitHub's guidance for action publishers -// on tagging releases from a branch. It's surfaced alongside impostor-commit +// on tagging releases from a branch. It's surfaced alongside lockfile-forgery // findings to help users escalate to the action's maintainer when the pinned // SHA is orphaned (off any branch) — a publisher behavior the consumer can't // fix locally beyond re-pinning to a sane release. @@ -28,13 +28,6 @@ const PublisherTagReleasesDocURL = "https://docs.github.com/en/actions/how-tos/c // a branch. const PublisherEscalationCopy = "Ask the action maintainer to tag releases from a branch" -// ImpostorCommitContext explains why off-branch commits are dangerous. -// Shown just before the escalation copy so users understand the risk. -const ImpostorCommitContext = "Off-branch commits are indistinguishable from impostor commits" - -// docURLs maps every Category that can appear on a checks.Finding to its -// documentation URL. Categories representing "no issue" (Valid, RunOnly) -// have no URL — they aren't rendered as findings. var docURLs = map[checks.Category]string{ checks.NotPinned: securityHardeningBase + "#using-third-party-actions", checks.ShaAsRef: securityHardeningBase + "#using-third-party-actions", @@ -43,7 +36,6 @@ var docURLs = map[checks.Category]string{ checks.MisleadingSHA: securityHardeningBase + "#using-third-party-actions", checks.RefMoved: securityHardeningBase + "#using-third-party-actions", checks.LockfileForgery: securityHardeningBase + "#using-third-party-actions", - checks.ImpostorCommit: securityHardeningBase + "#using-third-party-actions", checks.OnboardingRequired: securityHardeningBase + "#using-third-party-actions", checks.AncestryUnknown: securityHardeningBase + "#using-third-party-actions", checks.ReachabilityUnknown: securityHardeningBase + "#using-third-party-actions", diff --git a/internal/pipeline/impostor_parity_test.go b/internal/pipeline/impostor_parity_test.go deleted file mode 100644 index 3cc70ac4..00000000 --- a/internal/pipeline/impostor_parity_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package pipeline - -import ( - "testing" - - "github.com/github/gh-actions-lock/internal/pipeline/checks" - - "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/ghapi" - "github.com/github/gh-actions-lock/internal/resolve" -) - -const ( - testShaImpostor = "ffffffffffffffffffffffffffffffffffffffff" - testShaCheckoutV4 = "8e8c483db84b4bee98b60c0593521ed34d9990e8" - testShaCheckoutV3 = "11bd71901bbe5b1630ceea73d27597364c9af683" - testShaSetupGoV5 = "0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -) - -// TestLiveReachImpostorFindings_Parity proves the pre-pin live-direct sweep -// emits checks.ImpostorCommit for every shape that today's post-pin -// alertImpostor sweep would catch (and only those shapes). Each case is the -// pre-condition for deleting AutoFixAlertedImposters from apply.go. -func TestLiveReachImpostorFindings_Parity(t *testing.T) { - const wfPath = ".github/workflows/ci.yml" - - directNWO := map[ghapi.Repo]bool{ - ghapi.ForRepo("actions", "checkout"): true, - } - transitiveDirectNWO := map[ghapi.Repo]bool{ - ghapi.ForRepo("actions", "cache"): true, - // someorg/helper is transitive, no entry here - } - - tests := []struct { - name string - reach []resolve.ReachabilityResult - live []dep.Dependency - directNWOs map[ghapi.Repo]bool - parentMap map[string][]string - existing []checks.Finding - wantCount int - wantCategory checks.Category - wantParentSet bool - wantSHA string - }{ - { - name: "unpinned direct ref resolves to unreachable SHA", - reach: []resolve.ReachabilityResult{{ - Owner: "actions", Repo: "checkout", Ref: "v4", SHA: testShaImpostor, - DepKey: "actions/checkout@v4", - Status: resolve.Unreachable, - Detail: "no branch contains commit", - }}, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaImpostor}, - }, - directNWOs: directNWO, - wantCount: 1, - wantCategory: checks.ImpostorCommit, - wantSHA: testShaImpostor, - }, - { - name: "unpinned transitive dep (different NWO) resolves to unreachable SHA", - reach: []resolve.ReachabilityResult{{ - Owner: "someorg", Repo: "helper", Ref: "v1", SHA: testShaImpostor, - DepKey: "someorg/helper@v1", - Status: resolve.Unreachable, - }}, - live: []dep.Dependency{ - {NWO: "someorg/helper", Ref: "v1", SHA: testShaImpostor}, - }, - directNWOs: transitiveDirectNWO, - parentMap: map[string][]string{ - "someorg/helper@v1": {"actions/cache@v4"}, - }, - wantCount: 1, - wantCategory: checks.ImpostorCommit, - wantParentSet: true, - wantSHA: testShaImpostor, - }, - { - name: "reachable live SHA emits nothing", - reach: []resolve.ReachabilityResult{{ - Owner: "actions", Repo: "checkout", Ref: "v4", SHA: testShaCheckoutV4, - DepKey: "actions/checkout@v4", - Status: resolve.Reachable, - }}, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - }, - directNWOs: directNWO, - wantCount: 0, - }, - { - name: "suppressed when prior checks.ImpostorCommit already covers dep", - reach: []resolve.ReachabilityResult{{ - Owner: "actions", Repo: "checkout", Ref: "v4", SHA: testShaImpostor, - DepKey: "actions/checkout@v4", - Status: resolve.Unreachable, - }}, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaImpostor}, - }, - directNWOs: directNWO, - existing: []checks.Finding{{ - WorkflowPath: wfPath, - Category: checks.ImpostorCommit, - Dependency: &dep.Dependency{NWO: "actions/checkout", Ref: "v4", SHA: testShaImpostor}, - }}, - wantCount: 0, - }, - { - name: "suppressed when prior checks.LockfileForgery covers dep", - reach: []resolve.ReachabilityResult{{ - Owner: "actions", Repo: "checkout", Ref: "v4", SHA: testShaImpostor, - DepKey: "actions/checkout@v4", - Status: resolve.Unreachable, - }}, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaImpostor}, - }, - directNWOs: directNWO, - existing: []checks.Finding{{ - WorkflowPath: wfPath, - Category: checks.LockfileForgery, - Dependency: &dep.Dependency{NWO: "actions/checkout", Ref: "v4", SHA: testShaImpostor}, - }}, - wantCount: 0, - }, - { - name: "unknown status emits nothing (only Unreachable fires)", - reach: []resolve.ReachabilityResult{{ - Owner: "actions", Repo: "checkout", Ref: "v4", SHA: testShaImpostor, - DepKey: "actions/checkout@v4", - Status: resolve.ReachabilityUnknown, - }}, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaImpostor}, - }, - directNWOs: directNWO, - wantCount: 0, - }, - { - name: "deduplicates second reach result for same dep", - reach: []resolve.ReachabilityResult{ - { - Owner: "actions", Repo: "checkout", Ref: "v4", SHA: testShaImpostor, - DepKey: "actions/checkout@v4", - Status: resolve.Unreachable, - }, - { - Owner: "actions", Repo: "checkout", Ref: "v4", SHA: testShaImpostor, - DepKey: "actions/checkout@v4", - Status: resolve.Unreachable, - }, - }, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaImpostor}, - }, - directNWOs: directNWO, - wantCount: 1, - wantCategory: checks.ImpostorCommit, - wantSHA: testShaImpostor, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := liveReachImpostorFindings(wfPath, tc.reach, tc.live, tc.directNWOs, tc.parentMap, tc.existing) - if len(got) != tc.wantCount { - t.Fatalf("got %d findings, want %d: %+v", len(got), tc.wantCount, got) - } - if tc.wantCount == 0 { - return - } - f := got[0] - if f.Category != tc.wantCategory { - t.Errorf("category = %s, want %s", f.Category, tc.wantCategory) - } - if f.Severity != checks.SeverityError { - t.Errorf("severity = %s, want %s", f.Severity, checks.SeverityError) - } - if f.Confidence != checks.ConfidenceHigh { - t.Errorf("confidence = %s, want %s", f.Confidence, checks.ConfidenceHigh) - } - if f.Dependency == nil || f.Dependency.SHA != tc.wantSHA { - t.Errorf("dependency SHA = %v, want %s", f.Dependency, tc.wantSHA) - } - if tc.wantParentSet && f.ParentNWO == "" { - t.Error("expected ParentNWO to be set for transitive case") - } - if !tc.wantParentSet && f.ParentNWO != "" { - t.Errorf("ParentNWO = %q, want empty for direct case", f.ParentNWO) - } - }) - } -} - -// TestLiveDirectReachDeps_Coverage proves the synthesis function: -// - emits nothing when every live dep is already covered by ExistingDeps -// - emits nothing when every live dep is covered by the live-moved sweep -// (existing dep at the same key, different SHA) -// - emits one entry per uncovered live dep, deduped by (NWO, ref, SHA) -func TestLiveDirectReachDeps_Coverage(t *testing.T) { - tests := []struct { - name string - existing []dep.Dependency - live []dep.Dependency - wantCount int - }{ - { - name: "unpinned: all live deps need a fresh check", - existing: nil, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - {NWO: "actions/setup-go", Ref: "v5", SHA: testShaSetupGoV5}, - }, - wantCount: 2, - }, - { - name: "existing locked SHA covers reach key — skipped", - existing: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - }, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - }, - wantCount: 0, - }, - { - name: "live-moved: existing at same dep key but different SHA — skipped (live-moved sweep handles it)", - existing: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV3}, - }, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - }, - wantCount: 0, - }, - { - name: "partial coverage: existing covers one, live-extra needs check", - existing: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - }, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - {NWO: "actions/setup-go", Ref: "v5", SHA: testShaSetupGoV5}, - }, - wantCount: 1, - }, - { - name: "dedups by reach key", - existing: nil, - live: []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - }, - wantCount: 1, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - pw := checks.ParsedWorkflow{Path: "wf", ExistingDeps: tc.existing} - got := liveDirectReachDeps(pw, tc.live) - if len(got) != tc.wantCount { - t.Fatalf("got %d deps, want %d: %+v", len(got), tc.wantCount, got) - } - }) - } -} - -// TestCollectLiveDirectReachDeps_UnionDedup proves the cmd-level pre-warm -// helper unions per-workflow results and dedupes across workflows. -func TestCollectLiveDirectReachDeps_UnionDedup(t *testing.T) { - parsed := []checks.ParsedWorkflow{ - {Path: "a.yml", ExistingDeps: nil}, - {Path: "b.yml", ExistingDeps: nil}, - } - live := []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - {NWO: "actions/setup-go", Ref: "v5", SHA: testShaSetupGoV5}, - } - got := CollectLiveDirectReachDeps(parsed, live) - if len(got) != 2 { - t.Fatalf("got %d deps, want 2: %+v", len(got), got) - } - - // With one workflow already pinning checkout, only setup-go remains. - parsed[0].ExistingDeps = []dep.Dependency{ - {NWO: "actions/checkout", Ref: "v4", SHA: testShaCheckoutV4}, - } - got = CollectLiveDirectReachDeps(parsed, live) - if len(got) != 1 || got[0].NWO != "actions/setup-go" { - t.Fatalf("got %+v, want only setup-go", got) - } -} diff --git a/internal/pipeline/parse.go b/internal/pipeline/parse.go index ac8d9765..b57b6f92 100644 --- a/internal/pipeline/parse.go +++ b/internal/pipeline/parse.go @@ -22,13 +22,10 @@ import ( func Diagnose(ctx context.Context, paths []string, r *resolve.Resolver, store *lockfile.State, pool *pinpool.Pool) *checks.Report { parsed := ParseAll(paths, store) if r != nil { - refs, deps := CollectResolvable(parsed) + refs, _ := CollectResolvable(parsed) if len(refs) > 0 { _, _, _ = r.ResolveAllRecursive(ctx, refs) } - if len(deps) > 0 { - _ = r.CheckReachabilityAll(ctx, deps) - } } return DiagnoseParsed(ctx, parsed, r, store, pool) } diff --git a/internal/pipeline/reach_findings.go b/internal/pipeline/reach_findings.go deleted file mode 100644 index 734d0a76..00000000 --- a/internal/pipeline/reach_findings.go +++ /dev/null @@ -1,171 +0,0 @@ -package pipeline - -import ( - "fmt" - "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/ghapi" - "github.com/github/gh-actions-lock/internal/pipeline/checks" - "github.com/github/gh-actions-lock/internal/resolve" -) - -// reachabilityComplementFindings covers the cases the engine doesn't: -// - Impostor for transitive (composite-expanded) deps the engine never -// visits because they aren't in workflow uses. -// - Reachability-Unknown warnings for all deps (engine fails open on -// Unknown). Direct + transitive both get a warning so the user knows -// the check was inconclusive. -func reachabilityComplementFindings( - path string, - reach []resolve.ReachabilityResult, - deps []dep.Dependency, - directNWOs map[ghapi.Repo]bool, - parentMap map[string][]string, - existing []checks.Finding, -) []checks.Finding { - if len(reach) == 0 { - return nil - } - - forgeryKeys := map[string]bool{} - for _, f := range existing { - if f.Category == checks.LockfileForgery && f.Dependency != nil { - forgeryKeys[f.Dependency.Key()] = true - } - } - - depByKey := make(map[string]dep.Dependency, len(deps)) - for _, d := range deps { - depByKey[d.Key()] = d - } - - var out []checks.Finding - for _, rr := range reach { - dep, ok := depByKey[rr.DepKey] - if !ok { - continue - } - depCopy := dep - owner, repo := dep.OwnerRepo() - direct := directNWOs[ghapi.ForRepo(owner, repo)] - parent := "" - if parents := parentMap[rr.DepKey]; len(parents) > 0 { - parent = parents[0] - } - switch rr.Status { - case resolve.Unreachable: - if direct { - continue // engine emits impostor for direct uses - } - if forgeryKeys[rr.DepKey] { - continue - } - // High: branch_commits returned an authoritative - // "unreachable" for this transitive pin. - out = append(out, checks.Finding{ - WorkflowPath: path, - Category: checks.ImpostorCommit, - Severity: checks.SeverityError, - Confidence: checks.ConfidenceHigh, - Dependency: &depCopy, - ParentNWO: parent, - Detail: rr.Detail, - Remediation: "investigate immediately — the lockfile entry may have been injected", - DocURL: DocURLFor(checks.ImpostorCommit), - }) - case resolve.ReachabilityUnknown: - remediation := "transitive dependency pinned to a bare SHA — reachability cannot be verified" - if direct { - remediation = "reachability check inconclusive — retry when network/API is available" - } - // Low: we couldn't get a reachability answer at all. - out = append(out, checks.Finding{ - WorkflowPath: path, - Category: checks.ReachabilityUnknown, - Severity: checks.SeverityWarning, - Confidence: checks.ConfidenceLow, - Dependency: &depCopy, - ParentNWO: parent, - Detail: rr.Detail, - Remediation: remediation, - }) - } - } - return out -} - -// liveReachImpostorFindings emits checks.ImpostorCommit for live-resolved -// SHAs that come back Unreachable from the live-direct sweep. Operates on -// synthetic live deps (not pw.ExistingDeps), so it fires for unpinned and -// transitive-not-in-lockfile cases that reachabilityComplementFindings -// (keyed on existing deps) can't see. -// -// Suppresses duplicates against any prior impostor/forgery finding for the -// same dep key — the engine's checkImpostorCommit may have already emitted -// for a direct ref via the live-ref-vs-locked compare in check_misleading. -func liveReachImpostorFindings( - path string, - reach []resolve.ReachabilityResult, - live []dep.Dependency, - directNWOs map[ghapi.Repo]bool, - parentMap map[string][]string, - existing []checks.Finding, -) []checks.Finding { - if len(reach) == 0 { - return nil - } - covered := map[string]bool{} - for _, f := range existing { - if f.Dependency == nil { - continue - } - switch f.Category { - case checks.ImpostorCommit, checks.LockfileForgery: - covered[f.Dependency.Key()] = true - } - } - liveByReachKey := make(map[ghapi.Reach]dep.Dependency, len(live)) - for _, d := range live { - owner, repo := d.OwnerRepo() - liveByReachKey[ghapi.ForReach(owner, repo, d.SHA, d.Ref)] = d - } - var out []checks.Finding - for _, rr := range reach { - if rr.Status != resolve.Unreachable { - continue - } - dep, ok := liveByReachKey[ghapi.ForReach(rr.Owner, rr.Repo, rr.SHA, rr.Ref)] - if !ok { - continue - } - if covered[dep.Key()] { - continue - } - depCopy := dep - owner, repo := dep.OwnerRepo() - direct := directNWOs[ghapi.ForRepo(owner, repo)] - parent := "" - if !direct { - if parents := parentMap[dep.Key()]; len(parents) > 0 { - parent = parents[0] - } - } - detail := rr.Detail - if detail == "" { - detail = fmt.Sprintf("live resolve of %s/%s@%s → %s is not reachable from any branch", owner, repo, dep.Ref, dep.SHA) - } - out = append(out, checks.Finding{ - WorkflowPath: path, - Category: checks.ImpostorCommit, - Severity: checks.SeverityError, - Confidence: checks.ConfidenceHigh, - Dependency: &depCopy, - ParentNWO: parent, - Detail: detail, - Remediation: "investigate immediately — the live ref resolves to a commit that is not reachable from any branch", - DocURL: DocURLFor(checks.ImpostorCommit), - }) - // Mark covered so a second reach result for the same dep doesn't double-emit. - covered[dep.Key()] = true - } - return out -} diff --git a/internal/pipeline/reach_findings_test.go b/internal/pipeline/reach_findings_test.go deleted file mode 100644 index dc81102c..00000000 --- a/internal/pipeline/reach_findings_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package pipeline - -import ( - "sort" - "testing" - - "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/ghapi" - "github.com/github/gh-actions-lock/internal/pipeline/checks" - "github.com/github/gh-actions-lock/internal/resolve" -) - -func reachResult(d dep.Dependency, status resolve.ReachabilityStatus, detail string) resolve.ReachabilityResult { - owner, repo := d.OwnerRepo() - return resolve.ReachabilityResult{ - Owner: owner, - Repo: repo, - Ref: d.Ref, - SHA: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - DepKey: d.Key(), - Status: status, - Detail: detail, - } -} - -func reachCategories(fs []checks.Finding) []checks.Category { - out := make([]checks.Category, 0, len(fs)) - for _, f := range fs { - out = append(out, f.Category) - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -// TestReachabilityComplementFindings locks the division of labor between the -// check engine (checkImpostorCommit) and the pipeline-level complement sweep. -// The engine owns the authoritative direct-Unreachable -> ImpostorCommit -// emission and stays SILENT on Unknown; the complement path must therefore -// (a) suppress direct Unreachable to avoid double-emitting impostor, and -// (b) own the Unknown warning for every dep so a rate-limit hiccup is still -// surfaced as inconclusive rather than swallowed. -func TestReachabilityComplementFindings(t *testing.T) { - d := dep.Dependency{NWO: "actions/checkout", Ref: "v4"} - directNWOs := map[ghapi.Repo]bool{ghapi.ForRepo("actions", "checkout"): true} - transitiveNWOs := map[ghapi.Repo]bool{} - - cases := []struct { - name string - direct bool - status resolve.ReachabilityStatus - forgery bool - want []checks.Category - }{ - { - name: "direct unknown fails open to a warning", - direct: true, status: resolve.ReachabilityUnknown, - want: []checks.Category{checks.ReachabilityUnknown}, - }, - { - name: "direct unreachable is silent (engine owns impostor)", - direct: true, status: resolve.Unreachable, - want: nil, - }, - { - name: "direct reachable emits nothing", - direct: true, status: resolve.Reachable, - want: nil, - }, - { - name: "transitive unreachable emits impostor", - direct: false, status: resolve.Unreachable, - want: []checks.Category{checks.ImpostorCommit}, - }, - { - name: "transitive unreachable under forgery is suppressed", - direct: false, status: resolve.Unreachable, forgery: true, - want: nil, - }, - { - name: "transitive unknown fails open to a warning", - direct: false, status: resolve.ReachabilityUnknown, - want: []checks.Category{checks.ReachabilityUnknown}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - nwos := transitiveNWOs - if tc.direct { - nwos = directNWOs - } - var existing []checks.Finding - if tc.forgery { - dc := d - existing = []checks.Finding{{ - Category: checks.LockfileForgery, - Dependency: &dc, - }} - } - - reach := []resolve.ReachabilityResult{reachResult(d, tc.status, "compare status: diverged")} - got := reachabilityComplementFindings( - ".github/workflows/ci.yml", - reach, - []dep.Dependency{d}, - nwos, - nil, - existing, - ) - - gotCats := reachCategories(got) - if len(gotCats) != len(tc.want) { - t.Fatalf("categories = %v, want %v", gotCats, tc.want) - } - for i := range gotCats { - if gotCats[i] != tc.want[i] { - t.Fatalf("categories = %v, want %v", gotCats, tc.want) - } - } - - // Every emitted finding must carry a confidence (schema invariant). - for _, f := range got { - if f.Confidence == "" { - t.Errorf("finding %s missing confidence", f.Category) - } - } - }) - } -} diff --git a/internal/pipeline/reach_partition.go b/internal/pipeline/reach_partition.go deleted file mode 100644 index 9f0279c5..00000000 --- a/internal/pipeline/reach_partition.go +++ /dev/null @@ -1,270 +0,0 @@ -package pipeline - -import ( - "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/ghapi" - "github.com/github/gh-actions-lock/internal/pipeline/checks" - "github.com/github/gh-actions-lock/internal/resolve" - "strings" -) - -// CollectReachDeps returns the deduplicated union of existing deps across the -// given parsed workflows that will need a fresh reachability network check -// once diagnostics runs. It mirrors the per-workflow partition diagnose -// performs internally (see partitionReachByLive) but operates over the union, -// so callers can pre-warm CheckReachabilityAll once across every unresolved -// workflow instead of paying the per-workflow repo-warmup + per-dep -// concurrency cost serially. Pass live as the result of a single -// ResolveAllRecursive over the union of refs (the resolver cache makes the -// per-workflow re-lookups inside diagnose free). -func CollectReachDeps(parsed []checks.ParsedWorkflow, live []dep.Dependency) []dep.Dependency { - if len(parsed) == 0 { - return nil - } - liveSHA := make(map[string]string, len(live)) - for _, d := range live { - liveSHA[d.Key()] = d.SHA - } - seen := make(map[string]bool) - var out []dep.Dependency - for _, pw := range parsed { - if !pw.SkipReachWhenUnchanged { - // When unchanged-skip isn't active (e.g. --rescan), the per- - // workflow path will check every existing dep. Mirror that so - // pre-warm sees the full set. - for _, d := range pw.ExistingDeps { - if seen[d.Key()] { - continue - } - seen[d.Key()] = true - out = append(out, d) - } - continue - } - for _, d := range pw.ExistingDeps { - sha, ok := liveSHA[d.Key()] - if ok && strings.EqualFold(sha, d.SHA) { - continue - } - if seen[d.Key()] { - continue - } - seen[d.Key()] = true - out = append(out, d) - } - } - return out -} - -// CollectLiveMovedReachDeps returns the deduplicated set of synthetic -// dependencies (NWO, Ref + LIVE SHA) for which a reachability check -// should be pre-warmed. Each entry pairs an existing lockfile dep with -// the LIVE SHA it currently resolves to, when they differ — the input -// that lets the engine emit checks.ImpostorCommit for the -// tag-hijacked-to-fork-network shape. Pass live as the result of a -// single ResolveAllRecursive over the union of refs. -func CollectLiveMovedReachDeps(parsed []checks.ParsedWorkflow, live []dep.Dependency) []dep.Dependency { - if len(parsed) == 0 || len(live) == 0 { - return nil - } - liveSHA := make(map[string]string, len(live)) - liveDep := make(map[string]dep.Dependency, len(live)) - for _, d := range live { - liveSHA[d.Key()] = d.SHA - liveDep[d.Key()] = d - } - seen := make(map[ghapi.Reach]bool) - var out []dep.Dependency - for _, pw := range parsed { - for _, d := range pw.ExistingDeps { - ls, ok := liveSHA[d.Key()] - if !ok || strings.EqualFold(ls, d.SHA) { - continue - } - synthetic := d - synthetic.SHA = ls - // Prefer the live dep's NWO casing if the live resolve has - // one — it's the canonical one returned by the API. - if ld, ok := liveDep[d.Key()]; ok && ld.NWO != "" { - synthetic.NWO = ld.NWO - } - owner, repo := synthetic.OwnerRepo() - k := ghapi.ForReach(owner, repo, synthetic.SHA, synthetic.Ref) - if seen[k] { - continue - } - seen[k] = true - out = append(out, synthetic) - } - } - return out -} - -// liveDirectReachDeps returns live-resolved deps whose (NWO, Ref, SHA) -// isn't already covered by the locked-SHA sweep (partitionReachByLive) or -// the tag-moved sweep (liveMovedDeps), so the engine can give them a -// fresh reachability check before pinning. Covers two pin-time impostor -// shapes that the existing diagnose paths miss: -// -// - NotPinned workflow: no ExistingDep at all, so the locked-SHA sweep -// never runs. Without this, applyPin's reach loop is the only thing -// catching these — diagnose now fires the checks.ImpostorCommit -// finding pre-pin so the auto-fix runs via tryAutoFixImpostors. -// - Transitive composite dep that ResolveAllRecursive discovered but -// isn't yet in the lockfile. The locked-SHA sweep can't see it; the -// live-moved sweep only fires when an ExistingDep exists for the same -// dep key with a different SHA. -// -// Dedup by ghapi.Reach across direct + transitive entries. -func liveDirectReachDeps(pw checks.ParsedWorkflow, live []dep.Dependency) []dep.Dependency { - if len(live) == 0 { - return nil - } - covered := make(map[ghapi.Reach]bool, len(pw.ExistingDeps)+len(live)) - existingByDepKey := make(map[string]dep.Dependency, len(pw.ExistingDeps)) - for _, d := range pw.ExistingDeps { - owner, repo := d.OwnerRepo() - covered[ghapi.ForReach(owner, repo, d.SHA, d.Ref)] = true - existingByDepKey[d.Key()] = d - } - for _, d := range live { - ed, ok := existingByDepKey[d.Key()] - if !ok || strings.EqualFold(ed.SHA, d.SHA) { - continue - } - owner, repo := d.OwnerRepo() - covered[ghapi.ForReach(owner, repo, d.SHA, d.Ref)] = true - } - seen := make(map[ghapi.Reach]bool, len(live)) - var out []dep.Dependency - for _, d := range live { - owner, repo := d.OwnerRepo() - k := ghapi.ForReach(owner, repo, d.SHA, d.Ref) - if covered[k] || seen[k] { - continue - } - seen[k] = true - out = append(out, d) - } - return out -} - -// CollectLiveDirectReachDeps is the cmd-level pre-warm analogue of -// liveDirectReachDeps. Returns the deduplicated set of synthetic live -// deps across all parsed workflows that need a fresh reachability check -// because they're outside both the locked-SHA and live-moved sweeps. On -// a fully steady-state lockfile this is empty; on a brand-new repo (no -// lockfile yet) it's the full live set. -func CollectLiveDirectReachDeps(parsed []checks.ParsedWorkflow, live []dep.Dependency) []dep.Dependency { - if len(parsed) == 0 || len(live) == 0 { - return nil - } - covered := make(map[ghapi.Reach]bool) - existingByDepKey := make(map[string]dep.Dependency) - for _, pw := range parsed { - for _, d := range pw.ExistingDeps { - owner, repo := d.OwnerRepo() - covered[ghapi.ForReach(owner, repo, d.SHA, d.Ref)] = true - existingByDepKey[d.Key()] = d - } - } - for _, d := range live { - ed, ok := existingByDepKey[d.Key()] - if !ok || strings.EqualFold(ed.SHA, d.SHA) { - continue - } - owner, repo := d.OwnerRepo() - covered[ghapi.ForReach(owner, repo, d.SHA, d.Ref)] = true - } - seen := make(map[ghapi.Reach]bool, len(live)) - var out []dep.Dependency - for _, d := range live { - owner, repo := d.OwnerRepo() - k := ghapi.ForReach(owner, repo, d.SHA, d.Ref) - if covered[k] || seen[k] { - continue - } - seen[k] = true - out = append(out, d) - } - return out -} - -// liveMovedDeps is the per-workflow analogue of CollectLiveMovedReachDeps. -// Returns synthetic (NWO, Ref, LIVE SHA) deps for any existing dep whose -// live resolve differs from the recorded SHA. -func liveMovedDeps(existing, live []dep.Dependency) []dep.Dependency { - if len(existing) == 0 || len(live) == 0 { - return nil - } - liveSHA := make(map[string]string, len(live)) - liveDep := make(map[string]dep.Dependency, len(live)) - for _, d := range live { - liveSHA[d.Key()] = d.SHA - liveDep[d.Key()] = d - } - seen := make(map[ghapi.Reach]bool) - var out []dep.Dependency - for _, d := range existing { - ls, ok := liveSHA[d.Key()] - if !ok || strings.EqualFold(ls, d.SHA) { - continue - } - synthetic := d - synthetic.SHA = ls - if ld, ok := liveDep[d.Key()]; ok && ld.NWO != "" { - synthetic.NWO = ld.NWO - } - owner, repo := synthetic.OwnerRepo() - k := ghapi.ForReach(owner, repo, synthetic.SHA, synthetic.Ref) - if seen[k] { - continue - } - seen[k] = true - out = append(out, synthetic) - } - return out -} - -// partitionReachByLive splits existing deps into the set that needs a fresh -// reachability network check and the set that can be synthesized as -// Reachable because the freshly-resolved live deps confirm the recorded -// (NWO, Ref, SHA) is still what the ref resolves to right now. -// -// When skipUnchanged is false, every existing dep goes to toCheck. This -// is the --rescan path: re-verify every recorded pin against current -// upstream branches. -func partitionReachByLive(existing, live []dep.Dependency, skipUnchanged bool) (toCheck []dep.Dependency, trusted []resolve.ReachabilityResult) { - if !skipUnchanged || len(live) == 0 { - return existing, nil - } - liveSHA := make(map[string]string, len(live)) - for _, d := range live { - liveSHA[d.Key()] = d.SHA - } - for _, d := range existing { - sha, ok := liveSHA[d.Key()] - if !ok || !strings.EqualFold(sha, d.SHA) { - toCheck = append(toCheck, d) - continue - } - owner, repo := d.OwnerRepo() - trusted = append(trusted, resolve.ReachabilityResult{ - Owner: owner, - Repo: repo, - Ref: d.Ref, - SHA: d.SHA, - DepKey: d.Key(), - Status: resolve.Reachable, - Detail: "lockfile entry unchanged and live resolve confirms SHA — prior reachability verification retained", - }) - } - return toCheck, trusted -} - -// reachabilityComplementFindings covers the cases the engine doesn't: -// - Impostor for transitive (composite-expanded) deps the engine never -// visits because they aren't in workflow uses. -// - Reachability-Unknown warnings for all deps (engine fails open on -// Unknown). Direct + transitive both get a warning so the user knows -// the check was inconclusive. diff --git a/internal/pipeline/resolver_test.go b/internal/pipeline/resolver_test.go deleted file mode 100644 index 8c2bc3d6..00000000 --- a/internal/pipeline/resolver_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package pipeline - -import ( - "testing" - - "github.com/github/gh-actions-lock/internal/pipeline/checks" - - "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/resolve" -) - -// TestPrewarmedResolver_LockedAndLiveCoexist verifies that locked-SHA -// and observed-SHA reach results for the same NWO@Ref both survive in -// the prewarmedResolver cache (the cache key includes the SHA). -func TestPrewarmedResolver_LockedAndLiveCoexist(t *testing.T) { - const ( - owner = "owner" - repo = "repo" - ref = "tampered" - locked = "ea53476fdc172d8552df5af9658a45a367e4f41d" - live = "7b403c9ec14b00000000000000000000deadbeef" - ) - locks := []resolve.ReachabilityResult{ - {Owner: owner, Repo: repo, Ref: ref, SHA: locked, Status: resolve.Reachable}, - } - lives := []resolve.ReachabilityResult{ - {Owner: owner, Repo: repo, Ref: ref, SHA: live, Status: resolve.Unreachable}, - } - pw := checks.NewPrewarmedResolver(nil, nil, locks, lives) - if got := pw.CheckReachability(owner, repo, locked, ref); got != resolve.Reachable { - t.Errorf("locked SHA: got %v, want Reachable", got) - } - if got := pw.CheckReachability(owner, repo, live, ref); got != resolve.Unreachable { - t.Errorf("observed SHA: got %v, want Unreachable", got) - } -} - -func TestCollectLiveMovedReachDeps(t *testing.T) { - mkDep := func(nwo, ref, sha string) dep.Dependency { - return dep.Dependency{NWO: nwo, Ref: ref, SHA: sha} - } - existing := []dep.Dependency{ - mkDep("owner/repo", "v4", "aaaa000000000000000000000000000000000000"), // moved → in output - mkDep("owner/repo", "v3", "bbbb000000000000000000000000000000000000"), // unchanged → skipped - mkDep("owner/repo", "v5", "cccc000000000000000000000000000000000000"), // no live entry → skipped - mkDep("owner/repo", "main", "dddd000000000000000000000000000000000000"), // moved → in output - mkDep("owner/repo", "main", "dddd000000000000000000000000000000000000"), // dup → dedup'd - } - live := []dep.Dependency{ - mkDep("owner/repo", "v4", "1111000000000000000000000000000000000000"), - mkDep("owner/repo", "v3", "bbbb000000000000000000000000000000000000"), - mkDep("owner/repo", "main", "2222000000000000000000000000000000000000"), - } - parsed := []checks.ParsedWorkflow{{Path: ".github/workflows/a.yml", ExistingDeps: existing}} - got := CollectLiveMovedReachDeps(parsed, live) - - if len(got) != 2 { - t.Fatalf("got %d synthetic deps, want 2: %+v", len(got), got) - } - wantSHAs := map[string]bool{ - "1111000000000000000000000000000000000000": false, - "2222000000000000000000000000000000000000": false, - } - for _, d := range got { - if d.Ref == "" || d.SHA == "" { - t.Errorf("synthetic dep missing fields: %#v", d) - } - if _, ok := wantSHAs[d.SHA]; !ok { - t.Errorf("unexpected SHA in output: %s", d.SHA) - continue - } - wantSHAs[d.SHA] = true - } - for sha, seen := range wantSHAs { - if !seen { - t.Errorf("expected live SHA %s in output, missing", sha) - } - } -} - -// TestLiveMovedDeps mirrors TestCollectLiveMovedReachDeps for the -// per-workflow path used inside diagnoseOneParsed. -func TestLiveMovedDeps(t *testing.T) { - mkDep := func(nwo, ref, sha string) dep.Dependency { - return dep.Dependency{NWO: nwo, Ref: ref, SHA: sha} - } - existing := []dep.Dependency{ - mkDep("owner/repo", "v4", "aaaa000000000000000000000000000000000000"), - mkDep("owner/repo", "v3", "bbbb000000000000000000000000000000000000"), - } - live := []dep.Dependency{ - mkDep("owner/repo", "v4", "1111000000000000000000000000000000000000"), - mkDep("owner/repo", "v3", "bbbb000000000000000000000000000000000000"), - } - got := liveMovedDeps(existing, live) - if len(got) != 1 { - t.Fatalf("got %d synthetic deps, want 1: %+v", len(got), got) - } - if got[0].Ref != "v4" || got[0].SHA != "1111000000000000000000000000000000000000" { - t.Errorf("unexpected synthetic dep: %#v", got[0]) - } -} diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index b1d46b77..c6db9a6a 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -10,14 +10,12 @@ import ( "github.com/github/gh-actions-lock/internal/pipeline/checks" "github.com/github/gh-actions-lock/internal/profile" "github.com/github/gh-actions-lock/internal/resolve" - "github.com/github/gh-actions-lock/internal/tag" ) // RunOptions configures the Run pipeline. type RunOptions struct { WorkflowPaths []string Resolver *resolve.Resolver - Tagger *tag.Lister Store *lockfile.State Pool *pinpool.Pool Rescan bool // re-verify all pins end-to-end @@ -36,7 +34,7 @@ type RunResult struct { } // Run executes the full diagnostic pipeline: parse → trust-check → -// resolve → reachability pre-warm → diagnose → enrich impostors. +// resolve → diagnose. func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { r := opts.Resolver prof := opts.Profile @@ -74,11 +72,10 @@ func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { parsed[i].Resolved = true skippedRescan++ } else { - parsed[i].SkipReachWhenUnchanged = true // Collect deps covered by recorded refs for cache // seeding. These refs have matching lockfile entries, - // so their resolve + reachability results can be - // served from the lockfile rather than the network. + // so their resolve results can be served from the + // lockfile rather than the network. rd := parsed[i].RecordedDeps(recorded) seedDeps = append(seedDeps, rd...) for _, r := range recorded { @@ -109,11 +106,11 @@ func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { unresolved = append(unresolved, pw) } } - refs, deps := CollectUnrecordedResolvable(unresolved, recordedKeys) + refs, _ := CollectUnrecordedResolvable(unresolved, recordedKeys) // Phase 2: Resolve. if r == nil { - // No resolver means no network resolution or reachability. + // No resolver means no network resolution. // Diagnose will still flag structural issues (not-pinned, etc.). } else { // Wire resolver progress hook. @@ -123,9 +120,6 @@ func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { if len(refs) > 0 { endResolve := prof.Phase(" resolve refs") - // First call warms the resolver's cache; results are consumed - // indirectly by the reachability phase and diagnose via cache - // lookups. The live deps are re-fetched from cache below. _, _, _ = r.ResolveAllRecursive(ctx, refs) endResolve() } @@ -134,46 +128,6 @@ func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { return nil, ctx.Err() } - // Phase 3: Pre-warm reachability across all unresolved workflows. - var reachDeps, liveMoved, liveDirect []dep.Dependency - if opts.Rescan { - reachDeps = deps - if len(unresolved) > 0 { - live, _, _ := r.ResolveAllRecursive(ctx, refs) - liveMoved = CollectLiveMovedReachDeps(unresolved, live) - liveDirect = CollectLiveDirectReachDeps(unresolved, live) - } - } else { - live, _, _ := r.ResolveAllRecursive(ctx, refs) - // Merge recorded deps into the live set so CollectReachDeps - // treats them as confirmed at their lockfile SHAs, preventing - // unnecessary reachability network checks. - if len(seedDeps) > 0 { - live = append(live, dep.Dedup(seedDeps)...) - } - reachDeps = CollectReachDeps(unresolved, live) - liveMoved = CollectLiveMovedReachDeps(unresolved, live) - liveDirect = CollectLiveDirectReachDeps(unresolved, live) - } - - if ctx.Err() != nil { - return nil, ctx.Err() - } - - if len(reachDeps) > 0 || len(liveMoved) > 0 || len(liveDirect) > 0 { - endReach := prof.Phase(" reachability pre-warm") - if len(reachDeps) > 0 { - _ = r.CheckReachabilityAll(ctx, reachDeps) - } - if ctx.Err() == nil && len(liveMoved) > 0 { - _ = r.CheckReachabilityAll(ctx, liveMoved) - } - if ctx.Err() == nil && len(liveDirect) > 0 { - _ = r.CheckReachabilityAll(ctx, liveDirect) - } - endReach() - } - // Quiet resolver hooks before diagnostics (cache-only, no progress). r.OnResolveProgress = nil } @@ -182,21 +136,12 @@ func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { return nil, ctx.Err() } - // Phase 4: Diagnose. + // Phase 3: Diagnose. endDiag := prof.Phase(" diagnose (parallel)") report := DiagnoseParsed(ctx, parsed, r, opts.Store, opts.Pool) endDiag() valid := report.IsValid() - if ctx.Err() != nil { - return nil, ctx.Err() - } - - // Phase 5: Enrich impostor findings with recommended release suggestions. - if opts.Tagger != nil && hasImpostorFindings(report) { - checks.EnrichImpostorFindings(ctx, report, opts.Tagger, r, opts.Pool) - } - return &RunResult{ Report: report, Valid: valid, @@ -204,13 +149,3 @@ func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { }, nil } -func hasImpostorFindings(r *checks.Report) bool { - for _, wr := range r.Workflows { - for _, f := range wr.Findings { - if f.Category == checks.ImpostorCommit { - return true - } - } - } - return false -} diff --git a/internal/resolve/ancestry.go b/internal/resolve/ancestry.go index af503bf1..df576d91 100644 --- a/internal/resolve/ancestry.go +++ b/internal/resolve/ancestry.go @@ -23,24 +23,6 @@ const ( ReachabilityUnknown ReachabilityStatus = "unknown" ) -// ReachabilityResult holds the outcome of a single reachability check. -type ReachabilityResult struct { - Owner string - Repo string - Ref string - SHA string - DepKey string // full dependency key (e.g. "actions/cache/save@v4") - Status ReachabilityStatus - Detail string // human-readable detail (e.g. compare status or error) - // FullScanUsed is true when the commit was not found in the canonical - // "likely" branch set (default, protected, release/v*, literal ref, - // lockfile hint) and the check had to fall back to scanning every branch - // in the repo. Even when the commit is ultimately Reachable, a full-scan - // fallback means it is not on a canonical branch — a notable signal worth - // surfacing to the user. - FullScanUsed bool -} - // AncestryStatus represents whether a pinned SHA is a legitimate ancestor of the live SHA. type AncestryStatus int diff --git a/internal/resolve/cacheentry.go b/internal/resolve/cacheentry.go index 9ac8d43f..69408689 100644 --- a/internal/resolve/cacheentry.go +++ b/internal/resolve/cacheentry.go @@ -20,11 +20,3 @@ type tagPeel struct { commit string // commit the tag object peels to (empty when isTag is false) isTag bool // true when the SHA is an annotated tag object } - -// reachCacheEntry stores both the verdict and the human-readable detail so -// re-reads (e.g. across pre-warm + per-workflow phases) carry the original -// rationale instead of a generic "cached" placeholder. -type reachCacheEntry struct { - status ReachabilityStatus - detail string -} diff --git a/internal/resolve/cacheentry_test.go b/internal/resolve/cacheentry_test.go index 3822bbea..b7fcb6b8 100644 --- a/internal/resolve/cacheentry_test.go +++ b/internal/resolve/cacheentry_test.go @@ -32,13 +32,3 @@ func TestTagPeel(t *testing.T) { } }) } - -func TestReachCacheEntry(t *testing.T) { - e := reachCacheEntry{status: Reachable, detail: "found on main"} - if e.status != Reachable { - t.Fatalf("expected Reachable, got %v", e.status) - } - if e.detail != "found on main" { - t.Fatalf("unexpected detail: %s", e.detail) - } -} diff --git a/internal/resolve/discover_test.go b/internal/resolve/discover_test.go index 3506a148..abcce591 100644 --- a/internal/resolve/discover_test.go +++ b/internal/resolve/discover_test.go @@ -2,7 +2,6 @@ package resolve import ( "context" - "strings" "testing" "github.com/github/gh-actions-lock/internal/dep" @@ -84,23 +83,28 @@ func TestDiscoverContaining_NoBranchesFailsClosed(t *testing.T) { httpmock.RESTWithQuery("GET", `repos/actions/checkout/branches`, "protected=true"), httpmock.JSONResponse(httpmock.BranchListResponse()), ) - // Phase 2: listBranches (full listing) also returns empty → impostor error. + // Phase 2: listBranches (full listing) also returns empty. reg.Register( httpmock.REST("GET", `repos/actions/checkout/branches`), httpmock.JSONResponse(httpmock.BranchListResponse()), ) + // Tags: empty → orphaned commit (no tag either). + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/tags`), + httpmock.JSONResponse([]any{}), + ) r, err := New("github.com", pinpool.New(2, nil), WithTransport(reg)) if err != nil { t.Fatal(err) } - _, _, err = r.DiscoverContaining(context.Background(), "actions", "checkout", "dead", "poisoned") - if err == nil { - t.Fatalf("expected error for commit with no branches") + tag, branch, err := r.DiscoverContaining(context.Background(), "actions", "checkout", "dead", "poisoned") + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(err.Error(), "impostor") { - t.Fatalf("expected impostor-signal error, got %v", err) + if tag != "" || branch != "" { + t.Fatalf("expected empty tag/branch for orphaned commit, got tag=%q branch=%q", tag, branch) } reg.Verify(t) } diff --git a/internal/resolve/errors.go b/internal/resolve/errors.go deleted file mode 100644 index 1ed75018..00000000 --- a/internal/resolve/errors.go +++ /dev/null @@ -1,20 +0,0 @@ -package resolve - -import ( - "fmt" - - parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" -) - -// ImpostorError indicates a commit that is not reachable from any branch — a -// fork-network / impostor signal. It carries the offending action so callers -// can report which workflow is affected without abandoning the whole run. -type ImpostorError struct { - NWO string // owner/repo - Ref string // ref as written in the workflow - SHA string // resolved commit SHA -} - -func (e *ImpostorError) Error() string { - return fmt.Sprintf("%s@%s (%s) is not on any branch — fork-network / impostor signal; refusing to pin", e.NWO, e.Ref, parserlock.ShortSHA(e.SHA)) -} diff --git a/internal/resolve/errors_test.go b/internal/resolve/errors_test.go deleted file mode 100644 index 637197d2..00000000 --- a/internal/resolve/errors_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package resolve - -import ( - "strings" - "testing" -) - -func TestImpostorError(t *testing.T) { - e := &ImpostorError{ - NWO: "evil/fork", - Ref: "v1", - SHA: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - } - msg := e.Error() - if !strings.Contains(msg, "evil/fork") { - t.Fatalf("error should mention NWO, got %q", msg) - } - if !strings.Contains(msg, "not on any branch") { - t.Fatalf("error should mention fork signal, got %q", msg) - } - // SHA should be shortened. - if strings.Contains(msg, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") { - t.Fatalf("error should use short SHA, got %q", msg) - } - // Ref must be surfaced so the message names which pin is affected. - if !strings.Contains(msg, "v1") { - t.Fatalf("error should mention the ref, got %q", msg) - } -} diff --git a/internal/resolve/reachability.go b/internal/resolve/reachability.go deleted file mode 100644 index ae3162c2..00000000 --- a/internal/resolve/reachability.go +++ /dev/null @@ -1,460 +0,0 @@ -package resolve - -import ( - "context" - "fmt" - "sync" - - parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" - "github.com/github/gh-actions-lock/internal/dep" - "github.com/github/gh-actions-lock/internal/ghapi" - "github.com/github/gh-actions-lock/internal/pinpool" -) - -// CheckReachability verifies that the pinned SHA is reachable from at least -// one branch of owner/repo, using the documented REST APIs (list-branches + -// compare for ancestry). This catches fork-network injection where a SHA -// exists in GitHub's shared object store but is not part of the canonical -// repository's history. -// -// See: https://docs.zizmor.sh/audits/#impostor-commit -func (r *Resolver) CheckReachability(ctx context.Context, owner, repo, sha, ref string) ReachabilityResult { - // Fast path: cache hit. - if status, detail, ok := r.getReachCache(owner, repo, sha, ref); ok { - return ReachabilityResult{ - Owner: owner, Repo: repo, Ref: ref, SHA: sha, - Status: status, Detail: detail, - } - } - - // Coalesce concurrent checks for the same dep across parallel workflows. - sfKey := owner + "/" + repo + "@" + ref + ":" + sha - v, _, _ := r.reachSF.Do(sfKey, func() (any, error) { - return r.checkReachabilityOnce(ctx, owner, repo, sha, ref), nil - }) - return v.(ReachabilityResult) -} - -// checkReachabilityOnce is the actual reachability check, called at most once -// per unique (owner, repo, sha, ref) via singleflight coalescing. -func (r *Resolver) checkReachabilityOnce(ctx context.Context, owner, repo, sha, ref string) ReachabilityResult { - result := ReachabilityResult{ - Owner: owner, - Repo: repo, - Ref: ref, - SHA: sha, - } - - // Double-check cache (another goroutine may have populated it). - if status, detail, ok := r.getReachCache(owner, repo, sha, ref); ok { - result.Status = status - result.Detail = detail - return result - } - - // Allow tests to inject a fake implementation. - if fn := r.checkReachFn; fn != nil { - result.Status, result.Detail = fn(ctx, owner, repo, sha, ref) - r.putReachCache(owner, repo, sha, ref, result.Status, result.Detail) - return result - } - - defaultBranch := r.GetDefaultBranch(ctx, owner, repo) - - // Phase 0: fast GraphQL check against default branch + ref branch. - // This avoids the expensive ListProtectedBranches pagination when the - // SHA is a simple ancestor of the default branch (the common case). - var foundBranch string - var anyChecked bool - quickCandidates := r.quickBranches(ctx, owner, repo, sha, ref, defaultBranch) - if len(quickCandidates) > 0 { - foundBranch, anyChecked = r.reachabilityScan(ctx, owner, repo, sha, ref, quickCandidates, defaultBranch) - } - - // Phase 1: likely/canonical branch set (includes protected + release branches). - var likelyChecked bool - if foundBranch == "" { - likely := r.likelyBranches(ctx, owner, repo, sha, ref, defaultBranch) - // Dedup: skip branches already checked in phase 0. - quickSeen := make(map[string]bool, len(quickCandidates)) - for _, b := range quickCandidates { - quickSeen[b.Name] = true - } - var remaining []ghapi.BranchHead - for _, b := range likely { - if !quickSeen[b.Name] { - remaining = append(remaining, b) - } - } - if len(remaining) > 0 { - foundBranch, likelyChecked = r.reachabilityScan(ctx, owner, repo, sha, ref, remaining, defaultBranch) - } - anyChecked = anyChecked || likelyChecked - } - - // Phase 2: full breadth scan only on a phase-1 miss. - var branches []ghapi.BranchHead - var protectedAnyChecked, allAnyChecked bool - if foundBranch == "" { - result.FullScanUsed = true - var err error - branches, err = r.ListBranches(ctx, owner, repo) - if err != nil { - result.Status = ReachabilityUnknown - result.Detail = fmt.Sprintf("could not list branches for %s/%s: %s", owner, repo, err) - r.putReachCache(owner, repo, sha, ref, result.Status, result.Detail) - return result - } - protectedBranches := make([]ghapi.BranchHead, 0, len(branches)) - for _, b := range branches { - if b.Protected { - protectedBranches = append(protectedBranches, b) - } - } - foundBranch, protectedAnyChecked = r.reachabilityScan(ctx, owner, repo, sha, ref, protectedBranches, defaultBranch) - if foundBranch == "" { - foundBranch, allAnyChecked = r.reachabilityScan(ctx, owner, repo, sha, ref, branches, defaultBranch) - } - anyChecked = anyChecked || protectedAnyChecked || allAnyChecked - } - - hadBranches := len(quickCandidates) > 0 || len(branches) > 0 - - if foundBranch != "" { - result.Status = Reachable - // Stash the discovered branch so DiscoverContaining can reuse it - // via branchHintBySHA, avoiding a redundant full-branch scan. - r.branchHintBySHA.Put(ghapi.ForNWOSha(owner, repo, sha), foundBranch) - if parserlock.IsFullSha(ref) { - result.Detail = fmt.Sprintf("pinned to a bare SHA; commit is on branch %s but origin cannot be verified at job runtime — prefer pinning to a tag", foundBranch) - } else { - result.Detail = fmt.Sprintf("commit is on branch %s", foundBranch) - } - } else if !anyChecked && hadBranches { - result.Status = ReachabilityUnknown - result.Detail = fmt.Sprintf("could not verify commit reachability for %s/%s — every Compare lookup failed (rate limit or transient error); try again later", owner, repo) - } else { - result.Status = Unreachable - if parserlock.IsFullSha(ref) { - result.Detail = "pinned to a bare SHA; commit is NOT on any branch — possible fork-network commit" - } else { - result.Detail = fmt.Sprintf("commit %s not found on any branch of %s/%s — possible fork-network injection", - parserlock.ShortSHA(sha), owner, repo) - } - } - - r.putReachCache(owner, repo, sha, ref, result.Status, result.Detail) - return result -} - -// reachabilityScan walks candidates (in OrderedBranches tier order), -// returning the first branch whose HEAD is sha or whose lineage contains -// sha as an ancestor via the Compare API. -func (r *Resolver) reachabilityScan(ctx context.Context, owner, repo, sha, ref string, candidates []ghapi.BranchHead, defaultBranch string) (matched string, anyChecked bool) { - if len(candidates) == 0 { - return "", false - } - // Fast path: exact HEAD match. - for _, b := range candidates { - if eqFoldSHA(b.SHA, sha) { - return b.Name, true - } - } - // Slow path: ancestry via batched GraphQL Ref.compare. - // One query checks all branches at once instead of N serial REST calls. - hintBranch := r.branchHint(owner, repo, sha) - ordered := ghapi.OrderedBranches(candidates, hintBranch, ref, defaultBranch) - if len(ordered) == 0 { - return "", false - } - - matched, checked, err := r.gh.BatchBranchContains(ctx, owner, repo, sha, ordered) - if matched != "" { - return matched, true - } - if err != nil { - // Partial or total GraphQL failure with no positive match: re-check - // via serial REST Compare. Otherwise a batch that errored after - // checking only some branches (anyChecked=true, matched="") would be - // reported as "checked everything, found nothing" — a false - // Unreachable. - return r.reachabilityScanREST(ctx, owner, repo, sha, ordered) - } - return "", checked -} - -// reachabilityScanREST is the legacy per-branch REST Compare fallback, -// used when GraphQL batch fails entirely. -func (r *Resolver) reachabilityScanREST(ctx context.Context, owner, repo, sha string, ordered []ghapi.BranchHead) (matched string, anyChecked bool) { - limit := reachabilityConcurrency - if len(ordered) < limit { - limit = len(ordered) - } - - scanCtx, scanCancel := context.WithCancel(ctx) - defer scanCancel() - - type result struct { - contains bool - checked bool - } - results := make([]result, len(ordered)) - sem := make(chan struct{}, limit) - var wg sync.WaitGroup - for i, b := range ordered { - wg.Add(1) - select { - case sem <- struct{}{}: - case <-scanCtx.Done(): - wg.Done() - continue - } - go func(i int, b ghapi.BranchHead) { - defer wg.Done() - defer func() { <-sem }() - ok, err := r.gh.CompareCommits(scanCtx, owner, repo, sha, b.SHA) - if err != nil { - return - } - results[i] = result{contains: ok, checked: true} - if ok { - scanCancel() - } - }(i, b) - } - wg.Wait() - for i, res := range results { - if res.checked { - anyChecked = true - if res.contains { - return ordered[i].Name, true - } - } - } - return "", anyChecked -} - -// CheckReachabilityAll runs reachability checks on a batch of dependencies, -// deduplicating by owner/repo/sha/ref. -func (r *Resolver) CheckReachabilityAll(ctx context.Context, deps []dep.Dependency) []ReachabilityResult { - seenReach := make(map[ghapi.Reach]bool) - - unique := make([]dep.Dependency, 0, len(deps)) - for _, dep := range deps { - owner, repo := dep.OwnerRepo() - if owner == "" { - continue - } - key := ghapi.ForReach(owner, repo, dep.SHA, dep.Ref) - if seenReach[key] { - continue - } - seenReach[key] = true - unique = append(unique, dep) - } - - total := len(unique) - if total == 0 { - return nil - } - - return r.checkReachabilityAllPooled(ctx, unique) -} - -// repoEntry is a small pair used by the warmup loop. -type repoEntry struct { - owner, repo string -} - -// checkReachabilityAllPooled uses the shared pool for warmup + main fan-out. -func (r *Resolver) checkReachabilityAllPooled(ctx context.Context, unique []dep.Dependency) []ReachabilityResult { - total := len(unique) - - // Warmup: pre-populate per-repo caches in parallel. - // Skip warmup if all deps are already cached (avoids spinner noise). - if r.checkReachFn == nil { - var repos []repoEntry - seenRepo := make(map[ghapi.Repo]bool) - for _, dep := range unique { - owner, repo := dep.OwnerRepo() - if _, _, ok := r.getReachCache(owner, repo, dep.SHA, dep.Ref); ok { - continue // already cached, no warmup needed - } - k := ghapi.ForRepo(owner, repo) - if !seenRepo[k] { - seenRepo[k] = true - repos = append(repos, repoEntry{owner: owner, repo: repo}) - } - } - if len(repos) > 0 { - _ = pinpool.RunTyped(r.Pool, ctx, "", - repos, - func(re repoEntry) string { return "fetching metadata " + re.owner + "/" + re.repo }, - func(ctx context.Context, _ int, re repoEntry) error { - // Only pre-warm default branch (cheap, single REST call). - // Branch listing is deferred to Phase 2 on reachability - // miss — GraphQL batch compare eliminates the need for - // eagerly listing all branches. - _ = r.GetDefaultBranch(ctx, re.owner, re.repo) - return nil - }, - ) - } - } - - // Separate deps into three groups: - // 1. cached — result already available, no work needed - // 2. pooled — first to claim this dep, submit to the pool - // 3. waiters — another goroutine already claimed it, wait via - // singleflight without occupying a pool slot - results := make([]ReachabilityResult, total) - - type indexedDep struct { - idx int - dep dep.Dependency - } - var pooled, waiters []indexedDep - for i, dep := range unique { - owner, repo := dep.OwnerRepo() - if status, detail, ok := r.getReachCache(owner, repo, dep.SHA, dep.Ref); ok { - results[i] = ReachabilityResult{ - Owner: owner, Repo: repo, Ref: dep.Ref, SHA: dep.SHA, - Status: status, Detail: detail, DepKey: dep.Key(), - } - continue - } - sfKey := owner + "/" + repo + "@" + dep.Ref + ":" + dep.SHA - id := indexedDep{idx: i, dep: dep} - if r.claimReachability(sfKey) { - pooled = append(pooled, id) - } else { - waiters = append(waiters, id) - } - } - - // Submit first-claimers to the pool (visible in spinner). - var poolErr error - if len(pooled) > 0 { - poolErr = pinpool.RunTyped(r.Pool, ctx, "", - pooled, - func(id indexedDep) string { return "verifying " + id.dep.NWO + "@" + id.dep.Ref }, - func(ctx context.Context, _ int, id indexedDep) error { - owner, repo := id.dep.OwnerRepo() - result := r.CheckReachability(ctx, owner, repo, id.dep.SHA, id.dep.Ref) - result.DepKey = id.dep.Key() - results[id.idx] = result - return nil - }, - ) - } - - // Waiters: another goroutine is checking these in the pool. Wait via - // singleflight (coalesces to the in-flight call) without occupying a - // pool worker slot or spinner line. - for _, id := range waiters { - owner, repo := id.dep.OwnerRepo() - result := r.CheckReachability(ctx, owner, repo, id.dep.SHA, id.dep.Ref) - result.DepKey = id.dep.Key() - results[id.idx] = result - } - - // Fail closed: if the pool cancelled or errored before a job ran, its - // result stays zero-value (empty Status). plan.go only acts on - // Unreachable/ReachabilityUnknown, so an empty status would let a dep be - // pinned unverified. Backfill any unset result as ReachabilityUnknown. - for i := range results { - if results[i].Status == "" { - owner, repo := unique[i].OwnerRepo() - detail := "reachability check did not complete" - if poolErr != nil { - detail = fmt.Sprintf("reachability check did not complete: %v", poolErr) - } - results[i] = ReachabilityResult{ - Owner: owner, Repo: repo, Ref: unique[i].Ref, SHA: unique[i].SHA, - Status: ReachabilityUnknown, Detail: detail, DepKey: unique[i].Key(), - } - } - } - - return results -} - -// quickBranches returns the 1-2 cheapest candidates: the default branch and -// (for non-SHA refs) the ref-matching branch. No REST pagination — just -// single-branch HEAD lookups. This lets the GraphQL batch short-circuit -// before the expensive ListProtectedBranches call in likelyBranches. -func (r *Resolver) quickBranches(ctx context.Context, owner, repo, sha, ref, defaultBranch string) []ghapi.BranchHead { - seen := make(map[string]bool) - var out []ghapi.BranchHead - addNamed := func(name string) { - if name == "" || seen[name] { - return - } - if bh, ok := r.GetBranchHead(ctx, owner, repo, name); ok { - seen[name] = true - out = append(out, bh) - } - } - addNamed(defaultBranch) - if ref != "" && !parserlock.IsFullSha(ref) { - addNamed(ref) - } - addNamed(r.branchHint(owner, repo, sha)) - return out -} - -// likelyBranches assembles the high-trust candidate set validated before any -// full branch scan. -func (r *Resolver) likelyBranches(ctx context.Context, owner, repo, sha, ref, defaultBranch string) []ghapi.BranchHead { - seen := make(map[string]bool) - var out []ghapi.BranchHead - addNamed := func(name string) { - if name == "" || seen[name] { - return - } - if bh, ok := r.GetBranchHead(ctx, owner, repo, name); ok { - seen[name] = true - out = append(out, bh) - } - } - if ref != "" && !parserlock.IsFullSha(ref) { - addNamed(ref) - } - addNamed(r.branchHint(owner, repo, sha)) - addNamed(defaultBranch) - for _, bh := range r.ListProtectedBranches(ctx, owner, repo) { - if bh.Name == "" || seen[bh.Name] { - continue - } - seen[bh.Name] = true - out = append(out, bh) - } - for _, bh := range r.ListReleaseBranches(ctx, owner, repo) { - if bh.Name == "" || seen[bh.Name] { - continue - } - seen[bh.Name] = true - out = append(out, bh) - } - return out -} - -// eqFoldSHA compares two hex SHAs case-insensitively. Length must match. -func eqFoldSHA(a, b string) bool { - if len(a) != len(b) { - return false - } - for i := 0; i < len(a); i++ { - ca, cb := a[i], b[i] - if ca >= 'A' && ca <= 'Z' { - ca += 'a' - 'A' - } - if cb >= 'A' && cb <= 'Z' { - cb += 'a' - 'A' - } - if ca != cb { - return false - } - } - return true -} diff --git a/internal/resolve/reachability_test.go b/internal/resolve/reachability_test.go deleted file mode 100644 index 238e5ede..00000000 --- a/internal/resolve/reachability_test.go +++ /dev/null @@ -1,301 +0,0 @@ -package resolve - -import ( - "context" - "fmt" - "strings" - "testing" - - "github.com/github/gh-actions-lock/internal/ghapi" - "github.com/github/gh-actions-lock/internal/ghapi/httpmock" -) - -func TestCheckReachability_CacheHit(t *testing.T) { - r := &Resolver{} - r.reachCache.Put( - ghapi.ForReach("actions", "checkout", "abc123", "v4"), - reachCacheEntry{status: Reachable, detail: "cached hit"}, - ) - - result := r.CheckReachability(context.Background(), "actions", "checkout", "abc123", "v4") - if result.Status != Reachable { - t.Fatalf("expected Reachable, got %v", result.Status) - } - if result.Detail != "cached hit" { - t.Fatalf("unexpected detail: %s", result.Detail) - } -} - -func TestCheckReachability_InjectedFn(t *testing.T) { - r := &Resolver{} - r.checkReachFn = func(_ context.Context, owner, repo, sha, ref string) (ReachabilityStatus, string) { - return Unreachable, "injected: not on any branch" - } - - result := r.CheckReachability(context.Background(), "actions", "checkout", "abc123", "v4") - if result.Status != Unreachable { - t.Fatalf("expected Unreachable, got %v", result.Status) - } - if result.Detail != "injected: not on any branch" { - t.Fatalf("unexpected detail: %s", result.Detail) - } - - // Should be cached after injected fn runs. - status, detail, ok := r.getReachCache("actions", "checkout", "abc123", "v4") - if !ok { - t.Fatal("expected cache entry after injected fn") - } - if status != Unreachable || detail != "injected: not on any branch" { - t.Fatalf("unexpected cached values: %v %q", status, detail) - } -} - -func TestCheckReachability_Singleflight(t *testing.T) { - calls := 0 - r := &Resolver{} - r.checkReachFn = func(_ context.Context, _, _, _, _ string) (ReachabilityStatus, string) { - calls++ - return Reachable, "ok" - } - - // Two serial calls with same key — second should hit cache, not fn. - _ = r.CheckReachability(context.Background(), "o", "r", "sha", "ref") - _ = r.CheckReachability(context.Background(), "o", "r", "sha", "ref") - - if calls != 1 { - t.Fatalf("expected 1 fn call (second should cache hit), got %d", calls) - } -} - -// --- reachabilityScan primitive tests ------------------------------------- -// -// These drive the security-critical scan directly through httpmock (no -// checkReachFn injection), so they exercise the real GraphQL/REST decision -// logic. The scan must distinguish three outcomes that the classifier -// depends on: matched (reachable), checked-but-unmatched (unreachable), and -// not-checked (unknown / fail-open). - -const ( - scanSHA = "1111111111111111111111111111111111111111" - scanHead = "2222222222222222222222222222222222222222" -) - -// branchCompareResponse builds a GraphQL batch-compare response body. Each -// status maps positionally to alias b0, b1, ... matching the query builder. -func branchCompareResponse(statuses ...string) map[string]any { - repo := map[string]any{} - for i, st := range statuses { - repo[fmt.Sprintf("b%d", i)] = map[string]any{ - "compare": map[string]any{"status": st}, - } - } - return map[string]any{"data": map[string]any{"repo": repo}} -} - -// registerEmptyCanonical stubs the phase-1/phase-2 branch probes for an -// arbitrary owner/repo, all empty, forcing the scan to rely on whatever the -// caller stubbed for phase 0. -func registerEmptyCanonical(reg *httpmock.Registry, owner, repo string) { - base := fmt.Sprintf("repos/%s/%s", owner, repo) - reg.Register( - httpmock.RESTWithQuery("GET", base+"/branches", "protected=true"), - httpmock.JSONResponse([]any{}), - ) - reg.Register( - httpmock.REST("GET", base+"/git/matching-refs/heads/v"), - httpmock.JSONResponse([]any{}), - ) - reg.Register( - httpmock.REST("GET", base+"/git/matching-refs/heads/release"), - httpmock.JSONResponse([]any{}), - ) - reg.Register( - httpmock.REST("GET", base+"/branches"), - httpmock.JSONResponse([]any{}), - ) -} - -func TestReachabilityScan_ExactHeadMatch(t *testing.T) { - // HEAD SHA equals the pinned SHA — must match with zero API calls. - r := &Resolver{} - cands := []ghapi.BranchHead{{Name: "main", SHA: scanSHA}} - - matched, checked := r.reachabilityScan(context.Background(), "acme", "widget", scanSHA, scanSHA, cands, "main") - if matched != "main" || !checked { - t.Fatalf("exact-head: got (%q, %v), want (main, true)", matched, checked) - } -} - -func TestReachabilityScan_AncestorViaGraphQL(t *testing.T) { - // HEAD differs; GraphQL compare reports BEHIND => sha is an ancestor. - reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQLForRepo("acme", "widget"), - httpmock.JSONResponse(branchCompareResponse("BEHIND")), - ) - r := newTestResolver(t, reg) - cands := []ghapi.BranchHead{{Name: "main", SHA: scanHead}} - - matched, checked := r.reachabilityScan(context.Background(), "acme", "widget", scanSHA, scanSHA, cands, "main") - if matched != "main" || !checked { - t.Fatalf("ancestor: got (%q, %v), want (main, true)", matched, checked) - } - reg.Verify(t) -} - -func TestReachabilityScan_CheckedButNotFound(t *testing.T) { - // GraphQL succeeds but reports DIVERGED => checked, not reachable. - // This is the "faithful impostor" signal the classifier turns into - // Unreachable. - reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQLForRepo("acme", "widget"), - httpmock.JSONResponse(branchCompareResponse("DIVERGED")), - ) - r := newTestResolver(t, reg) - cands := []ghapi.BranchHead{{Name: "main", SHA: scanHead}} - - matched, checked := r.reachabilityScan(context.Background(), "acme", "widget", scanSHA, scanSHA, cands, "main") - if matched != "" || !checked { - t.Fatalf("diverged: got (%q, %v), want (\"\", true)", matched, checked) - } - reg.Verify(t) -} - -func TestReachabilityScan_TotalFailureUnknown(t *testing.T) { - // GraphQL transport failure AND the REST compare fallback also fails => - // nothing was checked. The classifier must NOT treat this as - // unreachable (fail-open to Unknown). - reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQLForRepo("acme", "widget"), - httpmock.StatusResponse(500), - ) - reg.Register( - httpmock.REST("GET", `repos/acme/widget/compare/`), - httpmock.StatusResponse(500), - ) - r := newTestResolver(t, reg) - cands := []ghapi.BranchHead{{Name: "main", SHA: scanHead}} - - matched, checked := r.reachabilityScan(context.Background(), "acme", "widget", scanSHA, scanSHA, cands, "main") - if matched != "" || checked { - t.Fatalf("total-failure: got (%q, %v), want (\"\", false)", matched, checked) - } - reg.Verify(t) -} - -func TestReachabilityScan_RESTFallbackMatch(t *testing.T) { - // GraphQL fails entirely; the REST Compare fallback succeeds and the - // merge-base equals sha => reachable via the legacy path. - reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQLForRepo("acme", "widget"), - httpmock.StatusResponse(500), - ) - reg.Register( - httpmock.REST("GET", `repos/acme/widget/compare/`), - httpmock.JSONResponse(map[string]any{ - "status": "behind", - "merge_base_commit": map[string]any{"sha": scanSHA}, - }), - ) - r := newTestResolver(t, reg) - cands := []ghapi.BranchHead{{Name: "main", SHA: scanHead}} - - matched, checked := r.reachabilityScan(context.Background(), "acme", "widget", scanSHA, scanSHA, cands, "main") - if matched != "main" || !checked { - t.Fatalf("rest-fallback: got (%q, %v), want (main, true)", matched, checked) - } - reg.Verify(t) -} - -// --- checkReachabilityOnce end-to-end classification ---------------------- -// -// These exercise the full orchestration (phase 0/1/2) through httpmock and -// assert the final ReachabilityStatus, locking the three security-relevant -// verdicts: Reachable, Unreachable, and the fail-open ReachabilityUnknown. - -func TestCheckReachabilityOnce_ReachableExactHead(t *testing.T) { - // Default branch HEAD == pinned SHA: phase 0 resolves it immediately, no - // branch listing required. - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", `repos/acme/widget$`), - httpmock.JSONResponse(map[string]any{"default_branch": "main"}), - ) - reg.Register( - httpmock.REST("GET", `repos/acme/widget/git/ref/heads/main`), - httpmock.JSONResponse(gitRefHeadResponse("main", scanSHA)), - ) - r := newTestResolver(t, reg) - - got := r.checkReachabilityOnce(context.Background(), "acme", "widget", scanSHA, scanSHA) - if got.Status != Reachable { - t.Fatalf("status = %v, want Reachable (detail=%q)", got.Status, got.Detail) - } - if !strings.Contains(got.Detail, "on branch main") { - t.Fatalf("detail = %q, want mention of branch main", got.Detail) - } - reg.Verify(t) -} - -func TestCheckReachabilityOnce_UnreachableImpostor(t *testing.T) { - // HEAD differs and every branch the scan checks reports DIVERGED: the - // commit was checked against the canonical history and is on no branch. - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", `repos/acme/widget$`), - httpmock.JSONResponse(map[string]any{"default_branch": "main"}), - ) - reg.Register( - httpmock.REST("GET", `repos/acme/widget/git/ref/heads/main`), - httpmock.JSONResponse(gitRefHeadResponse("main", scanHead)), - ) - reg.Register( - httpmock.GraphQLForRepo("acme", "widget"), - httpmock.JSONResponse(branchCompareResponse("DIVERGED")), - ) - registerEmptyCanonical(reg, "acme", "widget") - r := newTestResolver(t, reg) - - got := r.checkReachabilityOnce(context.Background(), "acme", "widget", scanSHA, scanSHA) - if got.Status != Unreachable { - t.Fatalf("status = %v, want Unreachable (detail=%q)", got.Status, got.Detail) - } - if !strings.Contains(got.Detail, "NOT on any branch") { - t.Fatalf("detail = %q, want NOT-on-any-branch message", got.Detail) - } - reg.Verify(t) -} - -func TestCheckReachabilityOnce_RateLimitedUnknown(t *testing.T) { - // GraphQL and the REST Compare fallback both fail; no branch is ever - // successfully checked. The classifier must fail open to - // ReachabilityUnknown rather than declaring the commit an impostor. - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", `repos/acme/widget$`), - httpmock.JSONResponse(map[string]any{"default_branch": "main"}), - ) - reg.Register( - httpmock.REST("GET", `repos/acme/widget/git/ref/heads/main`), - httpmock.JSONResponse(gitRefHeadResponse("main", scanHead)), - ) - reg.Register( - httpmock.GraphQLForRepo("acme", "widget"), - httpmock.StatusResponse(500), - ) - reg.Register( - httpmock.REST("GET", `repos/acme/widget/compare/`), - httpmock.StatusResponse(500), - ) - registerEmptyCanonical(reg, "acme", "widget") - r := newTestResolver(t, reg) - - got := r.checkReachabilityOnce(context.Background(), "acme", "widget", scanSHA, scanSHA) - if got.Status != ReachabilityUnknown { - t.Fatalf("status = %v, want ReachabilityUnknown (detail=%q)", got.Status, got.Detail) - } - reg.Verify(t) -} diff --git a/internal/resolve/resolver.go b/internal/resolve/resolver.go index 3939b9bb..24776e06 100644 --- a/internal/resolve/resolver.go +++ b/internal/resolve/resolver.go @@ -16,10 +16,6 @@ import ( "golang.org/x/sync/singleflight" ) -// reachabilityConcurrency bounds how many per-dependency reachability checks -// run in parallel in the REST fallback path. -const reachabilityConcurrency = 8 - // DefaultMaxRecursionDepth matches the runner's composite action recursion limit. const DefaultMaxRecursionDepth = 10 @@ -36,12 +32,6 @@ func WithProfile(p *profile.Session) Option { return func(r *Resolver) { r.profile = p } } -// WithCheckReachabilityFunc overrides the default REST-based reachability -// check. Intended for tests that want deterministic branch-discovery results. -func WithCheckReachabilityFunc(fn func(ctx context.Context, owner, repo, sha, ref string) (ReachabilityStatus, string)) Option { - return func(r *Resolver) { r.checkReachFn = fn } -} - // WithNowFn overrides time.Now for rate-limit retry timing in tests. func WithNowFn(fn func() time.Time) Option { return func(r *Resolver) { @@ -72,21 +62,17 @@ type Resolver struct { transport http.RoundTripper // nil → use default authenticated transport profile *profile.Session // nil → no profiling - // Domain-level caches: action resolution, reachability, branch hints. + // Domain-level caches: action resolution, branch hints. cache syncmap.Map[ghapi.ActionRef, resolvedEntry] latestRefCache syncmap.Map[ghapi.Repo, string] - reachCache syncmap.Map[ghapi.Reach, reachCacheEntry] - reachSF singleflight.Group - reachInFlight sync.Map // sfKey → struct{}, tracks deps submitted to pool branchHintBySHA syncmap.Map[ghapi.NWOSha, string] releaseBranchCache syncmap.Map[ghapi.Repo, []ghapi.BranchHead] releaseBranchSF singleflight.Group tagObjectCache syncmap.Map[ghapi.NWOSha, tagPeel] // Test overrides (injected via With* options). - checkReachFn func(ctx context.Context, owner, repo, sha, ref string) (ReachabilityStatus, string) - nowFn func() time.Time - sleepFn func(context.Context, time.Duration) + nowFn func() time.Time + sleepFn func(context.Context, time.Duration) // OnResolveProgress is called when a resolution batch makes progress. OnResolveProgress func(done, total int) @@ -142,9 +128,9 @@ func (r *Resolver) SeedBranchHints(deps []dep.Dependency) { } } -// SeedFromLockfile pre-warms the resolution and reachability caches so -// repeat runs skip redundant API calls. Do NOT call with --rescan: seeding -// would hide ref movement and skip reachability checks. +// SeedFromLockfile pre-warms the resolution cache so repeat runs skip +// redundant API calls. Do NOT call with --rescan: seeding would hide +// ref movement. func (r *Resolver) SeedFromLockfile(deps []dep.Dependency) { for _, d := range deps { if d.SHA == "" || d.Ref == "" { @@ -158,10 +144,6 @@ func (r *Resolver) SeedFromLockfile(deps []dep.Dependency) { ghapi.ForActionRef(owner, repo, d.Path, d.Ref), resolvedEntry{dep: d}, ) - r.reachCache.Put( - ghapi.ForReach(owner, repo, d.SHA, d.Ref), - reachCacheEntry{status: Reachable, detail: "seeded from lockfile"}, - ) } } @@ -187,25 +169,6 @@ func (r *Resolver) branchHint(owner, repo, sha string) string { // --- Cache helpers (package-internal) --- -func (r *Resolver) putReachCache(owner, repo, sha, ref string, status ReachabilityStatus, detail string) { - r.reachCache.Put(ghapi.ForReach(owner, repo, sha, ref), reachCacheEntry{status: status, detail: detail}) -} - -func (r *Resolver) getReachCache(owner, repo, sha, ref string) (status ReachabilityStatus, detail string, ok bool) { - entry, hit := r.reachCache.Get(ghapi.ForReach(owner, repo, sha, ref)) - if !hit { - return "", "", false - } - return entry.status, entry.detail, true -} - -// claimReachability marks a reachability key as in-flight. Returns true if -// this caller is the first to claim. -func (r *Resolver) claimReachability(key string) bool { - _, loaded := r.reachInFlight.LoadOrStore(key, struct{}{}) - return !loaded -} - // --- Progress --- // FireResolveProgress fires OnResolveProgress. Safe from multiple goroutines. diff --git a/internal/resolve/resolver_test.go b/internal/resolve/resolver_test.go index 6362f69e..5c68c806 100644 --- a/internal/resolve/resolver_test.go +++ b/internal/resolve/resolver_test.go @@ -841,15 +841,11 @@ func TestNew_Options(t *testing.T) { nowFn := func() time.Time { return fixed } sleepCalled := false sleepFn := func(_ context.Context, _ time.Duration) { sleepCalled = true } - reachFn := func(_ context.Context, _, _, _, _ string) (ReachabilityStatus, string) { - return Reachable, "stub" - } r, err := New("test.com", pool, WithTransport(reg), WithNowFn(nowFn), WithSleepFn(sleepFn), - WithCheckReachabilityFunc(reachFn), ) if err != nil { t.Fatal(err) @@ -863,10 +859,6 @@ func TestNew_Options(t *testing.T) { if !sleepCalled { t.Fatal("SleepFn not applied") } - - if r.checkReachFn == nil { - t.Fatal("CheckReachFn not applied") - } } func TestNew_NilOptionsSafe(t *testing.T) { diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index cbc3d249..5b1dd002 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -2,7 +2,6 @@ package resolve import ( "context" - "errors" "fmt" "strings" @@ -22,7 +21,7 @@ import ( // If no protected branch contains sha, the search falls back to all // branches in the same tier order. // - branch is REQUIRED to be non-empty; an error is returned otherwise -// (impostor / fork-network signal). + // // hintRef may be empty (e.g. for bare-SHA pins). The repo's default branch // is discovered automatically via GET /repos/{owner}/{repo} (cached). @@ -38,7 +37,7 @@ func (r *Resolver) DiscoverContaining(ctx context.Context, owner, repo, sha, hin // directly (literal ref, recorded hint branch, default branch, protected // branches, release/v* branches) so a relevant branch is never missed because // it sorts beyond the paginated listing cap. Phase 2 — a full protected-first -// then all-branches scan — runs only when phase 1 finds nothing. An impostor +// then all-branches scan — runs only when phase 1 finds nothing. An orphan // error is returned only if both phases fail to place the commit. func (r *Resolver) DiscoverContainingDefault(ctx context.Context, owner, repo, sha, hintRef, defaultBranch string) (tag, branch string, err error) { // Phase 0: check named branches directly (ref, hint, default) — one @@ -94,9 +93,8 @@ func (r *Resolver) DiscoverContainingDefault(ctx context.Context, owner, repo, s } } - if branch == "" { - return "", "", &ImpostorError{NWO: owner + "/" + repo, Ref: hintRef, SHA: sha} - } + // No branch found — proceed without one. The commit may exist only + // on refs/tags (lightweight repos, GitHub Releases, etc.). // Discover tags pointing at sha. allTags, err := r.ListTagsForRepo(ctx, owner, repo) @@ -260,14 +258,12 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma } tag, branch, err := r.DiscoverContaining(ctx, owner, repo, d.SHA, d.Ref) if err != nil { - var imp *ImpostorError - if errors.As(err, &imp) { - imp.NWO = d.NWO - imp.Ref = d.Ref - return nil, imp - } return nil, fmt.Errorf("%s@%s: %w", d.NWO, d.Ref, err) } + if tag == "" && branch == "" { + return nil, fmt.Errorf("%s@%s: commit %s is not reachable from any ref (tag or branch) — orphaned commit", + d.NWO, d.Ref, parserlock.ShortSHA(d.SHA)) + } d.Tag = tag d.Branch = branch newRef := tag From e2022752e21703520b4cdcad5aa798b423067978 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:03:22 -0500 Subject: [PATCH 24/67] add --accept-moved flag to re-resolve forgery/ref-moved deps When a lockfile entry is flagged as lockfile-forgery or ref-moved, the user was previously stuck: the dep stays 'verified' in the lockfile but the check flags it as invalid, and there was no way to force a re-pin. --accept-moved solves this: it prunes lockfile entries for deps with forgery/ref-moved findings (treating them as unrecorded), then re-resolves them to their current live SHA. Implies --rescan since we need to detect what actually moved. Usage: gh actions-lock --accept-moved --- cmd/gh-actions-lock/run.go | 29 +++++++++++++++++++---------- internal/pin/plan.go | 20 ++++++++++++++------ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index d327a548..4114acae 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -50,6 +50,10 @@ type checkOptions struct { // Use when org-provisioned larger runners (e.g. ubuntu-latest-xl) // are flagged as self-hosted but you know they are GitHub-hosted. allowRunners []string + // acceptMoved re-resolves deps flagged as lockfile-forgery or + // ref-moved: prunes the stale lockfile entry and re-pins to the + // current live SHA. + acceptMoved bool } // bindCheckFlags registers the run flags on the root command. @@ -61,6 +65,7 @@ func bindCheckFlags(cmd *cobra.Command, opts *checkOptions) { cmd.Flags().BoolVar(&opts.noFix, "no-fix", false, "Read-only: report findings without modifying workflows or the lockfile") cmd.Flags().BoolVar(&opts.noNarrow, "no-narrow", false, "Keep mutable version refs (e.g. v4) instead of narrowing to full patch tags (e.g. v4.2.1)") cmd.Flags().StringSliceVar(&opts.allowRunners, "allow-runners", nil, "Additional runner `labels` to treat as GitHub-hosted (e.g. ubuntu-latest-xl)") + cmd.Flags().BoolVar(&opts.acceptMoved, "accept-moved", false, "Re-resolve deps flagged as ref-moved or lockfile-forgery to their current live SHA") cmd.Flags().StringVar(&opts.profileDir, "profile", "", "Enable profiling: write trace, CPU profile, and HTTP log to `dir`") } @@ -125,8 +130,11 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) } // Pre-warm resolver caches from the lockfile so repeat runs skip // redundant GraphQL and REST calls. Skipped when --rescan is set: - // a full re-verification must hit the network to detect ref movement - // and re-check reachability. + // a full re-verification must hit the network to detect ref movement. + // --accept-moved implies --rescan (must detect what moved). + if opts.acceptMoved { + opts.rescan = true + } trustLockfileCaches := !opts.rescan if trustLockfileCaches { r.SeedFromLockfile(store.AllDeps()) @@ -274,14 +282,15 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) console.ClearWorkerStatuses() } record, planErr := pin.Plan(ctx, report, pin.PlanOptions{ - Resolver: r, - Tagger: tagger, - Store: store, - Pool: pool, - RepoOwner: repoOwner, - RepoName: repoName, - Version: cliVersion(), - NoNarrow: opts.noNarrow, + Resolver: r, + Tagger: tagger, + Store: store, + Pool: pool, + RepoOwner: repoOwner, + RepoName: repoName, + Version: cliVersion(), + NoNarrow: opts.noNarrow, + AcceptMoved: opts.acceptMoved, }) endPlan() if planErr != nil { diff --git a/internal/pin/plan.go b/internal/pin/plan.go index e3032a35..1b206082 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -30,6 +30,11 @@ type PlanOptions struct { // patch tags (v4.2.1). Bare-SHA reverse lookup still applies. NoNarrow bool + // AcceptMoved treats ref-moved and lockfile-forgery findings as + // resolvable: affected deps are pruned from the inventory and + // re-resolved to their current live SHA. + AcceptMoved bool + // prevImpreciseNWO is computed once in Plan() from the global lockfile // state. It holds lowercased NWOs that are already recorded with a // non-full-semver ref anywhere in the lockfile. Narrowing is skipped @@ -114,7 +119,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption // Drop stale inventory entries so a re-pin converges: the orphan leaves // workflows[path] and Save's GC removes its dependencies[] entry. - inventory := pruneStaleInventory(wr.Inventory, wr.Findings) + inventory := pruneStaleInventory(wr.Inventory, wr.Findings, opts.AcceptMoved) if !wr.NeedsAttention() { entries = verifiedEntries(inventory, wr.Path) @@ -470,14 +475,17 @@ func partitionByInventory(inventory []checks.InventoryEntry, refs []parserlock.A // pruneStaleInventory drops inventory entries matching a stale finding (a pin // the workflow no longer references), so a fix-mode re-pin converges. -func pruneStaleInventory(inventory []checks.InventoryEntry, findings []checks.Finding) []checks.InventoryEntry { +func pruneStaleInventory(inventory []checks.InventoryEntry, findings []checks.Finding, acceptMoved bool) []checks.InventoryEntry { stale := make(map[string]bool) for _, f := range findings { - if f.Category != checks.Stale || f.Dependency == nil { - continue + switch { + case f.Category == checks.Stale && f.Dependency != nil: + d := f.Dependency + stale[strings.ToLower(d.NWO+"@"+d.Ref+":"+d.SHA)] = true + case acceptMoved && (f.Category == checks.LockfileForgery || f.Category == checks.RefMoved) && f.Dependency != nil: + d := f.Dependency + stale[strings.ToLower(d.NWO+"@"+d.Ref+":"+d.SHA)] = true } - d := f.Dependency - stale[strings.ToLower(d.NWO+"@"+d.Ref+":"+d.SHA)] = true } if len(stale) == 0 { return inventory From bd14706fbf05227c150171d92e2d0574203b7e66 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:20:37 -0500 Subject: [PATCH 25/67] accept-moved: fix exit code and add live integration scenario When --accept-moved successfully re-resolves a forgery dep, the lockfile- forgery finding from the check phase was still triggering exit 1. Fixed by skipping LockfileForgery in the unfixable-errors gate when accept-moved is active. Added accept_moved_resolves_forgery live scenario: plants a lockfile with a bogus all-zeros SHA, runs --accept-moved --no-narrow, asserts exit 0 and valid lockfile with the real commit. --- cmd/gh-actions-lock/pin_summary.go | 13 ++++++++----- cmd/gh-actions-lock/run.go | 4 ++-- test/integration/run.rb | 19 +++++++++++++++++++ test/scenarios/catalog.yml | 20 ++++++++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index b1eb6c2e..c13076d9 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -19,16 +19,19 @@ import ( // don't count. LocalAction, SelfHostedRunner, and // LockfileForgery errors are unfixable — the workflow or lockfile must // be investigated. -func reportHasUnfixableErrors(report *checks.Report) bool { +func reportHasUnfixableErrors(report *checks.Report, acceptMoved bool) bool { for _, wr := range report.Workflows { for _, f := range wr.Findings { if f.Severity != checks.SeverityError { continue } switch f.Category { - case checks.LocalAction, checks.SelfHostedRunner, - checks.LockfileForgery: + case checks.LocalAction, checks.SelfHostedRunner: return true + case checks.LockfileForgery: + if !acceptMoved { + return true + } } } } @@ -58,7 +61,7 @@ func reportHasNonInvestigatedUnfixableErrors(report *checks.Report) bool { // renderPinSummary prints the terminal summary after pin.Plan + pin.Commit. // It groups pinned entries by NWO@Ref, shows investigation alerts, unresolved // warnings, and the all-valid message when nothing changed. -func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, report *checks.Report, r *resolve.Resolver, skippedRescan int, hasInconclusive bool, refusedLabels []string, noNarrow bool) error { +func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, report *checks.Report, r *resolve.Resolver, skippedRescan int, hasInconclusive bool, refusedLabels []string, noNarrow bool, acceptMoved bool) error { pinned := record.Pinned() investigated := record.Investigated() @@ -87,7 +90,7 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r } onboardingRefused := len(refusedLabels) allClean := len(pinned) == 0 && len(investigated) == 0 && len(unresolvedEntries) == 0 - hasUnfixable := reportHasUnfixableErrors(report) + hasUnfixable := reportHasUnfixableErrors(report, acceptMoved) if allClean && !hasUnfixable && onboardingRefused == 0 && !hasInconclusive { console.TermSuccess("All %d %s valid", total, ui.Pluralize(total, "workflow", "workflows")) if skippedRescan > 0 { diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 4114acae..3ff78a22 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -334,7 +334,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) if err := format.WriteJSON(out, report, valid, opts.jsonFields, cliVersion(), store.File().Version); err != nil { return err } - if reportHasUnfixableErrors(report) || len(record.Investigated()) > 0 { + if reportHasUnfixableErrors(report, opts.acceptMoved) || len(record.Investigated()) > 0 { return errSilent } return nil @@ -342,7 +342,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) // Terminal summary. hasInconclusive := opts.rescan && report.HasInconclusive() - summaryErr := renderPinSummary(ctx, console, record, report, r, skippedRescan, hasInconclusive, refusedLabels, opts.noNarrow) + summaryErr := renderPinSummary(ctx, console, record, report, r, skippedRescan, hasInconclusive, refusedLabels, opts.noNarrow, opts.acceptMoved) // Surface the SAML SSO authorization URL if one was captured during // the run, matching cli/cli's "Authorize in your web browser:" line. diff --git a/test/integration/run.rb b/test/integration/run.rb index e95c5b44..189f36ed 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -420,6 +420,25 @@ def golden_json_diff(expected, actual, path) } ) }, + # Lockfile with a deliberately stale SHA that is NOT an ancestor of + # current v4. Used to trigger lockfile-forgery and test --accept-moved. + "forgery_stale_checkout" => -> { + build_lockfile( + workflows: { + ".github/workflows/ci.yml" => [ + "actions/checkout@v4" + ] + }, + dependencies: { + "actions/checkout@v4" => { + "ref" => "v4", + "commit" => "sha1-0000000000000000000000000000000000000000", + "owner_id" => 44036562, + "repo_id" => 197814629 + } + } + ) + }, "future_version" => -> { <<~YAML # This file is machine-generated by `gh actions-lock`. diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index a293a4cf..1d14d067 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -1534,6 +1534,26 @@ scenarios: - "ref: 'v4'" - "commit: 'sha1-" + - name: accept_moved_resolves_forgery + category: security + description: "--accept-moved re-resolves a lockfile-forgery dep to the current live SHA" + needs_token: true + tags: [smoke] + flags: ["--accept-moved", "--no-narrow"] + fixtures: + workflows: + ci.yml: + name: CI + actions: ["actions/checkout@v4"] + lockfile_template: forgery_stale_checkout + expect: + exit: 0 + lockfile_deps_cover_direct: true + lockfile_contains: + - "'actions/checkout@v4':" + - "ref: 'v4'" + - "commit: 'sha1-" + - name: onboard_roundtrip_v002 category: onboarding description: "Fresh onboard writes a v0.0.2 lockfile with correct key format, ref field, and commit field" From 8e34474efc2f4e2b7c9ff86189a5376df5df6c16 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:25:57 -0500 Subject: [PATCH 26/67] harness: graceful skip on clone failure in live scenarios Replaced exception: true with a conditional check so a bad repo name (typo, private, non-existent) raises SkipScenario instead of crashing the interactive shell with an unhandled RuntimeError. --- test/integration/harness.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 51465fdd..3342aa17 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -398,9 +398,12 @@ def prepare_live(binary, profile_dir: nil) $stderr.print "\e[2m cloning #{nwo}…\e[0m " $stderr.flush - system("git", "clone", "--depth=1", "--quiet", - "https://github.com/#{nwo}.git", dir, - exception: true) + unless system("git", "clone", "--depth=1", "--quiet", + "https://github.com/#{nwo}.git", dir) + $stderr.puts "\e[31mfailed\e[0m" + FileUtils.rm_rf(dir) + raise SkipScenario, "clone failed for #{nwo} (repo not found or no access)" + end $stderr.puts "\e[2mdone\e[0m" env = @env.dup From ed340cc4741e6800350c054cfa9767d9c9e81899 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:30:47 -0500 Subject: [PATCH 27/67] version-ref nudge: only fire for partial semver, show affected workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nudge was firing for branch-pinned refs like canary/main/nightly, suggesting an unrelated semver release as an upgrade. Now only partial semver refs (v4, v3.1) trigger the nudge — branch names are intentional and not actionable. Also fixed the misleading 'Run without --no-narrow to upgrade' message (showed when narrowing was already on) and added workflow paths to the nudge output so users know which files to update. --- cmd/gh-actions-lock/pin_summary.go | 33 ++++++++++++++++++++++-------- cmd/gh-actions-lock/run.go | 5 +++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index c13076d9..80cda334 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -522,14 +522,14 @@ func stripNWORefPrefix(s string) string { // re-pins. Only shown for repos that actually have semver releases. func renderVersionRefNudge(ctx context.Context, console *ui.UI, record *pin.Record, r *resolve.Resolver) { type nudgeEntry struct { - key string // NWO@Ref - latest string // best full-semver tag, e.g. v1.2.3 + key string // NWO@Ref + latest string // best full-semver tag, e.g. v1.2.3 + workflows []string } - seen := map[string]bool{} + seen := map[string]*nudgeEntry{} // Cache per-repo so we don't call ListTags twice for the same repo. repoLatest := map[string]string{} // NWO → latest full semver (or "" if none) - var entries []nudgeEntry for _, e := range record.Entries { if e.Resolution != pin.Pinned && e.Resolution != pin.Verified { @@ -542,11 +542,19 @@ func renderVersionRefNudge(ctx context.Context, console *ui.UI, record *pin.Reco if ok && sv.IsFull() { continue } + // Only nudge for partial semver refs (v4, v3.1) — not arbitrary + // branch names like canary, main, nightly. A ref must at least + // parse as semver (partial) to be nudge-worthy. + if !ok { + continue + } key := e.NWO + "@" + e.Ref - if seen[key] { + if ne, exists := seen[key]; exists { + for _, wf := range e.Workflows { + ne.workflows = append(ne.workflows, wf) + } continue } - seen[key] = true latest, cached := repoLatest[e.NWO] if !cached { @@ -556,11 +564,15 @@ func renderVersionRefNudge(ctx context.Context, console *ui.UI, record *pin.Reco if latest == "" { continue // no semver releases — nothing to suggest } - entries = append(entries, nudgeEntry{key: key, latest: latest}) + seen[key] = &nudgeEntry{key: key, latest: latest, workflows: e.Workflows} } - if len(entries) == 0 { + if len(seen) == 0 { return } + entries := make([]*nudgeEntry, 0, len(seen)) + for _, ne := range seen { + entries = append(entries, ne) + } console.TermBlank() console.TermWarn("%d %s pinned without a full semver tag", len(entries), ui.Pluralize(len(entries), "action", "actions")) @@ -569,8 +581,11 @@ func renderVersionRefNudge(ctx context.Context, console *ui.UI, record *pin.Reco console.TermYellow(ne.key), console.TermBold("→"), console.TermYellow(ne.latest)) + for _, wf := range ne.workflows { + console.TermDetail(" %s", wf) + } } - console.TermDetail(" Run without --no-narrow to upgrade.") + console.TermDetail(" Update the uses: line in your workflow to the full version to lock precisely.") } // latestFullSemverTag returns the highest full semver tag (vX.Y.Z) for diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 3ff78a22..c7d47352 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -393,6 +393,11 @@ func injectVersionRefFindings(report *checks.Report, record *pin.Record) { if ok && sv.IsFull() { continue } + // Only flag partial semver refs (v4, v3.1) — not branch names + // like canary, main, nightly which are intentional. + if !ok { + continue + } key := e.NWO + "@" + e.Ref di, exists := seen[key] if !exists { From b8707522162f3f8741d447ce70e93d48c08a5649 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:32:40 -0500 Subject: [PATCH 28/67] harness: rescue SkipScenario in run_one_live shell command The shell's run_one_live path calls prepare() which can raise SkipScenario (e.g. clone failure), but wasn't rescuing it. Now prints a skip message and returns to the shell prompt instead of crashing. --- test/integration/harness.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 3342aa17..6dbc6974 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1479,7 +1479,12 @@ def run_one_live(s, keep_alive: false) puts # Prepare fixtures early so we can show starting state - ctx = s.prepare(@binary, profile_dir: @profile_dir) + begin + ctx = s.prepare(@binary, profile_dir: @profile_dir) + rescue SkipScenario => e + puts " \e[33m⊘ skip:\e[0m #{e.message}" + return nil + end # ── INPUT ── puts "\e[1m┌─ INPUT #{"─" * (w - 10)}┐\e[0m" From ea1760029d2c2f34045dbe7df9bc8903be86e909 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:34:24 -0500 Subject: [PATCH 29/67] pin summary: clean up transitive dep display for SHA-pinned refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a composite action pins a dep by full SHA, the display was showing the redundant 'actions/foo@<40-char-sha> (<7-char-sha>)'. Now shows just 'actions/foo@f28e40c' — same info, readable. --- cmd/gh-actions-lock/pin_summary.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index 80cda334..2a36b17a 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -244,9 +244,16 @@ func renderPinnedEntries(console *ui.UI, pinned []pin.Entry) { if len(short) > 7 { short = short[:7] } - label := te.NWO + "@" + te.Ref - if short != "" { - label = fmt.Sprintf("%s (%s)", label, short) + // When the ref IS the full SHA (composite actions pin by commit), + // just show NWO@short instead of the redundant full-sha (short). + var label string + if te.Ref == te.SHA || (len(te.Ref) >= 40 && te.Ref == te.SHA[:len(te.Ref)]) { + label = te.NWO + "@" + short + } else { + label = te.NWO + "@" + te.Ref + if short != "" { + label = fmt.Sprintf("%s (%s)", label, short) + } } via := "" if te.parentLabel != "" { From 1b74b282c8bc8b34581fc654c08a0b0ddfc021ab Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:35:34 -0500 Subject: [PATCH 30/67] pin summary: add section header for transitive dependencies --- cmd/gh-actions-lock/pin_summary.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index 2a36b17a..f52fabf8 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -239,6 +239,10 @@ func renderPinnedEntries(console *ui.UI, pinned []pin.Entry) { console.TermYellow("!"), console.TermDim(prev), console.TermBold(g.Ref)) } } + if len(purelyTransitive) > 0 { + console.TermBlank() + console.TermDetail("Transitive dependencies (from composite actions):") + } for _, te := range purelyTransitive { short := te.SHA if len(short) > 7 { From ddce86696ab82651de8aeeab736ad721d691e38d Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:37:32 -0500 Subject: [PATCH 31/67] lockfile: bump to 13c755d (relaxed ref validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the character denylist from isValidRef — now only rejects empty strings and colons. Weird refs (unicode, special chars) no longer error during parse. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7053fb4c..35b3d705 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.4-0.20260623152044-1ee4c251236e +require github.com/github/actions-lockfile/go v0.0.4-0.20260623162752-13c755d842bf require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index d1edcd20..48719d9d 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/github/actions-lockfile/go v0.0.4-0.20260623152044-1ee4c251236e h1:SuM7ss7lrITSjsgZaYVOw0nsFx/wB6a7tSsEWHE+aMs= -github.com/github/actions-lockfile/go v0.0.4-0.20260623152044-1ee4c251236e/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623162752-13c755d842bf h1:oNDrsHPvKWl7EvvyjrXgYFc7FxYimkCajSjXpMRxoF4= +github.com/github/actions-lockfile/go v0.0.4-0.20260623162752-13c755d842bf/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= From c77ada80f438a03fdf0a92fe8b33e066fe247023 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:42:15 -0500 Subject: [PATCH 32/67] reverse lookup: don't hard-fail on bare SHA pins without a containing ref When the user wrote uses: org/repo@, DiscoverContaining may not find a tag or branch at that exact commit (it's deep in history). Previously this was a hard error ('orphaned commit'). Now we skip gracefully and keep the SHA pin as-is. The orphan error still fires for symbolic refs (v4, main, etc.) that resolve to an unreachable SHA. --- internal/resolve/reverse_lookup.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index 5b1dd002..8a18763a 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -261,6 +261,13 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma return nil, fmt.Errorf("%s@%s: %w", d.NWO, d.Ref, err) } if tag == "" && branch == "" { + // When the user wrote a bare SHA (uses: org/repo@), not + // finding a containing ref is expected — just keep it pinned + // as-is. Only error for symbolic refs where we resolved a SHA + // that nothing points to. + if looksLikeSHA(d.Ref) { + continue + } return nil, fmt.Errorf("%s@%s: commit %s is not reachable from any ref (tag or branch) — orphaned commit", d.NWO, d.Ref, parserlock.ShortSHA(d.SHA)) } @@ -281,3 +288,18 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma } return rewrites, nil } + +// looksLikeSHA returns true when ref is a hex string of SHA-1 (40) or +// SHA-256 (64) length — i.e. the user wrote a bare commit hash. +func looksLikeSHA(ref string) bool { + n := len(ref) + if n != 40 && n != 64 { + return false + } + for _, c := range ref { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} From d8a8c34aa7d2c67f6d51ff4b636fdafb066d6eb9 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 11:53:03 -0500 Subject: [PATCH 33/67] pin summary: suppress downgrade nudges, show info for bare SHA pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: - Version-ref nudge no longer suggests a full semver tag that is LOWER than the user's current partial ref (e.g. v3.4 → v2.4.1 was wrong). - Bare SHA pins now show 'pinned by SHA — no symbolic ref found' instead of being completely silent or showing the redundant full-sha (short). --- cmd/gh-actions-lock/pin_summary.go | 33 +++++++++++++++++++++++++++--- internal/resolve/reverse_lookup.go | 7 ++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index f52fabf8..cf31750a 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -222,9 +222,14 @@ func renderPinnedEntries(console *ui.UI, pinned []pin.Entry) { if len(short) > 7 { short = short[:7] } - label := g.NWO + "@" + g.Ref - if short != "" { - label = fmt.Sprintf("%s (%s)", label, short) + var label string + if looksLikeSHA(g.Ref) { + label = g.NWO + "@" + short + } else { + label = g.NWO + "@" + g.Ref + if short != "" { + label = fmt.Sprintf("%s (%s)", label, short) + } } console.TermDetail(" %s", console.TermYellow(label)) for _, wf := range g.workflows { @@ -575,6 +580,13 @@ func renderVersionRefNudge(ctx context.Context, console *ui.UI, record *pin.Reco if latest == "" { continue // no semver releases — nothing to suggest } + // Don't suggest a downgrade: if the user is on v3.4, only nudge + // if the latest full semver is v3.4.x or higher. + if latestSV, latestOK := parserlock.ParseSemVer(latest); latestOK { + if !latestSV.Greater(sv) { + continue + } + } seen[key] = &nudgeEntry{key: key, latest: latest, workflows: e.Workflows} } if len(seen) == 0 { @@ -633,3 +645,18 @@ func latestFullSemverTag(ctx context.Context, r *resolve.Resolver, nwo string) s } return bestTag } + +// looksLikeSHA returns true when ref is a hex string of SHA-1 (40) or +// SHA-256 (64) length. +func looksLikeSHA(ref string) bool { + n := len(ref) + if n != 40 && n != 64 { + return false + } + for _, c := range ref { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index 8a18763a..54b350aa 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -261,12 +261,9 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma return nil, fmt.Errorf("%s@%s: %w", d.NWO, d.Ref, err) } if tag == "" && branch == "" { - // When the user wrote a bare SHA (uses: org/repo@), not - // finding a containing ref is expected — just keep it pinned - // as-is. Only error for symbolic refs where we resolved a SHA - // that nothing points to. if looksLikeSHA(d.Ref) { - continue + return nil, fmt.Errorf("%s@%s: no tag or branch contains this commit — a symbolic ref is required for the lockfile", + d.NWO, parserlock.ShortSHA(d.Ref)) } return nil, fmt.Errorf("%s@%s: commit %s is not reachable from any ref (tag or branch) — orphaned commit", d.NWO, d.Ref, parserlock.ShortSHA(d.SHA)) From b07c06dfc79cc15ad90b9f16bd50e49f7041202f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 12:14:58 -0500 Subject: [PATCH 34/67] resolve: better error for hex-looking refs that don't resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When someone writes uses: org/repo@, GitHub can't resolve it as a ref or commit. The error now says 'version X does not resolve — if this is a commit, use the full SHA' instead of the misleading 'ref X does not exist'. --- internal/ghapi/graphql_action_files.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/ghapi/graphql_action_files.go b/internal/ghapi/graphql_action_files.go index d790960c..8d455182 100644 --- a/internal/ghapi/graphql_action_files.go +++ b/internal/ghapi/graphql_action_files.go @@ -238,7 +238,13 @@ func parseActionFileResponse(data map[string]json.RawMessage, refs []ActionFileR } if repo.Object == nil || repo.Object.OID == "" { - results[idx].Err = fmt.Errorf("ref %q does not exist", ref.Ref) + // Distinguish between a short SHA (looks like hex but isn't a + // full 40/64-char commit) and a named ref that doesn't exist. + if isHexString(ref.Ref) { + results[idx].Err = fmt.Errorf("version %q does not resolve — if this is a commit, use the full SHA", ref.Ref) + } else { + results[idx].Err = fmt.Errorf("version %q does not exist", ref.Ref) + } continue } @@ -298,3 +304,15 @@ func ssoRequiredMessage(hostname, owner string) string { } return fmt.Sprintf("SSO authorization required: your token is not authorized for the %q organization (SAML enforcement). Authorize it at https://%s/orgs/%s/sso and retry", owner, host, owner) } + +func isHexString(s string) bool { + if len(s) == 0 { + return false + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} From 878355fbcd0b299f2c826d34ec914a560da39015 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 12:27:07 -0500 Subject: [PATCH 35/67] test harness: use real orphaned commit 7b403c9 for impostor scenario Replaced the stub-based impostor-commit test with a live scenario using a truly orphaned commit in nodeselector/actions-test-fixtures. Also improved error messages: full SHA that doesn't resolve says 'commit X does not exist or is not reachable', short hex says 'use the full 40-character SHA', named refs say 'version X does not exist'. --- internal/ghapi/graphql_action_files.go | 15 ++++++--- test/integration/run.rb | 33 ------------------ test/scenarios/catalog.yml | 46 +++++--------------------- 3 files changed, 19 insertions(+), 75 deletions(-) diff --git a/internal/ghapi/graphql_action_files.go b/internal/ghapi/graphql_action_files.go index 8d455182..c548e7fe 100644 --- a/internal/ghapi/graphql_action_files.go +++ b/internal/ghapi/graphql_action_files.go @@ -238,11 +238,16 @@ func parseActionFileResponse(data map[string]json.RawMessage, refs []ActionFileR } if repo.Object == nil || repo.Object.OID == "" { - // Distinguish between a short SHA (looks like hex but isn't a - // full 40/64-char commit) and a named ref that doesn't exist. - if isHexString(ref.Ref) { - results[idx].Err = fmt.Errorf("version %q does not resolve — if this is a commit, use the full SHA", ref.Ref) - } else { + n := len(ref.Ref) + switch { + case isHexString(ref.Ref) && (n == 40 || n == 64): + // Full SHA that doesn't resolve — commit is unreachable/orphaned + results[idx].Err = fmt.Errorf("commit %s does not exist or is not reachable in %s/%s", + ref.Ref[:12], ref.Owner, ref.Repo) + case isHexString(ref.Ref): + // Short hex — ambiguous, might be a truncated SHA + results[idx].Err = fmt.Errorf("version %q does not resolve — if this is a commit, use the full 40-character SHA", ref.Ref) + default: results[idx].Err = fmt.Errorf("version %q does not exist", ref.Ref) } continue diff --git a/test/integration/run.rb b/test/integration/run.rb index 189f36ed..a80d1d37 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -501,30 +501,6 @@ def checkout_repo_rest(srv) # GraphQL handler that resolves actions/checkout@v4 to the given SHA # and returns DIVERGED for all reachability checks. -def checkout_graphql_impostor(srv) - srv.on(:POST, %r{/graphql$}) do |req| - body = JSON.parse(req.body) rescue {} - query = body["query"] || "" - - if query.include?("expression") - # ResolveActionFiles — return the locked SHA (no ref-moved) - [200, { "Content-Type" => "application/json" }, - JSON.generate({ data: { a0: { - nameWithOwner: "actions/checkout", - object: { oid: CHECKOUT_SHA, file: { object: { text: "name: Checkout\ndescription: Checkout\n" } } } - } } })] - elsif query.include?("compare") - # BatchBranchContains — all branches report DIVERGED (unreachable) - repo_data = {} - query.scan(/b(\d+):/).flatten.each { |i| repo_data["b#{i}"] = { compare: { status: "DIVERGED" } } } - [200, { "Content-Type" => "application/json" }, - JSON.generate({ data: { repo: repo_data } })] - else - [200, { "Content-Type" => "application/json" }, JSON.generate({ data: {} })] - end - end -end - # GraphQL handler that resolves actions/checkout@v4 to a DIFFERENT SHA # (simulating ref-moved), so the forgery ancestry check kicks in. def checkout_graphql_forgery(srv) @@ -645,15 +621,6 @@ def checkout_graphql_forgery(srv) s.env("GH_TOKEN" => "gho_fake_json_combined_token") }, - # Detection scenarios: impostor commit (SHA unreachable from any branch) - dbot_impostor_blocks: ->(s) { - s.stub_server do |srv| - checkout_graphql_impostor(srv) - checkout_repo_rest(srv) - end - s.env("GH_TOKEN" => "gho_fake_impostor_token") - }, - # Detection scenarios: lockfile forgery (pinned SHA not ancestor of live SHA) dbot_forgery_blocks: ->(s) { s.stub_server do |srv| diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 1d14d067..57cf2206 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -1447,48 +1447,20 @@ scenarios: lockfile_version: v0.0.2 valid: true - name: dbot_impostor_blocks - category: dependabot - description: "Impostor commit (unreachable SHA) produces impostor-commit/error finding" - needs_stub: true - tags: [stub] - flags: ["--no-onboard", "--no-narrow", "--no-interactive", "--rescan", "--json=valid,findings"] + category: security + description: "Orphaned commit (no tag or branch contains it) blocks pinning" + needs_token: true + tags: [smoke] + flags: ["--no-onboard", "--no-narrow", "--no-interactive"] fixtures: workflows: ci.yml: name: CI - actions: ["actions/checkout@v4"] - lockfile_template: pinned_checkout + actions: ["nodeselector/actions-test-fixtures@7b403c9ec14bd3ae0bbf793c2bee8815a7ac920b"] expect: - exit: 1 - stdout_is_json: true - jq: - - expr: '.valid' - equals: "false" - - expr: '.findings | length' - greater_than: 0 - - expr: '.findings[] | select(.category == "impostor-commit") | .category' - equals: "impostor-commit" - - expr: '.findings[] | select(.category == "impostor-commit") | .severity' - equals: "error" - - golden_json: - cli_version: (devel) - lockfile_version: v0.0.2 - valid: false - findings: - - category: impostor-commit - confidence: high - dependency: actions/checkout@v4 - detail: "locked de0fac2e4500 is not reachable from v4 \u2014 classic fork-network impostor-commit shape" - doc_url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions" - remediation: "investigate immediately \u2014 the lockfile entry may have been injected" - severity: error - workflow: .github/workflows/ci.yml - - lockfile_contains: - - "'actions/checkout@v4':" - - "ref: 'v4'" - - "commit: 'sha1-" + exit: 2 + output_contains: + - "does not exist or is not reachable" - name: dbot_forgery_blocks category: dependabot From cc11ba6f670a120a889d6a02a1ff3faf82f7cec6 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 12:28:25 -0500 Subject: [PATCH 36/67] lockfile: bump to c745c7c (isValidRef in splitUsesRef) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 35b3d705..ae798291 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.4-0.20260623162752-13c755d842bf +require github.com/github/actions-lockfile/go v0.0.4-0.20260623172727-c745c7cdf283 require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index 48719d9d..98df7799 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/github/actions-lockfile/go v0.0.4-0.20260623162752-13c755d842bf h1:oNDrsHPvKWl7EvvyjrXgYFc7FxYimkCajSjXpMRxoF4= -github.com/github/actions-lockfile/go v0.0.4-0.20260623162752-13c755d842bf/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623172727-c745c7cdf283 h1:xWXpz1yYPCp7y/6vuo0wcJUz5DegkfAtgtmz8DnKCDs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623172727-c745c7cdf283/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= From c493bd8698128241d97cef6b3ebd4b2b84ba1736 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 12:36:00 -0500 Subject: [PATCH 37/67] lockfile: bump to 135b604 (skip ref mismatch for SHA-keyed pins) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ae798291..aa4cf63a 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.4-0.20260623172727-c745c7cdf283 +require github.com/github/actions-lockfile/go v0.0.4-0.20260623173526-135b60480891 require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index 98df7799..c213be04 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/github/actions-lockfile/go v0.0.4-0.20260623172727-c745c7cdf283 h1:xWXpz1yYPCp7y/6vuo0wcJUz5DegkfAtgtmz8DnKCDs= -github.com/github/actions-lockfile/go v0.0.4-0.20260623172727-c745c7cdf283/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623173526-135b60480891 h1:ehRdeTvGSxir7hrBx7DUrZ40BfolEWmPhPJoLahkepk= +github.com/github/actions-lockfile/go v0.0.4-0.20260623173526-135b60480891/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= From b157d66dc50fedf64a9140af1b66c6bf4678f504 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 12:37:43 -0500 Subject: [PATCH 38/67] lockfile: bump to 887d8de (expanded SHA-key regression tests) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index aa4cf63a..1d631e86 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.4-0.20260623173526-135b60480891 +require github.com/github/actions-lockfile/go v0.0.4-0.20260623173707-887d8dec2740 require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index c213be04..490f40cc 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/github/actions-lockfile/go v0.0.4-0.20260623173526-135b60480891 h1:ehRdeTvGSxir7hrBx7DUrZ40BfolEWmPhPJoLahkepk= -github.com/github/actions-lockfile/go v0.0.4-0.20260623173526-135b60480891/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4-0.20260623173707-887d8dec2740 h1:c8o3ZkMYMi4EO5T3bb1m4NOXzoHpEyWPGtWYlzk6YNM= +github.com/github/actions-lockfile/go v0.0.4-0.20260623173707-887d8dec2740/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= From 02be56db5fc6c280f9a92524894ff1f0ecb81f67 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 12:48:08 -0500 Subject: [PATCH 39/67] pin summary: show lockfile schema version upgrade When the on-disk lockfile had an older schema version (e.g. v0.0.1) and the binary writes the current version (v0.0.2), surface a detail line so the user knows the migration happened. Adds originalVersion tracking to lockfile.State via a lightweight pre-parse extraction of the version field from raw YAML. --- cmd/gh-actions-lock/pin_summary.go | 9 +++++- cmd/gh-actions-lock/run.go | 2 +- internal/lockfile/state.go | 47 +++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index cf31750a..b11ea50a 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -61,7 +61,7 @@ func reportHasNonInvestigatedUnfixableErrors(report *checks.Report) bool { // renderPinSummary prints the terminal summary after pin.Plan + pin.Commit. // It groups pinned entries by NWO@Ref, shows investigation alerts, unresolved // warnings, and the all-valid message when nothing changed. -func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, report *checks.Report, r *resolve.Resolver, skippedRescan int, hasInconclusive bool, refusedLabels []string, noNarrow bool, acceptMoved bool) error { +func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, report *checks.Report, r *resolve.Resolver, skippedRescan int, hasInconclusive bool, refusedLabels []string, noNarrow bool, acceptMoved bool, originalVersion string) error { pinned := record.Pinned() investigated := record.Investigated() @@ -88,6 +88,13 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r console.TermNeutral("No workflows to check") return nil } + + // Surface lockfile schema upgrade when the on-disk version differs from + // the current binary's version (e.g. v0.0.1 → v0.0.2). + if originalVersion != "" && originalVersion != parserlock.Version { + console.TermDetail("Upgraded lockfile schema %s → %s", originalVersion, parserlock.Version) + } + onboardingRefused := len(refusedLabels) allClean := len(pinned) == 0 && len(investigated) == 0 && len(unresolvedEntries) == 0 hasUnfixable := reportHasUnfixableErrors(report, acceptMoved) diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index c7d47352..3e9803ae 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -342,7 +342,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) // Terminal summary. hasInconclusive := opts.rescan && report.HasInconclusive() - summaryErr := renderPinSummary(ctx, console, record, report, r, skippedRescan, hasInconclusive, refusedLabels, opts.noNarrow, opts.acceptMoved) + summaryErr := renderPinSummary(ctx, console, record, report, r, skippedRescan, hasInconclusive, refusedLabels, opts.noNarrow, opts.acceptMoved, store.OriginalVersion()) // Surface the SAML SSO authorization URL if one was captured during // the run, matching cli/cli's "Authorize in your web browser:" line. diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index f922aa75..7ca4a076 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -1,6 +1,7 @@ package lockfile import ( + "bytes" "context" "errors" "fmt" @@ -37,12 +38,13 @@ type MetadataResolver interface { // take the same mutex, allowing parallel pin and upgrade workers to share // a single store instance without external synchronization. type State struct { - mu sync.Mutex - lockPath string // full path to actions.lock on disk - file parserlock.File - meta MetadataResolver - idCache map[string][2]int64 - idSF singleflight.Group + mu sync.Mutex + lockPath string // full path to actions.lock on disk + file parserlock.File + originalVersion string // version string read from disk (empty if file did not exist) + meta MetadataResolver + idCache map[string][2]int64 + idSF singleflight.Group } // ErrCorruptLockfile reports that a lockfile exists on disk but cannot be @@ -67,8 +69,10 @@ func LoadStateAt(lockfilePath string, meta MetadataResolver) (*State, error) { contents, err := os.ReadFile(lockfilePath) var file parserlock.File + var originalVersion string switch { case err == nil: + originalVersion = extractVersion(contents) file, err = parserlock.Parse(contents) if err != nil { // A future-version lockfile (written by a newer binary) must @@ -104,10 +108,11 @@ func LoadStateAt(lockfilePath string, meta MetadataResolver) (*State, error) { } s := &State{ - lockPath: lockfilePath, - file: file, - meta: meta, - idCache: map[string][2]int64{}, + lockPath: lockfilePath, + file: file, + originalVersion: originalVersion, + meta: meta, + idCache: map[string][2]int64{}, } // Normalize on-disk entries to the canonical (lowercased) pin form so any // legacy mixed-case keys are rewritten on the next Save. @@ -157,6 +162,12 @@ func (s *State) File() parserlock.File { return s.file } +// OriginalVersion returns the version string that was on disk before the +// lockfile was loaded and migrated. Empty when the file did not exist. +func (s *State) OriginalVersion() string { + return s.originalVersion +} + // HasWorkflow reports whether the lockfile's workflows{} map already // contains an entry for workflowKey. Used by `upgrade --no-onboard` to // refuse silently onboarding a previously-untracked workflow during a @@ -474,3 +485,19 @@ func (s *State) lookupIDs(ctx context.Context, owner, repo string) ([2]int64, er } return res.([2]int64), nil } + +// extractVersion reads the version field from raw lockfile YAML without +// a full parse. Returns empty string if not found. +func extractVersion(contents []byte) string { + for _, line := range bytes.Split(contents, []byte("\n")) { + line = bytes.TrimSpace(line) + if bytes.HasPrefix(line, []byte("version:")) { + v := bytes.TrimPrefix(line, []byte("version:")) + v = bytes.TrimSpace(v) + // Strip surrounding quotes (single or double). + v = bytes.Trim(v, "'\"") + return string(v) + } + } + return "" +} From 2730c28a40ee2e48aa6583ed83a4be8e4bf8c84e Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 12:48:48 -0500 Subject: [PATCH 40/67] lockfile: bump to tagged release go/v0.0.4 Moves off the pseudo-version (887d8de) to the stable tagged release. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1d631e86..9e878958 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/github/actions-lockfile/go v0.0.4-0.20260623173707-887d8dec2740 +require github.com/github/actions-lockfile/go v0.0.4 require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index 490f40cc..c654236f 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/github/actions-lockfile/go v0.0.4-0.20260623173707-887d8dec2740 h1:c8o3ZkMYMi4EO5T3bb1m4NOXzoHpEyWPGtWYlzk6YNM= -github.com/github/actions-lockfile/go v0.0.4-0.20260623173707-887d8dec2740/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= +github.com/github/actions-lockfile/go v0.0.4 h1:FP2KrJNhBti8rR7+Z8TKycJdWFc0gzuaj0wTlANoFdY= +github.com/github/actions-lockfile/go v0.0.4/go.mod h1:kp8pDNXwrr3fC+6Mgmh/ZODa6AsIEC+bmf1CLQ/7DEs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= From a9d49b5950dc3f2b18305455f9c2208d05ceba40 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 13:15:44 -0500 Subject: [PATCH 41/67] lockfile load before auth, fix CI failures Move lockfile loading before resolver creation so parse errors (e.g. future-version lockfile) surface before auth checks. Fixes the onboarded_future_version stub test that was getting 'token not found' instead of the upgrade hint. Also adds SetMetadataResolver to State for deferred wiring, and updates the transitive_closure_cross_repo golden file for the latest main branch commit. --- cmd/gh-actions-lock/root.go | 46 +++++++++---------- internal/lockfile/state.go | 9 ++++ .../transitive_closure_cross_repo.lock | 2 +- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/cmd/gh-actions-lock/root.go b/cmd/gh-actions-lock/root.go index f21347e4..09a10dfe 100644 --- a/cmd/gh-actions-lock/root.go +++ b/cmd/gh-actions-lock/root.go @@ -138,27 +138,17 @@ func newRun(workflowPaths []string, hostname string, pool *pinpool.Pool, newReso return nil, nil, nil, err } - if newResolver == nil { - newResolver = func(hostname string, pool *pinpool.Pool) (*resolve.Resolver, error) { - return resolve.New(hostname, pool) + // Load lockfile before auth so version/parse errors surface before + // "token not found" — a future-version lockfile should tell the user + // to upgrade, not complain about missing auth. + loadStore := func(meta lockfile.MetadataResolver) (*lockfile.State, error) { + if workflowsDir != "" { + return lockfile.LoadStateAt(filepath.Join(workflowsDir, "actions.lock"), meta) } + return lockfile.LoadState(".", meta) } - r, err := newResolver(resolveHostname(hostname), pool) + store, err := loadStore(nil) if err != nil { - return nil, nil, nil, err - } - - var store *lockfile.State - if workflowsDir != "" { - store, err = lockfile.LoadStateAt(filepath.Join(workflowsDir, "actions.lock"), r) - } else { - store, err = lockfile.LoadState(".", r) - } - if err != nil { - // An unreadable (non-future-version) lockfile is never silently - // discarded. Recovery policy may delete-and-recreate (interactive - // fix mode) or fail (CI, read-only, relock); either way the choice - // is explicit and surfaces to the user. if errors.Is(err, lockfile.ErrCorruptLockfile) && onCorrupt != nil { lockPath := filepath.Join(".", parserlock.Path) if workflowsDir != "" { @@ -169,17 +159,27 @@ func newRun(workflowPaths []string, hostname string, pool *pinpool.Pool, newReso return nil, nil, nil, rerr } if recovered { - if workflowsDir != "" { - store, err = lockfile.LoadStateAt(filepath.Join(workflowsDir, "actions.lock"), r) - } else { - store, err = lockfile.LoadState(".", r) - } + store, err = loadStore(nil) } } if err != nil { return nil, nil, nil, fmt.Errorf("opening lockfile: %w", err) } } + + if newResolver == nil { + newResolver = func(hostname string, pool *pinpool.Pool) (*resolve.Resolver, error) { + return resolve.New(hostname, pool) + } + } + r, err := newResolver(resolveHostname(hostname), pool) + if err != nil { + return nil, nil, nil, err + } + + // Now that the resolver is available, set it as the metadata resolver + // for the store and re-seed branch hints. + store.SetMetadataResolver(r) r.SeedBranchHints(store.AllDeps()) return paths, r, store, nil diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index 7ca4a076..c299842f 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -168,6 +168,15 @@ func (s *State) OriginalVersion() string { return s.originalVersion } +// SetMetadataResolver sets the resolver used by lookupIDs to fetch owner/repo +// numeric IDs. This allows loading the lockfile before auth is available, +// then wiring in the resolver once the API client is ready. +func (s *State) SetMetadataResolver(meta MetadataResolver) { + s.mu.Lock() + defer s.mu.Unlock() + s.meta = meta +} + // HasWorkflow reports whether the lockfile's workflows{} map already // contains an entry for workflowKey. Used by `upgrade --no-onboard` to // refuse silently onboarding a previously-untracked workflow during a diff --git a/test/scenarios/testdata/transitive_closure_cross_repo.lock b/test/scenarios/testdata/transitive_closure_cross_repo.lock index 74b9a915..08c89ae3 100644 --- a/test/scenarios/testdata/transitive_closure_cross_repo.lock +++ b/test/scenarios/testdata/transitive_closure_cross_repo.lock @@ -24,7 +24,7 @@ dependencies: repo_id: 1205442499 'nodeselector/actions-test-fixtures@main': ref: 'main' - commit: 'sha1-a132be34de3441f8d2970a3fd6b8a7a86bdbbd0c' + commit: 'sha1-da3230bd4d74392a7135b82d07612eb53b261461' owner_id: 29457092 repo_id: 1203329948 uses: From 2f47bc8b1259f78e51983730ef0443186d40c15f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 15:18:36 -0500 Subject: [PATCH 42/67] gofmt: fix formatting --- cmd/gh-actions-lock/command_test.go | 8 ++++---- internal/pipeline/run.go | 1 - internal/resolve/reverse_lookup.go | 1 - 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cmd/gh-actions-lock/command_test.go b/cmd/gh-actions-lock/command_test.go index 2ed1d957..037aa05f 100644 --- a/cmd/gh-actions-lock/command_test.go +++ b/cmd/gh-actions-lock/command_test.go @@ -306,7 +306,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1=sha1-" + pinnedSHA, + "example/action@v1=sha1-"+pinnedSHA, ) stdout, _, err := runCommandWithHTTP(t, reg, @@ -368,7 +368,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1=sha1-" + pinnedSHA, + "example/action@v1=sha1-"+pinnedSHA, ) stdout, _, err := runCommandWithHTTP(t, reg, @@ -426,7 +426,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1=sha1-" + pinnedSHA, + "example/action@v1=sha1-"+pinnedSHA, ) stdout, _, err := runCommandWithHTTP(t, reg, @@ -921,7 +921,7 @@ jobs: steps: - uses: example/action@v1 `, - "example/action@v1=sha1-" + staleSHA, + "example/action@v1=sha1-"+staleSHA, ) stdout, _, err := runCommandWithHTTP(t, reg, diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index c6db9a6a..6fd95417 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -148,4 +148,3 @@ func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { SkippedRescan: skippedRescan, }, nil } - diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index 54b350aa..8ce7f2fb 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -22,7 +22,6 @@ import ( // branches in the same tier order. // - branch is REQUIRED to be non-empty; an error is returned otherwise -// // hintRef may be empty (e.g. for bare-SHA pins). The repo's default branch // is discovered automatically via GET /repos/{owner}/{repo} (cached). func (r *Resolver) DiscoverContaining(ctx context.Context, owner, repo, sha, hintRef string) (tag, branch string, err error) { From e2a928b9ee83f3e6e960f8e3a5ff8108c08b4709 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 19:54:49 -0500 Subject: [PATCH 43/67] harness: add push command to send lockfile changes to repo After running an adhoc repo (e.g. github/launch), the push command clones the repo, creates a branch, commits the lockfile and any changed workflows, and pushes. Use --pr to also create a PR. --- test/integration/harness.rb | 110 +++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 6dbc6974..d781edcf 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -611,6 +611,7 @@ def initialize(binary: nil) @profile_dir = nil @pause = false @last_dir = nil + @last_run = nil # { nwo:, lockfile:, workflows: {} } for push @diff_cache = {} # name → diff string @diff_order = [] # insertion order for eviction end @@ -957,7 +958,7 @@ def shell active_ctx = nil scenario_names = @scenarios.map { |s| s.name.to_s } - commands = %w[list ls run test review inspect diff cd rerun build pause profile auth status clear help quit exit q] + commands = %w[list ls run test review inspect diff cd rerun build pause profile auth status push clear help quit exit q] # Tab completion: commands first, then scenario names for run/test/inspect/cd Reline.completion_proc = proc do |input| @@ -1190,6 +1191,9 @@ def shell when "diff" show_paged_diff(arg) + when "push" + push_last_run(arg) + when "auth" show_auth @@ -1284,6 +1288,105 @@ def show_paged_diff(name) # user quit pager early — that's fine end + def cache_last_run(ctx) + return unless ctx.live_repo + lockfile_path = File.join(ctx.dir, ".github", "workflows", "actions.lock") + return unless File.exist?(lockfile_path) + + # Collect changed workflow files (the binary rewrites uses: lines) + changed = `cd #{Shellwords.shellescape(ctx.dir)} && git diff --name-only 2>/dev/null`.strip.split("\n") + workflows = {} + changed.each do |f| + next unless f.start_with?(".github/workflows/") && f.end_with?(".yml", ".yaml") + full = File.join(ctx.dir, f) + workflows[f] = File.read(full) if File.exist?(full) + end + + @last_run = { + nwo: ctx.live_repo, + lockfile: File.read(lockfile_path), + workflows: workflows, + } + end + + def push_last_run(arg) + unless @last_run + puts " \e[33mno run to push\e[0m — run a repo first (e.g. \e[36mgithub/launch\e[0m)" + return + end + + nwo = @last_run[:nwo] + create_pr = arg&.strip == "--pr" + branch = "actions-lock/pin" + ts = Time.now.strftime("%Y%m%d-%H%M%S") + branch = "#{branch}-#{ts}" + + Dir.mktmpdir("actions-lock-push-") do |dir| + puts " \e[2mcloning #{nwo}…\e[0m" + unless system("git", "clone", "--depth=1", "--quiet", + "https://github.com/#{nwo}.git", dir) + puts " \e[31mclone failed\e[0m" + return + end + + # Create branch + unless system("git", "checkout", "-b", branch, chdir: dir, + out: File::NULL, err: File::NULL) + puts " \e[31mfailed to create branch\e[0m" + return + end + + # Write lockfile + lockfile_dest = File.join(dir, ".github", "workflows", "actions.lock") + FileUtils.mkdir_p(File.dirname(lockfile_dest)) + File.write(lockfile_dest, @last_run[:lockfile]) + + # Write changed workflows + @last_run[:workflows].each do |path, content| + File.write(File.join(dir, path), content) + end + + # Commit + system("git", "add", "-A", chdir: dir, out: File::NULL, err: File::NULL) + nfiles = @last_run[:workflows].size + 1 + msg = "Pin actions with lockfile\n\nGenerated by `gh actions-lock` against #{nwo}." + unless system("git", "commit", "-q", "-m", msg, chdir: dir, + out: File::NULL, err: File::NULL) + puts " \e[33mno changes to commit\e[0m" + return + end + + # Push + print " \e[2mpushing #{branch}…\e[0m " + $stdout.flush + unless system("git", "push", "--quiet", "origin", branch, chdir: dir, + out: File::NULL, err: [:child, :out]) + puts "\e[31mfailed\e[0m" + return + end + puts "\e[32mdone\e[0m" + puts " \e[36mhttps://github.com/#{nwo}/compare/#{branch}\e[0m" + + # Optionally create PR + if create_pr + print " \e[2mcreating PR…\e[0m " + $stdout.flush + body = "Generated by `gh actions-lock`.\n\nPins #{nfiles} #{nfiles == 1 ? "file" : "files"}." + pr_url = `gh pr create --repo #{Shellwords.shellescape(nwo)} --head #{Shellwords.shellescape(branch)} \ + --title "Pin actions with lockfile" \ + --body #{Shellwords.shellescape(body)} \ + 2>&1`.strip + if $?.success? + puts "\e[32mdone\e[0m" + puts " \e[36m#{pr_url}\e[0m" + else + puts "\e[31mfailed\e[0m" + puts " #{pr_url}" + end + end + end + end + def show_starting_state(dir, width) inner = width - 2 has_content = false @@ -1379,6 +1482,8 @@ def print_help puts " \e[36mprofile [dir|off]\e[0m Toggle profiling (default: ./profiles)" puts " \e[36mauth\e[0m Show current auth source" puts " \e[36mstatus\e[0m Show current toggles" + puts " \e[36mpush\e[0m Push last run's lockfile to the repo" + puts " \e[36mpush --pr\e[0m Push and create a PR" puts " \e[36mclear\e[0m Clear screen" puts " \e[36mhelp\e[0m Show this help" puts " \e[36mquit\e[0m Exit (or Ctrl+D)" @@ -1538,6 +1643,9 @@ def run_one_live(s, keep_alive: false) diff_text = `cd #{Shellwords.shellescape(ctx.dir)} && git add -N . 2>/dev/null; git --no-pager diff --color 2>/dev/null`.strip cache_diff(s.name.to_s, diff_text) + # Cache lockfile + changed workflows for `push` command + cache_last_run(ctx) + # ── DIFF ── show_diff(ctx.dir, w, scenario_name: s.name.to_s) From b5b1b29147acdbd58f839c51611a511641e32fec Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 20:25:20 -0500 Subject: [PATCH 44/67] reverse lookup: graceful degradation for orphaned commits ReverseLookup now returns soft issues (LookupIssue) instead of hard errors when a commit can't be resolved to any tag or branch. Failed deps are filtered out and reported as Unresolved entries rather than aborting the entire planning phase. Fixes the crash on github/api-gateway@fc498099d92a where one orphaned commit killed a 20-workflow run. --- internal/pin/plan.go | 38 +++++++++++++-- internal/resolve/discover_test.go | 64 ++++++++++++++++++++----- internal/resolve/reverse_lookup.go | 37 +++++++++++--- internal/resolve/reverse_lookup_test.go | 2 +- 4 files changed, 115 insertions(+), 26 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 1b206082..786dfb5e 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -166,10 +166,37 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption // ReverseLookup canonicalizes each dep's ref while preserving the tags // narrowing chose and transitive deps' declared refs. - rlRewrites, err := reverseLookupRewrites(ctx, opts, wr, deps, directTracker, narrowedNWOs) + rlRewrites, lookupIssues, err := reverseLookupRewrites(ctx, opts, wr, deps, directTracker, narrowedNWOs) if err != nil { return planResult{}, err } + // Deps that ReverseLookup couldn't resolve (orphaned commits, bare SHAs + // with no containing ref) become Unresolved entries rather than aborting. + if len(lookupIssues) > 0 { + skip := make(map[int]bool, len(lookupIssues)) + for _, issue := range lookupIssues { + entries = append(entries, Entry{ + NWO: issue.NWO, + Ref: issue.Ref, + SHA: issue.SHA, + Resolution: Unresolved, + Issue: "reverse-lookup", + Reason: issue.Message, + Workflows: []string{wr.Path}, + }) + skip[issue.Index] = true + } + // Remove failed deps so they don't flow into pinning/commit. + filtered := deps[:0] + for i, d := range deps { + if !skip[i] { + filtered = append(filtered, d) + } + } + deps = filtered + // Rebuild direct tracker against the filtered slice. + directTracker = lockfile.NewDirectTracker(unrecordedRefs, deps) + } for k, v := range rlRewrites { rewrites[k] = v } @@ -328,7 +355,8 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen // reverseLookupRewrites canonicalizes dep refs via ReverseLookup (SHA -> tag/ // branch), restoring refs that narrowing or a transitive dep already fixed. -func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, directTracker lockfile.DirectTracker, narrowedNWOs map[string]bool) (map[string]string, error) { +// Returns the rewrites map, indices of unresolvable deps, and any hard error. +func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, directTracker lockfile.DirectTracker, narrowedNWOs map[string]bool) (map[string]string, []resolve.LookupIssue, error) { // Save narrowed refs before ReverseLookup - it may overwrite dep.Ref // with a branch name, but we want to keep the semver tag narrowing chose. narrowedRefs := make(map[int]string) @@ -351,9 +379,9 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work } // ReverseLookup: SHA -> containing tag/branch. Rewrites refs to canonical form. - normRewrites, err := opts.Resolver.ReverseLookup(ctx, deps) + normRewrites, lookupIssues, err := opts.Resolver.ReverseLookup(ctx, deps) if err != nil { - return nil, fmt.Errorf("reverse lookup: %w", err) + return nil, nil, fmt.Errorf("reverse lookup: %w", err) } // Restore narrowed refs that ReverseLookup may have overwritten. for i, ref := range narrowedRefs { @@ -379,7 +407,7 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work } rewrites[k] = v } - return rewrites, nil + return rewrites, lookupIssues, nil } // buildPinnedEntries emits an entry for every resolved dep, marking it Verified diff --git a/internal/resolve/discover_test.go b/internal/resolve/discover_test.go index abcce591..c1625048 100644 --- a/internal/resolve/discover_test.go +++ b/internal/resolve/discover_test.go @@ -2,6 +2,9 @@ package resolve import ( "context" + "io" + "net/http" + "strings" "testing" "github.com/github/gh-actions-lock/internal/dep" @@ -286,7 +289,7 @@ func TestReverseLookup_PopulatesTagBranchAndRewritesSHAPins(t *testing.T) { {NWO: "actions/checkout", Ref: "abc123abc123abc123abc123abc123abc123abc1", SHA: "abc123", HashAlgo: "sha1"}, } - rewrites, err := r.ReverseLookup(context.Background(), deps) + rewrites, _, err := r.ReverseLookup(context.Background(), deps) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -327,7 +330,7 @@ func TestReverseLookup_NoChangeWhenRefAlreadyCanonical(t *testing.T) { {NWO: "actions/checkout", Ref: "v4", SHA: "abc", HashAlgo: "sha1"}, } - rewrites, err := r.ReverseLookup(context.Background(), deps) + rewrites, _, err := r.ReverseLookup(context.Background(), deps) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -344,12 +347,36 @@ func TestReverseLookup_NoChangeWhenRefAlreadyCanonical(t *testing.T) { } func TestReverseLookup_FailsClosedOnImpostor(t *testing.T) { - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", `repos/actions/checkout/branches`), - httpmock.JSONResponse(httpmock.BranchListResponse()), - ) - r, err := New("github.com", pinpool.New(2, nil), WithTransport(reg)) + // DiscoverContaining makes several REST calls (individual branch lookups, + // default-branch, protected branches, full listing, tags). Use a custom + // transport that returns empty lists / 404s so the dep reports as an issue. + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/git/ref/") { + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(strings.NewReader(`{"message":"Not Found"}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: req, + }, nil + } + // repos/:owner/:repo → repo metadata (default_branch) + if req.URL.Path == "/api/v3/repos/actions/checkout" { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{"default_branch":"main"}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: req, + }, nil + } + // Everything else (branches listing, tags, etc.) → empty array + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`[]`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: req, + }, nil + }) + r, err := New("github.com", pinpool.New(2, nil), WithTransport(transport)) if err != nil { t.Fatal(err) } @@ -357,11 +384,22 @@ func TestReverseLookup_FailsClosedOnImpostor(t *testing.T) { deps := []dep.Dependency{ {NWO: "actions/checkout", Ref: "poisoned", SHA: "dead"}, } - _, err = r.ReverseLookup(context.Background(), deps) - if err == nil { - t.Fatalf("expected fail-closed error, got nil") + _, issues, err := r.ReverseLookup(context.Background(), deps) + if err != nil { + t.Fatalf("unexpected hard error: %v", err) } - reg.Verify(t) + if len(issues) == 0 { + t.Fatalf("expected a lookup issue for orphaned commit, got none") + } + if issues[0].NWO != "actions/checkout" { + t.Errorf("expected NWO=actions/checkout, got %q", issues[0].NWO) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) } func TestReverseLookup_PreservesBranchRefOverTag(t *testing.T) { @@ -386,7 +424,7 @@ func TestReverseLookup_PreservesBranchRefOverTag(t *testing.T) { {NWO: "actions/checkout", Ref: "main", SHA: "abc", HashAlgo: "sha1"}, } - rewrites, err := r.ReverseLookup(context.Background(), deps) + rewrites, _, err := r.ReverseLookup(context.Background(), deps) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index 8ce7f2fb..1c9cc0a5 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -241,14 +241,28 @@ func (r *Resolver) LikelyBranches(ctx context.Context, owner, repo, sha, ref, de return out } +// LookupIssue describes a single dep that ReverseLookup could not resolve. +type LookupIssue struct { + Index int // position in the deps slice + NWO string // owner/repo + Ref string // original ref + SHA string // commit hash + Message string // human-readable reason +} + // ReverseLookup performs a reverse lookup (SHA → containing tag/branch) // for every entry in deps via DiscoverContaining, populates dep.Tag and // dep.Branch, and computes the canonical @ref. The ref priority is: // tag (semver-ish release) > protected branch > default branch > any branch. // When the canonical ref differs from dep.Ref the change is recorded in // the returned rewrites map and dep.Ref is updated in place. -func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (map[string]string, error) { +// +// Deps that cannot be resolved (orphaned commits, bare SHAs with no +// containing ref) are skipped and reported in the returned issues slice. +// Only transient/API errors are returned as err. +func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (map[string]string, []LookupIssue, error) { rewrites := map[string]string{} + var issues []LookupIssue for i := range deps { d := &deps[i] owner, repo := d.OwnerRepo() @@ -257,15 +271,24 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma } tag, branch, err := r.DiscoverContaining(ctx, owner, repo, d.SHA, d.Ref) if err != nil { - return nil, fmt.Errorf("%s@%s: %w", d.NWO, d.Ref, err) + return nil, nil, fmt.Errorf("%s@%s: %w", d.NWO, d.Ref, err) } if tag == "" && branch == "" { + var msg string if looksLikeSHA(d.Ref) { - return nil, fmt.Errorf("%s@%s: no tag or branch contains this commit — a symbolic ref is required for the lockfile", - d.NWO, parserlock.ShortSHA(d.Ref)) + msg = fmt.Sprintf("no tag or branch contains this commit — a symbolic ref is required for the lockfile") + } else { + msg = fmt.Sprintf("commit %s is not reachable from any ref (tag or branch) — orphaned commit", + parserlock.ShortSHA(d.SHA)) } - return nil, fmt.Errorf("%s@%s: commit %s is not reachable from any ref (tag or branch) — orphaned commit", - d.NWO, d.Ref, parserlock.ShortSHA(d.SHA)) + issues = append(issues, LookupIssue{ + Index: i, + NWO: d.NWO, + Ref: d.Ref, + SHA: d.SHA, + Message: msg, + }) + continue } d.Tag = tag d.Branch = branch @@ -282,7 +305,7 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma rewrites[d.NWO+"@"+d.Ref] = d.NWO + "@" + newRef d.Ref = newRef } - return rewrites, nil + return rewrites, issues, nil } // looksLikeSHA returns true when ref is a hex string of SHA-1 (40) or diff --git a/internal/resolve/reverse_lookup_test.go b/internal/resolve/reverse_lookup_test.go index 006fa719..f2b61671 100644 --- a/internal/resolve/reverse_lookup_test.go +++ b/internal/resolve/reverse_lookup_test.go @@ -78,7 +78,7 @@ func TestReverseLookup_SkipsEmptyOwnerRepo(t *testing.T) { deps := []dep.Dependency{ {NWO: "", Ref: "v1", SHA: "abc"}, } - rewrites, err := r.ReverseLookup(context.Background(), deps) + rewrites, _, err := r.ReverseLookup(context.Background(), deps) if err != nil { t.Fatal(err) } From 25222a4bfb936becce06ff668848225a89a5176c Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 20:59:35 -0500 Subject: [PATCH 45/67] pin summary: add spacing between skip warnings and result --- cmd/gh-actions-lock/pin_summary.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index b11ea50a..986b49cc 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -66,6 +66,7 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r investigated := record.Investigated() if len(pinned) > 0 { + console.TermBlank() renderPinnedEntries(console, pinned) } @@ -99,6 +100,7 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r allClean := len(pinned) == 0 && len(investigated) == 0 && len(unresolvedEntries) == 0 hasUnfixable := reportHasUnfixableErrors(report, acceptMoved) if allClean && !hasUnfixable && onboardingRefused == 0 && !hasInconclusive { + console.TermBlank() console.TermSuccess("All %d %s valid", total, ui.Pluralize(total, "workflow", "workflows")) if skippedRescan > 0 { console.TermDetail("Trusted lockfile for %d already-pinned %s; run `gh actions-lock --rescan` to re-verify reachability.", From 2844d480a5f47cf5cebc1ca6ed145d5e17281a06 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 21:06:27 -0500 Subject: [PATCH 46/67] allow-runners: support wildcard '*' to trust all labels --- internal/workflowfile/runson.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/workflowfile/runson.go b/internal/workflowfile/runson.go index 250824aa..9c97f685 100644 --- a/internal/workflowfile/runson.go +++ b/internal/workflowfile/runson.go @@ -70,6 +70,7 @@ var ( // RegisterOrgHostedLabels adds labels to the hosted runner allowlist. // Call before ParseAll; safe to call multiple times. +// The special value "*" marks all labels as hosted. func RegisterOrgHostedLabels(labels []string) { if len(labels) == 0 { return @@ -92,7 +93,7 @@ func IsHostedRunnerLabel(label string) bool { } orgHostedMu.RLock() defer orgHostedMu.RUnlock() - return orgHostedLabels[lower] + return orgHostedLabels["*"] || orgHostedLabels[lower] } // ExtractRunsOnLabels returns the deduplicated runs-on labels across all jobs. From db687b1d60748e1f53975d718b767ffa7cdc16b8 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 21:08:21 -0500 Subject: [PATCH 47/67] allow-all-runners: add -A flag to skip all runner checks --- cmd/gh-actions-lock/run.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 3e9803ae..60e3a919 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -49,7 +49,8 @@ type checkOptions struct { // allowRunners lists additional runner labels to treat as hosted. // Use when org-provisioned larger runners (e.g. ubuntu-latest-xl) // are flagged as self-hosted but you know they are GitHub-hosted. - allowRunners []string + allowRunners []string + allowAllRunners bool // acceptMoved re-resolves deps flagged as lockfile-forgery or // ref-moved: prunes the stale lockfile entry and re-pins to the // current live SHA. @@ -65,6 +66,7 @@ func bindCheckFlags(cmd *cobra.Command, opts *checkOptions) { cmd.Flags().BoolVar(&opts.noFix, "no-fix", false, "Read-only: report findings without modifying workflows or the lockfile") cmd.Flags().BoolVar(&opts.noNarrow, "no-narrow", false, "Keep mutable version refs (e.g. v4) instead of narrowing to full patch tags (e.g. v4.2.1)") cmd.Flags().StringSliceVar(&opts.allowRunners, "allow-runners", nil, "Additional runner `labels` to treat as GitHub-hosted (e.g. ubuntu-latest-xl)") + cmd.Flags().BoolVarP(&opts.allowAllRunners, "allow-all-runners", "A", false, "Treat all runner labels as GitHub-hosted (skip self-hosted checks)") cmd.Flags().BoolVar(&opts.acceptMoved, "accept-moved", false, "Re-resolve deps flagged as ref-moved or lockfile-forgery to their current live SHA") cmd.Flags().StringVar(&opts.profileDir, "profile", "", "Enable profiling: write trace, CPU profile, and HTTP log to `dir`") } @@ -108,7 +110,9 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) pool := pinpool.New(0, console) // 0 → DefaultWorkers // Register user-supplied runner labels as hosted before parsing. - if len(opts.allowRunners) > 0 { + if opts.allowAllRunners { + workflowfile.RegisterOrgHostedLabels([]string{"*"}) + } else if len(opts.allowRunners) > 0 { workflowfile.RegisterOrgHostedLabels(opts.allowRunners) } From 503e5c6d87d1326a9b5d36c27a60d0da3dcfc1b9 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 21:58:17 -0500 Subject: [PATCH 48/67] pin summary: surface narrowed refs instead of silent rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a verified workflow has its ref narrowed (main → v6.0.2), the output now shows what happened instead of just 'All valid'. --- cmd/gh-actions-lock/pin_summary.go | 18 ++++++++++++++++++ internal/pin/record.go | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index 986b49cc..7c38cd52 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -64,12 +64,20 @@ func reportHasNonInvestigatedUnfixableErrors(report *checks.Report) bool { func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, report *checks.Report, r *resolve.Resolver, skippedRescan int, hasInconclusive bool, refusedLabels []string, noNarrow bool, acceptMoved bool, originalVersion string) error { pinned := record.Pinned() investigated := record.Investigated() + narrowed := record.Narrowed() if len(pinned) > 0 { console.TermBlank() renderPinnedEntries(console, pinned) } + if len(narrowed) > 0 && len(pinned) == 0 { + console.TermBlank() + } + if len(narrowed) > 0 { + renderNarrowedEntries(console, narrowed) + } + renderFullScanWarnings(console, pinned) if !noNarrow { renderVersionRefNudge(ctx, console, record, r) @@ -142,6 +150,16 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r return nil } +// renderNarrowedEntries shows refs that were upgraded from mutable (main, v4) +// to full semver (v6.0.2) on already-pinned workflows. +func renderNarrowedEntries(console *ui.UI, narrowed []pin.Entry) { + console.TermSuccess("Narrowed %d %s to full semver", + len(narrowed), ui.Pluralize(len(narrowed), "ref", "refs")) + for _, e := range narrowed { + console.TermDetail(" %s@%s → %s", e.NWO, e.AutoFixedRef, e.Ref) + } +} + // renderPinnedEntries prints the "Pinned N actions across M workflows" block, // deduplicating entries by NWO@Ref. func renderPinnedEntries(console *ui.UI, pinned []pin.Entry) { diff --git a/internal/pin/record.go b/internal/pin/record.go index 2872fc71..47ccfb6e 100644 --- a/internal/pin/record.go +++ b/internal/pin/record.go @@ -90,6 +90,17 @@ func (r *Record) Unresolved() []Entry { return r.byResolution(Unresolved) } +// Narrowed returns verified entries whose refs were upgraded (AutoFixedRef set). +func (r *Record) Narrowed() []Entry { + var out []Entry + for _, e := range r.Entries { + if e.Resolution == Verified && e.AutoFixedRef != "" { + out = append(out, e) + } + } + return out +} + // Valid reports whether the record contains no investigate or unresolved entries. func (r *Record) Valid() bool { for _, e := range r.Entries { From 8370a8a112092cced8b3e1c21d407377ffdbf4a9 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 22:13:19 -0500 Subject: [PATCH 49/67] test: fix onboarded_full_tag_unchanged to match what it claims Workflow ref was @v4 but lockfile had @v4.2.0, so the refs didn't match and the tool re-resolved from scratch. Now both say @v4.2.0, testing the actual 'preserved precision on re-run' invariant. --- test/scenarios/catalog.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 57cf2206..492b01c2 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -881,18 +881,18 @@ scenarios: - name: onboarded_full_tag_unchanged category: narrowing - description: "Lockfile dep key is v4.2.0 (from prior narrowed pin), workflow says @v4 (never locked as v4) — re-pin keeps the lockfile's full semver precision" + description: "Workflow and lockfile both say v4.2.0 — re-run is a no-op, full semver precision is preserved" needs_token: true fixtures: workflows: ci.yml: name: CI - actions: ["actions/checkout@v4"] + actions: ["actions/checkout@v4.2.0"] lockfile_template: pinned_checkout_full expect: exit: 0 lockfile_deps_cover_direct: true - lockfile_comment_matches: 'v4\.\d+\.\d+' + lockfile_comment_matches: 'v4\.2\.0' - name: onboarded_no_narrow_keeps_major category: narrowing From ed72e0f5f4498cc8b1037d309bf35b3c74561c34 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 22:26:14 -0500 Subject: [PATCH 50/67] transitive deps: discover ref only for bare-SHA pins Transitive deps keep the composite's declared ref (v1, v2, main) when it's a valid symbolic ref. Only bare-SHA refs (40/64 hex chars) are replaced by ReverseLookup's discovered tag/branch, since a SHA is not a valid symbolic ref for the lockfile. Updated scenario descriptions to match the actual contract. --- internal/pin/plan.go | 33 +++++++++++++++++++----------- internal/pin/plan_test.go | 12 ++++------- internal/resolve/pick_test.go | 1 + internal/resolve/reverse_lookup.go | 6 +++--- test/scenarios/catalog.yml | 6 +++--- 5 files changed, 32 insertions(+), 26 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 786dfb5e..e0e192ed 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -367,14 +367,13 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work } } - // Preserve transitive deps' declared refs across ReverseLookup. We still - // want the tag/branch metadata it populates (the lockfile write requires - // a branch), but the ref itself must stay exactly as the composite's - // action.yml declares it - we don't own it and must not rewrite it. - transitiveRefs := make(map[int]string) + // Snapshot transitive deps' declared refs before ReverseLookup mutates + // them. We restore symbolic refs (tags/branches) afterward but let + // ReverseLookup's discovered ref stick when the original is a bare SHA. + transitiveOrigRefs := make(map[int]string) for i := range deps { if !directTracker.IsDirect(i) { - transitiveRefs[i] = deps[i].Ref + transitiveOrigRefs[i] = deps[i].Ref } } @@ -387,12 +386,22 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work for i, ref := range narrowedRefs { deps[i].Ref = ref } - // Restore transitive deps' declared refs and suppress any rewrite - // ReverseLookup produced for them - keyed by the declared NWO@ref. - transitiveRewriteKeys := make(map[string]bool, len(transitiveRefs)) - for i, ref := range transitiveRefs { - deps[i].Ref = ref - transitiveRewriteKeys[deps[i].NWO+"@"+ref] = true + // Restore transitive deps' declared refs — we don't own the composite's + // action.yml so the lockfile should keep the ref it declares. Exception: + // when the declared ref is a bare SHA (commit hash), keep ReverseLookup's + // discovered tag/branch since a SHA is not a valid symbolic ref. + transitiveRewriteKeys := make(map[string]bool) + for i := range deps { + if directTracker.IsDirect(i) { + continue + } + origRef := transitiveOrigRefs[i] + if resolve.LooksLikeSHA(origRef) { + // Bare SHA — keep ReverseLookup's discovered ref. + continue + } + deps[i].Ref = origRef + transitiveRewriteKeys[deps[i].NWO+"@"+origRef] = true } rewrites := make(map[string]string) for k, v := range normRewrites { diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index bedbfa72..28ec23e1 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -171,13 +171,10 @@ func TestPlanWorkflow_AllResolutionsFail(t *testing.T) { } } -// TestPlanWorkflow_DoesNotNarrowTransitiveDeps verifies that a transitive -// dependency discovered from a composite action's action.yml keeps the ref the -// composite author declared, even when a narrower full-semver tag exists at the -// same commit. We only own (and may narrow) refs that literally appear in a -// workflow `uses:` line; rewriting a composite's internal refs is churn and can -// invent refs the composite never declared. -func TestPlanWorkflow_DoesNotNarrowTransitiveDeps(t *testing.T) { +// TestPlanWorkflow_TransitiveDepUsesDiscoveredRef verifies that a transitive +// dependency keeps the composite's declared ref when it's a valid symbolic ref +// (tag/branch). Only bare-SHA refs are replaced by ReverseLookup's discovery. +func TestPlanWorkflow_TransitiveDepUsesDiscoveredRef(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) @@ -296,7 +293,6 @@ func TestPlanWorkflow_DoesNotNarrowTransitiveDeps(t *testing.T) { trans, ok := byNWO["trans/dep"] require.True(t, ok, "transitive dep should be pinned") assert.Equal(t, "v2", trans.Ref, "transitive ref must stay as the composite declared it") - assert.NotEqual(t, "v2.3.4", trans.Ref, "transitive dep must not be narrowed") assert.Equal(t, transSHA, trans.SHA) assert.False(t, trans.Direct, "transitive dep must not be marked Direct") assert.Contains(t, trans.RequiredBy, "comp/action@v1.0.0") diff --git a/internal/resolve/pick_test.go b/internal/resolve/pick_test.go index 445445a8..9baffd58 100644 --- a/internal/resolve/pick_test.go +++ b/internal/resolve/pick_test.go @@ -62,6 +62,7 @@ func TestPickPreferredTag(t *testing.T) { {"full semver beats major-only", []string{"v5", "v4.3.1"}, "", "v4.3.1"}, {"full semver beats higher major-only", []string{"v9", "v2.1.0"}, "", "v2.1.0"}, {"major-only when no full semver", []string{"v4", "v3"}, "", "v4"}, + {"hint major wins when present", []string{"v4", "v3"}, "v4", "v4"}, {"no semver falls to lex", []string{"beta", "alpha"}, "", "alpha"}, {"mixed semver and non-semver", []string{"latest", "v1.0.0", "v2.0.0"}, "", "v2.0.0"}, {"single candidate", []string{"v1.0.0"}, "", "v1.0.0"}, diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index 1c9cc0a5..fd7c162e 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -275,7 +275,7 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma } if tag == "" && branch == "" { var msg string - if looksLikeSHA(d.Ref) { + if LooksLikeSHA(d.Ref) { msg = fmt.Sprintf("no tag or branch contains this commit — a symbolic ref is required for the lockfile") } else { msg = fmt.Sprintf("commit %s is not reachable from any ref (tag or branch) — orphaned commit", @@ -308,9 +308,9 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma return rewrites, issues, nil } -// looksLikeSHA returns true when ref is a hex string of SHA-1 (40) or +// LooksLikeSHA returns true when ref is a hex string of SHA-1 (40) or // SHA-256 (64) length — i.e. the user wrote a bare commit hash. -func looksLikeSHA(ref string) bool { +func LooksLikeSHA(ref string) bool { n := len(ref) if n != 40 && n != 64 { return false diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 492b01c2..914513ea 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -950,7 +950,7 @@ scenarios: - name: transitive_major_ref_not_narrowed category: narrowing - description: "Transitive dep of a composite keeps the major ref the composite declares (actions/attest-build-provenance@v1 stays v1, not narrowed to v1.x.y) — we don't own composite-internal refs" + description: "Transitive dep keeps the composite's declared ref (v1) — only bare-SHA refs are replaced by ReverseLookup's discovered tag/branch" needs_token: true fixtures: workflows: @@ -967,7 +967,7 @@ scenarios: - name: transitive_version_ref_nudge_suppressed category: narrowing - description: "Transitive deps with imprecise refs do not trigger the version-ref nudge — user cannot change composite-internal refs" + description: "Transitive deps with composite-declared refs do not trigger the version-ref nudge — user cannot change composite-internal refs" needs_token: true fixtures: workflows: @@ -981,7 +981,7 @@ scenarios: - name: transitive_provenance_ref_not_narrowed category: narrowing - description: "Second-hop transitive dep (actions/attest-build-provenance pinned by bare SHA, pulled in via attest-build-provenance@v1) stays verbatim rather than being reverse-looked-up into a malformed ref" + description: "Transitive dep keeps composite's declared ref (v1) — bare-SHA refs would be replaced by discovered tag/branch, but symbolic refs are preserved" needs_token: true fixtures: workflows: From 84a78e173739f7e01b32877b4207b7ae07b27938 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 22:35:05 -0500 Subject: [PATCH 51/67] lockfile: decouple pin key from ref field for bare-SHA transitive deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lockfile key (NWO@ref) now preserves the original SHA that the composite action.yml declared. The ref: metadata field uses the discovered tag/branch from ReverseLookup. This keeps the key stable (matching what the composite declares) while recording the symbolic ref for provenance. state.Set prefers dep.Tag, then dep.Branch over dep.Ref when the ref is a bare SHA (40/64 hex chars). plan.go restores all transitive deps' original refs unconditionally — the SHA/symbolic distinction is now handled at the serialization layer. --- internal/lockfile/state.go | 24 ++++++++++++++++++++++++ internal/pin/plan.go | 10 +++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index c299842f..6921fb33 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -361,7 +361,16 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen // The action's Ref must match the pin key's ref (which is d.Ref). // Preserve existing ref when the dep arrives without one (carried // unchanged from a previous lockfile). + // When the dep's ref is a bare SHA (transitive dep pinned by commit), + // prefer the discovered tag or branch for the metadata ref field. ref := d.Ref + if isSHARef(ref) { + if d.Tag != "" { + ref = d.Tag + } else if d.Branch != "" { + ref = d.Branch + } + } if existing, ok := s.file.Dependencies[pinKey]; ok { if ref == "" { ref = existing.Ref @@ -510,3 +519,18 @@ func extractVersion(contents []byte) string { } return "" } + +// isSHARef returns true when ref is a hex string of SHA-1 (40) or +// SHA-256 (64) length. +func isSHARef(ref string) bool { + n := len(ref) + if n != 40 && n != 64 { + return false + } + for _, c := range ref { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} diff --git a/internal/pin/plan.go b/internal/pin/plan.go index e0e192ed..0beea103 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -387,19 +387,15 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work deps[i].Ref = ref } // Restore transitive deps' declared refs — we don't own the composite's - // action.yml so the lockfile should keep the ref it declares. Exception: - // when the declared ref is a bare SHA (commit hash), keep ReverseLookup's - // discovered tag/branch since a SHA is not a valid symbolic ref. + // action.yml so the lockfile key must match what it declares. The + // discovered tag/branch is preserved in dep.Tag/dep.Branch and used by + // state.Set for the lockfile ref: field. transitiveRewriteKeys := make(map[string]bool) for i := range deps { if directTracker.IsDirect(i) { continue } origRef := transitiveOrigRefs[i] - if resolve.LooksLikeSHA(origRef) { - // Bare SHA — keep ReverseLookup's discovered ref. - continue - } deps[i].Ref = origRef transitiveRewriteKeys[deps[i].NWO+"@"+origRef] = true } From d8173e5bdc1c28072feb095ce941b5e6505f28c6 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 23 Jun 2026 22:47:10 -0500 Subject: [PATCH 52/67] scenarios: merge three transitive-ref tests into one transitive_major_ref_not_narrowed, transitive_version_ref_nudge_suppressed, and transitive_provenance_ref_not_narrowed all used the same fixture and tested overlapping invariants. Merged into transitive_ref_preserved with all unique assertions combined. --- test/scenarios/catalog.yml | 35 +++-------------------------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 914513ea..9dd67246 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -948,9 +948,9 @@ scenarios: - expr: '.findings[0].dependency' contains: 'actions/checkout' - - name: transitive_major_ref_not_narrowed + - name: transitive_ref_preserved category: narrowing - description: "Transitive dep keeps the composite's declared ref (v1) — only bare-SHA refs are replaced by ReverseLookup's discovered tag/branch" + description: "Transitive dep keeps the composite's declared ref (v1) — not narrowed, no version-ref nudge, and bare-SHA refs get a discovered tag in the ref field" needs_token: true fixtures: workflows: @@ -961,40 +961,11 @@ scenarios: exit: 0 lockfile_deps_cover_direct: true lockfile_deps_cover_indirect: true - lockfile_contains: - - "'actions/attest-build-provenance@v1':" - lockfile_comment_excludes: 'actions/attest-build-provenance@v1\.\d+\.\d+' - - - name: transitive_version_ref_nudge_suppressed - category: narrowing - description: "Transitive deps with composite-declared refs do not trigger the version-ref nudge — user cannot change composite-internal refs" - needs_token: true - fixtures: - workflows: - ci.yml: - name: CI - actions: ["cli/gh-extension-precompile@v2.1.0"] - expect: - exit: 0 - lockfile_deps_cover_direct: true - output_excludes: ["pinned without a full semver tag"] - - - name: transitive_provenance_ref_not_narrowed - category: narrowing - description: "Transitive dep keeps composite's declared ref (v1) — bare-SHA refs would be replaced by discovered tag/branch, but symbolic refs are preserved" - needs_token: true - fixtures: - workflows: - ci.yml: - name: CI - actions: ["cli/gh-extension-precompile@v2.1.0"] - expect: - exit: 0 - lockfile_deps_cover_direct: true lockfile_contains: - "'actions/attest-build-provenance@v1':" - "ref: 'v1'" lockfile_comment_excludes: 'actions/attest-build-provenance@v1\.\d+\.\d+' + output_excludes: ["pinned without a full semver tag"] # ╔═════════════════════════════════════════════════════════════════════════╗ # ║══════════════════════════════ onboarding ═══════════════════════════════║ From cd7588a5686056e0cd0d89d4c7812673f50f0c5e Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 05:28:56 -0500 Subject: [PATCH 53/67] workflowfile: reword expression-in-uses warning as parse error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expressions in uses: are illegal — they're not a pinning limitation, they're an unparseable workflow entry. Updated the warning copy to reflect this. --- internal/workflowfile/workflowfile.go | 2 +- internal/workflowfile/workflowfile_test.go | 2 +- test/scenarios/catalog.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/workflowfile/workflowfile.go b/internal/workflowfile/workflowfile.go index 6ac049ab..357de6ad 100644 --- a/internal/workflowfile/workflowfile.go +++ b/internal/workflowfile/workflowfile.go @@ -61,7 +61,7 @@ func (f *File) ExtractActionRefs() ([]parserlock.ActionRef, []string, []string) } value = strings.TrimSpace(value) if strings.Contains(value, "${") { - warnings = append(warnings, fmt.Sprintf("can't pin expression-based uses: %s", value)) + warnings = append(warnings, fmt.Sprintf("skipping unparseable uses: value %q (expressions are not supported)", value)) return } if strings.HasPrefix(value, "./") { diff --git a/internal/workflowfile/workflowfile_test.go b/internal/workflowfile/workflowfile_test.go index 0677efbd..a9a258ce 100644 --- a/internal/workflowfile/workflowfile_test.go +++ b/internal/workflowfile/workflowfile_test.go @@ -40,7 +40,7 @@ func TestExtractActionRefsMixed(t *testing.T) { assert.Equal(t, "./local-action", localPaths[0]) assert.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "expression-based") + assert.Contains(t, warnings[0], "unparseable uses:") } func TestDiscoverWorkflowsIn(t *testing.T) { diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 9dd67246..3c5eb61a 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -370,7 +370,7 @@ scenarios: - name: expression_in_uses category: workflow_parsing - description: "Expression-based uses: (${{ }}) — skipped with warning" + description: "Expression-based uses: (${{ }}) — unparseable, skipped with warning" needs_token: true fixtures: workflows: From 77de8ad9c40628f8958346105bc44ba705afea76 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 05:34:42 -0500 Subject: [PATCH 54/67] scenarios: no_fix_mode expects exit 1 (unpinned deps exist) --- test/scenarios/catalog.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 3c5eb61a..29c31141 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -470,7 +470,7 @@ scenarios: - name: no_fix_mode category: output_modes - description: "--no-fix with valid lockfile — exit 0, nothing written" + description: "--no-fix reports findings without writing — exits 1 when unpinned deps exist" needs_token: true flags: ["--no-fix"] fixtures: @@ -479,7 +479,7 @@ scenarios: name: CI actions: ["actions/checkout@v4"] expect: - exit_any: [0, 1] + exit: 1 - name: json_output_valid category: output_modes From 2ead28377c81446b4bf3214ff7c42a7008c68ec1 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 05:35:17 -0500 Subject: [PATCH 55/67] scenarios: json_output_valid expects exit 1 --- test/scenarios/catalog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 29c31141..1f70f735 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -492,7 +492,7 @@ scenarios: name: CI actions: ["actions/checkout@v4"] expect: - exit_any: [0, 1] + exit: 1 stdout_is_json: true - name: json_output_findings From c599c44ac41315074f43911aa30d90afa5175f31 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 05:37:48 -0500 Subject: [PATCH 56/67] run: suppress SSO hint in JSON mode to keep stdout valid JSON The 'Authorize in your web browser' message was written to stdout even when --json was active, corrupting the JSON output. Now gated behind opts.jsonFields == "" in both --no-fix and fix paths. --- cmd/gh-actions-lock/run.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 60e3a919..97e095b8 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -258,10 +258,13 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) } // Surface SSO URL even in read-only mode — it's the actionable fix // for SAML-gated repos and shouldn't require a --fix run to see. - if gc := r.GHClient(); gc != nil { - if ssoURL := gc.SSOURL(); ssoURL != "" { - console.TermBlank() - console.TermDetail("Authorize in your web browser: %s", ssoURL) + // Suppressed in JSON mode to avoid corrupting stdout. + if opts.jsonFields == "" { + if gc := r.GHClient(); gc != nil { + if ssoURL := gc.SSOURL(); ssoURL != "" { + console.TermBlank() + console.TermDetail("Authorize in your web browser: %s", ssoURL) + } } } if !valid { @@ -352,10 +355,13 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) // the run, matching cli/cli's "Authorize in your web browser:" line. // This runs even when renderPinSummary returns errSilent (unresolved // entries exist) because the SSO hint is the fix for those entries. - if gc := r.GHClient(); gc != nil { - if ssoURL := gc.SSOURL(); ssoURL != "" { - console.TermBlank() - console.TermDetail("Authorize in your web browser: %s", ssoURL) + // Suppressed in JSON mode to avoid corrupting stdout. + if opts.jsonFields == "" { + if gc := r.GHClient(); gc != nil { + if ssoURL := gc.SSOURL(); ssoURL != "" { + console.TermBlank() + console.TermDetail("Authorize in your web browser: %s", ssoURL) + } } } From 1033ceb9cdf93043efc8b460a9012e0024e5a79b Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 05:44:42 -0500 Subject: [PATCH 57/67] golden: update transitive_closure_cross_repo for new @main commit The fixtures repo's lockfile was upgraded to v0.0.2 in-repo, which advanced the @main commit SHA. --- test/scenarios/testdata/transitive_closure_cross_repo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scenarios/testdata/transitive_closure_cross_repo.lock b/test/scenarios/testdata/transitive_closure_cross_repo.lock index 08c89ae3..32b5f9e3 100644 --- a/test/scenarios/testdata/transitive_closure_cross_repo.lock +++ b/test/scenarios/testdata/transitive_closure_cross_repo.lock @@ -24,7 +24,7 @@ dependencies: repo_id: 1205442499 'nodeselector/actions-test-fixtures@main': ref: 'main' - commit: 'sha1-da3230bd4d74392a7135b82d07612eb53b261461' + commit: 'sha1-c4f0377e00326eece75d8a3d5dc9088fb955341b' owner_id: 29457092 repo_id: 1203329948 uses: From 3e4c7636f290361f8b86c826c109e86cbad06db2 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 05:49:18 -0500 Subject: [PATCH 58/67] scenarios: clarify onboarded_branch_ref_narrows description --- test/scenarios/catalog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 1f70f735..f8e9095d 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -726,7 +726,7 @@ scenarios: - name: onboarded_branch_ref_narrows category: narrowing - description: "Onboarded workflow at @main — verified dep narrowing upgrades to full semver" + description: "Already-pinned dep at @main gets narrowed to full semver (v6.x.y) on re-run" needs_token: true fixtures: workflows: From 203a929f34e4237e3e471a4ffb89c79b65335b59 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 05:52:41 -0500 Subject: [PATCH 59/67] commit: write lockfile when narrowing changes a dep key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflowsWithNewPins only checked Resolution==Pinned, so narrowed entries (Verified + AutoFixedRef set) never triggered a lockfile write. The workflow YAML was rewritten @main→v6.0.2 but the lockfile kept the stale @main key. Now narrowed entries also mark the workflow for a Set() call, which updates the dep key and lets Save's GC drop the orphaned @main entry. --- internal/pin/commit.go | 5 +++-- test/scenarios/catalog.yml | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/pin/commit.go b/internal/pin/commit.go index 63697c7e..45af7464 100644 --- a/internal/pin/commit.go +++ b/internal/pin/commit.go @@ -197,11 +197,12 @@ func buildDirectKeys(rec *Record, wfPath string) map[string]bool { } // workflowsWithNewPins returns the set of workflow paths that contain at -// least one entry with Resolution == Pinned (i.e. genuinely new or changed). +// least one entry with Resolution == Pinned (i.e. genuinely new or changed) +// or a narrowed ref (Verified with AutoFixedRef set, meaning the dep key changed). func workflowsWithNewPins(rec *Record) map[string]bool { m := make(map[string]bool) for _, e := range rec.Entries { - if e.Resolution != Pinned { + if e.Resolution != Pinned && !(e.Resolution == Verified && e.AutoFixedRef != "") { continue } for _, wf := range e.Workflows { diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index f8e9095d..c0776d5d 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -738,6 +738,7 @@ scenarios: exit: 0 lockfile_deps_cover_direct: true lockfile_comment_matches: 'v\d+\.\d+\.\d+' + lockfile_comment_excludes: "'actions/checkout@main'" - name: local_action_only category: workflow_parsing From 5411d97f2ae6a83799322677c368daf1df7b878f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 05:56:35 -0500 Subject: [PATCH 60/67] =?UTF-8?q?harness:=20add=20lockfile=E2=86=94workflo?= =?UTF-8?q?w=20coherence=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When lockfile_deps_cover_direct is set, also verify that every dep key listed under a workflow in the lockfile has a matching uses: line in the actual workflow YAML. Catches bugs where the lockfile is stale (e.g. narrowing rewrites the YAML but not the lockfile). --- test/integration/harness.rb | 34 ++++++++++++++++++++++++++++++++++ test/integration/run.rb | 2 ++ 2 files changed, 36 insertions(+) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index d781edcf..8f4e3bf8 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -331,6 +331,38 @@ def assert_lockfile_deps_cover_indirect self end + # Verify that the lockfile's workflow→dep mapping is coherent with the + # actual workflow YAML files. Every dep listed under a workflow key must + # correspond to a `uses:` line in that workflow file (by NWO@ref match). + # This catches bugs where the lockfile is stale relative to the YAML. + def assert_lockfile_workflow_coherence + @assertions << -> (r) { + lockpath = File.join(r.dir, ".github", "workflows", "actions.lock") + content = File.read(lockpath) rescue "" + lf = YAML.safe_load(content) rescue nil + unless lf.is_a?(Hash) && lf["workflows"].is_a?(Hash) + assert_true("lockfile coherence: parseable lockfile", false) + next + end + lf["workflows"].each do |wf_path, refs| + next unless refs.is_a?(Array) + wf_file = File.join(r.dir, wf_path) + next unless File.exist?(wf_file) + wf_content = File.read(wf_file) + refs.each do |ref| + # Extract NWO@tag from the dep key + parts = ref.split("@", 2) + next unless parts.length == 2 + nwo, tag = parts + # The workflow should have `uses: NWO@tag` (or NWO/sub@tag) + uses_pat = /uses:\s*#{Regexp.escape(nwo)}(?:\/[^@]+)?@#{Regexp.escape(tag)}/ + assert_true("lockfile coherence: #{wf_path} uses #{ref}", wf_content.match?(uses_pat)) + end + end + } + self + end + def assert_custom(&block) @assertions << block self @@ -1799,6 +1831,8 @@ def format_expect_checks(spec, result, failures) if spec["lockfile_deps_cover_direct"] ok = !failures.any? { |f| f.include?("direct dep covered:") } checks << ["lockfile deps cover direct", ok] + ok = !failures.any? { |f| f.include?("lockfile coherence:") } + checks << ["lockfile ↔ workflow coherence", ok] end if spec["lockfile_deps_cover_indirect"] ok = !failures.any? { |f| f.include?("indirect dep covered:") } diff --git a/test/integration/run.rb b/test/integration/run.rb index a80d1d37..057d911c 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -178,6 +178,8 @@ def hydrate_assertions(s, expect, needs_token: false) if expect["lockfile_deps_cover_direct"] s.assert_lockfile_deps_cover_direct + # Also verify lockfile workflow refs match the actual YAML files. + s.assert_lockfile_workflow_coherence end if expect["lockfile_deps_cover_indirect"] From 04e7a7b1309e136e587a1aee28e32eac2a6ca9c4 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 24 Jun 2026 06:00:56 -0500 Subject: [PATCH 61/67] narrowing: only narrow version-shaped refs, mention -A in runner hints Non-version refs like main, canary, or releases/v4 are intentional choices. Narrowing them risks picking up unrelated semver tags (e.g. vercel/next.js framework releases instead of the action's tags). Now only imprecise semver refs (v4, v4.2) are narrowing candidates. Also updated the self-hosted runner skip hint to mention -A as an alternative to --allow-runners . --- cmd/gh-actions-lock/format/terminal.go | 6 +++--- internal/pin/plan.go | 22 +++++++++++++++++----- internal/pin/plan_test.go | 20 ++++++++++---------- test/scenarios/catalog.yml | 15 ++++++++------- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/cmd/gh-actions-lock/format/terminal.go b/cmd/gh-actions-lock/format/terminal.go index bbb4cc14..265fd6f0 100644 --- a/cmd/gh-actions-lock/format/terminal.go +++ b/cmd/gh-actions-lock/format/terminal.go @@ -191,7 +191,7 @@ func renderSelfHostedGroup(out *ui.UI, findings []checks.Finding) { labels = append(labels, l) } sort.Strings(labels) - out.Detail(" ↳ re-run with --allow-runners %s", strings.Join(labels, ",")) + out.Detail(" ↳ re-run with --allow-runners %s or -A to allow all", strings.Join(labels, ",")) } } @@ -325,10 +325,10 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) { labels = append(labels, l) } sort.Strings(labels) - out.TermDetail("↳ if these are org-hosted larger runners, re-run with --allow-runners %s", + out.TermDetail("↳ if these are org-hosted larger runners, re-run with --allow-runners %s or -A to allow all", strings.Join(labels, ",")) } else { - out.TermDetail("↳ if these are org-hosted larger runners, re-run with --allow-runners