From 5c7e004264ec9298171d868624af3e7a12c9e7b9 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 11:58:47 -0700 Subject: [PATCH 1/8] tagging: keep ancestor fallback in the requested version family --- internal/pin/plan.go | 6 ++-- internal/tag/tagging.go | 12 ++++--- internal/tag/tags_test.go | 73 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index aac509e9..60cbfb53 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -353,7 +353,7 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen continue } if patchTag == "" { - patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA) + patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA, "") if err != nil || patchTag == "" { continue } @@ -391,7 +391,7 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen // 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) + patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA, dep.Ref) if err != nil || patchTag == "" { continue } @@ -668,7 +668,7 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption continue } if patchTag == "" { - patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, e.SHA) + patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, e.SHA, e.Ref) if err != nil || patchTag == "" { continue } diff --git a/internal/tag/tagging.go b/internal/tag/tagging.go index 3f33513b..7e5853ce 100644 --- a/internal/tag/tagging.go +++ b/internal/tag/tagging.go @@ -56,16 +56,17 @@ func (tl *Lister) BestPatchTagForSHA(ctx context.Context, owner, repo, sha strin return best.Raw, nil } -// BestAncestorTag returns the latest full-semver tag that is an ancestor of -// the given SHA. Used when no tag points at the exact SHA but the repo -// follows semver release conventions — we walk back to the nearest release. +// BestAncestorTag returns the latest full-semver tag in ref's version family +// that is an ancestor of the given SHA. An empty ref accepts any family. // Checks at most 3 candidate tags (latest first) to limit API calls. -func (tl *Lister) BestAncestorTag(ctx context.Context, owner, repo, sha string) (string, error) { +func (tl *Lister) BestAncestorTag(ctx context.Context, owner, repo, sha, ref string) (string, error) { all, err := tl.ListTags(ctx, owner, repo) if err != nil { return "", err } + refSV, restrictFamily := parserlock.ParseSemVer(ref) + // Collect full-semver candidates, already sorted latest-first by ListTags. var candidates []Info for _, t := range all { @@ -76,6 +77,9 @@ func (tl *Lister) BestAncestorTag(ctx context.Context, owner, repo, sha string) if !ok || !sv.IsFull() || sv.Rest != "" { continue } + if restrictFamily && (sv.Major != refSV.Major || !refSV.IsMajorOnly() && sv.Minor != refSV.Minor) { + continue + } candidates = append(candidates, t) if len(candidates) >= 3 { break diff --git a/internal/tag/tags_test.go b/internal/tag/tags_test.go index 250e804b..f403b1b7 100644 --- a/internal/tag/tags_test.go +++ b/internal/tag/tags_test.go @@ -2,6 +2,7 @@ package tag import ( "context" + "fmt" "testing" "github.com/github/gh-actions-lock/internal/ghapi/httpmock" @@ -65,3 +66,75 @@ func TestListTags_SemverOrdering(t *testing.T) { } } } + +func TestBestAncestorTag_RefFamily(t *testing.T) { + const headSHA = "ffffffffffffffffffffffffffffffffffffffff" + + tests := []struct { + name string + ref string + tags any + ancestorSHA string + want string + }{ + { + name: "major ref excludes another major", + ref: "v18", + tags: httpmock.TagListResponse( + "v3.12.0", "3333333333333333333333333333333333333333", + ), + }, + { + name: "major ref accepts its major", + ref: "v4", + tags: httpmock.TagListResponse("v4.2.1", "4444444444444444444444444444444444444444"), + ancestorSHA: "4444444444444444444444444444444444444444", + want: "v4.2.1", + }, + { + name: "minor ref accepts its minor", + ref: "v4.2", + tags: httpmock.TagListResponse( + "v4.3.0", "4343434343434343434343434343434343434343", + "v4.2.1", "4242424242424242424242424242424242424242", + ), + ancestorSHA: "4242424242424242424242424242424242424242", + want: "v4.2.1", + }, + { + name: "bare SHA accepts any family", + tags: httpmock.TagListResponse("v3.12.0", "3333333333333333333333333333333333333333"), + ancestorSHA: "3333333333333333333333333333333333333333", + want: "v3.12.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/tags`), + httpmock.JSONResponse(tt.tags), + ) + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/releases`), + httpmock.JSONResponse([]map[string]any{}), + ) + if tt.ancestorSHA != "" { + reg.Register( + httpmock.REST("GET", fmt.Sprintf(`repos/actions/checkout/compare/%s\.\.\.%s`, tt.ancestorSHA, headSHA)), + httpmock.JSONResponse(httpmock.CompareAncestorResponse(tt.ancestorSHA)), + ) + } + + tl := NewListerForTest(t, reg) + got, err := tl.BestAncestorTag(context.Background(), "actions", "checkout", headSHA, tt.ref) + if err != nil { + t.Fatalf("BestAncestorTag: %v", err) + } + if got != tt.want { + t.Fatalf("BestAncestorTag() = %q, want %q", got, tt.want) + } + }) + } +} From bf621adda78f029424ffa253f2a77748cb6d090f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 14:24:39 -0700 Subject: [PATCH 2/8] tagging: apply version families to exact tag lookup --- internal/pin/plan.go | 6 +-- internal/pin/plan_test.go | 34 ++++++++++++ internal/tag/tagging.go | 23 +++++---- internal/tag/tags_test.go | 105 +++++++++++++++++++++++++++++++------- 4 files changed, 136 insertions(+), 32 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 60cbfb53..b7b0ce9b 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -348,7 +348,7 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen narrowedNWOs[strings.ToLower(dep.NWO)] = true continue } - patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) + patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA, "") if err != nil { continue } @@ -384,7 +384,7 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen continue } - patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) + patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA, dep.Ref) if err != nil { continue } @@ -663,7 +663,7 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption continue } // Try exact tag match, then ancestor fallback. - patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, e.SHA) + patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, e.SHA, e.Ref) if err != nil { continue } diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index 2c3955f5..f1c6d4da 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -9,6 +9,7 @@ import ( parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" "github.com/github/gh-actions-lock/internal/ghapi/httpmock" + "github.com/github/gh-actions-lock/internal/lockfile" "github.com/github/gh-actions-lock/internal/pinpool" "github.com/github/gh-actions-lock/internal/resolve" "github.com/github/gh-actions-lock/internal/tag" @@ -16,6 +17,39 @@ import ( "github.com/stretchr/testify/require" ) +func TestNarrowDirectDeps_PreservesRefWhenExactTagIsFromAnotherFamily(t *testing.T) { + const sha = "94de994a9f6fffee200243214e17002e2920bb59" + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-send-mail/tags`), + httpmock.JSONResponse(httpmock.TagListResponse("v18", sha, "v3.12.0", sha)), + ) + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-send-mail/releases`), + httpmock.JSONResponse([]map[string]any{}), + ) + + deps := []dep.Dependency{{NWO: "dawidd6/action-send-mail", Ref: "v18", SHA: sha}} + direct := lockfile.NewDirectTracker( + []parserlock.ActionRef{{Owner: "dawidd6", Repo: "action-send-mail", Ref: "v18"}}, + deps, + ) + rewrites := make(map[string]string) + + narrowDirectDeps( + context.Background(), + PlanOptions{Tagger: tag.NewListerForTest(t, reg)}, + deps, + direct, + rewrites, + make(map[string]bool), + ) + + assert.Equal(t, "v18", deps[0].Ref) + assert.Empty(t, rewrites) +} + // 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 diff --git a/internal/tag/tagging.go b/internal/tag/tagging.go index 7e5853ce..63a6962a 100644 --- a/internal/tag/tagging.go +++ b/internal/tag/tagging.go @@ -23,15 +23,15 @@ type PickerTag struct { Installed bool // true if this tag matches the currently pinned SHA } -// BestPatchTagForSHA returns the highest full-semver patch tag pointing at the -// given SHA, or "" if none exists. This is used to narrow mutable version refs -// (like "v4") to a specific patch version (like "v4.2.1") when pinning. -func (tl *Lister) BestPatchTagForSHA(ctx context.Context, owner, repo, sha string) (string, error) { +// BestPatchTagForSHA returns the highest full-semver patch tag in ref's version +// family pointing at the given SHA. An empty ref accepts any family. +func (tl *Lister) BestPatchTagForSHA(ctx context.Context, owner, repo, sha, ref string) (string, error) { matching, err := tl.TagsForSHA(ctx, owner, repo, sha) if err != nil { return "", err } + refSV, restrictFamily := parserlock.ParseSemVer(ref) var best parserlock.SemVer bestFound := false for _, t := range matching { @@ -42,6 +42,9 @@ func (tl *Lister) BestPatchTagForSHA(ctx context.Context, owner, repo, sha strin if !ok || !sv.IsFull() { continue } + if restrictFamily && !sameVersionFamily(sv, refSV) { + continue + } if !bestFound || sv.Major > best.Major || (sv.Major == best.Major && sv.Minor > best.Minor) || (sv.Major == best.Major && sv.Minor == best.Minor && sv.Patch > best.Patch) { @@ -77,7 +80,7 @@ func (tl *Lister) BestAncestorTag(ctx context.Context, owner, repo, sha, ref str if !ok || !sv.IsFull() || sv.Rest != "" { continue } - if restrictFamily && (sv.Major != refSV.Major || !refSV.IsMajorOnly() && sv.Minor != refSV.Minor) { + if restrictFamily && !sameVersionFamily(sv, refSV) { continue } candidates = append(candidates, t) @@ -124,11 +127,7 @@ func (tl *Lister) UniquePatchTagForRef(ctx context.Context, owner, repo, sha, re continue } // Must be in the same family as the original ref. - if sv.Major != refSV.Major { - continue - } - // If original ref specifies minor (e.g. "v4.2"), patch must match that minor. - if ref != refSV.MajorTag() && sv.Minor != refSV.Minor { + if !sameVersionFamily(sv, refSV) { continue } candidates = append(candidates, sv) @@ -140,6 +139,10 @@ func (tl *Lister) UniquePatchTagForRef(ctx context.Context, owner, repo, sha, re return candidates[0].Raw, nil } +func sameVersionFamily(candidate, ref parserlock.SemVer) bool { + return candidate.Major == ref.Major && (ref.IsMajorOnly() || candidate.Minor == ref.Minor) +} + // TagsForSHA returns all tags whose commit SHA matches the given SHA. func (tl *Lister) TagsForSHA(ctx context.Context, owner, repo, sha string) ([]Info, error) { all, err := tl.ListTags(ctx, owner, repo) diff --git a/internal/tag/tags_test.go b/internal/tag/tags_test.go index f403b1b7..b2f60f1a 100644 --- a/internal/tag/tags_test.go +++ b/internal/tag/tags_test.go @@ -71,11 +71,11 @@ func TestBestAncestorTag_RefFamily(t *testing.T) { const headSHA = "ffffffffffffffffffffffffffffffffffffffff" tests := []struct { - name string - ref string - tags any - ancestorSHA string - want string + name string + ref string + tags any + ancestorSHAs []string + want string }{ { name: "major ref excludes another major", @@ -83,13 +83,20 @@ func TestBestAncestorTag_RefFamily(t *testing.T) { tags: httpmock.TagListResponse( "v3.12.0", "3333333333333333333333333333333333333333", ), + ancestorSHAs: []string{"3333333333333333333333333333333333333333"}, }, { - name: "major ref accepts its major", - ref: "v4", - tags: httpmock.TagListResponse("v4.2.1", "4444444444444444444444444444444444444444"), - ancestorSHA: "4444444444444444444444444444444444444444", - want: "v4.2.1", + name: "major ref accepts its major", + ref: "v4", + tags: httpmock.TagListResponse( + "v5.0.0", "5555555555555555555555555555555555555555", + "v4.2.1", "4444444444444444444444444444444444444444", + ), + ancestorSHAs: []string{ + "5555555555555555555555555555555555555555", + "4444444444444444444444444444444444444444", + }, + want: "v4.2.1", }, { name: "minor ref accepts its minor", @@ -98,14 +105,17 @@ func TestBestAncestorTag_RefFamily(t *testing.T) { "v4.3.0", "4343434343434343434343434343434343434343", "v4.2.1", "4242424242424242424242424242424242424242", ), - ancestorSHA: "4242424242424242424242424242424242424242", - want: "v4.2.1", + ancestorSHAs: []string{ + "4343434343434343434343434343434343434343", + "4242424242424242424242424242424242424242", + }, + want: "v4.2.1", }, { - name: "bare SHA accepts any family", - tags: httpmock.TagListResponse("v3.12.0", "3333333333333333333333333333333333333333"), - ancestorSHA: "3333333333333333333333333333333333333333", - want: "v3.12.0", + name: "bare SHA accepts any family", + tags: httpmock.TagListResponse("v3.12.0", "3333333333333333333333333333333333333333"), + ancestorSHAs: []string{"3333333333333333333333333333333333333333"}, + want: "v3.12.0", }, } @@ -120,10 +130,10 @@ func TestBestAncestorTag_RefFamily(t *testing.T) { httpmock.REST("GET", `repos/actions/checkout/releases`), httpmock.JSONResponse([]map[string]any{}), ) - if tt.ancestorSHA != "" { + for _, ancestorSHA := range tt.ancestorSHAs { reg.Register( - httpmock.REST("GET", fmt.Sprintf(`repos/actions/checkout/compare/%s\.\.\.%s`, tt.ancestorSHA, headSHA)), - httpmock.JSONResponse(httpmock.CompareAncestorResponse(tt.ancestorSHA)), + httpmock.REST("GET", fmt.Sprintf(`repos/actions/checkout/compare/%s\.\.\.%s`, ancestorSHA, headSHA)), + httpmock.JSONResponse(httpmock.CompareAncestorResponse(ancestorSHA)), ) } @@ -138,3 +148,60 @@ func TestBestAncestorTag_RefFamily(t *testing.T) { }) } } + +func TestBestPatchTagForSHA_RefFamily(t *testing.T) { + const sha = "ffffffffffffffffffffffffffffffffffffffff" + + tests := []struct { + name string + ref string + tags any + want string + }{ + { + name: "major ref excludes another major at the same commit", + ref: "v18", + tags: httpmock.TagListResponse("v3.12.0", sha), + }, + { + name: "major ref accepts its major", + ref: "v4", + tags: httpmock.TagListResponse("v5.0.0", sha, "v4.2.1", sha), + want: "v4.2.1", + }, + { + name: "minor ref accepts its minor", + ref: "v4.2", + tags: httpmock.TagListResponse("v4.3.0", sha, "v4.2.1", sha), + want: "v4.2.1", + }, + { + name: "bare SHA accepts any family", + tags: httpmock.TagListResponse("v3.12.0", sha), + want: "v3.12.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/tags`), + httpmock.JSONResponse(tt.tags), + ) + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/releases`), + httpmock.JSONResponse([]map[string]any{}), + ) + + tl := NewListerForTest(t, reg) + got, err := tl.BestPatchTagForSHA(context.Background(), "actions", "checkout", sha, tt.ref) + if err != nil { + t.Fatalf("BestPatchTagForSHA: %v", err) + } + if got != tt.want { + t.Fatalf("BestPatchTagForSHA() = %q, want %q", got, tt.want) + } + }) + } +} From 5e1032f79d6992bd82b6c78cb759e6710816832c Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 14:36:27 -0700 Subject: [PATCH 3/8] pinning: only rewrite refs to exact commit tags --- cmd/gh-actions-lock/command_test.go | 69 ++++++++++++++++++++++ internal/pin/plan.go | 35 +----------- internal/pin/plan_test.go | 39 +++++++------ internal/tag/tagging.go | 42 -------------- internal/tag/tags_test.go | 88 ----------------------------- 5 files changed, 90 insertions(+), 183 deletions(-) diff --git a/cmd/gh-actions-lock/command_test.go b/cmd/gh-actions-lock/command_test.go index 11cc69a0..0f26b3ea 100644 --- a/cmd/gh-actions-lock/command_test.go +++ b/cmd/gh-actions-lock/command_test.go @@ -67,6 +67,75 @@ jobs: assert.Empty(t, payload.Findings) } +func TestCheck_BareSHAUsesExactMajorTagAndVerifiesLocally(t *testing.T) { + const ( + sha = "b6e2e70617bc3265edd6dab6c906732b2f1ae151" + ancestorSHA = "09f2f74827fd0000000000000000000000000000" + ) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQLForRepo("dawidd6", "action-download-artifact"), + httpmock.JSONResponse(map[string]any{ + "data": map[string]any{ + "a0": testRepoResponse("dawidd6/action-download-artifact", sha, nodeActionYAML), + }, + }), + ) + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-download-artifact$`), + httpmock.JSONResponse(map[string]any{ + "default_branch": "main", + "id": 2, + "owner": map[string]any{"id": 1}, + }), + ) + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-download-artifact/git/ref/heads/main`), + httpmock.JSONResponse(map[string]any{ + "ref": "refs/heads/main", "object": map[string]any{"sha": sha, "type": "commit"}, + }), + ) + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-download-artifact/tags`), + httpmock.JSONResponse(httpmock.TagListResponse( + "v21", sha, + "v3.1.4", ancestorSHA, + )), + ) + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-download-artifact/releases`), + httpmock.JSONResponse([]map[string]any{}), + ) + + workflowPath := writeTempWorkflow(t, ` +name: ci +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 +`) + + _, _, err := runCommandWithHTTP(t, reg, workflowPath) + require.NoError(t, err) + + workflow, err := os.ReadFile(workflowPath) + require.NoError(t, err) + assert.Contains(t, string(workflow), "dawidd6/action-download-artifact@v21") + assert.NotContains(t, string(workflow), "@v3.1.4") + + lock := readTempLockfilePins(t) + assert.Contains(t, lock, "'dawidd6/action-download-artifact@v21':") + assert.Contains(t, lock, "ref: 'v21'") + assert.Contains(t, lock, "commit: 'sha1-"+sha+"'") + assert.NotContains(t, lock, "v3.1.4") + + _, _, err = runCommandWithHTTP(t, &httpmock.Registry{}, "--verify-local", workflowPath) + require.NoError(t, err) +} + const nodeActionYAML = "name: Test Action\nruns:\n using: node20\n" func testRepoResponse(nameWithOwner, oid, actionYAML string) map[string]any { diff --git a/internal/pin/plan.go b/internal/pin/plan.go index b7b0ce9b..79519940 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -346,23 +346,7 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen if parserlock.IsFullSha(dep.Ref) { if opts.NoNarrow { narrowedNWOs[strings.ToLower(dep.NWO)] = true - continue - } - 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 } @@ -385,17 +369,9 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen } patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA, dep.Ref) - if err != nil { + if err != nil || patchTag == "" { 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, dep.Ref) - if err != nil || patchTag == "" { - continue - } - } oldUses := dep.NWO + "@" + dep.Ref newUses := dep.NWO + "@" + patchTag rewrites[oldUses] = newUses @@ -662,17 +638,10 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption if sv.IsFull() { continue } - // Try exact tag match, then ancestor fallback. patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, e.SHA, e.Ref) - if err != nil { + if err != nil || patchTag == "" { continue } - if patchTag == "" { - patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, e.SHA, e.Ref) - if err != nil || patchTag == "" { - continue - } - } oldRef := e.Ref oldUses := e.NWO + "@" + oldRef newUses := e.NWO + "@" + patchTag diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index f1c6d4da..fe1ccb01 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -414,12 +414,13 @@ func TestPlanWorkflow_InvalidSelfRepositoryRefDoesNotMutateWorkflow(t *testing.T // (Resolver + ReverseLookup + Tagger), confirming that --no-narrow protects the // SHA from rewriting and that the default path still narrows it to a tag. func TestNoNarrow_BareSHA(t *testing.T) { - const sha = "abc1230000000000000000000000000000000000" + const ( + sha = "b6e2e70617bc3265edd6dab6c906732b2f1ae151" + ancestorSHA = "09f2f74827fd0000000000000000000000000000" + ) - // newSlowPathFixtures wires up a Resolver and a Tagger that would narrow - // the SHA to v4.2.1. // The report has a Finding so NeedsAttention() is true and the slow path runs. - newSlowPathFixtures := func(t *testing.T, reverseLookup bool) (*resolve.Resolver, *tag.Lister, checks.WorkflowReport, *httpmock.Registry) { + newSlowPathFixtures := func(t *testing.T) (*resolve.Resolver, *tag.Lister, checks.WorkflowReport, *httpmock.Registry) { t.Helper() reg := &httpmock.Registry{} @@ -439,20 +440,16 @@ func TestNoNarrow_BareSHA(t *testing.T) { }), ) - if reverseLookup { - reg.Register( - httpmock.REST("GET", `repos/actions/checkout/branches`), - httpmock.JSONResponse([]any{ - map[string]any{"name": "main", "commit": map[string]any{"sha": sha}}, - }), - ) - } + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/branches`), + httpmock.JSONResponse(httpmock.BranchListResponse("main", sha)), + ) 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}}, - }), + httpmock.JSONResponse(httpmock.TagListResponse( + "v21", sha, + "v3.1.4", ancestorSHA, + )), ) pool := pinpool.New(2, nil) @@ -478,7 +475,7 @@ func TestNoNarrow_BareSHA(t *testing.T) { } t.Run("no-narrow preserves bare SHA through ReverseLookup", func(t *testing.T) { - resolver, tagger, wr, _ := newSlowPathFixtures(t, true) + resolver, tagger, wr, _ := newSlowPathFixtures(t) opts := PlanOptions{ Resolver: resolver, @@ -503,8 +500,8 @@ func TestNoNarrow_BareSHA(t *testing.T) { assert.Empty(t, result.wplans[0].Rewrites, "no workflow rewrite when --no-narrow") }) - t.Run("default narrows bare SHA to tag", func(t *testing.T) { - resolver, tagger, wr, _ := newSlowPathFixtures(t, false) + t.Run("default narrows bare SHA to exact major tag", func(t *testing.T) { + resolver, tagger, wr, _ := newSlowPathFixtures(t) opts := PlanOptions{ Resolver: resolver, @@ -523,7 +520,9 @@ func TestNoNarrow_BareSHA(t *testing.T) { } } require.Len(t, pinned, 1, "expected exactly one pinned entry") - assert.Equal(t, "v4.2.1", pinned[0].Ref, "bare SHA should be narrowed to full semver tag") + assert.Equal(t, "v21", pinned[0].Ref) + assert.Equal(t, sha, pinned[0].SHA) + assert.Equal(t, "v21", pinned[0].Tag) require.Len(t, result.wplans, 1) assert.Contains(t, result.wplans[0].Rewrites, "actions/checkout@"+sha, diff --git a/internal/tag/tagging.go b/internal/tag/tagging.go index 63a6962a..5f77f2f6 100644 --- a/internal/tag/tagging.go +++ b/internal/tag/tagging.go @@ -59,48 +59,6 @@ func (tl *Lister) BestPatchTagForSHA(ctx context.Context, owner, repo, sha, ref return best.Raw, nil } -// BestAncestorTag returns the latest full-semver tag in ref's version family -// that is an ancestor of the given SHA. An empty ref accepts any family. -// Checks at most 3 candidate tags (latest first) to limit API calls. -func (tl *Lister) BestAncestorTag(ctx context.Context, owner, repo, sha, ref string) (string, error) { - all, err := tl.ListTags(ctx, owner, repo) - if err != nil { - return "", err - } - - refSV, restrictFamily := parserlock.ParseSemVer(ref) - - // Collect full-semver candidates, already sorted latest-first by ListTags. - var candidates []Info - for _, t := range all { - if t.IsMajor { - continue - } - sv, ok := parserlock.ParseSemVer(t.Name) - if !ok || !sv.IsFull() || sv.Rest != "" { - continue - } - if restrictFamily && !sameVersionFamily(sv, refSV) { - continue - } - candidates = append(candidates, t) - if len(candidates) >= 3 { - break - } - } - - for _, t := range candidates { - isAncestor, err := tl.client.CompareCommits(ctx, owner, repo, t.SHA, sha) - if err != nil { - continue - } - if isAncestor { - return t.Name, nil - } - } - return "", nil -} - // UniquePatchTagForRef returns the sole full-semver patch tag that matches the // given ref's family, or "" if the choice is ambiguous (0 or 2+ candidates). // For "v9" it only considers v9.x.y tags; for "v4.2" only v4.2.x tags. diff --git a/internal/tag/tags_test.go b/internal/tag/tags_test.go index b2f60f1a..3eae2e69 100644 --- a/internal/tag/tags_test.go +++ b/internal/tag/tags_test.go @@ -2,7 +2,6 @@ package tag import ( "context" - "fmt" "testing" "github.com/github/gh-actions-lock/internal/ghapi/httpmock" @@ -67,88 +66,6 @@ func TestListTags_SemverOrdering(t *testing.T) { } } -func TestBestAncestorTag_RefFamily(t *testing.T) { - const headSHA = "ffffffffffffffffffffffffffffffffffffffff" - - tests := []struct { - name string - ref string - tags any - ancestorSHAs []string - want string - }{ - { - name: "major ref excludes another major", - ref: "v18", - tags: httpmock.TagListResponse( - "v3.12.0", "3333333333333333333333333333333333333333", - ), - ancestorSHAs: []string{"3333333333333333333333333333333333333333"}, - }, - { - name: "major ref accepts its major", - ref: "v4", - tags: httpmock.TagListResponse( - "v5.0.0", "5555555555555555555555555555555555555555", - "v4.2.1", "4444444444444444444444444444444444444444", - ), - ancestorSHAs: []string{ - "5555555555555555555555555555555555555555", - "4444444444444444444444444444444444444444", - }, - want: "v4.2.1", - }, - { - name: "minor ref accepts its minor", - ref: "v4.2", - tags: httpmock.TagListResponse( - "v4.3.0", "4343434343434343434343434343434343434343", - "v4.2.1", "4242424242424242424242424242424242424242", - ), - ancestorSHAs: []string{ - "4343434343434343434343434343434343434343", - "4242424242424242424242424242424242424242", - }, - want: "v4.2.1", - }, - { - name: "bare SHA accepts any family", - tags: httpmock.TagListResponse("v3.12.0", "3333333333333333333333333333333333333333"), - ancestorSHAs: []string{"3333333333333333333333333333333333333333"}, - want: "v3.12.0", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", `repos/actions/checkout/tags`), - httpmock.JSONResponse(tt.tags), - ) - reg.Register( - httpmock.REST("GET", `repos/actions/checkout/releases`), - httpmock.JSONResponse([]map[string]any{}), - ) - for _, ancestorSHA := range tt.ancestorSHAs { - reg.Register( - httpmock.REST("GET", fmt.Sprintf(`repos/actions/checkout/compare/%s\.\.\.%s`, ancestorSHA, headSHA)), - httpmock.JSONResponse(httpmock.CompareAncestorResponse(ancestorSHA)), - ) - } - - tl := NewListerForTest(t, reg) - got, err := tl.BestAncestorTag(context.Background(), "actions", "checkout", headSHA, tt.ref) - if err != nil { - t.Fatalf("BestAncestorTag: %v", err) - } - if got != tt.want { - t.Fatalf("BestAncestorTag() = %q, want %q", got, tt.want) - } - }) - } -} - func TestBestPatchTagForSHA_RefFamily(t *testing.T) { const sha = "ffffffffffffffffffffffffffffffffffffffff" @@ -175,11 +92,6 @@ func TestBestPatchTagForSHA_RefFamily(t *testing.T) { tags: httpmock.TagListResponse("v4.3.0", sha, "v4.2.1", sha), want: "v4.2.1", }, - { - name: "bare SHA accepts any family", - tags: httpmock.TagListResponse("v3.12.0", sha), - want: "v3.12.0", - }, } for _, tt := range tests { From 76d99b915c95bb91464f95c53b23f884bea0f044 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 15:02:38 -0700 Subject: [PATCH 4/8] tests: lock exact-tag rewrites to verifiable state --- cmd/gh-actions-lock/command_test.go | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/cmd/gh-actions-lock/command_test.go b/cmd/gh-actions-lock/command_test.go index 0f26b3ea..99ff5745 100644 --- a/cmd/gh-actions-lock/command_test.go +++ b/cmd/gh-actions-lock/command_test.go @@ -107,6 +107,10 @@ func TestCheck_BareSHAUsesExactMajorTagAndVerifiesLocally(t *testing.T) { httpmock.REST("GET", `repos/dawidd6/action-download-artifact/releases`), httpmock.JSONResponse([]map[string]any{}), ) + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-download-artifact/compare/09f2f74827fd0000000000000000000000000000\.\.\.b6e2e70617bc3265edd6dab6c906732b2f1ae151`), + httpmock.JSONResponse(httpmock.CompareAncestorResponse(ancestorSHA)), + ) workflowPath := writeTempWorkflow(t, ` name: ci @@ -136,6 +140,50 @@ jobs: require.NoError(t, err) } +func TestCheck_ChangedTagAtSameCommitRekeysAndVerifiesLocally(t *testing.T) { + const sha = "94de994a9f6fffee200243214e17002e2920bb59" + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQLForRepo("dawidd6", "action-send-mail"), + httpmock.JSONResponse(map[string]any{ + "data": map[string]any{ + "a0": testRepoResponse("dawidd6/action-send-mail", sha, nodeActionYAML), + }, + }), + ) + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-send-mail/tags`), + httpmock.JSONResponse(httpmock.TagListResponse("v18", sha, "v3.12.0", sha)), + ) + reg.Register( + httpmock.REST("GET", `repos/dawidd6/action-send-mail/releases`), + httpmock.JSONResponse([]map[string]any{}), + ) + + workflowPath := writeTempWorkflow(t, ` +name: ci +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: dawidd6/action-send-mail@v18 +`, "dawidd6/action-send-mail@v3.12.0=sha1-"+sha) + + _, _, err := runCommandWithHTTP(t, reg, workflowPath) + require.NoError(t, err) + + lock := readTempLockfilePins(t) + assert.Contains(t, lock, "'dawidd6/action-send-mail@v18':") + assert.Contains(t, lock, "ref: 'v18'") + assert.Contains(t, lock, "commit: 'sha1-"+sha+"'") + assert.NotContains(t, lock, "v3.12.0") + + _, _, err = runCommandWithHTTP(t, &httpmock.Registry{}, "--verify-local", workflowPath) + require.NoError(t, err) +} + const nodeActionYAML = "name: Test Action\nruns:\n using: node20\n" func testRepoResponse(nameWithOwner, oid, actionYAML string) map[string]any { From 3995d02a3c3b980015a4253d85b6e9ef4ed030f1 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 15:12:17 -0700 Subject: [PATCH 5/8] pinning: preserve narrowed refs per dependency --- internal/pin/plan.go | 44 ++++++++++++--------------- internal/pin/plan_test.go | 64 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 79519940..247fe82c 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -195,17 +195,16 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption rootTracker := lockfile.NewDirectTracker(unrecordedRefs, deps) rewriteTracker := lockfile.NewDirectTracker(rewriteRefs, deps) - // Narrow mutable version tags to patch tags, and resolve bare-SHA refs - // to a symbolic tag when one exists. + // Narrow mutable version tags to exact patch tags. status("pinning " + wr.Path) rewrites := make(map[string]string) - narrowedNWOs := make(map[string]bool) // NWOs where narrowing chose a tag + preservedDeps := make(map[int]bool) - narrowDirectDeps(ctx, opts, deps, rewriteTracker, rewrites, narrowedNWOs) + narrowDirectDeps(ctx, opts, deps, rewriteTracker, rewrites, preservedDeps) // ReverseLookup canonicalizes each dep's ref while preserving the tags // narrowing chose and transitive deps' declared refs. - rlRewrites, lookupIssues, err := reverseLookupRewrites(ctx, opts, wr, deps, rewriteTracker, narrowedNWOs) + rlRewrites, lookupIssues, err := reverseLookupRewrites(ctx, opts, wr, deps, rewriteTracker, preservedDeps) if err != nil { return planResult{}, err } @@ -319,10 +318,9 @@ func unresolvedEntries(wr checks.WorkflowReport, unrecordedRefs []parserlock.Act return out } -// 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) { +// narrowDirectDeps rewrites direct partial semver refs to exact patch tags, +// leaving bare SHA and transitive refs for reverse lookup. +func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependency, directTracker lockfile.DirectTracker, rewrites map[string]string, preservedDeps map[int]bool) { if opts.Tagger == nil { return } @@ -340,12 +338,10 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen continue } - // Bare-SHA refs: find a tag pointing at the same commit. - // Skip if --no-narrow — the user wants to keep their commit SHA as-is. - // Mark narrowedNWOs so ReverseLookup also preserves the SHA ref. + // ReverseLookup owns bare-SHA normalization unless --no-narrow protects it. if parserlock.IsFullSha(dep.Ref) { if opts.NoNarrow { - narrowedNWOs[strings.ToLower(dep.NWO)] = true + preservedDeps[i] = true } continue } @@ -376,21 +372,22 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen newUses := dep.NWO + "@" + patchTag rewrites[oldUses] = newUses dep.Ref = patchTag - narrowedNWOs[nwoLower] = true + preservedDeps[i] = true } } // reverseLookupRewrites canonicalizes dep refs via ReverseLookup (SHA -> tag/ // branch), restoring refs that narrowing or a transitive dep already fixed. // 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) { +func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.WorkflowReport, deps []dep.Dependency, directTracker lockfile.DirectTracker, preservedDeps map[int]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) + preservedRefs := make(map[int]string) + preservedKeys := make(map[string]bool) for i := range deps { - nwo := strings.ToLower(deps[i].NWO) - if narrowedNWOs[nwo] { - narrowedRefs[i] = deps[i].Ref + if preservedDeps[i] { + preservedRefs[i] = deps[i].Ref + preservedKeys[deps[i].Key()] = true } } @@ -410,7 +407,7 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work return nil, nil, fmt.Errorf("reverse lookup: %w", err) } // Restore narrowed refs that ReverseLookup may have overwritten. - for i, ref := range narrowedRefs { + for i, ref := range preservedRefs { deps[i].Ref = ref } // Restore transitive deps' declared refs — we don't own the composite's @@ -431,11 +428,8 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work if transitiveRewriteKeys[k] { continue } - if at := strings.Index(k, "@"); at > 0 { - nwo := strings.ToLower(k[:at]) - if narrowedNWOs[nwo] { - continue - } + if preservedKeys[k] { + continue } rewrites[k] = v } diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index fe1ccb01..32d87207 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -43,13 +43,75 @@ func TestNarrowDirectDeps_PreservesRefWhenExactTagIsFromAnotherFamily(t *testing deps, direct, rewrites, - make(map[string]bool), + make(map[int]bool), ) assert.Equal(t, "v18", deps[0].Ref) assert.Empty(t, rewrites) } +func TestNarrowDirectDeps_SameNWOSiblingRefsNormalizeIndependently(t *testing.T) { + const ( + patchSHA = "4444444444444444444444444444444444444444" + bareSHA = "2121212121212121212121212121212121212121" + ) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/tags`), + httpmock.JSONResponse(httpmock.TagListResponse("v4.2.1", patchSHA, "v21", bareSHA)), + ) + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/tags`), + httpmock.JSONResponse(httpmock.TagListResponse("v4.2.1", patchSHA, "v21", bareSHA)), + ) + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/releases`), + httpmock.JSONResponse([]map[string]any{}), + ) + reg.Register( + httpmock.REST("GET", `repos/actions/checkout$`), + httpmock.JSONResponse(map[string]any{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("GET", `repos/actions/checkout/git/ref/heads/main`), + httpmock.JSONResponse(map[string]any{ + "ref": "refs/heads/main", "object": map[string]any{"sha": bareSHA, "type": "commit"}, + }), + ) + + deps := []dep.Dependency{ + {NWO: "actions/checkout", Ref: "v4", SHA: patchSHA}, + {NWO: "actions/checkout", Ref: bareSHA, SHA: bareSHA}, + } + refs := []parserlock.ActionRef{ + {Owner: "actions", Repo: "checkout", Ref: "v4"}, + {Owner: "actions", Repo: "checkout", Ref: bareSHA}, + } + direct := lockfile.NewDirectTracker(refs, deps) + rewrites := make(map[string]string) + preserved := make(map[int]bool) + tagger := tag.NewListerForTest(t, reg) + + narrowDirectDeps(context.Background(), PlanOptions{Tagger: tagger}, deps, direct, rewrites, preserved) + + resolver, err := resolve.New("github.com", pinpool.New(2, nil), resolve.WithTransport(reg)) + require.NoError(t, err) + reverseRewrites, issues, err := reverseLookupRewrites( + context.Background(), + PlanOptions{Resolver: resolver}, + checks.WorkflowReport{}, + deps, + direct, + preserved, + ) + require.NoError(t, err) + assert.Empty(t, issues) + assert.Equal(t, "v4.2.1", deps[0].Ref) + assert.Equal(t, "v21", deps[1].Ref) + assert.Equal(t, "actions/checkout@v21", reverseRewrites["actions/checkout@"+bareSHA]) +} + // 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 From 2fca8dce53ae70ff5dfd1dcf1da31ed519a43241 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 07:20:03 -0700 Subject: [PATCH 6/8] CI: retry CodeQL analysis From 563348263e37dc9962be0102bfa81e96db652c94 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 10:23:22 -0700 Subject: [PATCH 7/8] copy: explain how to replace orphaned SHA refs --- internal/resolve/discover_test.go | 4 ++++ internal/resolve/reverse_lookup.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/resolve/discover_test.go b/internal/resolve/discover_test.go index ed0c04bc..da2a85bb 100644 --- a/internal/resolve/discover_test.go +++ b/internal/resolve/discover_test.go @@ -389,6 +389,10 @@ func TestReverseLookup_FailsClosedOnImpostor(t *testing.T) { if issues[0].NWO != "actions/checkout" { t.Errorf("expected NWO=actions/checkout, got %q", issues[0].NWO) } + want := "this commit has no exact tag and is not on any branch — update the workflow to use a current upstream tag or branch, then rerun" + if issues[0].Message != want { + t.Errorf("message = %q, want %q", issues[0].Message, want) + } } type roundTripFunc func(*http.Request) (*http.Response, error) diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index d2bc453e..b658da17 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -279,7 +279,7 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma if tag == "" && branch == "" { var msg string if LooksLikeSHA(d.Ref) { - msg = fmt.Sprintf("no tag or branch contains this commit — a symbolic ref is required for the lockfile") + msg = "this commit has no exact tag and is not on any branch — update the workflow to use a current upstream tag or branch, then rerun" } else { msg = fmt.Sprintf("commit %s is not reachable from any ref (tag or branch) — orphaned commit", parserlock.ShortSHA(d.SHA)) From f9c185a52a28f7268ad48f8ede2793be71de7443 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 10:24:00 -0700 Subject: [PATCH 8/8] copy: remove em dash from orphaned SHA guidance --- internal/resolve/discover_test.go | 2 +- internal/resolve/reverse_lookup.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/resolve/discover_test.go b/internal/resolve/discover_test.go index da2a85bb..1940b686 100644 --- a/internal/resolve/discover_test.go +++ b/internal/resolve/discover_test.go @@ -389,7 +389,7 @@ func TestReverseLookup_FailsClosedOnImpostor(t *testing.T) { if issues[0].NWO != "actions/checkout" { t.Errorf("expected NWO=actions/checkout, got %q", issues[0].NWO) } - want := "this commit has no exact tag and is not on any branch — update the workflow to use a current upstream tag or branch, then rerun" + want := "this commit has no exact tag and is not on any branch. Update the workflow to use a current upstream tag or branch, then rerun" if issues[0].Message != want { t.Errorf("message = %q, want %q", issues[0].Message, want) } diff --git a/internal/resolve/reverse_lookup.go b/internal/resolve/reverse_lookup.go index b658da17..0541aeb5 100644 --- a/internal/resolve/reverse_lookup.go +++ b/internal/resolve/reverse_lookup.go @@ -279,7 +279,7 @@ func (r *Resolver) ReverseLookup(ctx context.Context, deps []dep.Dependency) (ma if tag == "" && branch == "" { var msg string if LooksLikeSHA(d.Ref) { - msg = "this commit has no exact tag and is not on any branch — update the workflow to use a current upstream tag or branch, then rerun" + msg = "this commit has no exact tag and is not on any branch. Update the workflow to use a current upstream tag or branch, then rerun" } else { msg = fmt.Sprintf("commit %s is not reachable from any ref (tag or branch) — orphaned commit", parserlock.ShortSHA(d.SHA))