Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a5104a0
skip lockfile onboarding for workflows with local path actions
nodeselector Jun 15, 2026
52551d2
error when onboarded workflow adds local path actions
nodeselector Jun 15, 2026
89baa9d
catalog: add scenario for onboarded workflow with local path action
nodeselector Jun 15, 2026
9bbadd8
harness: fix shell method visibility
nodeselector Jun 15, 2026
c0d1775
narrow non-semver refs to full semver on public actions
nodeselector Jun 15, 2026
ebd88c8
narrow to ancestor semver tag when no exact tag matches
nodeselector Jun 15, 2026
1ea7bd0
narrow verified deps and prevent ReverseLookup from overriding
nodeselector Jun 15, 2026
6acfe30
reachability: stash found branch for DiscoverContaining reuse
nodeselector Jun 15, 2026
dda67ad
narrow same-owner private repos that publish semver tags
nodeselector Jun 15, 2026
0c133bb
harness: keep adhoc context alive for rerun
nodeselector Jun 15, 2026
ce85559
harness: checkpoint state between runs for clean rerun diffs
nodeselector Jun 15, 2026
fbd68f9
harness: add done command to teardown active adhoc context
nodeselector Jun 15, 2026
8d6eee5
harness: add rescan and edit commands for active context
nodeselector Jun 15, 2026
adbea79
harness: pass --rescan through rerun instead of separate command
nodeselector Jun 15, 2026
a72bd70
harness: chdir into scenario dir before launching editor
nodeselector Jun 15, 2026
ac7ee0b
harness: add rescan as shorthand for rerun --rescan
nodeselector Jun 15, 2026
636c696
harness: use bash pushd/popd for edit command
nodeselector Jun 15, 2026
76be3aa
harness: drop '.' arg from editor invocation
nodeselector Jun 15, 2026
95ef75c
pipeline: skip resolve/reachability for local-path workflows
nodeselector Jun 15, 2026
920224a
catalog: add scenarios for branch-ref narrowing and local-path-only w…
nodeselector Jun 15, 2026
f1b059c
address CCR review feedback
nodeselector Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/gh-actions-lock/format/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
10 changes: 9 additions & 1 deletion cmd/gh-actions-lock/format/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand All @@ -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),
Expand Down
123 changes: 107 additions & 16 deletions internal/pin/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand All @@ -273,50 +280,66 @@ 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)
if opts.NoNarrow || opts.prevImpreciseNWO[nwoLower] {
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
}
}

Expand All @@ -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
}

Expand All @@ -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,
Expand Down Expand Up @@ -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]
}
5 changes: 5 additions & 0 deletions internal/pipeline/checks/category.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion internal/pipeline/checks/category_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down
6 changes: 4 additions & 2 deletions internal/pipeline/checks/finding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
20 changes: 13 additions & 7 deletions internal/pipeline/checks/parsed.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
type ParsedWorkflow struct {
Path string
Refs []parserlock.ActionRef
LocalPaths []string
ExistingDeps []dep.Dependency
ParseWarnings []string
LoadErr error
Expand All @@ -39,18 +40,22 @@ 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
}
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] {
Expand All @@ -69,16 +74,17 @@ 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 {
refKeys[strings.ToLower(r.Owner+"/"+r.Repo)+"@"+r.Ref] = true
}
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)
}
}
Expand Down
Loading
Loading