Skip to content

Commit 6272966

Browse files
Give missing refs and invalid workflows their own JSON categories; report post-fix state in fix-mode JSON (#232)
* Give missing refs and invalid workflows their own JSON categories; report post-fix state in fix-mode JSON Missing uses: refs are now ref-missing and unloadable workflows invalid-workflow, instead of not-pinned. A successful fix run's --json reports what the commit left: errors it fixed are dropped and valid is recomputed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Expect ref-missing in the impostor-commit scenario Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent dabb479 commit 6272966

17 files changed

Lines changed: 163 additions & 37 deletions

‎cmd/gh-actions-lock/format/json.go‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,7 @@ type Workflow struct {
9292
Dependencies []Dependency `json:"dependencies,omitempty"`
9393
}
9494

95-
// Pin is the post-fix outcome for one action from the pin plan. The other
96-
// fields describe the pre-fix diagnosis.
95+
// Pin is the post-fix outcome for one action from the pin plan.
9796
type Pin struct {
9897
Hostname string `json:"hostname,omitempty"`
9998
NWO string `json:"nwo"`

‎cmd/gh-actions-lock/format/terminal.go‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func PresentResults(out *ui.UI, report *checks.Report, valid bool, willRemediate
6565

6666
func findingExcluded(f checks.Finding, exclude map[checks.Category]bool, willRemediate bool) bool {
6767
if f.Category == checks.NotPinned && !f.IsRemediableNotPinned() {
68-
// Workflow-level NotPinned is a load failure, not a pin finding.
68+
// A non-remediable NotPinned can't be fixed by pinning.
6969
return false
7070
}
7171
return exclude[f.Category] || willRemediate && f.Category == checks.NotPinned
@@ -282,6 +282,10 @@ func categoryLabel(c checks.Category) string {
282282
switch c {
283283
case checks.NotPinned:
284284
return "Not pinned"
285+
case checks.MissingRef:
286+
return "Ref does not exist"
287+
case checks.InvalidWorkflow:
288+
return "Invalid workflow"
285289
case checks.RefChanged:
286290
return "Ref changed"
287291
case checks.MisleadingSHA:
@@ -397,7 +401,7 @@ func renderErrorFindings(out *ui.UI, report *checks.Report, failedCount, checked
397401
parts := []string{}
398402
for _, cat := range []checks.Category{
399403
checks.RepoReplaced, checks.RepoUnavailable, checks.RepoRenamed, checks.UnreachablePin, checks.TagCommitMismatch,
400-
checks.RefChanged, checks.NotPinned, checks.OnboardingRequired,
404+
checks.RefChanged, checks.NotPinned, checks.MissingRef, checks.InvalidWorkflow, checks.OnboardingRequired,
401405
checks.LocalAction, checks.InvalidSelfRepositoryRef,
402406
checks.Stale, checks.MisleadingSHA, checks.IncompleteLockfile,
403407
} {

‎cmd/gh-actions-lock/format/terminal_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ func TestPresentResults_ExcludedNotPinnedKeepsWorkflowLoadErrors(t *testing.T) {
445445
{
446446
Path: ".github/workflows/broken.yml",
447447
Findings: []checks.Finding{{
448-
Category: checks.NotPinned,
448+
Category: checks.InvalidWorkflow,
449449
Severity: checks.SeverityError,
450450
Detail: "failed to load workflow",
451451
}},
@@ -456,7 +456,7 @@ func TestPresentResults_ExcludedNotPinnedKeepsWorkflowLoadErrors(t *testing.T) {
456456
PresentResults(ui.NewPlain(&buf), report, false, false, checks.NotPinned)
457457

458458
got := buf.String()
459-
if !strings.Contains(got, "1 of 2 workflows failed: 1 not-pinned") {
459+
if !strings.Contains(got, "1 of 2 workflows failed: 1 invalid-workflow") {
460460
t.Errorf("workflow load error should remain after remediation:\n%s", got)
461461
}
462462
if !strings.Contains(got, ".github/workflows/broken.yml") ||

‎cmd/gh-actions-lock/missing_ref_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ jobs:
7878
require.NoError(t, json.Unmarshal([]byte(stdout), &out), stdout)
7979
found := false
8080
for _, f := range out.Findings {
81-
if f.Category == "not-pinned" && f.Severity == "error" {
81+
if f.Category == "ref-missing" && f.Severity == "error" {
8282
found = true
8383
assert.Equal(t, detail, f.Detail)
8484
assert.Equal(t, remedy, f.Remediation)

‎cmd/gh-actions-lock/pin_summary.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func reportHasUnfixableErrors(report *checks.Report, acceptMoved bool) bool {
2929
continue
3030
}
3131
switch f.Category {
32-
case checks.LocalAction, checks.InvalidSelfRepositoryRef, checks.RepoRenamed, checks.RepoReplaced, checks.RepoUnavailable, checks.ReachabilityUnknown, checks.AncestryUnknown:
32+
case checks.LocalAction, checks.InvalidSelfRepositoryRef, checks.MissingRef, checks.InvalidWorkflow, checks.RepoRenamed, checks.RepoReplaced, checks.RepoUnavailable, checks.ReachabilityUnknown, checks.AncestryUnknown:
3333
return true
3434
case checks.NotPinned:
3535
if !f.IsRemediableNotPinned() {
@@ -86,6 +86,8 @@ func reportHasNonInvestigatedUnfixableErrors(report *checks.Report) bool {
8686
}
8787
if f.Category == checks.LocalAction ||
8888
f.Category == checks.InvalidSelfRepositoryRef ||
89+
f.Category == checks.MissingRef ||
90+
f.Category == checks.InvalidWorkflow ||
8991
f.Category == checks.RepoRenamed ||
9092
f.Category == checks.RepoReplaced ||
9193
f.Category == checks.RepoUnavailable ||

‎cmd/gh-actions-lock/pin_summary_test.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,20 @@ func TestRenderNarrowedEntries_BranchRepair(t *testing.T) {
6262
func TestReportHasUnfixableErrors_ClassifiesWorkflowNotPinned(t *testing.T) {
6363
for _, tt := range []struct {
6464
name string
65+
category checks.Category
6566
remediable bool
6667
wantBlocked bool
6768
}{
68-
{name: "workflow load error", wantBlocked: true},
69-
{name: "dependency inventory error", remediable: true},
69+
{name: "malformed nested ref", category: checks.NotPinned, wantBlocked: true},
70+
{name: "dependency inventory error", category: checks.NotPinned, remediable: true},
71+
{name: "workflow load error", category: checks.InvalidWorkflow, wantBlocked: true},
72+
{name: "missing ref", category: checks.MissingRef, wantBlocked: true},
7073
} {
7174
t.Run(tt.name, func(t *testing.T) {
7275
report := &checks.Report{
7376
Workflows: []checks.WorkflowReport{{
7477
Findings: []checks.Finding{{
75-
Category: checks.NotPinned,
78+
Category: tt.category,
7679
Severity: checks.SeverityError,
7780
Remediable: tt.remediable,
7881
}},

‎cmd/gh-actions-lock/run.go‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -565,17 +565,37 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
565565
injectFreshTagFindings(ctx, report, record, tagger, cooldownCfg)
566566
}
567567

568-
// JSON mode emits the (pre-fix) diagnosis now — after the commit
569-
// succeeded — so machine consumers never see findings for a run that
570-
// then failed to write.
568+
// JSON mode reports the state the commit left, and only after it
569+
// succeeded, so machine consumers never see findings for a run that
570+
// then failed to write. The run log keeps the pre-fix diagnosis.
571571
if opts.jsonFields != "" {
572-
return format.WriteJSON(out, report, record, valid, opts.jsonFields, cliVersion(), store.File().Version, r.Hostname())
572+
fixed := postFixReport(report)
573+
return format.WriteJSON(out, fixed, record, fixed.IsValid(), opts.jsonFields, cliVersion(), store.File().Version, r.Hostname())
573574
}
574575

575576
// Terminal summary.
576577
return renderPinSummary(ctx, console, record, report, r, refusedLabels, opts.noNarrow, opts.acceptMoved, store.OriginalVersion(), staleWorkflows)
577578
}
578579

580+
// postFixReport is report as a committed write left it. A run commits only
581+
// when it can fix every error it found (see runHasBlockingErrors), so no error
582+
// stands afterwards; warnings and info findings remain.
583+
func postFixReport(report *checks.Report) *checks.Report {
584+
unfixed := func(ff []checks.Finding) []checks.Finding {
585+
return slices.DeleteFunc(slices.Clone(ff), func(f checks.Finding) bool {
586+
return f.Severity == checks.SeverityError
587+
})
588+
}
589+
out := *report
590+
out.RepoFindings = unfixed(report.RepoFindings)
591+
out.Workflows = make([]checks.WorkflowReport, len(report.Workflows))
592+
for i, wr := range report.Workflows {
593+
wr.Findings = unfixed(wr.Findings)
594+
out.Workflows[i] = wr
595+
}
596+
return &out
597+
}
598+
579599
// injectVersionRefFindings appends info-severity findings for entries pinned
580600
// with a non-full-semver ref (v4, v3.1, main, etc.). These surface in --json
581601
// output so machine consumers can detect imprecise refs.

‎cmd/gh-actions-lock/run_test.go‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,12 @@ updates:
9797
assert.True(t, payload.Valid)
9898
assert.Empty(t, payload.Findings)
9999
} else {
100-
require.Len(t, payload.Findings, 2)
100+
// The fix pinned the action; only the warning remains.
101+
assert.True(t, payload.Valid)
102+
require.Len(t, payload.Findings, 1)
101103
assert.Equal(t, "cooldown-config-ignored", payload.Findings[0].Category)
102104
assert.Equal(t, "warning", payload.Findings[0].Severity)
103105
assert.Equal(t, warning, payload.Findings[0].Detail)
104-
assert.Equal(t, "not-pinned", payload.Findings[1].Category)
105106
}
106107
} else if mode.readOnly {
107108
assert.NotContains(t, stdout+stderr, warning)
@@ -918,3 +919,77 @@ func TestRun_LocalActionErrorLeadsTheOutput(t *testing.T) {
918919
})
919920
}
920921
}
922+
923+
func TestPostFixReport(t *testing.T) {
924+
report := &checks.Report{
925+
Workflows: []checks.WorkflowReport{{
926+
Path: ".github/workflows/ci.yml",
927+
Findings: []checks.Finding{
928+
{Category: checks.NotPinned, Severity: checks.SeverityError},
929+
{Category: checks.FreshTag, Severity: checks.SeverityInfo},
930+
{Category: checks.LocalAction, Severity: checks.SeverityWarning},
931+
},
932+
}},
933+
RepoFindings: []checks.Finding{
934+
{Category: checks.Stale, Severity: checks.SeverityError},
935+
{Category: checks.StaleWorkflow, Severity: checks.SeverityInfo},
936+
},
937+
}
938+
require.False(t, report.IsValid())
939+
940+
got := postFixReport(report)
941+
942+
assert.True(t, got.IsValid())
943+
var cats []checks.Category
944+
for _, f := range got.Workflows[0].Findings {
945+
cats = append(cats, f.Category)
946+
}
947+
assert.Equal(t, []checks.Category{checks.FreshTag, checks.LocalAction}, cats, "non-blocking findings stay")
948+
require.Len(t, got.RepoFindings, 1)
949+
assert.Equal(t, checks.StaleWorkflow, got.RepoFindings[0].Category)
950+
assert.Len(t, report.Workflows[0].Findings, 3, "the run log keeps the pre-fix report")
951+
assert.Len(t, report.RepoFindings, 2)
952+
}
953+
954+
func TestRun_FixJSONReportsPostFixState(t *testing.T) {
955+
const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
956+
path := writeTempWorkflow(t, `
957+
name: ci
958+
on: push
959+
jobs:
960+
test:
961+
runs-on: ubuntu-latest
962+
steps:
963+
- uses: example/action@main
964+
`)
965+
t.Setenv("GH_ACTIONS_LOCK_CONFIG", filepath.Join(t.TempDir(), "none.yml"))
966+
reg := &httpmock.Registry{}
967+
reg.Register(httpmock.GraphQLForRepo("example", "action"), httpmock.JSONResponse(map[string]any{
968+
"data": map[string]any{"a0": testRepoResponse("example/action", sha, nodeActionYAML)},
969+
}))
970+
reg.Register(httpmock.REST("GET", "repos/example/action$"), httpmock.JSONResponse(map[string]any{
971+
"id": 2, "owner": map[string]any{"id": 1}, "default_branch": "main",
972+
}))
973+
974+
stdout, stderr, err := runCommandWithHTTP(t, reg, "--no-interactive", "--json=valid,findings,workflows,pins", path)
975+
require.NoError(t, err, "stderr:\n%s", stderr)
976+
977+
var payload struct {
978+
Valid bool `json:"valid"`
979+
Findings []format.Finding `json:"findings"`
980+
Workflows []format.Workflow `json:"workflows"`
981+
Pins []format.Pin `json:"pins"`
982+
}
983+
require.NoError(t, json.Unmarshal([]byte(stdout), &payload), stdout)
984+
assert.True(t, payload.Valid, stdout)
985+
for _, f := range payload.Findings {
986+
assert.NotEqual(t, "error", f.Severity, "%+v", f)
987+
}
988+
require.Len(t, payload.Workflows, 1)
989+
assert.True(t, payload.Workflows[0].Valid)
990+
require.Len(t, payload.Pins, 1)
991+
assert.Equal(t, "example/action", payload.Pins[0].NWO)
992+
assert.Equal(t, sha, payload.Pins[0].SHA)
993+
assert.Contains(t, readTempLockfilePins(t), sha)
994+
reg.Verify(t)
995+
}

‎cmd/gh-actions-lock/verify_test.go‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,15 @@ jobs:
210210
stale = append(stale, f)
211211
}
212212
}
213-
require.Len(t, stale, 1, "%+v", p.Findings)
214-
assert.Equal(t, "actions/setup-go@v5", stale[0].Dependency)
215-
assert.Equal(t, string(checks.SeverityError), stale[0].Severity)
216213
assert.Equal(t, !hasError, p.Valid, "valid must agree with severities")
214+
if !tc.wantErr {
215+
assert.Empty(t, stale, "a fix run reports the state it left")
216+
assert.True(t, p.Valid)
217+
} else {
218+
require.Len(t, stale, 1, "%+v", p.Findings)
219+
assert.Equal(t, "actions/setup-go@v5", stale[0].Dependency)
220+
assert.Equal(t, string(checks.SeverityError), stale[0].Severity)
221+
}
217222

218223
pins := readTempLockfilePins(t)
219224
if tc.wantErr {
@@ -293,7 +298,7 @@ jobs:
293298
}{
294299
{name: "verify untracked", mode: []string{"--verify"}, otherPins: []string{pin}, wantCategory: checks.NotPinned, wantErr: true},
295300
{name: "verify-local untracked", mode: []string{"--verify-local"}, otherPins: []string{pin}, wantCategory: checks.NotPinned, wantErr: true},
296-
{name: "fix run adds entry", otherPins: []string{pin}, wantCategory: checks.NotPinned, wantEntry: "'.github/workflows/workflow.yml': []"},
301+
{name: "fix run adds entry", otherPins: []string{pin}, wantEntry: "'.github/workflows/workflow.yml': []"},
297302
{name: "no-onboard leaves it", mode: []string{"--no-onboard"}, otherPins: []string{pin}, wantCategory: checks.OnboardingRequired},
298303
{name: "verify no-onboard opts out", mode: []string{"--verify", "--no-onboard"}, otherPins: []string{pin}, wantCategory: checks.OnboardingRequired},
299304
{name: "verify unused pin", mode: []string{"--verify"}, ownPins: []string{pin}, wantCategory: checks.Stale, wantErr: true},
@@ -319,11 +324,7 @@ jobs:
319324
hasError = hasError || f.Severity == string(checks.SeverityError)
320325
cats = append(cats, f.Category)
321326
}
322-
// Fix runs report the pre-fix diagnosis, so only the read-only
323-
// modes tie errors to the exit code.
324-
if len(tc.mode) > 0 {
325-
assert.Equal(t, tc.wantErr, hasError, "%+v", p.Findings)
326-
}
327+
assert.Equal(t, tc.wantErr, hasError, "%+v", p.Findings)
327328
assert.Equal(t, !hasError, p.Valid, "valid must agree with severities")
328329
if tc.wantCategory != "" {
329330
assert.Contains(t, cats, string(tc.wantCategory))

‎internal/pipeline/checks/category.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ const (
1111
// NotPinned means the workflow has action refs but no
1212
// corresponding dependencies entry in the lockfile.
1313
NotPinned Category = "not-pinned"
14+
// MissingRef means a `uses:` ref (tag, branch, or commit) does not exist
15+
// upstream, so there is nothing to pin. Only a source change fixes it.
16+
MissingRef Category = "ref-missing"
17+
// InvalidWorkflow means a workflow file failed to load, so none of its
18+
// actions could be checked.
19+
InvalidWorkflow Category = "invalid-workflow"
1420
// ShaAsRef means a dependency is pinned to a bare SHA with no
1521
// human-readable tag ref alongside it.
1622
ShaAsRef Category = "sha-as-ref"

0 commit comments

Comments
 (0)