Skip to content

Commit e522966

Browse files
committed
Merge main and preserve transfer rewrite coverage
2 parents 3472196 + 634cc11 commit e522966

16 files changed

Lines changed: 332 additions & 215 deletions

File tree

.github/workflows/dependency-review.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# This workflow is managed by gh actions-lock.
2-
31
name: dependency-review
42

53
on:

.github/workflows/release.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# This workflow is managed by gh actions-lock.
2-
31
name: Release
42

53
on:

.github/workflows/test.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# This workflow is managed by gh actions-lock.
2-
31
name: test
42

53
on:

cmd/gh-actions-lock/command_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,123 @@ func TestCheckCommand_RejectsTransferredRecordedRemoteCompositeRef(t *testing.T)
520520
assert.Equal(t, lockBefore, lockAfter)
521521
}
522522

523+
func TestCheck_BareSHAUsesExactMajorTagAndVerifiesLocally(t *testing.T) {
524+
const (
525+
sha = "b6e2e70617bc3265edd6dab6c906732b2f1ae151"
526+
ancestorSHA = "09f2f74827fd0000000000000000000000000000"
527+
)
528+
529+
reg := &httpmock.Registry{}
530+
reg.Register(
531+
httpmock.GraphQLForRepo("dawidd6", "action-download-artifact"),
532+
httpmock.JSONResponse(map[string]any{
533+
"data": map[string]any{
534+
"a0": testRepoResponse("dawidd6/action-download-artifact", sha, nodeActionYAML),
535+
},
536+
}),
537+
)
538+
reg.Register(
539+
httpmock.REST("GET", `repos/dawidd6/action-download-artifact$`),
540+
httpmock.JSONResponse(map[string]any{
541+
"default_branch": "main",
542+
"id": 2,
543+
"owner": map[string]any{"id": 1},
544+
}),
545+
)
546+
reg.Register(
547+
httpmock.REST("GET", `repos/dawidd6/action-download-artifact/git/ref/heads/main`),
548+
httpmock.JSONResponse(map[string]any{
549+
"ref": "refs/heads/main", "object": map[string]any{"sha": sha, "type": "commit"},
550+
}),
551+
)
552+
reg.Register(
553+
httpmock.REST("GET", `repos/dawidd6/action-download-artifact/tags`),
554+
httpmock.JSONResponse(httpmock.TagListResponse(
555+
"v21", sha,
556+
"v3.1.4", ancestorSHA,
557+
)),
558+
)
559+
reg.Register(
560+
httpmock.REST("GET", `repos/dawidd6/action-download-artifact/releases`),
561+
httpmock.JSONResponse([]map[string]any{}),
562+
)
563+
reg.Register(
564+
httpmock.REST("GET", `repos/dawidd6/action-download-artifact/compare/09f2f74827fd0000000000000000000000000000\.\.\.b6e2e70617bc3265edd6dab6c906732b2f1ae151`),
565+
httpmock.JSONResponse(httpmock.CompareAncestorResponse(ancestorSHA)),
566+
)
567+
568+
workflowPath := writeTempWorkflow(t, `
569+
name: ci
570+
on: push
571+
jobs:
572+
test:
573+
runs-on: ubuntu-latest
574+
steps:
575+
- uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151
576+
`)
577+
578+
_, _, err := runCommandWithHTTP(t, reg, workflowPath)
579+
require.NoError(t, err)
580+
581+
workflow, err := os.ReadFile(workflowPath)
582+
require.NoError(t, err)
583+
assert.Contains(t, string(workflow), "dawidd6/action-download-artifact@v21")
584+
assert.NotContains(t, string(workflow), "@v3.1.4")
585+
586+
lock := readTempLockfilePins(t)
587+
assert.Contains(t, lock, "'dawidd6/action-download-artifact@v21':")
588+
assert.Contains(t, lock, "ref: 'v21'")
589+
assert.Contains(t, lock, "commit: 'sha1-"+sha+"'")
590+
assert.NotContains(t, lock, "v3.1.4")
591+
592+
_, _, err = runCommandWithHTTP(t, &httpmock.Registry{}, "--verify-local", workflowPath)
593+
require.NoError(t, err)
594+
}
595+
596+
func TestCheck_ChangedTagAtSameCommitRekeysAndVerifiesLocally(t *testing.T) {
597+
const sha = "94de994a9f6fffee200243214e17002e2920bb59"
598+
599+
reg := &httpmock.Registry{}
600+
reg.Register(
601+
httpmock.GraphQLForRepo("dawidd6", "action-send-mail"),
602+
httpmock.JSONResponse(map[string]any{
603+
"data": map[string]any{
604+
"a0": testRepoResponse("dawidd6/action-send-mail", sha, nodeActionYAML),
605+
},
606+
}),
607+
)
608+
reg.Register(
609+
httpmock.REST("GET", `repos/dawidd6/action-send-mail/tags`),
610+
httpmock.JSONResponse(httpmock.TagListResponse("v18", sha, "v3.12.0", sha)),
611+
)
612+
reg.Register(
613+
httpmock.REST("GET", `repos/dawidd6/action-send-mail/releases`),
614+
httpmock.JSONResponse([]map[string]any{}),
615+
)
616+
617+
workflowPath := writeTempWorkflow(t, `
618+
name: ci
619+
on: push
620+
jobs:
621+
test:
622+
runs-on: ubuntu-latest
623+
steps:
624+
- uses: dawidd6/action-send-mail@v18
625+
`, "dawidd6/action-send-mail@v3.12.0=sha1-"+sha)
626+
627+
_, _, err := runCommandWithHTTP(t, reg, workflowPath)
628+
require.NoError(t, err)
629+
630+
lock := readTempLockfilePins(t)
631+
assert.Contains(t, lock, "'dawidd6/action-send-mail@v18':")
632+
assert.Contains(t, lock, "ref: 'v18'")
633+
assert.Contains(t, lock, "commit: 'sha1-"+sha+"'")
634+
assert.NotContains(t, lock, "v3.12.0")
635+
636+
_, _, err = runCommandWithHTTP(t, &httpmock.Registry{}, "--verify-local", workflowPath)
637+
require.NoError(t, err)
638+
}
639+
523640
const nodeActionYAML = "name: Test Action\nruns:\n using: node20\n"
524641

525642
func testRepoResponse(nameWithOwner, oid, actionYAML string) map[string]any {

cmd/gh-actions-lock/selfrepository_test.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"sync/atomic"
99
"testing"
1010

11-
"github.com/github/gh-actions-lock/internal/workflowfile"
1211
"github.com/stretchr/testify/assert"
1312
"github.com/stretchr/testify/require"
1413
)
@@ -51,7 +50,6 @@ jobs:
5150
gotWorkflow, err := os.ReadFile(workflowPath)
5251
require.NoError(t, err)
5352
assert.Equal(t, workflow, gotWorkflow)
54-
assert.NotContains(t, string(gotWorkflow), workflowfile.SentinelComment)
5553

5654
lockPath := filepath.Join(dir, ".github", "workflows", "actions.lock")
5755
if lockContent, readErr := os.ReadFile(lockPath); readErr == nil {
@@ -92,7 +90,6 @@ jobs:
9290
gotWorkflow, readErr := os.ReadFile(workflowPath)
9391
require.NoError(t, readErr)
9492
assert.Equal(t, workflow, gotWorkflow)
95-
assert.NotContains(t, string(gotWorkflow), workflowfile.SentinelComment)
9693
lockPath := filepath.Join(dir, ".github", "workflows", "actions.lock")
9794
_, readErr = os.Stat(lockPath)
9895
assert.ErrorIs(t, readErr, os.ErrNotExist)

internal/pin/commit.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,16 +133,14 @@ func rewriteWorkflow(wp WorkflowPlan) error {
133133
}
134134
}
135135

136-
content = workflowfile.EnsureSentinel(content)
137136
if bytes.Equal(content, wf.Content) {
138137
return nil
139138
}
140139
return os.WriteFile(wp.Path, content, 0o644)
141140
}
142141

143142
// rewriteSelfActionFiles applies each workflow's rewrites to the in-repo
144-
// action files it reaches via `$/…`. No sentinel comment: these are action
145-
// definitions, not managed workflows.
143+
// action files it reaches via `$/…`.
146144
func rewriteSelfActionFiles(plans []WorkflowPlan) error {
147145
merged := make(map[string]map[string]string)
148146
for _, wp := range plans {

internal/pin/commit_test.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,12 @@ func TestCommitRemovesDependenciesDroppedFromWorkflow(t *testing.T) {
113113
dir := t.TempDir()
114114
workflowPath := filepath.Join(".github", "workflows", "ci.yml")
115115
require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(workflowPath)), 0o755))
116-
require.NoError(t, os.WriteFile(filepath.Join(dir, workflowPath), []byte(`on: push
116+
workflow := []byte(`on: push
117117
jobs:
118118
lint:
119119
uses: owner/reusable/.github/workflows/lint.yml@main
120-
`), 0o644))
120+
`)
121+
require.NoError(t, os.WriteFile(filepath.Join(dir, workflowPath), workflow, 0o644))
121122
t.Chdir(dir)
122123

123124
store, err := lockfile.LoadState(dir, fakeMeta{})
@@ -144,6 +145,10 @@ jobs:
144145
}
145146
require.NoError(t, Commit(context.Background(), rec, store, nil))
146147

148+
gotWorkflow, err := os.ReadFile(filepath.Join(dir, workflowPath))
149+
require.NoError(t, err)
150+
assert.Equal(t, workflow, gotWorkflow)
151+
147152
got, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", "actions.lock"))
148153
require.NoError(t, err)
149154
assert.Contains(t, string(got), "actions/checkout@v7")

internal/pin/plan.go

Lines changed: 24 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -228,17 +228,16 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption
228228
}
229229
parentMap = dep.RekeyParentMap(parentMap, canonicalRekeys)
230230

231-
// Narrow mutable version tags to patch tags, and resolve bare-SHA refs
232-
// to a symbolic tag when one exists.
231+
// Narrow mutable version tags to exact patch tags.
233232
status("pinning " + wr.Path)
234233
rewrites := make(map[string]string)
235-
narrowedNWOs := make(map[string]bool) // NWOs where narrowing chose a tag
234+
preservedDeps := make(map[int]bool)
236235

237-
narrowDirectDeps(ctx, opts, deps, rewriteTracker, rewrites, narrowedNWOs)
236+
narrowDirectDeps(ctx, opts, deps, rewriteTracker, rewrites, preservedDeps)
238237

239238
// ReverseLookup canonicalizes each dep's ref while preserving the tags
240239
// narrowing chose and transitive deps' declared refs.
241-
rlRewrites, lookupIssues, err := reverseLookupRewrites(ctx, opts, wr, deps, rewriteTracker, narrowedNWOs)
240+
rlRewrites, lookupIssues, err := reverseLookupRewrites(ctx, opts, wr, deps, rewriteTracker, preservedDeps)
242241
if err != nil {
243242
return planResult{}, err
244243
}
@@ -308,8 +307,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption
308307
SelfActionFiles: wr.SelfActionFiles,
309308
})
310309
} else if len(wplans) == 0 {
311-
// No rewrites and no plan entry yet — still include the workflow
312-
// so EnsureSentinel can be applied during commit.
310+
// Keep the workflow in the plan so its lockfile entry is updated.
313311
wplans = append(wplans, WorkflowPlan{Path: wr.Path, SelfActionFiles: wr.SelfActionFiles})
314312
}
315313

@@ -404,10 +402,9 @@ func unresolvedEntries(wr checks.WorkflowReport, unrecordedRefs []parserlock.Act
404402
return out
405403
}
406404

407-
// narrowDirectDeps rewrites direct deps' mutable refs to precise tags (bare SHA
408-
// or partial/non-semver ref -> full patch tag), leaving transitive deps verbatim.
409-
// Each rewrite mutates deps[i].Ref and records the old->new uses and narrowed NWO.
410-
func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependency, directTracker lockfile.DirectTracker, rewrites map[string]string, narrowedNWOs map[string]bool) {
405+
// narrowDirectDeps rewrites direct partial semver refs to exact patch tags,
406+
// leaving bare SHA and transitive refs for reverse lookup.
407+
func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependency, directTracker lockfile.DirectTracker, rewrites map[string]string, preservedDeps map[int]bool) {
411408
if opts.Tagger == nil {
412409
return
413410
}
@@ -425,29 +422,11 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen
425422
continue
426423
}
427424

428-
// Bare-SHA refs: find a tag pointing at the same commit.
429-
// Skip if --no-narrow — the user wants to keep their commit SHA as-is.
430-
// Mark narrowedNWOs so ReverseLookup also preserves the SHA ref.
425+
// ReverseLookup owns bare-SHA normalization unless --no-narrow protects it.
431426
if parserlock.IsFullSha(dep.Ref) {
432427
if opts.NoNarrow {
433-
narrowedNWOs[strings.ToLower(dep.NWO)] = true
434-
continue
428+
preservedDeps[i] = true
435429
}
436-
patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA)
437-
if err != nil {
438-
continue
439-
}
440-
if patchTag == "" {
441-
patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA)
442-
if err != nil || patchTag == "" {
443-
continue
444-
}
445-
}
446-
oldUses := dep.NWO + "@" + dep.Ref
447-
newUses := dep.NWO + "@" + patchTag
448-
rewrites[oldUses] = newUses
449-
dep.Ref = patchTag
450-
narrowedNWOs[strings.ToLower(dep.NWO)] = true
451430
continue
452431
}
453432

@@ -469,37 +448,30 @@ func narrowDirectDeps(ctx context.Context, opts PlanOptions, deps []dep.Dependen
469448
continue
470449
}
471450

472-
patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA)
473-
if err != nil {
451+
patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA, dep.Ref)
452+
if err != nil || patchTag == "" {
474453
continue
475454
}
476-
// No exact tag match - if the repo publishes semver releases,
477-
// walk back to the latest tag that's an ancestor of this SHA.
478-
if patchTag == "" {
479-
patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA)
480-
if err != nil || patchTag == "" {
481-
continue
482-
}
483-
}
484455
oldUses := dep.NWO + "@" + dep.Ref
485456
newUses := dep.NWO + "@" + patchTag
486457
rewrites[oldUses] = newUses
487458
dep.Ref = patchTag
488-
narrowedNWOs[nwoLower] = true
459+
preservedDeps[i] = true
489460
}
490461
}
491462

492463
// reverseLookupRewrites canonicalizes dep refs via ReverseLookup (SHA -> tag/
493464
// branch), restoring refs that narrowing or a transitive dep already fixed.
494465
// Returns the rewrites map, indices of unresolvable deps, and any hard error.
495-
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) {
466+
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) {
496467
// Save narrowed refs before ReverseLookup - it may overwrite dep.Ref
497468
// with a branch name, but we want to keep the semver tag narrowing chose.
498-
narrowedRefs := make(map[int]string)
469+
preservedRefs := make(map[int]string)
470+
preservedKeys := make(map[string]bool)
499471
for i := range deps {
500-
nwo := strings.ToLower(deps[i].NWO)
501-
if narrowedNWOs[nwo] {
502-
narrowedRefs[i] = deps[i].Ref
472+
if preservedDeps[i] {
473+
preservedRefs[i] = deps[i].Ref
474+
preservedKeys[deps[i].Key()] = true
503475
}
504476
}
505477

@@ -519,7 +491,7 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work
519491
return nil, nil, fmt.Errorf("reverse lookup: %w", err)
520492
}
521493
// Restore narrowed refs that ReverseLookup may have overwritten.
522-
for i, ref := range narrowedRefs {
494+
for i, ref := range preservedRefs {
523495
deps[i].Ref = ref
524496
}
525497
// Restore transitive deps' declared refs — we don't own the composite's
@@ -540,11 +512,8 @@ func reverseLookupRewrites(ctx context.Context, opts PlanOptions, wr checks.Work
540512
if transitiveRewriteKeys[k] {
541513
continue
542514
}
543-
if at := strings.Index(k, "@"); at > 0 {
544-
nwo := strings.ToLower(k[:at])
545-
if narrowedNWOs[nwo] {
546-
continue
547-
}
515+
if preservedKeys[k] {
516+
continue
548517
}
549518
rewrites[k] = v
550519
}
@@ -747,17 +716,10 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption
747716
if sv.IsFull() {
748717
continue
749718
}
750-
// Try exact tag match, then ancestor fallback.
751-
patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, e.SHA)
752-
if err != nil {
719+
patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, e.SHA, e.Ref)
720+
if err != nil || patchTag == "" {
753721
continue
754722
}
755-
if patchTag == "" {
756-
patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, e.SHA)
757-
if err != nil || patchTag == "" {
758-
continue
759-
}
760-
}
761723
oldRef := e.Ref
762724
oldUses := e.NWO + "@" + oldRef
763725
newUses := e.NWO + "@" + patchTag

0 commit comments

Comments
 (0)