From bbfda36068cbcc9a0fe285f2f493ab14fec78255 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 11:03:48 -0700 Subject: [PATCH 01/23] Pinning: repair SHA refs from existing lock metadata --- cmd/gh-actions-lock/selfrepository_test.go | 53 ++++++++++++++++++++++ internal/pin/plan.go | 16 +++++++ 2 files changed, 69 insertions(+) diff --git a/cmd/gh-actions-lock/selfrepository_test.go b/cmd/gh-actions-lock/selfrepository_test.go index 80c5ec00..75a2a6fc 100644 --- a/cmd/gh-actions-lock/selfrepository_test.go +++ b/cmd/gh-actions-lock/selfrepository_test.go @@ -97,3 +97,56 @@ jobs: _, readErr = os.Stat(lockPath) assert.ErrorIs(t, readErr, os.ErrNotExist) } + +func TestExistingSHARefRewritesSelfRepositoryAction(t *testing.T) { + const sha = "bcd2ba49218906704ab6c1aa796996da409d3eb1" + transport := &requestCountingTransport{} + + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0o755)) + actionPath := filepath.Join(dir, ".github", "actions", "local", "action.yml") + require.NoError(t, os.MkdirAll(filepath.Dir(actionPath), 0o755)) + require.NoError(t, os.WriteFile(actionPath, []byte(`name: Local +runs: + using: composite + steps: + - uses: actions/create-github-app-token@`+sha+` +`), 0o600)) + + workflowPath := filepath.Join(dir, ".github", "workflows", "ci.yml") + require.NoError(t, os.MkdirAll(filepath.Dir(workflowPath), 0o755)) + require.NoError(t, os.WriteFile(workflowPath, []byte(`name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: $/.github/actions/local +`), 0o600)) + lockPath := filepath.Join(dir, ".github", "workflows", "actions.lock") + require.NoError(t, os.WriteFile(lockPath, []byte(`version: 'v0.0.2' +workflows: + '.github/workflows/ci.yml': + - 'actions/create-github-app-token@`+sha+`' +dependencies: + 'actions/create-github-app-token@`+sha+`': + ref: 'v3.2.0' + commit: 'sha1-`+sha+`' + owner_id: 44036562 + repo_id: 642580244 +`), 0o600)) + t.Chdir(dir) + + _, _, err := runCommandWithHTTP(t, transport, filepath.Join(".github", "workflows", "ci.yml")) + require.NoError(t, err) + assert.Zero(t, transport.calls.Load()) + + action, err := os.ReadFile(actionPath) + require.NoError(t, err) + assert.Contains(t, string(action), "uses: actions/create-github-app-token@v3.2.0") + + lock, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Contains(t, string(lock), "'actions/create-github-app-token@v3.2.0'") + assert.NotContains(t, string(lock), "'actions/create-github-app-token@"+sha+"'") +} diff --git a/internal/pin/plan.go b/internal/pin/plan.go index aac509e9..a9588a7f 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -616,6 +616,8 @@ func verifiedEntries(inventory []checks.InventoryEntry, path string) []Entry { Ref: inv.Dep.Ref, SHA: inv.Dep.SHA, Resolution: Verified, + OnBranch: inv.Dep.Branch, + Tag: inv.Dep.Tag, Workflows: []string{path}, Direct: inv.Direct, RequiredBy: inv.Parents, @@ -650,6 +652,20 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption if opts.prevImpreciseNWO[strings.ToLower(e.NWO)] { continue } + if parserlock.IsFullSha(e.Ref) { + newRef := e.Tag + if newRef == "" { + newRef = e.OnBranch + } + if newRef == "" { + continue + } + oldRef := e.Ref + rewrites[e.NWO+"@"+oldRef] = e.NWO + "@" + newRef + e.Ref = newRef + e.AutoFixedRef = oldRef + continue + } // Only narrow refs that are already version-shaped but imprecise // (e.g. v4, v4.2). Non-version refs like `main`, `canary`, or // `releases/v4` are intentional choices — narrowing them could pick From a53fa5f1eff05f9efd1ea29014a15c192bb6b5eb Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 11:21:12 -0700 Subject: [PATCH 02/23] Lockfile: preserve transitive edges when rekeying SHA refs --- cmd/gh-actions-lock/selfrepository_test.go | 9 +++++++++ internal/lockfile/state.go | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/gh-actions-lock/selfrepository_test.go b/cmd/gh-actions-lock/selfrepository_test.go index 75a2a6fc..41cb376f 100644 --- a/cmd/gh-actions-lock/selfrepository_test.go +++ b/cmd/gh-actions-lock/selfrepository_test.go @@ -134,6 +134,13 @@ dependencies: commit: 'sha1-`+sha+`' owner_id: 44036562 repo_id: 642580244 + uses: + - 'actions/checkout@v4' + 'actions/checkout@v4': + ref: 'v4' + commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + owner_id: 44036562 + repo_id: 197814629 `), 0o600)) t.Chdir(dir) @@ -148,5 +155,7 @@ dependencies: lock, err := os.ReadFile(lockPath) require.NoError(t, err) assert.Contains(t, string(lock), "'actions/create-github-app-token@v3.2.0'") + assert.Contains(t, string(lock), "'actions/checkout@v4'") + assert.Contains(t, string(lock), "uses:\n - 'actions/checkout@v4'") assert.NotContains(t, string(lock), "'actions/create-github-app-token@"+sha+"'") } diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index 00f96ec3..96629b7b 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -406,7 +406,13 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen ref = d.Branch } } - if existing, ok := s.file.Dependencies[pinKey]; ok { + existing, ok := s.file.Dependencies[pinKey] + if !ok && !isSHARef(d.Ref) && isSHARef(d.SHA) { + shaPin := pin + shaPin.Ref = d.SHA + existing, ok = s.file.Dependencies[shaPin.String()] + } + if ok { if ref == "" { ref = existing.Ref } From 8d1e5e95861b3c90cc458dbd3e6404fccf38eed2 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 15:03:35 -0700 Subject: [PATCH 03/23] Pinning: refuse shared local rewrites in partial scans --- cmd/gh-actions-lock/selfrepository_test.go | 32 ++++++++++++++++------ internal/pin/plan.go | 24 ++++++++++++++++ internal/pipeline/checks/finding.go | 1 + internal/pipeline/checks/parsed.go | 1 + internal/pipeline/diagnose.go | 1 + internal/pipeline/parse.go | 1 + internal/pipeline/verify_local.go | 1 + 7 files changed, 52 insertions(+), 9 deletions(-) diff --git a/cmd/gh-actions-lock/selfrepository_test.go b/cmd/gh-actions-lock/selfrepository_test.go index 41cb376f..26931293 100644 --- a/cmd/gh-actions-lock/selfrepository_test.go +++ b/cmd/gh-actions-lock/selfrepository_test.go @@ -115,23 +115,27 @@ runs: workflowPath := filepath.Join(dir, ".github", "workflows", "ci.yml") require.NoError(t, os.MkdirAll(filepath.Dir(workflowPath), 0o755)) - require.NoError(t, os.WriteFile(workflowPath, []byte(`name: CI + workflow := []byte(`name: CI on: push jobs: build: runs-on: ubuntu-latest steps: - uses: $/.github/actions/local -`), 0o600)) +`) + require.NoError(t, os.WriteFile(workflowPath, workflow, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(workflowPath), "other.yml"), workflow, 0o600)) lockPath := filepath.Join(dir, ".github", "workflows", "actions.lock") - require.NoError(t, os.WriteFile(lockPath, []byte(`version: 'v0.0.2' + originalLock := []byte(`version: 'v0.0.2' workflows: '.github/workflows/ci.yml': - - 'actions/create-github-app-token@`+sha+`' + - 'actions/create-github-app-token@` + sha + `' + '.github/workflows/other.yml': + - 'actions/create-github-app-token@` + sha + `' dependencies: - 'actions/create-github-app-token@`+sha+`': + 'actions/create-github-app-token@` + sha + `': ref: 'v3.2.0' - commit: 'sha1-`+sha+`' + commit: 'sha1-` + sha + `' owner_id: 44036562 repo_id: 642580244 uses: @@ -141,18 +145,28 @@ dependencies: commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' owner_id: 44036562 repo_id: 197814629 -`), 0o600)) +`) + require.NoError(t, os.WriteFile(lockPath, originalLock, 0o600)) t.Chdir(dir) _, _, err := runCommandWithHTTP(t, transport, filepath.Join(".github", "workflows", "ci.yml")) + require.ErrorContains(t, err, "partial workflow scan") + action, readErr := os.ReadFile(actionPath) + require.NoError(t, readErr) + assert.Contains(t, string(action), "uses: actions/create-github-app-token@"+sha) + lock, readErr := os.ReadFile(lockPath) + require.NoError(t, readErr) + assert.Equal(t, originalLock, lock) + + _, _, err = runCommandWithHTTP(t, transport) require.NoError(t, err) assert.Zero(t, transport.calls.Load()) - action, err := os.ReadFile(actionPath) + action, err = os.ReadFile(actionPath) require.NoError(t, err) assert.Contains(t, string(action), "uses: actions/create-github-app-token@v3.2.0") - lock, err := os.ReadFile(lockPath) + lock, err = os.ReadFile(lockPath) require.NoError(t, err) assert.Contains(t, string(lock), "'actions/create-github-app-token@v3.2.0'") assert.Contains(t, string(lock), "'actions/checkout@v4'") diff --git a/internal/pin/plan.go b/internal/pin/plan.go index a9588a7f..24905ee8 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -47,6 +47,7 @@ type PlanOptions struct { // for these to respect the user's prior precision choice and avoid // creating duplicate dep entries at different ref granularities. prevImpreciseNWO map[string]bool + partialScan bool // OnProgress is called at each phase boundary with a human-readable // label (e.g. "Resolving actions/checkout"). Nil means no progress. @@ -80,6 +81,18 @@ func Plan(ctx context.Context, report *checks.Report, opts PlanOptions) (*Record if opts.prevImpreciseNWO == nil && opts.Store != nil { opts.prevImpreciseNWO = impreciseDirectNWOs(opts.Store) } + if opts.Store != nil { + scanned := make(map[string]bool, len(report.Workflows)) + for _, wr := range report.Workflows { + scanned[workflowfile.KeyFromPath(wr.Path)] = true + } + for _, key := range opts.Store.WorkflowKeys() { + if !scanned[key] { + opts.partialScan = true + break + } + } + } results := make([]planResult, len(report.Workflows)) var planErr error @@ -128,6 +141,17 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption rewriteRefs = wr.ActionRefs } rewriteRefKeys := actionRefKeys(rewriteRefs) + selfActionRefKeys := actionRefKeys(wr.SelfActionRefs) + if opts.partialScan { + // ponytail: refuse any local-action repair in a partial scan; track + // action-to-workflow ownership if this conservative boundary hurts. + for _, inv := range wr.Inventory { + key := strings.ToLower(inv.Dep.NWO) + "@" + inv.Dep.Ref + if parserlock.IsFullSha(inv.Dep.Ref) && (inv.Dep.Tag != "" || inv.Dep.Branch != "") && selfActionRefKeys[key] { + return planResult{}, fmt.Errorf("cannot rewrite %s in a shared local action during a partial workflow scan; scan all workflows", key) + } + } + } // Drop stale inventory entries so a re-pin converges: the orphan leaves // workflows[path] and Save's GC removes its dependencies[] entry. diff --git a/internal/pipeline/checks/finding.go b/internal/pipeline/checks/finding.go index 48c7f6af..db1fd80b 100644 --- a/internal/pipeline/checks/finding.go +++ b/internal/pipeline/checks/finding.go @@ -71,6 +71,7 @@ type WorkflowReport struct { RewriteRefs []parserlock.ActionRef // SelfActionFiles are in-repo action definition files reached via `$/…`. SelfActionFiles []string + SelfActionRefs []parserlock.ActionRef // Deps are the existing pinned dependencies (nil if not pinned). Deps []dep.Dependency // Inventory lists all dependencies with direct/transitive classification. diff --git a/internal/pipeline/checks/parsed.go b/internal/pipeline/checks/parsed.go index de73caa5..4ec30753 100644 --- a/internal/pipeline/checks/parsed.go +++ b/internal/pipeline/checks/parsed.go @@ -21,6 +21,7 @@ type ParsedWorkflow struct { // SelfActionFiles are the in-repo action definition files reached through // step-level `$/…` refs. They are rewritten alongside the workflow. SelfActionFiles []string + SelfActionRefs []parserlock.ActionRef LocalPaths []string SelfRepositoryRefs []string // SelfRepositoryRefErrs holds malformed `$/…@ref` values (the invalid form). diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index 337f0ffd..6226b9db 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -196,6 +196,7 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W wr.RewriteRefs = pw.Refs } wr.SelfActionFiles = pw.SelfActionFiles + wr.SelfActionRefs = pw.SelfActionRefs wr.ParseWarnings = pw.ParseWarnings hasTerminalFinding := false diff --git a/internal/pipeline/parse.go b/internal/pipeline/parse.go index ac9e7b1a..75b4eb4e 100644 --- a/internal/pipeline/parse.go +++ b/internal/pipeline/parse.go @@ -52,6 +52,7 @@ func ParseAll(paths []string, store *lockfile.State) []checks.ParsedWorkflow { // rewritten alongside the workflow, so narrowing stays in sync. pw.RewriteRefs = pw.Refs pw.SelfActionFiles = selfScan.ActionFiles + pw.SelfActionRefs = selfScan.Refs pw.LocalPaths = mergeStrings(scan.LocalPaths, selfScan.LocalPaths) pw.SelfRepositoryRefs = mergeStrings(scan.SelfRepositoryRefs, selfScan.SelfRepositoryRefs) pw.SelfRepositoryRefErrs = mergeStrings(scan.SelfRepositoryRefErrs, selfScan.SelfRepositoryRefErrs) diff --git a/internal/pipeline/verify_local.go b/internal/pipeline/verify_local.go index b9be0768..f92514b8 100644 --- a/internal/pipeline/verify_local.go +++ b/internal/pipeline/verify_local.go @@ -29,6 +29,7 @@ func VerifyLocalCoverage(parsed []checks.ParsedWorkflow, store *lockfile.State) return pw.Refs }(), SelfActionFiles: pw.SelfActionFiles, + SelfActionRefs: pw.SelfActionRefs, Deps: pw.ExistingDeps, } From 677cde289a170de356f17ace946098257d74c649 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 15:14:10 -0700 Subject: [PATCH 04/23] Pinning: tighten scoped SHA repair boundaries --- cmd/gh-actions-lock/run.go | 1 + cmd/gh-actions-lock/selfrepository_test.go | 34 +++++++++++++++++++--- internal/lockfile/state.go | 17 +++++++---- internal/pin/plan.go | 20 ++++--------- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 0b7a3d32..547d5882 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -380,6 +380,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) NoNarrow: opts.noNarrow, AcceptMoved: opts.acceptMoved, Relock: opts.relock, + PartialScan: !fullScan, }) endPlan() if planErr != nil { diff --git a/cmd/gh-actions-lock/selfrepository_test.go b/cmd/gh-actions-lock/selfrepository_test.go index 26931293..84eb3faf 100644 --- a/cmd/gh-actions-lock/selfrepository_test.go +++ b/cmd/gh-actions-lock/selfrepository_test.go @@ -124,14 +124,21 @@ jobs: - uses: $/.github/actions/local `) require.NoError(t, os.WriteFile(workflowPath, workflow, 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(workflowPath), "other.yml"), workflow, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(workflowPath), "other.yml"), []byte(`name: Other +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@v3.2.0 +`), 0o600)) lockPath := filepath.Join(dir, ".github", "workflows", "actions.lock") originalLock := []byte(`version: 'v0.0.2' workflows: '.github/workflows/ci.yml': - 'actions/create-github-app-token@` + sha + `' '.github/workflows/other.yml': - - 'actions/create-github-app-token@` + sha + `' + - 'actions/create-github-app-token@v3.2.0' dependencies: 'actions/create-github-app-token@` + sha + `': ref: 'v3.2.0' @@ -140,23 +147,40 @@ dependencies: repo_id: 642580244 uses: - 'actions/checkout@v4' + 'actions/create-github-app-token@v3.2.0': + ref: 'v3.2.0' + commit: 'sha1-` + sha + `' + owner_id: 44036562 + repo_id: 642580244 + uses: + - 'actions/setup-go@v5' 'actions/checkout@v4': ref: 'v4' commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' owner_id: 44036562 repo_id: 197814629 + 'actions/setup-go@v5': + ref: 'v5' + commit: 'sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + owner_id: 44036562 + repo_id: 485264523 `) require.NoError(t, os.WriteFile(lockPath, originalLock, 0o600)) t.Chdir(dir) - _, _, err := runCommandWithHTTP(t, transport, filepath.Join(".github", "workflows", "ci.yml")) + _, _, err := runCommandWithHTTP(t, transport, "--no-narrow", filepath.Join(".github", "workflows", "ci.yml")) + require.NoError(t, err) + lockAfterNoNarrow, err := os.ReadFile(lockPath) + require.NoError(t, err) + + _, _, err = runCommandWithHTTP(t, transport, filepath.Join(".github", "workflows", "ci.yml")) require.ErrorContains(t, err, "partial workflow scan") action, readErr := os.ReadFile(actionPath) require.NoError(t, readErr) assert.Contains(t, string(action), "uses: actions/create-github-app-token@"+sha) lock, readErr := os.ReadFile(lockPath) require.NoError(t, readErr) - assert.Equal(t, originalLock, lock) + assert.Equal(t, lockAfterNoNarrow, lock) _, _, err = runCommandWithHTTP(t, transport) require.NoError(t, err) @@ -170,6 +194,8 @@ dependencies: require.NoError(t, err) assert.Contains(t, string(lock), "'actions/create-github-app-token@v3.2.0'") assert.Contains(t, string(lock), "'actions/checkout@v4'") + assert.Contains(t, string(lock), "'actions/setup-go@v5'") assert.Contains(t, string(lock), "uses:\n - 'actions/checkout@v4'") + assert.Contains(t, string(lock), "- 'actions/setup-go@v5'") assert.NotContains(t, string(lock), "'actions/create-github-app-token@"+sha+"'") } diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index 96629b7b..468fc379 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -407,11 +407,6 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen } } existing, ok := s.file.Dependencies[pinKey] - if !ok && !isSHARef(d.Ref) && isSHARef(d.SHA) { - shaPin := pin - shaPin.Ref = d.SHA - existing, ok = s.file.Dependencies[shaPin.String()] - } if ok { if ref == "" { ref = existing.Ref @@ -420,6 +415,18 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen usesSet[u] = true } } + if !isSHARef(d.Ref) && isSHARef(d.SHA) { + shaPin := pin + shaPin.Ref = d.SHA + if shaExisting, shaOK := s.file.Dependencies[shaPin.String()]; shaOK { + if !ok && ref == "" { + ref = shaExisting.Ref + } + for _, u := range shaExisting.Uses { + usesSet[u] = true + } + } + } var uses []string if len(usesSet) > 0 { uses = make([]string, 0, len(usesSet)) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 24905ee8..13e36458 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -41,13 +41,16 @@ type PlanOptions struct { // findings untouched so possible tampering stays a hard error. Relock bool + // PartialScan reports that explicit workflow paths, rather than the full + // workflow directory, define this run's mutation authority. + PartialScan 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 // for these to respect the user's prior precision choice and avoid // creating duplicate dep entries at different ref granularities. prevImpreciseNWO map[string]bool - partialScan bool // OnProgress is called at each phase boundary with a human-readable // label (e.g. "Resolving actions/checkout"). Nil means no progress. @@ -81,19 +84,6 @@ func Plan(ctx context.Context, report *checks.Report, opts PlanOptions) (*Record if opts.prevImpreciseNWO == nil && opts.Store != nil { opts.prevImpreciseNWO = impreciseDirectNWOs(opts.Store) } - if opts.Store != nil { - scanned := make(map[string]bool, len(report.Workflows)) - for _, wr := range report.Workflows { - scanned[workflowfile.KeyFromPath(wr.Path)] = true - } - for _, key := range opts.Store.WorkflowKeys() { - if !scanned[key] { - opts.partialScan = true - break - } - } - } - results := make([]planResult, len(report.Workflows)) var planErr error poolErr := pinpool.RunTyped(opts.Pool, ctx, "Planning pins", @@ -142,7 +132,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption } rewriteRefKeys := actionRefKeys(rewriteRefs) selfActionRefKeys := actionRefKeys(wr.SelfActionRefs) - if opts.partialScan { + if opts.PartialScan && !opts.NoNarrow && opts.Tagger != nil { // ponytail: refuse any local-action repair in a partial scan; track // action-to-workflow ownership if this conservative boundary hurts. for _, inv := range wr.Inventory { From ee4ea9002a1d2df11f3d49c233090485e6a267bd Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 31 Aug 2026 15:16:09 -0700 Subject: [PATCH 05/23] Pipeline: document local action refs --- internal/pipeline/checks/finding.go | 3 ++- internal/pipeline/checks/parsed.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/pipeline/checks/finding.go b/internal/pipeline/checks/finding.go index db1fd80b..c499d45b 100644 --- a/internal/pipeline/checks/finding.go +++ b/internal/pipeline/checks/finding.go @@ -71,7 +71,8 @@ type WorkflowReport struct { RewriteRefs []parserlock.ActionRef // SelfActionFiles are in-repo action definition files reached via `$/…`. SelfActionFiles []string - SelfActionRefs []parserlock.ActionRef + // SelfActionRefs are the remote refs found specifically inside SelfActionFiles. + SelfActionRefs []parserlock.ActionRef // Deps are the existing pinned dependencies (nil if not pinned). Deps []dep.Dependency // Inventory lists all dependencies with direct/transitive classification. diff --git a/internal/pipeline/checks/parsed.go b/internal/pipeline/checks/parsed.go index 4ec30753..b25feb6d 100644 --- a/internal/pipeline/checks/parsed.go +++ b/internal/pipeline/checks/parsed.go @@ -20,7 +20,8 @@ type ParsedWorkflow struct { RewriteRefs []parserlock.ActionRef // SelfActionFiles are the in-repo action definition files reached through // step-level `$/…` refs. They are rewritten alongside the workflow. - SelfActionFiles []string + SelfActionFiles []string + // SelfActionRefs are the remote refs found specifically inside SelfActionFiles. SelfActionRefs []parserlock.ActionRef LocalPaths []string SelfRepositoryRefs []string From 00a9c1a85822cf663b9635c764f32a6c570b14d4 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 07:21:15 -0700 Subject: [PATCH 06/23] Pinning: repair SHA metadata before sticky precision --- cmd/gh-actions-lock/pin_summary.go | 5 ++--- cmd/gh-actions-lock/pin_summary_test.go | 18 +++++++++++++++++ internal/pin/plan.go | 20 +++++++++--------- internal/pin/plan_test.go | 27 +++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index c7f9bc28..cdd8375e 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -176,10 +176,9 @@ func renderCooldownFindings(console *ui.UI, report *checks.Report) { } } -// renderNarrowedEntries shows refs that were upgraded from mutable (main, v4) -// to full semver (v6.0.2) on already-pinned workflows. +// renderNarrowedEntries shows refs updated on already-pinned workflows. func renderNarrowedEntries(console *ui.UI, narrowed []pin.Entry) { - console.TermSuccess("Narrowed %d %s to full semver", + console.TermSuccess("Updated %d %s", len(narrowed), ui.Pluralize(len(narrowed), "ref", "refs")) for _, e := range narrowed { console.TermDetail(" %s@%s → %s", e.NWO, e.AutoFixedRef, e.Ref) diff --git a/cmd/gh-actions-lock/pin_summary_test.go b/cmd/gh-actions-lock/pin_summary_test.go index 836383df..8e257bbb 100644 --- a/cmd/gh-actions-lock/pin_summary_test.go +++ b/cmd/gh-actions-lock/pin_summary_test.go @@ -11,6 +11,7 @@ import ( "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" + "github.com/stretchr/testify/assert" ) // A workflow shared between two different actions must still be listed under @@ -39,6 +40,23 @@ func TestRenderPinnedEntries_WorkflowSharedAcrossActions(t *testing.T) { } } +func TestRenderNarrowedEntries_BranchRepair(t *testing.T) { + var buf bytes.Buffer + console := ui.NewPlain(&buf) + + renderNarrowedEntries(console, []pin.Entry{{ + NWO: "owner/action", + Ref: "main", + AutoFixedRef: strings.Repeat("a", 40), + }}) + + out := buf.String() + assert.Contains(t, out, "Updated 1 ref") + assert.NotContains(t, out, "semver") + assert.Contains(t, out, "owner/action@aaaaaaaa") + assert.Contains(t, out, "→ main") +} + func TestReportHasUnfixableErrors_ClassifiesWorkflowNotPinned(t *testing.T) { for _, tt := range []struct { name string diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 13e36458..b2618a0e 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -656,16 +656,6 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption if !rewriteRefKeys[strings.ToLower(e.NWO)+"@"+e.Ref] { continue } - owner, repo := splitNWO(e.NWO) - 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 - } if parserlock.IsFullSha(e.Ref) { newRef := e.Tag if newRef == "" { @@ -680,6 +670,16 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption e.AutoFixedRef = oldRef continue } + owner, repo := splitNWO(e.NWO) + 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 + } // Only narrow refs that are already version-shaped but imprecise // (e.g. v4, v4.2). Non-version refs like `main`, `canary`, or // `releases/v4` are intentional choices — narrowing them could pick diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index 2c3955f5..b96aa8e3 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -297,6 +297,33 @@ func TestNarrowVerifiedEntries_StickyPrecision(t *testing.T) { assert.Empty(t, result.wplans[0].Rewrites, "no workflow rewrite for a sticky entry") }) + t.Run("sticky sibling does not suppress SHA metadata repair", func(t *testing.T) { + tagger, _ := newTagger(t) + report := fastPathReport(sha) + report.Inventory[0].Dep.Tag = "v4.2.1" + report.ActionRefs = []parserlock.ActionRef{{ + Owner: "actions", + Repo: "checkout", + Ref: sha, + }} + opts := PlanOptions{ + Tagger: tagger, + prevImpreciseNWO: map[string]bool{"actions/checkout": true}, + } + + result, err := planWorkflow(context.Background(), report, opts, func(string) {}) + require.NoError(t, err) + + require.Len(t, result.entries, 1) + assert.Equal(t, "v4.2.1", result.entries[0].Ref) + assert.Equal(t, sha, result.entries[0].AutoFixedRef) + require.Len(t, result.wplans, 1) + assert.Equal(t, + map[string]string{"actions/checkout@" + sha: "actions/checkout@v4.2.1"}, + result.wplans[0].Rewrites, + ) + }) + t.Run("branch ref main is NOT narrowed", func(t *testing.T) { // main is not version-shaped, so narrowing must not touch it. // Non-version refs are intentional choices (e.g. vercel/next.js@canary). From 33eaa962e2bc3f10892ac6491525326e2e6e14ec Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 07:22:56 -0700 Subject: [PATCH 07/23] CI: retry CodeQL analysis From 4e3742a32f2835d6a72d1492602dad36755ce122 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 07:58:53 -0700 Subject: [PATCH 08/23] Pinning: preserve locks for terminal workflow errors --- cmd/gh-actions-lock/migrate_test.go | 39 +++++++++++++++++++++++++++++ internal/pipeline/diagnose.go | 8 ++++++ 2 files changed, 47 insertions(+) diff --git a/cmd/gh-actions-lock/migrate_test.go b/cmd/gh-actions-lock/migrate_test.go index fe1028ef..df73e980 100644 --- a/cmd/gh-actions-lock/migrate_test.go +++ b/cmd/gh-actions-lock/migrate_test.go @@ -267,3 +267,42 @@ jobs: assert.NotContains(t, got, "$/", "opt-out means no migration to $/") }) } + +func TestNoMigrateLocalActions_PreservesExistingPins(t *testing.T) { + const workflow = `name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/foo + - uses: actions/checkout@v4 +` + const lock = `version: 'v0.0.2' +dependencies: + 'actions/checkout@v4': + ref: 'v4' + commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + owner_id: 1 + repo_id: 1 +workflows: + '.github/workflows/ci.yml': + - 'actions/checkout@v4' +` + + dir := t.TempDir() + t.Chdir(dir) + workflowPath := filepath.Join(".github", "workflows", "ci.yml") + require.NoError(t, os.MkdirAll(filepath.Dir(workflowPath), 0o755)) + require.NoError(t, os.WriteFile(workflowPath, []byte(workflow), 0o600)) + lockPath := filepath.Join(".github", "workflows", "actions.lock") + require.NoError(t, os.WriteFile(lockPath, []byte(lock), 0o600)) + + transport := &requestCountingTransport{} + _, _, err := runCommandWithHTTP(t, transport, "--rescan", "--no-migrate-local-actions") + require.Error(t, err) + got, readErr := os.ReadFile(lockPath) + require.NoError(t, readErr) + assert.Equal(t, 2, strings.Count(string(got), "'actions/checkout@v4'")) + assert.NotContains(t, string(got), "'.github/workflows/ci.yml': []") +} diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index 6226b9db..19eec086 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -249,6 +249,14 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W } if hasTerminalFinding { + wr.Deps = pw.ExistingDeps + for _, d := range pw.ExistingDeps { + wr.Inventory = append(wr.Inventory, checks.InventoryEntry{ + Dep: d, + File: pw.Path, + Direct: true, + }) + } return wr, true } From 4e1e8f51286633580a3b41d7c9edafa93749acd6 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 07:59:16 -0700 Subject: [PATCH 09/23] CI: retry CodeQL analysis From 3048945e8ab387966507cc6890e3416bd702e88b Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 08:08:20 -0700 Subject: [PATCH 10/23] Lockfile: preserve annotated tag rekey edges --- internal/dep/dependency.go | 2 ++ internal/lockfile/state.go | 8 +++-- internal/pin/commit.go | 11 +++---- internal/pin/commit_test.go | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/internal/dep/dependency.go b/internal/dep/dependency.go index 86dc7f03..6fd82fb1 100644 --- a/internal/dep/dependency.go +++ b/internal/dep/dependency.go @@ -27,6 +27,8 @@ type Dependency struct { Ref string // resolved ref as given in uses: SHA string // full commit hash HashAlgo string // "sha1" or "sha256" + // OriginalRef is the lockfile key ref before an in-memory rewrite. + OriginalRef string // 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 diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index 468fc379..c83b8a80 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -415,9 +415,13 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen usesSet[u] = true } } - if !isSHARef(d.Ref) && isSHARef(d.SHA) { + oldRef := d.OriginalRef + if oldRef == "" { + oldRef = d.SHA + } + if !isSHARef(d.Ref) && isSHARef(oldRef) { shaPin := pin - shaPin.Ref = d.SHA + shaPin.Ref = oldRef if shaExisting, shaOK := s.file.Dependencies[shaPin.String()]; shaOK { if !ok && ref == "" { ref = shaExisting.Ref diff --git a/internal/pin/commit.go b/internal/pin/commit.go index 6dcc3eef..9d48eece 100644 --- a/internal/pin/commit.go +++ b/internal/pin/commit.go @@ -156,11 +156,12 @@ func groupPinnedByWorkflow(rec *Record) map[string][]dep.Dependency { } for _, wf := range e.Workflows { result[wf] = append(result[wf], dep.Dependency{ - NWO: e.NWO, - Ref: e.Ref, - SHA: e.SHA, - Branch: e.OnBranch, - Tag: e.Tag, + NWO: e.NWO, + Ref: e.Ref, + SHA: e.SHA, + OriginalRef: e.AutoFixedRef, + Branch: e.OnBranch, + Tag: e.Tag, }) } } diff --git a/internal/pin/commit_test.go b/internal/pin/commit_test.go index 9abe31bf..e2c23abd 100644 --- a/internal/pin/commit_test.go +++ b/internal/pin/commit_test.go @@ -142,6 +142,7 @@ jobs: }}, Workflows: []WorkflowPlan{{Path: workflowPath}}, } + require.NoError(t, Commit(context.Background(), rec, store, nil)) got, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", "actions.lock")) @@ -150,3 +151,60 @@ jobs: assert.NotContains(t, string(got), "owner/action@main") assert.NotContains(t, string(got), "actions/setup-go@v5") } + +func TestCommitRekeysAnnotatedTagObjectWithUses(t *testing.T) { + const tagObjectSHA = "1111111111111111111111111111111111111111" + const commitSHA = "2222222222222222222222222222222222222222" + workflowPath := filepath.Join(".github", "workflows", "ci.yml") + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(workflowPath)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, workflowPath), []byte(`on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: owner/action@`+tagObjectSHA+` +`), 0o644)) + t.Chdir(dir) + + store, err := lockfile.LoadState(dir, fakeMeta{}) + require.NoError(t, err) + parent := dep.Dependency{ + NWO: "owner/action", Ref: tagObjectSHA, SHA: tagObjectSHA, + HashAlgo: "sha1", Tag: "v3.2.0", + } + child := dep.Dependency{ + NWO: "actions/checkout", Ref: "v4", SHA: strings.Repeat("3", 40), + HashAlgo: "sha1", + } + require.NoError(t, store.Set(context.Background(), workflowPath, + []dep.Dependency{parent, child}, + map[string][]string{child.Key(): {parent.Key()}}, + map[string]bool{parent.Key(): true})) + require.NoError(t, store.Save()) + + rec := &Record{ + Entries: []Entry{{ + NWO: parent.NWO, + Ref: "v3.2.0", + SHA: commitSHA, + Resolution: Verified, + AutoFixedRef: tagObjectSHA, + Direct: true, + Workflows: []string{workflowPath}, + }}, + Workflows: []WorkflowPlan{{ + Path: workflowPath, + Rewrites: map[string]string{ + parent.NWO + "@" + tagObjectSHA: parent.NWO + "@v3.2.0", + }, + }}, + } + require.NoError(t, Commit(context.Background(), rec, store, nil)) + + got, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", "actions.lock")) + require.NoError(t, err) + assert.Contains(t, string(got), "'owner/action@v3.2.0'") + assert.Contains(t, string(got), "uses:\n - 'actions/checkout@v4'") + assert.NotContains(t, string(got), "'owner/action@"+tagObjectSHA+"'") +} From 478cffd0ab1e18c39e77c78d54604a68704438b6 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 08:08:23 -0700 Subject: [PATCH 11/23] CI: retry CodeQL analysis From 07a137746cb16af29c4c027a4f2d85afd7cbfe02 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 08:17:24 -0700 Subject: [PATCH 12/23] Pinning: guard SHA metadata repairs --- internal/pin/plan.go | 31 +++++++++++++---- internal/pin/plan_test.go | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index b2618a0e..c5f0e7e3 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -130,7 +130,6 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption if rewriteRefs == nil { rewriteRefs = wr.ActionRefs } - rewriteRefKeys := actionRefKeys(rewriteRefs) selfActionRefKeys := actionRefKeys(wr.SelfActionRefs) if opts.PartialScan && !opts.NoNarrow && opts.Tagger != nil { // ponytail: refuse any local-action repair in a partial scan; track @@ -150,7 +149,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption if !wr.NeedsAttention() && !repinMoved { entries = verifiedEntries(inventory, wr.Path) - rw := narrowVerifiedEntries(ctx, entries, opts, rewriteRefKeys) + rw := narrowVerifiedEntries(ctx, entries, opts, rewriteRefs) wplans = append(wplans, WorkflowPlan{Path: wr.Path, Rewrites: rw, SelfActionFiles: wr.SelfActionFiles}) return planResult{entries: entries, wplans: wplans}, nil } @@ -184,7 +183,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption } if len(unrecordedRefs) == 0 { - rw := narrowVerifiedEntries(ctx, entries, opts, rewriteRefKeys) + rw := narrowVerifiedEntries(ctx, entries, opts, rewriteRefs) wplans = append(wplans, WorkflowPlan{Path: wr.Path, Rewrites: rw, SelfActionFiles: wr.SelfActionFiles}) return planResult{entries: entries, wplans: wplans}, nil } @@ -274,7 +273,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption // Record workflow plan if there are rewrites. // Also narrow any verified (already-recorded) entries that have imprecise refs. - if verifiedRW := narrowVerifiedEntries(ctx, entries, opts, rewriteRefKeys); len(verifiedRW) > 0 { + if verifiedRW := narrowVerifiedEntries(ctx, entries, opts, rewriteRefs); len(verifiedRW) > 0 { for k, v := range verifiedRW { rewrites[k] = v } @@ -643,10 +642,11 @@ func verifiedEntries(inventory []checks.InventoryEntry, path string) []Entry { // 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, rewriteRefKeys map[string]bool) map[string]string { +func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOptions, rewriteRefs []parserlock.ActionRef) map[string]string { if opts.NoNarrow || opts.Tagger == nil { return nil } + rewriteRefKeys := actionRefKeys(rewriteRefs) rewrites := make(map[string]string) for i := range entries { e := &entries[i] @@ -664,8 +664,15 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption if newRef == "" { continue } + if parserlock.IsFullSha(newRef) || hasConflictingLockTarget(opts.Store, e.NWO, newRef, e.SHA) { + continue + } oldRef := e.Ref - rewrites[e.NWO+"@"+oldRef] = e.NWO + "@" + newRef + for _, ref := range rewriteRefs { + if strings.EqualFold(ref.Owner+"/"+ref.Repo, e.NWO) && ref.Ref == oldRef { + rewrites[ref.FullName()+"@"+oldRef] = ref.FullName() + "@" + newRef + } + } e.Ref = newRef e.AutoFixedRef = oldRef continue @@ -716,6 +723,18 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption return rewrites } +func hasConflictingLockTarget(store *lockfile.State, nwo, ref, sha string) bool { + if store == nil { + return false + } + action, ok := store.File().Dependencies[nwo+"@"+ref] + if !ok { + return false + } + _, targetSHA, ok := strings.Cut(action.Commit, "-") + return ok && !strings.EqualFold(targetSHA, sha) +} + func actionRefKeys(refs []parserlock.ActionRef) map[string]bool { keys := make(map[string]bool, len(refs)) for _, ref := range refs { diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index b96aa8e3..aa0b3e3e 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -5,6 +5,7 @@ import ( "testing" "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" parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" @@ -324,6 +325,76 @@ func TestNarrowVerifiedEntries_StickyPrecision(t *testing.T) { ) }) + t.Run("SHA metadata does not rewrite to itself", func(t *testing.T) { + tagger, _ := newTagger(t) + report := fastPathReport(sha) + report.Inventory[0].Dep.Branch = sha + report.ActionRefs = []parserlock.ActionRef{{ + Owner: "actions", + Repo: "checkout", + Ref: sha, + }} + + result, err := planWorkflow(context.Background(), report, PlanOptions{Tagger: tagger}, func(string) {}) + require.NoError(t, err) + + require.Len(t, result.entries, 1) + assert.Equal(t, sha, result.entries[0].Ref) + assert.Empty(t, result.entries[0].AutoFixedRef) + assert.Empty(t, result.wplans[0].Rewrites) + }) + + t.Run("repair preserves source NWO spelling", func(t *testing.T) { + tagger, _ := newTagger(t) + report := fastPathReport(sha) + report.Inventory[0].Dep.Tag = "v4.2.1" + report.ActionRefs = []parserlock.ActionRef{{ + Owner: "Actions", + Repo: "Checkout", + Ref: sha, + }} + + result, err := planWorkflow(context.Background(), report, PlanOptions{Tagger: tagger}, func(string) {}) + require.NoError(t, err) + + require.Len(t, result.entries, 1) + assert.Equal(t, "v4.2.1", result.entries[0].Ref) + assert.Equal(t, + map[string]string{"Actions/Checkout@" + sha: "Actions/Checkout@v4.2.1"}, + result.wplans[0].Rewrites, + ) + }) + + t.Run("repair declines conflicting symbolic target", func(t *testing.T) { + tagger, _ := newTagger(t) + store, err := lockfile.LoadState(t.TempDir(), fakeMeta{}) + require.NoError(t, err) + target := dep.Dependency{ + NWO: "actions/checkout", + Ref: "v4.2.1", + SHA: "def4560000000000000000000000000000000000", + HashAlgo: "sha1", + } + require.NoError(t, store.Set(context.Background(), "other.yml", + []dep.Dependency{target}, nil, map[string]bool{target.Key(): true})) + + report := fastPathReport(sha) + report.Inventory[0].Dep.Tag = target.Ref + report.ActionRefs = []parserlock.ActionRef{{ + Owner: "actions", + Repo: "checkout", + Ref: sha, + }} + + result, err := planWorkflow(context.Background(), report, PlanOptions{Tagger: tagger, Store: store}, func(string) {}) + require.NoError(t, err) + + require.Len(t, result.entries, 1) + assert.Equal(t, sha, result.entries[0].Ref) + assert.Empty(t, result.entries[0].AutoFixedRef) + assert.Empty(t, result.wplans[0].Rewrites) + }) + t.Run("branch ref main is NOT narrowed", func(t *testing.T) { // main is not version-shaped, so narrowing must not touch it. // Non-version refs are intentional choices (e.g. vercel/next.js@canary). From 19692444959ab66569843b6787ed0bb1124458d5 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 08:17:26 -0700 Subject: [PATCH 13/23] CI: retry CodeQL analysis From e93317eb49f10f98eec5e9c0c939c721b1b5c570 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:21:07 -0700 Subject: [PATCH 14/23] Pinning: harden partial scans and edge rekeys --- cmd/gh-actions-lock/migrate_test.go | 4 +-- internal/lockfile/state.go | 3 --- internal/lockfile/state_test.go | 41 +++++++++++++++++++++++++++++ internal/pin/plan.go | 39 ++++++++++++++++++--------- internal/pin/plan_test.go | 14 ++++++++++ 5 files changed, 83 insertions(+), 18 deletions(-) diff --git a/cmd/gh-actions-lock/migrate_test.go b/cmd/gh-actions-lock/migrate_test.go index df73e980..5026c6d4 100644 --- a/cmd/gh-actions-lock/migrate_test.go +++ b/cmd/gh-actions-lock/migrate_test.go @@ -173,9 +173,7 @@ jobs: t.Chdir(dir) - _, _, err := runCommandWithHTTP(t, reg, - ".github/workflows/workflow.yml", - ) + _, _, err := runCommandWithHTTP(t, reg) require.NoError(t, err) read := func(rel string) string { diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index c83b8a80..93860cff 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -416,9 +416,6 @@ func (s *State) Set(ctx context.Context, workflowKey string, deps []dep.Dependen } } oldRef := d.OriginalRef - if oldRef == "" { - oldRef = d.SHA - } if !isSHARef(d.Ref) && isSHARef(oldRef) { shaPin := pin shaPin.Ref = oldRef diff --git a/internal/lockfile/state_test.go b/internal/lockfile/state_test.go index 39630d3d..af7b70f4 100644 --- a/internal/lockfile/state_test.go +++ b/internal/lockfile/state_test.go @@ -111,6 +111,47 @@ func actionKeys[V any](m map[string]V) []string { return keys } +func TestState_DoesNotMergeImplicitSHAEdges(t *testing.T) { + dir := t.TempDir() + store, err := LoadState(dir, fakeMetadataResolver{}) + if err != nil { + t.Fatal(err) + } + + const sha = "1111111111111111111111111111111111111111" + symbolic := dep.Dependency{NWO: "owner/action", Ref: "v1", SHA: sha, HashAlgo: "sha1"} + shaParent := dep.Dependency{NWO: "owner/action", Ref: sha, SHA: sha, HashAlgo: "sha1"} + child := dep.Dependency{ + NWO: "actions/checkout", Ref: "v4", + SHA: "2222222222222222222222222222222222222222", HashAlgo: "sha1", + } + ctx := context.Background() + if err := store.Set(ctx, "symbolic.yml", []dep.Dependency{symbolic}, nil, map[string]bool{symbolic.Key(): true}); err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, "sha.yml", []dep.Dependency{shaParent, child}, + map[string][]string{child.Key(): {shaParent.Key()}}, + map[string]bool{shaParent.Key(): true}); err != nil { + t.Fatal(err) + } + + if err := store.Set(ctx, "symbolic.yml", []dep.Dependency{symbolic}, nil, map[string]bool{symbolic.Key(): true}); err != nil { + t.Fatal(err) + } + store.PruneWorkflows(map[string]bool{"symbolic.yml": true}) + if err := store.Save(); err != nil { + t.Fatal(err) + } + + action := store.file.Dependencies["owner/action@v1"] + if len(action.Uses) != 0 { + t.Fatalf("symbolic dependency retained unrelated SHA edges: %v", action.Uses) + } + if _, ok := store.file.Dependencies[child.Key()]; ok { + t.Fatalf("stale child %q was not garbage-collected", child.Key()) + } +} + // TestState_SetAcceptsEmptyBranch verifies that deps with no discovered // branch are accepted — the new schema makes ref optional. func TestState_SetAcceptsEmptyBranch(t *testing.T) { diff --git a/internal/pin/plan.go b/internal/pin/plan.go index c5f0e7e3..f0dcb6e2 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -130,18 +130,6 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption if rewriteRefs == nil { rewriteRefs = wr.ActionRefs } - selfActionRefKeys := actionRefKeys(wr.SelfActionRefs) - if opts.PartialScan && !opts.NoNarrow && opts.Tagger != nil { - // ponytail: refuse any local-action repair in a partial scan; track - // action-to-workflow ownership if this conservative boundary hurts. - for _, inv := range wr.Inventory { - key := strings.ToLower(inv.Dep.NWO) + "@" + inv.Dep.Ref - if parserlock.IsFullSha(inv.Dep.Ref) && (inv.Dep.Tag != "" || inv.Dep.Branch != "") && selfActionRefKeys[key] { - return planResult{}, fmt.Errorf("cannot rewrite %s in a shared local action during a partial workflow scan; scan all workflows", key) - } - } - } - // 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, opts.AcceptMoved, opts.Relock) @@ -150,6 +138,9 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption if !wr.NeedsAttention() && !repinMoved { entries = verifiedEntries(inventory, wr.Path) rw := narrowVerifiedEntries(ctx, entries, opts, rewriteRefs) + if err := rejectPartialSelfActionRewrites(opts, wr.SelfActionRefs, rw); err != nil { + return planResult{}, err + } wplans = append(wplans, WorkflowPlan{Path: wr.Path, Rewrites: rw, SelfActionFiles: wr.SelfActionFiles}) return planResult{entries: entries, wplans: wplans}, nil } @@ -184,6 +175,9 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption if len(unrecordedRefs) == 0 { rw := narrowVerifiedEntries(ctx, entries, opts, rewriteRefs) + if err := rejectPartialSelfActionRewrites(opts, wr.SelfActionRefs, rw); err != nil { + return planResult{}, err + } wplans = append(wplans, WorkflowPlan{Path: wr.Path, Rewrites: rw, SelfActionFiles: wr.SelfActionFiles}) return planResult{entries: entries, wplans: wplans}, nil } @@ -278,6 +272,9 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption rewrites[k] = v } } + if err := rejectPartialSelfActionRewrites(opts, wr.SelfActionRefs, rewrites); err != nil { + return planResult{}, err + } if len(rewrites) > 0 { wplans = append(wplans, WorkflowPlan{ Path: wr.Path, @@ -299,6 +296,24 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption return planResult{entries: entries, wplans: wplans}, nil } +func rejectPartialSelfActionRewrites(opts PlanOptions, selfActionRefs []parserlock.ActionRef, rewrites map[string]string) error { + if !opts.PartialScan || len(rewrites) == 0 { + return nil + } + selfKeys := actionRefKeys(selfActionRefs) + for oldUses := range rewrites { + ref := parserlock.ParseActionRef(oldUses) + if ref == nil { + continue + } + key := strings.ToLower(ref.Owner+"/"+ref.Repo) + "@" + ref.Ref + if selfKeys[key] { + return fmt.Errorf("cannot rewrite %s in a shared local action during a partial workflow scan; scan all workflows", key) + } + } + return 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. diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index aa0b3e3e..66d80955 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -593,6 +593,20 @@ func TestNoNarrow_BareSHA(t *testing.T) { "actions/checkout@"+sha, "rewrite map should record the original SHA ref") }) + + t.Run("partial scan rejects unrecorded shared action rewrite", func(t *testing.T) { + resolver, tagger, wr, _ := newSlowPathFixtures(t, false) + wr.SelfActionRefs = append([]parserlock.ActionRef(nil), wr.ActionRefs...) + + _, err := planWorkflow(context.Background(), wr, PlanOptions{ + Resolver: resolver, + Tagger: tagger, + Pool: pinpool.New(2, nil), + PartialScan: true, + }, func(string) {}) + + require.ErrorContains(t, err, "shared local action during a partial workflow scan") + }) } // TestPlanWorkflow_CrossRefTransitiveClosure verifies that a composite at From f35e8d1b70be728c1b1cf2d6daa5c6ca8dc077d9 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:21:10 -0700 Subject: [PATCH 15/23] CI: retry CodeQL analysis From 2653e9e8d0288d81457fb683a7860fe93dd3ed27 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:27:47 -0700 Subject: [PATCH 16/23] Pinning: retain local-only workflow locks on errors --- cmd/gh-actions-lock/migrate_test.go | 47 +++++++++++++++++++---------- internal/pipeline/parse.go | 5 ++- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/cmd/gh-actions-lock/migrate_test.go b/cmd/gh-actions-lock/migrate_test.go index 5026c6d4..e7817d29 100644 --- a/cmd/gh-actions-lock/migrate_test.go +++ b/cmd/gh-actions-lock/migrate_test.go @@ -267,7 +267,7 @@ jobs: } func TestNoMigrateLocalActions_PreservesExistingPins(t *testing.T) { - const workflow = `name: CI + const workflowWithRemoteAction = `name: CI on: push jobs: build: @@ -275,6 +275,14 @@ jobs: steps: - uses: ./.github/actions/foo - uses: actions/checkout@v4 +` + const workflowWithOnlyLocalAction = `name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/foo ` const lock = `version: 'v0.0.2' dependencies: @@ -288,19 +296,26 @@ workflows: - 'actions/checkout@v4' ` - dir := t.TempDir() - t.Chdir(dir) - workflowPath := filepath.Join(".github", "workflows", "ci.yml") - require.NoError(t, os.MkdirAll(filepath.Dir(workflowPath), 0o755)) - require.NoError(t, os.WriteFile(workflowPath, []byte(workflow), 0o600)) - lockPath := filepath.Join(".github", "workflows", "actions.lock") - require.NoError(t, os.WriteFile(lockPath, []byte(lock), 0o600)) - - transport := &requestCountingTransport{} - _, _, err := runCommandWithHTTP(t, transport, "--rescan", "--no-migrate-local-actions") - require.Error(t, err) - got, readErr := os.ReadFile(lockPath) - require.NoError(t, readErr) - assert.Equal(t, 2, strings.Count(string(got), "'actions/checkout@v4'")) - assert.NotContains(t, string(got), "'.github/workflows/ci.yml': []") + for name, workflow := range map[string]string{ + "remote and local actions": workflowWithRemoteAction, + "only local action": workflowWithOnlyLocalAction, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + workflowPath := filepath.Join(".github", "workflows", "ci.yml") + require.NoError(t, os.MkdirAll(filepath.Dir(workflowPath), 0o755)) + require.NoError(t, os.WriteFile(workflowPath, []byte(workflow), 0o600)) + lockPath := filepath.Join(".github", "workflows", "actions.lock") + require.NoError(t, os.WriteFile(lockPath, []byte(lock), 0o600)) + + transport := &requestCountingTransport{} + _, _, err := runCommandWithHTTP(t, transport, "--rescan", "--no-migrate-local-actions") + require.Error(t, err) + got, readErr := os.ReadFile(lockPath) + require.NoError(t, readErr) + assert.Equal(t, 2, strings.Count(string(got), "'actions/checkout@v4'")) + assert.NotContains(t, string(got), "'.github/workflows/ci.yml': []") + }) + } } diff --git a/internal/pipeline/parse.go b/internal/pipeline/parse.go index 75b4eb4e..d5342dee 100644 --- a/internal/pipeline/parse.go +++ b/internal/pipeline/parse.go @@ -58,7 +58,10 @@ func ParseAll(paths []string, store *lockfile.State) []checks.ParsedWorkflow { pw.SelfRepositoryRefErrs = mergeStrings(scan.SelfRepositoryRefErrs, selfScan.SelfRepositoryRefErrs) pw.SelfRepositoryResolutionErrs = selfScan.Errors pw.ParseWarnings = append(scan.Warnings, selfScan.Warnings...) - if len(pw.Refs) > 0 && store != nil { + hasTerminalRefs := len(pw.LocalPaths) > 0 || + len(pw.SelfRepositoryRefErrs) > 0 || + len(pw.SelfRepositoryResolutionErrs) > 0 + if store != nil && (len(pw.Refs) > 0 || hasTerminalRefs) { wfKey := workflowfile.KeyFromPath(path) deps, depsErr := store.Get(wfKey) if depsErr != nil { From b7db7b229fcfa4d440e57be3add99c5dac4803ee Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:27:51 -0700 Subject: [PATCH 17/23] CI: retry CodeQL analysis From ac70e199bb28ff35f605fa819c61518d564693a5 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:35:54 -0700 Subject: [PATCH 18/23] Pinning: reject conflicting repairs and retain load failures --- cmd/gh-actions-lock/migrate_test.go | 1 + internal/pin/plan.go | 12 ++++++++++++ internal/pin/plan_test.go | 30 +++++++++++++++++++++++++++++ internal/pipeline/diagnose.go | 21 ++++++++++++-------- internal/pipeline/parse.go | 21 +++++++++----------- 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/cmd/gh-actions-lock/migrate_test.go b/cmd/gh-actions-lock/migrate_test.go index e7817d29..7237eb59 100644 --- a/cmd/gh-actions-lock/migrate_test.go +++ b/cmd/gh-actions-lock/migrate_test.go @@ -299,6 +299,7 @@ workflows: for name, workflow := range map[string]string{ "remote and local actions": workflowWithRemoteAction, "only local action": workflowWithOnlyLocalAction, + "malformed workflow": "name: [", } { t.Run(name, func(t *testing.T) { dir := t.TempDir() diff --git a/internal/pin/plan.go b/internal/pin/plan.go index f0dcb6e2..0b37708f 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -105,7 +105,19 @@ func Plan(ctx context.Context, report *checks.Report, opts PlanOptions) (*Record planErr = poolErr } + targetSHAs := make(map[string]string) for _, pr := range results { + for _, entry := range pr.entries { + if planErr != nil || entry.AutoFixedRef == "" { + continue + } + key := strings.ToLower(entry.NWO) + "@" + entry.Ref + if sha, ok := targetSHAs[key]; ok && !strings.EqualFold(sha, entry.SHA) { + planErr = fmt.Errorf("conflicting planned target %s resolves to both %s and %s", key, sha, entry.SHA) + continue + } + targetSHAs[key] = entry.SHA + } rec.Entries = append(rec.Entries, pr.entries...) rec.Workflows = append(rec.Workflows, pr.wplans...) } diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index 66d80955..c3dece2c 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -411,6 +411,36 @@ func TestNarrowVerifiedEntries_StickyPrecision(t *testing.T) { }) } +func TestPlanRejectsConflictingSameRunRepairs(t *testing.T) { + const firstSHA = "abc1230000000000000000000000000000000000" + const secondSHA = "def4560000000000000000000000000000000000" + report := func(path, sha string) checks.WorkflowReport { + return checks.WorkflowReport{ + Path: path, + ActionRefs: []parserlock.ActionRef{{ + Owner: "actions", Repo: "checkout", Ref: sha, + }}, + Inventory: []checks.InventoryEntry{{ + Dep: dep.Dependency{NWO: "actions/checkout", Ref: sha, SHA: sha, Tag: "v4.2.1"}, + File: path, + Direct: true, + }}, + } + } + + _, err := Plan(context.Background(), &checks.Report{ + Workflows: []checks.WorkflowReport{ + report(".github/workflows/first.yml", firstSHA), + report(".github/workflows/second.yml", secondSHA), + }, + }, PlanOptions{ + Tagger: new(tag.Lister), + Pool: pinpool.New(2, nil), + }) + + require.ErrorContains(t, err, "conflicting planned target actions/checkout@v4.2.1") +} + func TestPlanWorkflow_SelfRepositoryDependencyIsNotRewrittenOnFastPath(t *testing.T) { const sha = "abc1230000000000000000000000000000000000" diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index 19eec086..e2a0e643 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -187,6 +187,7 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W Detail: fmt.Sprintf("failed to load workflow: %s", pw.LoadErr), DocURL: DocURLFor(checks.NotPinned), }) + preserveExistingInventory(&wr, pw) return wr, true } @@ -249,14 +250,7 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W } if hasTerminalFinding { - wr.Deps = pw.ExistingDeps - for _, d := range pw.ExistingDeps { - wr.Inventory = append(wr.Inventory, checks.InventoryEntry{ - Dep: d, - File: pw.Path, - Direct: true, - }) - } + preserveExistingInventory(&wr, pw) return wr, true } @@ -292,6 +286,17 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W return wr, false } +func preserveExistingInventory(wr *checks.WorkflowReport, pw checks.ParsedWorkflow) { + wr.Deps = pw.ExistingDeps + for _, d := range pw.ExistingDeps { + wr.Inventory = append(wr.Inventory, checks.InventoryEntry{ + Dep: d, + File: pw.Path, + Direct: true, + }) + } +} + 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/parse.go b/internal/pipeline/parse.go index d5342dee..cae4b3f1 100644 --- a/internal/pipeline/parse.go +++ b/internal/pipeline/parse.go @@ -38,6 +38,15 @@ func ParseAll(paths []string, store *lockfile.State) []checks.ParsedWorkflow { out := make([]checks.ParsedWorkflow, 0, total) for _, path := range paths { pw := checks.ParsedWorkflow{Path: path} + if store != nil { + wfKey := workflowfile.KeyFromPath(path) + deps, depsErr := store.Get(wfKey) + if depsErr != nil { + pw.DepsErr = depsErr + } else { + pw.ExistingDeps = deps + } + } wf, err := workflowfile.Load(path) if err != nil { pw.LoadErr = err @@ -58,18 +67,6 @@ func ParseAll(paths []string, store *lockfile.State) []checks.ParsedWorkflow { pw.SelfRepositoryRefErrs = mergeStrings(scan.SelfRepositoryRefErrs, selfScan.SelfRepositoryRefErrs) pw.SelfRepositoryResolutionErrs = selfScan.Errors pw.ParseWarnings = append(scan.Warnings, selfScan.Warnings...) - hasTerminalRefs := len(pw.LocalPaths) > 0 || - len(pw.SelfRepositoryRefErrs) > 0 || - len(pw.SelfRepositoryResolutionErrs) > 0 - if store != nil && (len(pw.Refs) > 0 || hasTerminalRefs) { - wfKey := workflowfile.KeyFromPath(path) - deps, depsErr := store.Get(wfKey) - if depsErr != nil { - pw.DepsErr = depsErr - } else { - pw.ExistingDeps = deps - } - } out = append(out, pw) } return out From 43da52a22ba8983811eea6c379c3724aff4f63e4 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:35:57 -0700 Subject: [PATCH 19/23] CI: retry CodeQL analysis From 007150dd99ca623b50c202279073276f89d39f6f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:42:09 -0700 Subject: [PATCH 20/23] Pinning: validate all planned lock targets --- internal/pin/plan.go | 3 ++- internal/pin/plan_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 0b37708f..47fac609 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -108,7 +108,8 @@ func Plan(ctx context.Context, report *checks.Report, opts PlanOptions) (*Record targetSHAs := make(map[string]string) for _, pr := range results { for _, entry := range pr.entries { - if planErr != nil || entry.AutoFixedRef == "" { + if planErr != nil || entry.SHA == "" || + entry.Resolution != Pinned && entry.Resolution != Verified { continue } key := strings.ToLower(entry.NWO) + "@" + entry.Ref diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index c3dece2c..bb8213b5 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -441,6 +441,39 @@ func TestPlanRejectsConflictingSameRunRepairs(t *testing.T) { require.ErrorContains(t, err, "conflicting planned target actions/checkout@v4.2.1") } +func TestPlanRejectsRepairConflictingWithSymbolicEntry(t *testing.T) { + const repairedSHA = "abc1230000000000000000000000000000000000" + const symbolicSHA = "def4560000000000000000000000000000000000" + repair := checks.WorkflowReport{ + Path: ".github/workflows/repair.yml", + ActionRefs: []parserlock.ActionRef{{ + Owner: "actions", Repo: "checkout", Ref: repairedSHA, + }}, + Inventory: []checks.InventoryEntry{{ + Dep: dep.Dependency{NWO: "actions/checkout", Ref: repairedSHA, SHA: repairedSHA, Tag: "v4.2.1"}, + File: ".github/workflows/repair.yml", + Direct: true, + }}, + } + symbolic := checks.WorkflowReport{ + Path: ".github/workflows/symbolic.yml", + Inventory: []checks.InventoryEntry{{ + Dep: dep.Dependency{NWO: "actions/checkout", Ref: "v4.2.1", SHA: symbolicSHA}, + File: ".github/workflows/symbolic.yml", + Direct: true, + }}, + } + + _, err := Plan(context.Background(), &checks.Report{ + Workflows: []checks.WorkflowReport{repair, symbolic}, + }, PlanOptions{ + Tagger: new(tag.Lister), + Pool: pinpool.New(2, nil), + }) + + require.ErrorContains(t, err, "conflicting planned target actions/checkout@v4.2.1") +} + func TestPlanWorkflow_SelfRepositoryDependencyIsNotRewrittenOnFastPath(t *testing.T) { const sha = "abc1230000000000000000000000000000000000" From d6e3d1ae9748969428c2bd84d459f058147dae92 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:42:13 -0700 Subject: [PATCH 21/23] CI: retry CodeQL analysis From 4aaea42152b7c043f36164506049110306c65255 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:51:08 -0700 Subject: [PATCH 22/23] Pinning: exclude malformed workflows from commit --- cmd/gh-actions-lock/command_test.go | 6 +++- internal/pin/plan.go | 3 ++ internal/pin/plan_test.go | 45 +++++++++++++++++++++++++++++ internal/pipeline/checks/finding.go | 2 ++ internal/pipeline/diagnose.go | 1 + 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/cmd/gh-actions-lock/command_test.go b/cmd/gh-actions-lock/command_test.go index 11cc69a0..f50cc39f 100644 --- a/cmd/gh-actions-lock/command_test.go +++ b/cmd/gh-actions-lock/command_test.go @@ -1056,7 +1056,11 @@ func TestCheckCommand_LoadErrorFailsFixMode(t *testing.T) { args := append(tt.args, workflowPath) _, stderr, err := runCommandWithHTTP(t, reg, args...) require.Error(t, err) - assert.Contains(t, err.Error(), "parsing workflow YAML") + if tt.name == "migration enabled" { + assert.Contains(t, err.Error(), "parsing workflow YAML") + } else { + require.ErrorIs(t, err, errSilent) + } assert.NotContains(t, stderr, "All 1 workflow valid") }) } diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 47fac609..6287cb05 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -134,6 +134,9 @@ type planResult struct { func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOptions, status func(string)) (planResult, error) { var entries []Entry var wplans []WorkflowPlan + if wr.SkipCommit { + return planResult{entries: verifiedEntries(wr.Inventory, wr.Path)}, nil + } for _, finding := range wr.Findings { if finding.Category == checks.InvalidSelfRepositoryRef { return planResult{}, nil diff --git a/internal/pin/plan_test.go b/internal/pin/plan_test.go index bb8213b5..a290ab5e 100644 --- a/internal/pin/plan_test.go +++ b/internal/pin/plan_test.go @@ -344,6 +344,28 @@ func TestNarrowVerifiedEntries_StickyPrecision(t *testing.T) { assert.Empty(t, result.wplans[0].Rewrites) }) + t.Run("SHA metadata repairs to branch", func(t *testing.T) { + tagger, _ := newTagger(t) + report := fastPathReport(sha) + report.Inventory[0].Dep.Branch = "main" + report.ActionRefs = []parserlock.ActionRef{{ + Owner: "actions", + Repo: "checkout", + Ref: sha, + }} + + result, err := planWorkflow(context.Background(), report, PlanOptions{Tagger: tagger}, func(string) {}) + require.NoError(t, err) + + require.Len(t, result.entries, 1) + assert.Equal(t, "main", result.entries[0].Ref) + assert.Equal(t, sha, result.entries[0].AutoFixedRef) + assert.Equal(t, + map[string]string{"actions/checkout@" + sha: "actions/checkout@main"}, + result.wplans[0].Rewrites, + ) + }) + t.Run("repair preserves source NWO spelling", func(t *testing.T) { tagger, _ := newTagger(t) report := fastPathReport(sha) @@ -474,6 +496,29 @@ func TestPlanRejectsRepairConflictingWithSymbolicEntry(t *testing.T) { require.ErrorContains(t, err, "conflicting planned target actions/checkout@v4.2.1") } +func TestPlanExcludesLoadFailuresFromCommit(t *testing.T) { + const sha = "abc1230000000000000000000000000000000000" + blocked := checks.WorkflowReport{ + Path: ".github/workflows/broken.yml", + SkipCommit: true, + Inventory: []checks.InventoryEntry{{ + Dep: dep.Dependency{NWO: "actions/checkout", Ref: "v4", SHA: sha}, + File: ".github/workflows/broken.yml", + }}, + } + valid := checks.WorkflowReport{Path: ".github/workflows/valid.yml"} + + record, err := Plan(context.Background(), &checks.Report{ + Workflows: []checks.WorkflowReport{blocked, valid}, + }, PlanOptions{Pool: pinpool.New(2, nil)}) + require.NoError(t, err) + + require.Len(t, record.Workflows, 1) + assert.Equal(t, valid.Path, record.Workflows[0].Path) + require.Len(t, record.Entries, 1) + assert.Equal(t, blocked.Path, record.Entries[0].Workflows[0]) +} + func TestPlanWorkflow_SelfRepositoryDependencyIsNotRewrittenOnFastPath(t *testing.T) { const sha = "abc1230000000000000000000000000000000000" diff --git a/internal/pipeline/checks/finding.go b/internal/pipeline/checks/finding.go index c499d45b..0d82da92 100644 --- a/internal/pipeline/checks/finding.go +++ b/internal/pipeline/checks/finding.go @@ -63,6 +63,8 @@ type InventoryEntry struct { type WorkflowReport struct { Path string Findings []Finding + // SkipCommit prevents terminal parse failures from entering the write phase. + SkipCommit bool // ActionRefs are all remote dependency roots attributed to the workflow, // including refs found inside in-repo `$/…` actions. ActionRefs []parserlock.ActionRef diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index e2a0e643..ac715eba 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -178,6 +178,7 @@ func precheckWorkflow(pw checks.ParsedWorkflow, store *lockfile.State) (checks.W wr := checks.WorkflowReport{Path: pw.Path} if pw.LoadErr != nil { + wr.SkipCommit = true wr.Findings = append(wr.Findings, checks.Finding{ WorkflowPath: pw.Path, Category: checks.NotPinned, From dc1a5cf59bfd9e270d13092b46cf758e8713bfd6 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 1 Sep 2026 09:51:12 -0700 Subject: [PATCH 23/23] CI: retry CodeQL analysis