diff --git a/cmd/gh-actions-lock/format/json.go b/cmd/gh-actions-lock/format/json.go index 04ebdc35..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.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.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/cmd/gh-actions-lock/format/terminal.go b/cmd/gh-actions-lock/format/terminal.go index 7a33c646..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 { @@ -187,11 +188,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 +217,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/pin/plan.go b/internal/pin/plan.go index 289b158f..384f16b3 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 } @@ -273,32 +280,28 @@ 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) - 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 dep.Ref = patchTag + narrowedNWOs[strings.ToLower(dep.NWO)] = true 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,17 +309,37 @@ 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 == "" { + if err != nil { continue } + // No exact tag match — if the repo publishes semver releases, + // walk back to the latest tag that's an ancestor of this SHA. + if patchTag == "" { + patchTag, err = opts.Tagger.BestAncestorTag(ctx, owner, repo, dep.SHA) + if err != nil || patchTag == "" { + continue + } + } oldUses := dep.NWO + "@" + dep.Ref newUses := dep.NWO + "@" + patchTag rewrites[oldUses] = newUses dep.Ref = patchTag + narrowedNWOs[nwoLower] = true + } + } + + // Save narrowed refs before ReverseLookup — it may overwrite dep.Ref + // 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 } } @@ -337,7 +360,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 } @@ -360,6 +393,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, @@ -537,3 +576,55 @@ 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 + } + // 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 + } + } + oldRef := e.Ref + oldUses := e.NWO + "@" + oldRef + newUses := e.NWO + "@" + patchTag + rewrites[oldUses] = newUses + e.Ref = patchTag + e.AutoFixedRef = oldRef + } + 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] +} 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..e92e85ce 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 f.Severity != SeverityError 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..677e9150 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 @@ -39,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 @@ -48,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] { @@ -69,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 { @@ -78,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/diagnose.go b/internal/pipeline/diagnose.go index 3a05cc8c..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. @@ -64,6 +65,29 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve wr.ActionRefs = pw.Refs wr.ParseWarnings = pw.ParseWarnings + if len(pw.LocalPaths) > 0 { + 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 + } + if len(pw.Refs) == 0 { wr.Findings = append(wr.Findings, checks.Finding{ WorkflowPath: pw.Path, @@ -214,7 +238,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/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") +} 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/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) { 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 { 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. diff --git a/test/integration/harness.rb b/test/integration/harness.rb index a37cfa4e..359901f8 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 @@ -889,6 +890,8 @@ def yaml_scalar(v) end end + public + # ── Interactive shell ────────────────────────────────────────── def shell @@ -975,7 +978,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 @@ -1048,15 +1051,70 @@ 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 - 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 + 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"] : [] + 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, extra_args: extra) + 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 + 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) + 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." 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 "edit" + dir = if active_ctx + active_ctx.dir + elsif @last_dir && File.directory?(@last_dir) + @last_dir + end + if dir + 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 + when "profile" if arg.nil? || arg == "on" @profile_dir = File.expand_path("profiles") @@ -1257,7 +1315,11 @@ 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[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)" puts " \e[36mpause\e[0m Toggle pause between scenarios in run-all" puts " \e[36mprofile [dir|off]\e[0m Toggle profiling (default: ./profiles)" @@ -1350,7 +1412,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 ── @@ -1406,7 +1468,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) @@ -1445,9 +1507,15 @@ def run_one_live(s) end @last_dir = ctx.dir puts + 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 + nil end def format_expect(spec) 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 f2d94d5e..fffc590e 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: @@ -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" @@ -631,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) 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: "Branch ref (main) is not a semver — narrowing skips it" + description: "--no-narrow: branch ref (main) stays as main" needs_token: true + flags: ["--no-narrow"] fixtures: workflows: ci.yml: @@ -644,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)"