From a5104a0832e6a05cef7bca996555720592bba3f2 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 06:42:49 -0500 Subject: [PATCH 01/21] skip lockfile onboarding for workflows with local path actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflows that use local path actions (uses: ./some-path) are now bailed out of entirely — no lockfile entry is created for the workflow, even if it also has remote action refs. The diagnose phase emits a LocalAction warning and returns early. Previously local paths were silently discarded and only the remote refs were onboarded, which is wrong: we don't support local actions yet, so the lockfile would be incomplete and misleading. --- cmd/gh-actions-lock/format/json.go | 4 ++-- cmd/gh-actions-lock/format/terminal.go | 9 ++++++++- internal/pipeline/checks/category.go | 5 +++++ internal/pipeline/checks/category_test.go | 3 ++- internal/pipeline/checks/finding.go | 6 ++++-- internal/pipeline/checks/parsed.go | 1 + internal/pipeline/diagnose.go | 13 ++++++++++++- internal/pipeline/parse.go | 2 +- test/scenarios/catalog.yml | 2 +- 9 files changed, 36 insertions(+), 9 deletions(-) diff --git a/cmd/gh-actions-lock/format/json.go b/cmd/gh-actions-lock/format/json.go index 04ebdc35..9d5dd965 100644 --- a/cmd/gh-actions-lock/format/json.go +++ b/cmd/gh-actions-lock/format/json.go @@ -115,7 +115,7 @@ func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVer } for _, wr := range report.Workflows { for _, f := range wr.Findings { - if f.Category == checks.RunOnly || (f.Category == checks.Valid && f.Severity == checks.SeverityOK) { + if f.Category == checks.RunOnly || f.Category == checks.LocalAction || (f.Category == checks.Valid && f.Severity == checks.SeverityOK) { continue } allFindings = append(allFindings, findingFromReport(f)) @@ -185,7 +185,7 @@ func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVer Findings: []Finding{}, } for _, f := range wr.Findings { - if f.Category == checks.RunOnly || (f.Category == checks.Valid && f.Severity == checks.SeverityOK) { + if f.Category == checks.RunOnly || f.Category == checks.LocalAction || (f.Category == checks.Valid && f.Severity == checks.SeverityOK) { continue } wf.Findings = append(wf.Findings, findingFromReport(f)) diff --git a/cmd/gh-actions-lock/format/terminal.go b/cmd/gh-actions-lock/format/terminal.go index 7a33c646..ab5c33aa 100644 --- a/cmd/gh-actions-lock/format/terminal.go +++ b/cmd/gh-actions-lock/format/terminal.go @@ -187,11 +187,13 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) { } // Triage warnings into buckets. - var unpinnedWorkflows, bareSHADeps, otherDetailWarnings []string + var unpinnedWorkflows, localActionWorkflows, bareSHADeps, otherDetailWarnings []string for _, key := range warnOrder { wg := warnMap[key] f := wg.finding switch { + case f.Category == checks.LocalAction: + localActionWorkflows = append(localActionWorkflows, f.WorkflowPath) case f.Category == checks.NotPinned && f.ActionRef == nil: unpinnedWorkflows = append(unpinnedWorkflows, f.WorkflowPath) case f.Category == checks.ShaAsRef: @@ -214,6 +216,11 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) { } } + if len(localActionWorkflows) > 0 { + out.TermCaution("%d %s skipped — local path actions are not yet supported", + len(localActionWorkflows), + ui.Pluralize(len(localActionWorkflows), "workflow", "workflows")) + } if len(unpinnedWorkflows) > 0 { out.TermWarn("%d %s not yet pinned", len(unpinnedWorkflows), diff --git a/internal/pipeline/checks/category.go b/internal/pipeline/checks/category.go index beea8008..d2ee3a89 100644 --- a/internal/pipeline/checks/category.go +++ b/internal/pipeline/checks/category.go @@ -64,6 +64,11 @@ const ( // tags (v4.2.1) each resolve to exactly one commit, making the lock // comment durable across re-pins. VersionRef Category = "version-ref" + // LocalAction means the workflow uses at least one local path action + // (uses: ./some-path). Lockfile onboarding is not supported for + // workflows that reference local actions — the entire workflow is + // skipped. + LocalAction Category = "local-action" ) // IsInconclusive reports whether c represents a diagnostic that diff --git a/internal/pipeline/checks/category_test.go b/internal/pipeline/checks/category_test.go index 185361a3..441bcc5c 100644 --- a/internal/pipeline/checks/category_test.go +++ b/internal/pipeline/checks/category_test.go @@ -25,6 +25,7 @@ func TestCategoryStringsAreFrozen(t *testing.T) { {ReachabilityUnknown, "reachability-unknown"}, {OnboardingRequired, "onboarding-required"}, {VersionRef, "version-ref"}, + {LocalAction, "local-action"}, } for _, c := range cases { if string(c.got) != c.want { @@ -46,7 +47,7 @@ func TestCategoryIsInconclusive(t *testing.T) { blocking := []Category{ NotPinned, ShaAsRef, RefChanged, RefMoved, Stale, ImpostorCommit, MisleadingSHA, LockfileForgery, - Valid, RunOnly, OnboardingRequired, VersionRef, + Valid, RunOnly, OnboardingRequired, VersionRef, LocalAction, } for _, c := range blocking { if c.IsInconclusive() { diff --git a/internal/pipeline/checks/finding.go b/internal/pipeline/checks/finding.go index 10af6cab..c2b7b6df 100644 --- a/internal/pipeline/checks/finding.go +++ b/internal/pipeline/checks/finding.go @@ -78,7 +78,7 @@ func (r *WorkflowReport) NeedsAttention() bool { continue } switch f.Category { - case Valid, RunOnly, MisleadingSHA, RefMoved, VersionRef: + case Valid, RunOnly, LocalAction, MisleadingSHA, RefMoved, VersionRef: continue default: return true @@ -107,7 +107,7 @@ func (f *Finding) IsValid() bool { return true } switch f.Category { - case Valid, RunOnly, ShaAsRef, RefMoved, VersionRef, OnboardingRequired: + case Valid, RunOnly, LocalAction, ShaAsRef, RefMoved, VersionRef, OnboardingRequired: return true case NotPinned: return f.ActionRef == nil // workflow-level is a warning @@ -123,6 +123,8 @@ func (f *Finding) IsWarning() bool { return true case f.Category == RefMoved: return true + case f.Category == LocalAction: + return true case f.Category.IsInconclusive(): return true case f.Category == NotPinned && f.ActionRef == nil: diff --git a/internal/pipeline/checks/parsed.go b/internal/pipeline/checks/parsed.go index 6a9d5ab2..6660267f 100644 --- a/internal/pipeline/checks/parsed.go +++ b/internal/pipeline/checks/parsed.go @@ -13,6 +13,7 @@ import ( type ParsedWorkflow struct { Path string Refs []parserlock.ActionRef + LocalPaths []string ExistingDeps []dep.Dependency ParseWarnings []string LoadErr error diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index 3a05cc8c..8333d9de 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -64,6 +64,17 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve wr.ActionRefs = pw.Refs wr.ParseWarnings = pw.ParseWarnings + if len(pw.LocalPaths) > 0 { + wr.Findings = append(wr.Findings, checks.Finding{ + WorkflowPath: pw.Path, + Category: checks.LocalAction, + Severity: checks.SeverityWarning, + Confidence: checks.ConfidenceHigh, + Detail: "workflow uses local path actions; lockfile onboarding is not supported", + }) + return wr + } + if len(pw.Refs) == 0 { wr.Findings = append(wr.Findings, checks.Finding{ WorkflowPath: pw.Path, @@ -214,7 +225,7 @@ func hasIssues(ff []checks.Finding) bool { if f.Category.IsInconclusive() { continue } - if f.Category != checks.Valid && f.Category != checks.RunOnly && f.Severity == checks.SeverityWarning { + if f.Category != checks.Valid && f.Category != checks.RunOnly && f.Category != checks.LocalAction && f.Severity == checks.SeverityWarning { return true } } diff --git a/internal/pipeline/parse.go b/internal/pipeline/parse.go index 14651325..11c7ed90 100644 --- a/internal/pipeline/parse.go +++ b/internal/pipeline/parse.go @@ -47,7 +47,7 @@ func ParseAll(paths []string, store *lockfile.State) []checks.ParsedWorkflow { out = append(out, pw) continue } - pw.Refs, _, pw.ParseWarnings = wf.ExtractActionRefs() + pw.Refs, pw.LocalPaths, pw.ParseWarnings = wf.ExtractActionRefs() if len(pw.Refs) > 0 { wfKey := workflowfile.KeyFromPath(path) deps, depsErr := store.Get(wfKey) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index f2d94d5e..9cbb5a67 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -370,7 +370,7 @@ scenarios: - name: local_action_skipped category: workflow_parsing - description: "Local action (./path) — skipped without warning" + description: "Local action (./path) — entire workflow skipped" needs_token: true fixtures: workflows: From 52551d21b18bb99c65d3dbe17be5911da0cbf9ab Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 07:06:47 -0500 Subject: [PATCH 02/21] error when onboarded workflow adds local path actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a workflow already has a lockfile entry and then adds a local path action (uses: ./…), the check now fails with an error instead of silently skipping. The user must remove the local path steps or split them into a separate workflow. New workflows with local paths still get the non-blocking warning and are skipped from onboarding. --- cmd/gh-actions-lock/format/terminal.go | 1 + internal/pipeline/diagnose.go | 27 ++++++++++---- internal/pipeline/diagnose_test.go | 49 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 internal/pipeline/diagnose_test.go diff --git a/cmd/gh-actions-lock/format/terminal.go b/cmd/gh-actions-lock/format/terminal.go index ab5c33aa..3c9affc5 100644 --- a/cmd/gh-actions-lock/format/terminal.go +++ b/cmd/gh-actions-lock/format/terminal.go @@ -96,6 +96,7 @@ func renderErrorFindings(out *ui.UI, report *checks.Report, failedCount, checked for _, cat := range []checks.Category{ checks.LockfileForgery, checks.RefChanged, checks.NotPinned, checks.OnboardingRequired, + checks.LocalAction, checks.Stale, checks.MisleadingSHA, checks.ImpostorCommit, } { if n, ok := catCounts[cat]; ok { diff --git a/internal/pipeline/diagnose.go b/internal/pipeline/diagnose.go index 8333d9de..aac96312 100644 --- a/internal/pipeline/diagnose.go +++ b/internal/pipeline/diagnose.go @@ -12,6 +12,7 @@ import ( "github.com/github/gh-actions-lock/internal/pinpool" "github.com/github/gh-actions-lock/internal/pipeline/checks" "github.com/github/gh-actions-lock/internal/resolve" + "github.com/github/gh-actions-lock/internal/workflowfile" ) // DiagnoseParsed runs the engine diagnostics for each pre-parsed workflow. @@ -65,13 +66,25 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve wr.ParseWarnings = pw.ParseWarnings if len(pw.LocalPaths) > 0 { - wr.Findings = append(wr.Findings, checks.Finding{ - WorkflowPath: pw.Path, - Category: checks.LocalAction, - Severity: checks.SeverityWarning, - Confidence: checks.ConfidenceHigh, - Detail: "workflow uses local path actions; lockfile onboarding is not supported", - }) + wfKey := workflowfile.KeyFromPath(pw.Path) + if store != nil && store.HasWorkflow(wfKey) { + wr.Findings = append(wr.Findings, checks.Finding{ + WorkflowPath: pw.Path, + Category: checks.LocalAction, + Severity: checks.SeverityError, + Confidence: checks.ConfidenceHigh, + Detail: "workflow uses local path actions which are not supported; remove local path actions to continue using the lockfile", + Remediation: "remove `uses: ./…` steps or move them to a separate workflow", + }) + } else { + wr.Findings = append(wr.Findings, checks.Finding{ + WorkflowPath: pw.Path, + Category: checks.LocalAction, + Severity: checks.SeverityWarning, + Confidence: checks.ConfidenceHigh, + Detail: "workflow uses local path actions; lockfile onboarding is not supported", + }) + } return wr } diff --git a/internal/pipeline/diagnose_test.go b/internal/pipeline/diagnose_test.go new file mode 100644 index 00000000..207d8dc8 --- /dev/null +++ b/internal/pipeline/diagnose_test.go @@ -0,0 +1,49 @@ +package pipeline + +import ( + "context" + "testing" + + "github.com/github/gh-actions-lock/internal/lockfile" + "github.com/github/gh-actions-lock/internal/pipeline/checks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type noopMeta struct{} + +func (noopMeta) RepoIDs(context.Context, string, string) (int64, int64, error) { + return 0, 0, nil +} + +func TestDiagnoseOneParsed_LocalAction_NotOnboarded(t *testing.T) { + pw := checks.ParsedWorkflow{ + Path: ".github/workflows/ci.yml", + LocalPaths: []string{"./my-local-action"}, + } + wr := diagnoseOneParsed(context.Background(), pw, nil, nil, nil) + + assert.Len(t, wr.Findings, 1) + assert.Equal(t, checks.LocalAction, wr.Findings[0].Category) + assert.Equal(t, checks.SeverityWarning, wr.Findings[0].Severity) +} + +func TestDiagnoseOneParsed_LocalAction_AlreadyOnboarded(t *testing.T) { + dir := t.TempDir() + store, err := lockfile.LoadState(dir, noopMeta{}) + require.NoError(t, err) + + wfKey := ".github/workflows/ci.yml" + require.NoError(t, store.Set(context.Background(), wfKey, nil, nil, nil)) + + pw := checks.ParsedWorkflow{ + Path: wfKey, + LocalPaths: []string{"./my-local-action"}, + } + wr := diagnoseOneParsed(context.Background(), pw, nil, store, nil) + + assert.Len(t, wr.Findings, 1) + assert.Equal(t, checks.LocalAction, wr.Findings[0].Category) + assert.Equal(t, checks.SeverityError, wr.Findings[0].Severity) + assert.Contains(t, wr.Findings[0].Remediation, "remove") +} From 89baa9da86d9162e80738fbba00b34d59e622eda Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 07:10:29 -0500 Subject: [PATCH 03/21] catalog: add scenario for onboarded workflow with local path action Covers the case where a previously-onboarded workflow adds a local path action (uses: ./path). This should produce a hard error (exit 1) since we can't track local paths in the lockfile. --- test/scenarios/catalog.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 9cbb5a67..27b8f940 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -387,6 +387,27 @@ scenarios: expect: exit: 0 + - name: local_action_onboarded_error + category: workflow_parsing + description: "Onboarded workflow adds local path action — hard error" + needs_token: true + fixtures: + workflows: + ci.yml: + raw: | + name: CI + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: ./my-local-action + - uses: actions/checkout@v4 + lockfile_template: pinned_checkout + expect: + exit: 1 + output_contains: ["local path actions"] + - name: sub_path_action category: workflow_parsing description: "Sub-path action (actions/cache/restore@v4) handled" From 9bbadd8a33916b7a5e7a1fbcdc325d4e6f7a14ae Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 09:43:10 -0500 Subject: [PATCH 04/21] harness: fix shell method visibility The interactive shell REPL was defined after a private keyword, making it inaccessible from the top-level runner invocation. --- test/integration/harness.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index a37cfa4e..fb8f82c1 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -889,6 +889,8 @@ def yaml_scalar(v) end end + public + # ── Interactive shell ────────────────────────────────────────── def shell From c0d1775111cb7bdef492f6a0750a45e3c32b2452 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 09:55:19 -0500 Subject: [PATCH 05/21] narrow non-semver refs to full semver on public actions Previously only partial semver refs (v4, v3.1) were narrowed to patch tags. Non-semver refs like @main were left as-is, even on third-party public actions where tracking a branch is fragile. Now any non-full-semver ref on a public action gets narrowed to the best patch tag for its resolved SHA when one exists. Same-owner internal/private repos are still skipped (they may intentionally track a branch). --- internal/pin/plan.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 289b158f..6f8bf7aa 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -298,7 +298,8 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption continue } - // Version tags without full semver (v4, v3.1): narrow to patch release. + // Narrow to a full semver patch tag when possible. Covers + // partial semver (v4, v3.1) and non-semver refs (main, master). // Skip if --no-narrow or if the lockfile already recorded this // dep without a full semver ref (respect prior precision choice). nwoLower := strings.ToLower(dep.NWO) @@ -306,9 +307,10 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption continue } sv, ok := parserlock.ParseSemVer(dep.Ref) - if !ok || sv.IsFull() { + if ok && sv.IsFull() { continue } + patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) if err != nil || patchTag == "" { continue From ebd88c89b30920590f563511c4e8365c4e7528c4 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 10:10:09 -0500 Subject: [PATCH 06/21] narrow to ancestor semver tag when no exact tag matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When BestPatchTagForSHA finds no tag at the exact SHA (common for repos where dependabot walks the commit forward past the latest release), fall back to BestAncestorTag — checks the latest 3 semver tags and returns the first one that's an ancestor of the current SHA. Covers both bare-SHA refs and non-semver refs like @main. For toshimaru/auto-author-assign@main this narrows to v3.0.3 instead of leaving a 'pinned without full semver tag' warning. --- internal/pin/plan.go | 18 ++++++++++++++++-- internal/tag/tagging.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index 6f8bf7aa..ddebb53c 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -288,9 +288,15 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption // Bare-SHA refs: find a tag pointing at the same commit. if parserlock.IsFullSha(dep.Ref) { patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) - if err != nil || patchTag == "" { + 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 @@ -312,9 +318,17 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption } patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) - if err != nil || patchTag == "" { + if err != nil { continue } + // No exact tag match — if the repo publishes semver releases, + // walk back to the latest tag that's an ancestor of this SHA. + if patchTag == "" { + patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA) + if err != nil || patchTag == "" { + continue + } + } oldUses := dep.NWO + "@" + dep.Ref newUses := dep.NWO + "@" + patchTag rewrites[oldUses] = newUses diff --git a/internal/tag/tagging.go b/internal/tag/tagging.go index 2423d44f..3f33513b 100644 --- a/internal/tag/tagging.go +++ b/internal/tag/tagging.go @@ -56,6 +56,44 @@ 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. +// 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) { + all, err := tl.ListTags(ctx, owner, repo) + if err != nil { + return "", err + } + + // 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 + } + 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. From 1ea7bd033f43ea964f7f9a9c665413bb8b70bc60 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 10:27:57 -0500 Subject: [PATCH 07/21] narrow verified deps and prevent ReverseLookup from overriding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified deps (already in the lockfile) with imprecise refs like @main or bare SHAs were never reaching the narrowing block — they took the fast inventory path and skipped resolve/narrow entirely. Add narrowVerifiedEntries() to upgrade imprecise refs on verified entries at all three return points in planWorkflow. Also track which NWOs were narrowed and restore their refs after ReverseLookup, which otherwise overwrites the narrowed semver tag with the branch name (e.g. main). --- internal/pin/plan.go | 93 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index ddebb53c..fd9fd903 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -119,6 +119,9 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption if !wr.NeedsAttention() { entries = verifiedEntries(inventory, wr.Path) + if rw := narrowVerifiedEntries(ctx, entries, opts); len(rw) > 0 { + wplans = append(wplans, WorkflowPlan{Path: wr.Path, Rewrites: rw}) + } return planResult{entries: entries, wplans: wplans}, nil } @@ -127,6 +130,9 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption entries = verifiedEntries(inventory, wr.Path) if len(unrecordedRefs) == 0 { + if rw := narrowVerifiedEntries(ctx, entries, opts); len(rw) > 0 { + wplans = append(wplans, WorkflowPlan{Path: wr.Path, Rewrites: rw}) + } return planResult{entries: entries, wplans: wplans}, nil } @@ -261,6 +267,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption // to a symbolic tag when one exists. status("pinning " + wr.Path) rewrites := make(map[string]string) + narrowedNWOs := make(map[string]bool) // NWOs where narrowing chose a tag for k, v := range autoFixRewrites { rewrites[k] = v } @@ -301,6 +308,7 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption newUses := dep.NWO + "@" + patchTag rewrites[oldUses] = newUses dep.Ref = patchTag + narrowedNWOs[strings.ToLower(dep.NWO)] = true continue } @@ -333,6 +341,17 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption newUses := dep.NWO + "@" + patchTag rewrites[oldUses] = newUses dep.Ref = patchTag + narrowedNWOs[nwoLower] = true + } + } + + // Save narrowed refs before ReverseLookup — it may overwrite dep.Ref + // with a branch name, but we want to keep the semver tag narrowing chose. + narrowedRefs := make(map[int]string) + for i := range deps { + nwo := strings.ToLower(deps[i].NWO) + if narrowedNWOs[nwo] { + narrowedRefs[i] = deps[i].Ref } } @@ -353,7 +372,17 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption } return planResult{}, fmt.Errorf("reverse lookup: %w", err) } + // Restore narrowed refs that ReverseLookup may have overwritten. + for i, ref := range narrowedRefs { + deps[i].Ref = ref + } for k, v := range normRewrites { + if at := strings.Index(k, "@"); at > 0 { + nwo := strings.ToLower(k[:at]) + if narrowedNWOs[nwo] { + continue + } + } rewrites[k] = v } @@ -376,6 +405,12 @@ 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); len(verifiedRW) > 0 { + for k, v := range verifiedRW { + rewrites[k] = v + } + } if len(rewrites) > 0 { wplans = append(wplans, WorkflowPlan{ Path: wr.Path, @@ -553,3 +588,61 @@ func verifiedEntries(inventory []checks.InventoryEntry, path string) []Entry { } return out } + +// narrowVerifiedEntries upgrades already-recorded deps from imprecise refs +// (main, v4, etc.) to full semver tags when possible. Returns rewrites for +// the workflow YAML. Skipped when --no-narrow is set. +func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOptions) map[string]string { + if opts.NoNarrow || opts.Tagger == nil { + return nil + } + rewrites := make(map[string]string) + for i := range entries { + e := &entries[i] + owner, repo := splitNWO(e.NWO) + if owner == "" { + continue + } + // Skip same-owner internal repos. + if opts.RepoOwner != "" && owner == opts.RepoOwner { + info, err := opts.Tagger.GetRepoInfo(ctx, owner, repo) + if err == nil && info.IsInternal() { + continue + } + } + // Already full semver — nothing to do. + sv, ok := parserlock.ParseSemVer(e.Ref) + if ok && sv.IsFull() { + continue + } + // Try exact tag match, then ancestor fallback. + patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, e.SHA) + if err != nil { + continue + } + if patchTag == "" { + patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, e.SHA) + if err != nil || patchTag == "" { + continue + } + } + oldUses := e.NWO + "@" + e.Ref + newUses := e.NWO + "@" + patchTag + rewrites[oldUses] = newUses + e.Ref = patchTag + e.AutoFixedRef = oldUses + } + if len(rewrites) == 0 { + return nil + } + return rewrites +} + +// splitNWO splits "owner/repo" or "owner/repo/sub" into (owner, repo). +func splitNWO(nwo string) (string, string) { + parts := strings.SplitN(nwo, "/", 3) + if len(parts) < 2 { + return "", "" + } + return parts[0], parts[1] +} From 6acfe30e792301918095e278207debf2b92cae3c Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 10:32:26 -0500 Subject: [PATCH 08/21] reachability: stash found branch for DiscoverContaining reuse CheckReachability and DiscoverContaining both do the same 3-phase branch scan independently. For repos like codeql-action with ~250 branches, the redundant Phase 2 scan in DiscoverContaining added ~250 sequential CompareCommits API calls and ~1m30s of wall time. Store the discovered branch in branchHintBySHA so DiscoverContaining's Phase 0 (named branches) picks it up immediately, skipping the expensive Phase 1+2 scans entirely. github/launch drops from ~2m10s to ~30s. --- internal/resolve/reachability.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/resolve/reachability.go b/internal/resolve/reachability.go index 71b1decd..ae3162c2 100644 --- a/internal/resolve/reachability.go +++ b/internal/resolve/reachability.go @@ -122,6 +122,9 @@ func (r *Resolver) checkReachabilityOnce(ctx context.Context, owner, repo, sha, if foundBranch != "" { result.Status = Reachable + // Stash the discovered branch so DiscoverContaining can reuse it + // via branchHintBySHA, avoiding a redundant full-branch scan. + r.branchHintBySHA.Put(ghapi.ForNWOSha(owner, repo, sha), foundBranch) if parserlock.IsFullSha(ref) { result.Detail = fmt.Sprintf("pinned to a bare SHA; commit is on branch %s but origin cannot be verified at job runtime — prefer pinning to a tag", foundBranch) } else { From dda67adfc96c1b78ed705be7f1c7f30f1a691e9f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 10:37:42 -0500 Subject: [PATCH 09/21] narrow same-owner private repos that publish semver tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isInternal skip was too aggressive — it blocked narrowing for all same-owner private repos, even ones that publish semver releases like github/go-linter. The tag lookup already no-ops gracefully for repos without tags, so the guard was unnecessary. --- internal/pin/plan.go | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index fd9fd903..a19157a6 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -280,18 +280,6 @@ func planWorkflow(ctx context.Context, wr checks.WorkflowReport, opts PlanOption continue } - // Skip narrowing for same-owner internal repos. - isInternal := false - if opts.RepoOwner != "" && owner == opts.RepoOwner { - info, err := opts.Tagger.GetRepoInfo(ctx, owner, repo) - if err == nil && info.IsInternal() { - isInternal = true - } - } - if isInternal { - continue - } - // Bare-SHA refs: find a tag pointing at the same commit. if parserlock.IsFullSha(dep.Ref) { patchTag, err := opts.Tagger.BestPatchTagForSHA(ctx, owner, repo, dep.SHA) @@ -603,13 +591,6 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption if owner == "" { continue } - // Skip same-owner internal repos. - if opts.RepoOwner != "" && owner == opts.RepoOwner { - info, err := opts.Tagger.GetRepoInfo(ctx, owner, repo) - if err == nil && info.IsInternal() { - continue - } - } // Already full semver — nothing to do. sv, ok := parserlock.ParseSemVer(e.Ref) if ok && sv.IsFull() { From 0c133bb25b8783e43b5116ad07bcda6f5ac1e51f Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:25:43 -0500 Subject: [PATCH 10/21] harness: keep adhoc context alive for rerun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run owner/repo` now keeps the cloned repo and lockfile state so `rerun` re-executes against the same checkout. This makes it easy to verify idempotent re-pin behavior (fresh pin → rerun should be a fast noop). The rerun output now includes timing, diff, and profile info matching the first run's format. --- test/integration/harness.rb | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index fb8f82c1..3e732dbb 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -977,7 +977,7 @@ def shell end elsif arg && repo_nwo?(arg.split(/\s+--\s+/, 2)[0]) nwo, extra = split_adhoc_args(arg) - run_one_live(adhoc_scenario(nwo, extra_args: extra)) + active_ctx = run_one_live(adhoc_scenario(nwo, extra_args: extra), keep_alive: true) else s = find_scenario(arg) next unless s @@ -1052,8 +1052,27 @@ def shell when "rerun" if active_ctx - puts "\e[1;36m── re-running #{active_ctx.scenario.name} ──\e[0m\n\n" - active_ctx.run_pty(input_prompts: active_ctx.scenario.input_spec) + w = 62 + puts "\e[1;36m── re-running #{active_ctx.scenario.name} ──\e[0m" + puts "\e[2m$\e[0m #{active_ctx.cmd_string}" + puts + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = active_ctx.run_pty(input_prompts: active_ctx.scenario.input_spec) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + puts + + diff_text = `cd #{Shellwords.shellescape(active_ctx.dir)} && git add -N . 2>/dev/null; git --no-pager diff --color 2>/dev/null`.strip + cache_diff(active_ctx.scenario.name.to_s, diff_text) + show_diff(active_ctx.dir, w, scenario_name: active_ctx.scenario.name.to_s) + + if @profile_dir + pdir = File.join(@profile_dir, active_ctx.scenario.name.to_s) + puts " \e[2mprofile: #{pdir}\e[0m" + end + puts + status_color = result.exit_code == 0 ? "42" : "41" + status_icon = result.exit_code == 0 ? "✓ PASS" : "✗ FAIL" + puts "\e[#{status_color};1;37m #{status_icon} \e[0m exit #{result.exit_code} \e[2m(#{format_elapsed(elapsed)})\e[0m" puts else puts "No active scenario. Use \e[36mrun \e[0m first." @@ -1259,7 +1278,7 @@ def print_help puts " \e[36mdiff\e[0m Show full diff from last run (pager)" puts " \e[36mdiff \e[0m Show cached diff for a specific scenario" puts " \e[36mcd \e[0m Prepare scenario and drop into its dir" - puts " \e[36mrerun\e[0m Re-run active scenario" + puts " \e[36mrerun\e[0m Re-run active scenario (keeps lockfile state)" puts " \e[36mbuild\e[0m Rebuild the binary (go build)" puts " \e[36mpause\e[0m Toggle pause between scenarios in run-all" puts " \e[36mprofile [dir|off]\e[0m Toggle profiling (default: ./profiles)" @@ -1352,7 +1371,7 @@ def repo_nwo?(str) str.match?(%r{\A[A-Za-z0-9._-]+/[A-Za-z0-9._-]+\z}) end - def run_one_live(s) + def run_one_live(s, keep_alive: false) w = 62 # ── TITLE ── @@ -1408,7 +1427,7 @@ def run_one_live(s) puts "\e[1;35m── OUTPUT #{"─" * (w - 10)}\e[0m" puts "\e[2m$\e[0m #{ctx.cmd_string}" puts - keep = ENV["KEEP_FIXTURES"] + keep = ENV["KEEP_FIXTURES"] || keep_alive t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) begin result = ctx.run_pty(input_prompts: s.input_spec) @@ -1447,9 +1466,11 @@ def run_one_live(s) end @last_dir = ctx.dir puts + return ctx if keep_alive ensure ctx.teardown unless keep end + nil end def format_expect(spec) From ce85559b1cb7b7a57b7ef4d158eef722c92827fd Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:32:33 -0500 Subject: [PATCH 11/21] harness: checkpoint state between runs for clean rerun diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the first `run`, git-commit the working tree so the rerun diff shows only what changed since the last execution. When nothing changed (the expected idempotent case), prints '✓ no changes from previous run' instead of repeating the full lockfile diff. Each rerun also checkpoints so chained reruns stay clean. --- test/integration/harness.rb | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 3e732dbb..6b9efede 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1062,8 +1062,17 @@ def shell puts diff_text = `cd #{Shellwords.shellescape(active_ctx.dir)} && git add -N . 2>/dev/null; git --no-pager diff --color 2>/dev/null`.strip - cache_diff(active_ctx.scenario.name.to_s, diff_text) - show_diff(active_ctx.dir, w, scenario_name: active_ctx.scenario.name.to_s) + if diff_text.empty? + puts "\e[1;35m── DIFF #{"─" * (w - 8)}\e[0m" + puts " \e[32m✓ no changes from previous run\e[0m" + puts + else + cache_diff(active_ctx.scenario.name.to_s, diff_text) + show_diff(active_ctx.dir, w, scenario_name: active_ctx.scenario.name.to_s) + end + + # Checkpoint so the next rerun diff is also a delta + system("cd #{Shellwords.shellescape(active_ctx.dir)} && git add -A && git commit -q --allow-empty -m rerun-state >/dev/null 2>&1") if @profile_dir pdir = File.join(@profile_dir, active_ctx.scenario.name.to_s) @@ -1466,7 +1475,11 @@ def run_one_live(s, keep_alive: false) end @last_dir = ctx.dir puts - return ctx if keep_alive + if keep_alive + # Checkpoint working tree so rerun diff shows only the delta + system("cd #{Shellwords.shellescape(ctx.dir)} && git add -A && git commit -q --allow-empty -m pin-state >/dev/null 2>&1") + return ctx + end ensure ctx.teardown unless keep end From fbd68f9f48e1c00db2b1495a1b68d0780bc35612 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:39:03 -0500 Subject: [PATCH 12/21] harness: add done command to teardown active adhoc context --- test/integration/harness.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 6b9efede..88ae60cc 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1087,6 +1087,16 @@ def shell puts "No active scenario. Use \e[36mrun \e[0m first." end + when "done" + if active_ctx + name = active_ctx.scenario.name + active_ctx.teardown + active_ctx = nil + puts "Tore down \e[36m#{name}\e[0m context." + else + puts "No active scenario." + end + when "profile" if arg.nil? || arg == "on" @profile_dir = File.expand_path("profiles") @@ -1288,6 +1298,7 @@ def print_help puts " \e[36mdiff \e[0m Show cached diff for a specific scenario" puts " \e[36mcd \e[0m Prepare scenario and drop into its dir" puts " \e[36mrerun\e[0m Re-run active scenario (keeps lockfile state)" + puts " \e[36mdone\e[0m Teardown active scenario context" puts " \e[36mbuild\e[0m Rebuild the binary (go build)" puts " \e[36mpause\e[0m Toggle pause between scenarios in run-all" puts " \e[36mprofile [dir|off]\e[0m Toggle profiling (default: ./profiles)" From 8d6eee5e0f4ec68427e768fe6ebaf6b5745ce109 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:40:03 -0500 Subject: [PATCH 13/21] harness: add rescan and edit commands for active context --- test/integration/harness.rb | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 88ae60cc..860e5a2c 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1097,6 +1097,34 @@ def shell puts "No active scenario." end + when "rescan" + if active_ctx + w = 62 + diff_text = `cd #{Shellwords.shellescape(active_ctx.dir)} && git add -N . 2>/dev/null; git --no-pager diff --color 2>/dev/null`.strip + if diff_text.empty? + puts "\e[32m✓ no uncommitted changes\e[0m" + else + cache_diff(active_ctx.scenario.name.to_s, diff_text) + show_diff(active_ctx.dir, w, scenario_name: active_ctx.scenario.name.to_s) + end + else + puts "No active scenario." + end + + when "edit" + dir = if active_ctx + active_ctx.dir + elsif @last_dir && File.directory?(@last_dir) + @last_dir + end + if dir + editor = ENV["EDITOR"] || "code" + puts "\e[2m$ #{editor} #{dir}\e[0m" + system(editor, dir) + else + puts "No active scenario directory." + end + when "profile" if arg.nil? || arg == "on" @profile_dir = File.expand_path("profiles") @@ -1298,6 +1326,8 @@ def print_help puts " \e[36mdiff \e[0m Show cached diff for a specific scenario" puts " \e[36mcd \e[0m Prepare scenario and drop into its dir" puts " \e[36mrerun\e[0m Re-run active scenario (keeps lockfile state)" + puts " \e[36mrescan\e[0m Show current diff in active scenario dir" + puts " \e[36medit\e[0m Open active scenario dir in $EDITOR" puts " \e[36mdone\e[0m Teardown active scenario context" puts " \e[36mbuild\e[0m Rebuild the binary (go build)" puts " \e[36mpause\e[0m Toggle pause between scenarios in run-all" From adbea791736686c7d1d91688d1d364cc917c01fb Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:42:57 -0500 Subject: [PATCH 14/21] harness: pass --rescan through rerun instead of separate command `rerun --rescan` appends --rescan to the binary invocation so the CLI rescans workflows against the existing lockfile. Removed the standalone rescan harness command. --- test/integration/harness.rb | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 860e5a2c..218911a4 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -464,11 +464,12 @@ def run_captured # input_prompts: optional array of {prompt:, response:} hashes. # When the accumulated output matches a prompt pattern, the # corresponding response is written to the PTY's stdin. - def run_pty(input_prompts: nil) + def run_pty(input_prompts: nil, extra_args: []) + cmd = @cmd + extra_args flat_env = @env.map { |k, v| "#{k}=#{Shellwords.shellescape(v)}" } shell_cmd = "cd #{Shellwords.shellescape(@dir)} && " + flat_env.join(" ") + " " + - @cmd.map { |c| Shellwords.shellescape(c) }.join(" ") + cmd.map { |c| Shellwords.shellescape(c) }.join(" ") combined = String.new exit_code = nil @@ -533,8 +534,8 @@ def env_exports @env.map { |k, v| "export #{k}=#{Shellwords.shellescape(v)}" }.join("\n") end - def cmd_string - @cmd.map { |c| Shellwords.shellescape(c) }.join(" ") + def cmd_string(extra_args: []) + (@cmd + extra_args).map { |c| Shellwords.shellescape(c) }.join(" ") end def teardown @@ -1053,11 +1054,14 @@ def shell when "rerun" if active_ctx w = 62 - puts "\e[1;36m── re-running #{active_ctx.scenario.name} ──\e[0m" - puts "\e[2m$\e[0m #{active_ctx.cmd_string}" + rescan = (arg == "--rescan") + mode_label = rescan ? "re-scanning" : "re-running" + puts "\e[1;36m── #{mode_label} #{active_ctx.scenario.name} ──\e[0m" + extra = rescan ? ["--rescan"] : [] + puts "\e[2m$\e[0m #{active_ctx.cmd_string(extra_args: extra)}" puts t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - result = active_ctx.run_pty(input_prompts: active_ctx.scenario.input_spec) + result = active_ctx.run_pty(input_prompts: active_ctx.scenario.input_spec, extra_args: extra) elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 puts @@ -1097,20 +1101,6 @@ def shell puts "No active scenario." end - when "rescan" - if active_ctx - w = 62 - diff_text = `cd #{Shellwords.shellescape(active_ctx.dir)} && git add -N . 2>/dev/null; git --no-pager diff --color 2>/dev/null`.strip - if diff_text.empty? - puts "\e[32m✓ no uncommitted changes\e[0m" - else - cache_diff(active_ctx.scenario.name.to_s, diff_text) - show_diff(active_ctx.dir, w, scenario_name: active_ctx.scenario.name.to_s) - end - else - puts "No active scenario." - end - when "edit" dir = if active_ctx active_ctx.dir @@ -1326,7 +1316,7 @@ def print_help puts " \e[36mdiff \e[0m Show cached diff for a specific scenario" puts " \e[36mcd \e[0m Prepare scenario and drop into its dir" puts " \e[36mrerun\e[0m Re-run active scenario (keeps lockfile state)" - puts " \e[36mrescan\e[0m Show current diff in active scenario dir" + puts " \e[36mrerun --rescan\e[0m Re-run with --rescan flag" puts " \e[36medit\e[0m Open active scenario dir in $EDITOR" puts " \e[36mdone\e[0m Teardown active scenario context" puts " \e[36mbuild\e[0m Rebuild the binary (go build)" From a72bd7027b622fa59a66a0a9b6f3e4abc4b344f3 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:43:29 -0500 Subject: [PATCH 15/21] harness: chdir into scenario dir before launching editor --- test/integration/harness.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 218911a4..ede160b7 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1109,8 +1109,8 @@ def shell end if dir editor = ENV["EDITOR"] || "code" - puts "\e[2m$ #{editor} #{dir}\e[0m" - system(editor, dir) + puts "\e[2m$ cd #{dir} && #{editor} .\e[0m" + Dir.chdir(dir) { system(editor, ".") } else puts "No active scenario directory." end From ac7ee0bbdd472ade7893ed9ff66ac872918290fa Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:45:19 -0500 Subject: [PATCH 16/21] harness: add rescan as shorthand for rerun --rescan --- test/integration/harness.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index ede160b7..880f1de3 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1051,10 +1051,10 @@ def shell system(sub_env, ENV.fetch("SHELL", "/bin/bash"), chdir: ctx.dir) puts "\nBack in integration shell. Scenario dir still live at #{ctx.dir}" - when "rerun" + when "rerun", "rescan" if active_ctx w = 62 - rescan = (arg == "--rescan") + rescan = (verb == "rescan" || arg == "--rescan") mode_label = rescan ? "re-scanning" : "re-running" puts "\e[1;36m── #{mode_label} #{active_ctx.scenario.name} ──\e[0m" extra = rescan ? ["--rescan"] : [] @@ -1317,6 +1317,7 @@ def print_help puts " \e[36mcd \e[0m Prepare scenario and drop into its dir" puts " \e[36mrerun\e[0m Re-run active scenario (keeps lockfile state)" puts " \e[36mrerun --rescan\e[0m Re-run with --rescan flag" + puts " \e[36mrescan\e[0m Shorthand for rerun --rescan" puts " \e[36medit\e[0m Open active scenario dir in $EDITOR" puts " \e[36mdone\e[0m Teardown active scenario context" puts " \e[36mbuild\e[0m Rebuild the binary (go build)" From 636c69653b8ccf3e9558afc98eebbba2a4ab7bbc Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:46:08 -0500 Subject: [PATCH 17/21] harness: use bash pushd/popd for edit command Spawns a real bash subshell so vim/nvim gets a proper TTY context with the scenario dir as cwd. --- test/integration/harness.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 880f1de3..85e20bb1 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1109,8 +1109,8 @@ def shell end if dir editor = ENV["EDITOR"] || "code" - puts "\e[2m$ cd #{dir} && #{editor} .\e[0m" - Dir.chdir(dir) { system(editor, ".") } + puts "\e[2m$ pushd #{dir} && #{editor} . && popd\e[0m" + system("bash", "-c", "pushd #{Shellwords.shellescape(dir)} && #{editor} . && popd") else puts "No active scenario directory." end From 76be3aa0f6930ca887ef9362f8d66be5f582ce9b Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 11:48:48 -0500 Subject: [PATCH 18/21] harness: drop '.' arg from editor invocation --- test/integration/harness.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 85e20bb1..0d07ac82 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1109,8 +1109,8 @@ def shell end if dir editor = ENV["EDITOR"] || "code" - puts "\e[2m$ pushd #{dir} && #{editor} . && popd\e[0m" - system("bash", "-c", "pushd #{Shellwords.shellescape(dir)} && #{editor} . && popd") + puts "\e[2m$ pushd #{dir} && #{editor} && popd\e[0m" + system("bash", "-c", "pushd #{Shellwords.shellescape(dir)} && #{editor} && popd") else puts "No active scenario directory." end From 95ef75ce143335970f366e43647dd73376297b64 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 12:11:01 -0500 Subject: [PATCH 19/21] pipeline: skip resolve/reachability for local-path workflows Local-path workflows are bailed out at diagnose time, but their action refs were still fed through the resolve and reachability phases because PartitionRefs found no matching lockfile entries. On mcv3-boot this caused 65 redundant HTTP calls (~6s) on every rerun. Fix: mark local-path workflows as Resolved=true before the partition loop so they never enter the network path. Also teach PartitionRefs to match bare-SHA refs against lockfile deps by SHA (not just by tag ref) for correctness in mixed-pinning repos. Result: mcv3-boot rerun drops from ~6s to ~200ms, 0 HTTP requests. --- internal/pipeline/checks/parsed.go | 19 ++++++++++++------- internal/pipeline/run.go | 7 +++++++ internal/pipeline/run_test.go | 13 +++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/internal/pipeline/checks/parsed.go b/internal/pipeline/checks/parsed.go index 6660267f..677e9150 100644 --- a/internal/pipeline/checks/parsed.go +++ b/internal/pipeline/checks/parsed.go @@ -40,8 +40,8 @@ type ParsedWorkflow struct { } // PartitionRefs splits refs into recorded (matching a lockfile entry by -// NWO@Ref) and unrecorded (need network resolution). When an error -// prevented loading refs or deps, everything is unrecorded. +// NWO@Ref or NWO@SHA) and unrecorded (need network resolution). When an +// error prevented loading refs or deps, everything is unrecorded. func (pw ParsedWorkflow) PartitionRefs() (recorded, unrecorded []parserlock.ActionRef) { if pw.LoadErr != nil || pw.DepsErr != nil { return nil, pw.Refs @@ -49,9 +49,13 @@ func (pw ParsedWorkflow) PartitionRefs() (recorded, unrecorded []parserlock.Acti if len(pw.Refs) == 0 { return nil, nil } - haveDep := make(map[string]bool, len(pw.ExistingDeps)) + haveDep := make(map[string]bool, len(pw.ExistingDeps)*2) for _, d := range pw.ExistingDeps { - haveDep[strings.ToLower(d.NWO)+"@"+d.Ref] = true + nwo := strings.ToLower(d.NWO) + haveDep[nwo+"@"+d.Ref] = true + if d.SHA != "" { + haveDep[nwo+"@"+strings.ToLower(d.SHA)] = true + } } for _, r := range pw.Refs { if haveDep[strings.ToLower(r.Owner+"/"+r.Repo)+"@"+r.Ref] { @@ -70,8 +74,8 @@ func (pw ParsedWorkflow) IsFullyRecorded() bool { return len(pw.Refs) == 0 || len(unrecorded) == 0 } -// RecordedDeps returns the subset of ExistingDeps whose NWO@Ref matches -// one of the given recorded refs. +// RecordedDeps returns the subset of ExistingDeps whose NWO@Ref or +// NWO@SHA matches one of the given recorded refs. func (pw ParsedWorkflow) RecordedDeps(recorded []parserlock.ActionRef) []dep.Dependency { refKeys := make(map[string]bool, len(recorded)) for _, r := range recorded { @@ -79,7 +83,8 @@ func (pw ParsedWorkflow) RecordedDeps(recorded []parserlock.ActionRef) []dep.Dep } var out []dep.Dependency for _, d := range pw.ExistingDeps { - if refKeys[d.Key()] { + nwo := strings.ToLower(d.NWO) + if refKeys[nwo+"@"+d.Ref] || refKeys[nwo+"@"+strings.ToLower(d.SHA)] { out = append(out, d) } } diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index 47dca1e2..55633e70 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -58,6 +58,13 @@ func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { recordedKeys := make(map[string]bool) if !opts.Rescan { for i := range parsed { + // Local-path workflows are skipped at diagnose time; don't + // waste network calls resolving their refs. + if len(parsed[i].LocalPaths) > 0 { + parsed[i].Resolved = true + skippedRescan++ + continue + } recorded, unrecorded := parsed[i].PartitionRefs() if len(parsed[i].Refs) == 0 || len(unrecorded) == 0 { parsed[i].Resolved = true diff --git a/internal/pipeline/run_test.go b/internal/pipeline/run_test.go index 521f8c96..90679bcd 100644 --- a/internal/pipeline/run_test.go +++ b/internal/pipeline/run_test.go @@ -100,6 +100,19 @@ func TestPartitionRefs(t *testing.T) { wantRecordedLen: 2, // both sub-actions match the dep wantUnrecordLen: 0, }, + { + name: "bare SHA ref matches by SHA", + pw: checks.ParsedWorkflow{ + Refs: []parserlock.ActionRef{ + ref("actions", "checkout", "", "de0fac2e4500dabe0009e67214ff5f5447ce83dd"), + }, + ExistingDeps: []dep.Dependency{ + mkDep("actions/checkout", "v6.0.2", "de0fac2e4500dabe0009e67214ff5f5447ce83dd"), + }, + }, + wantRecordedLen: 1, + wantUnrecordLen: 0, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 920224a5ca8156baaf9bfe707b5252a656e9b672 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 13:12:13 -0500 Subject: [PATCH 20/21] catalog: add scenarios for branch-ref narrowing and local-path-only workflows New scenarios: - fresh_branch_ref_narrows: @main on public action narrows to full semver - fresh_branch_ref_no_narrow: --no-narrow keeps @main as-is - onboarded_branch_ref_narrows: verified dep at @main gets narrowed on repin - local_action_only: workflow with only local path steps, no remote refs Replaces the now-wrong fresh_branch_ref_skipped which asserted @main stayed as main (before non-semver narrowing was added). --- test/integration/run.rb | 20 +++++++++++++++ test/scenarios/catalog.yml | 51 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/test/integration/run.rb b/test/integration/run.rb index f0c73203..594a1c20 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -373,6 +373,26 @@ def golden_json_diff(expected, actual, path) } ) }, + # Lockfile with a branch-ref dep key (main) — represents a workflow + # onboarded with --no-narrow that should be narrowed on re-pin. + "pinned_checkout_main" => -> { + build_lockfile( + workflows: { + ".github/workflows/ci.yml" => [ + "actions/checkout@main:sha1-#{CHECKOUT_SHA}" + ] + }, + dependencies: { + "actions/checkout@main:sha1-#{CHECKOUT_SHA}" => { + "tag" => "", + "branch" => "main", + "commit" => "sha1-#{CHECKOUT_SHA}", + "owner_id" => 44036562, + "repo_id" => 197814629 + } + } + ) + }, "future_version" => -> { <<~YAML # This file is machine-generated by `gh actions-lock`. diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 27b8f940..fffc590e 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -652,10 +652,24 @@ scenarios: exit: 0 lockfile_comment_matches: 'v4\.2\.0' - - name: fresh_branch_ref_skipped + - name: fresh_branch_ref_narrows category: narrowing - description: "Branch ref (main) is not a semver — narrowing skips it" + description: "Branch ref (main) on public action narrows to full semver" needs_token: true + fixtures: + workflows: + ci.yml: + name: CI + actions: ["actions/checkout@main"] + expect: + exit: 0 + lockfile_comment_matches: 'v\d+\.\d+\.\d+' + + - name: fresh_branch_ref_no_narrow + category: narrowing + description: "--no-narrow: branch ref (main) stays as main" + needs_token: true + flags: ["--no-narrow"] fixtures: workflows: ci.yml: @@ -665,6 +679,39 @@ scenarios: exit: 0 lockfile_comment_matches: 'main' + - name: onboarded_branch_ref_narrows + category: narrowing + description: "Onboarded workflow at @main — verified dep narrowing upgrades to full semver" + needs_token: true + fixtures: + workflows: + ci.yml: + name: CI + actions: ["actions/checkout@main"] + lockfile_template: pinned_checkout_main + expect: + exit: 0 + lockfile_comment_matches: 'v\d+\.\d+\.\d+' + + - name: local_action_only + category: workflow_parsing + description: "Workflow with only local path actions (no remote refs) — skipped cleanly" + needs_token: true + fixtures: + workflows: + ci.yml: + raw: | + name: CI + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/my-action + - uses: ./.github/actions/other-action + expect: + exit: 0 + - name: fresh_no_narrow_keeps_major category: narrowing description: "--no-narrow: splat ref v4 stays v4 in lockfile (not narrowed to v4.x.y)" From f1b059cdc11bc089e8d42ae52b57f69012b32379 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 15 Jun 2026 13:14:01 -0500 Subject: [PATCH 21/21] address CCR review feedback - narrowVerifiedEntries: set AutoFixedRef to just the ref, not NWO@ref - IsWarning: return false for error-level LocalAction findings - json: only skip warning-level LocalAction findings, not errors - harness edit: use Shellwords.split + system(*cmd, chdir:) instead of interpolating EDITOR into a bash -c string --- cmd/gh-actions-lock/format/json.go | 4 ++-- internal/pin/plan.go | 5 +++-- internal/pipeline/checks/finding.go | 2 +- test/integration/harness.rb | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cmd/gh-actions-lock/format/json.go b/cmd/gh-actions-lock/format/json.go index 9d5dd965..25c1dd75 100644 --- a/cmd/gh-actions-lock/format/json.go +++ b/cmd/gh-actions-lock/format/json.go @@ -115,7 +115,7 @@ func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVer } for _, wr := range report.Workflows { for _, f := range wr.Findings { - if f.Category == checks.RunOnly || f.Category == checks.LocalAction || (f.Category == checks.Valid && f.Severity == checks.SeverityOK) { + if f.Category == checks.RunOnly || (f.Category == checks.LocalAction && f.Severity != checks.SeverityError) || (f.Category == checks.Valid && f.Severity == checks.SeverityOK) { continue } allFindings = append(allFindings, findingFromReport(f)) @@ -185,7 +185,7 @@ func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVer Findings: []Finding{}, } for _, f := range wr.Findings { - if f.Category == checks.RunOnly || f.Category == checks.LocalAction || (f.Category == checks.Valid && f.Severity == checks.SeverityOK) { + if f.Category == checks.RunOnly || (f.Category == checks.LocalAction && f.Severity != checks.SeverityError) || (f.Category == checks.Valid && f.Severity == checks.SeverityOK) { continue } wf.Findings = append(wf.Findings, findingFromReport(f)) diff --git a/internal/pin/plan.go b/internal/pin/plan.go index a19157a6..384f16b3 100644 --- a/internal/pin/plan.go +++ b/internal/pin/plan.go @@ -607,11 +607,12 @@ func narrowVerifiedEntries(ctx context.Context, entries []Entry, opts PlanOption continue } } - oldUses := e.NWO + "@" + e.Ref + oldRef := e.Ref + oldUses := e.NWO + "@" + oldRef newUses := e.NWO + "@" + patchTag rewrites[oldUses] = newUses e.Ref = patchTag - e.AutoFixedRef = oldUses + e.AutoFixedRef = oldRef } if len(rewrites) == 0 { return nil diff --git a/internal/pipeline/checks/finding.go b/internal/pipeline/checks/finding.go index c2b7b6df..e92e85ce 100644 --- a/internal/pipeline/checks/finding.go +++ b/internal/pipeline/checks/finding.go @@ -124,7 +124,7 @@ func (f *Finding) IsWarning() bool { case f.Category == RefMoved: return true case f.Category == LocalAction: - return true + return f.Severity != SeverityError case f.Category.IsInconclusive(): return true case f.Category == NotPinned && f.ActionRef == nil: diff --git a/test/integration/harness.rb b/test/integration/harness.rb index 0d07ac82..359901f8 100644 --- a/test/integration/harness.rb +++ b/test/integration/harness.rb @@ -1108,9 +1108,9 @@ def shell @last_dir end if dir - editor = ENV["EDITOR"] || "code" - puts "\e[2m$ pushd #{dir} && #{editor} && popd\e[0m" - system("bash", "-c", "pushd #{Shellwords.shellescape(dir)} && #{editor} && popd") + editor_cmd = Shellwords.split(ENV["EDITOR"] || "code") + puts "\e[2m$ cd #{dir} && #{editor_cmd.join(' ')}\e[0m" + system(*editor_cmd, chdir: dir) else puts "No active scenario directory." end