@@ -2,6 +2,7 @@ package format
22
33import (
44 "fmt"
5+ "sort"
56 "strings"
67
78 "github.com/github/gh-actions-lock/internal/pipeline/checks"
@@ -84,12 +85,22 @@ func renderErrorFindings(out *ui.UI, report *checks.Report, failedCount, checked
8485 continue
8586 }
8687
88+ // Self-hosted-runner findings share the same empty dep key;
89+ // render them as a deduplicated group showing affected workflows.
90+ var selfHostedFindings []checks.Finding
8791 for _ , f := range dg .findings {
8892 if f .Category == checks .NotPinned {
8993 continue
9094 }
95+ if f .Category == checks .SelfHostedRunner {
96+ selfHostedFindings = append (selfHostedFindings , f )
97+ continue
98+ }
9199 renderFindingDetail (out , f , dep )
92100 }
101+ if len (selfHostedFindings ) > 0 {
102+ renderSelfHostedGroup (out , selfHostedFindings )
103+ }
93104 }
94105
95106 parts := []string {}
@@ -124,11 +135,11 @@ func renderFindingDetail(out *ui.UI, f checks.Finding, dep string) {
124135 if f .Category == checks .LockfileForgery && f .Dependency != nil {
125136 owner , repo := f .Dependency .OwnerRepo ()
126137 if owner != "" {
127- out .Detail (" → %s" , out .Dim (fmt .Sprintf ("https://github.com/%s/%s/releases" , owner , repo )))
138+ out .Detail (" ↳ %s" , out .Dim (fmt .Sprintf ("https://github.com/%s/%s/releases" , owner , repo )))
128139 }
129140 }
130141 if IsAlertedCategory (f .Category ) && f .Remediation != "" {
131- out .Detail (" %s %s" , out . Bold ( "⚠" ) , f .Remediation )
142+ out .Detail (" %s %s" , ui . IconWarning , f .Remediation )
132143 }
133144 if f .RecommendedTag != "" {
134145 nwo := ""
@@ -139,20 +150,79 @@ func renderFindingDetail(out *ui.UI, f checks.Finding, dep string) {
139150 if len (sha ) > 7 {
140151 sha = sha [:7 ]
141152 }
142- out .Detail (" %s Suggested re-pin: %s@%s (%s) — latest release reachable from a branch" ,
143- out . Bold ( "→" ), nwo , f .RecommendedTag , sha )
153+ out .Detail (" ↳ Suggested re-pin: %s@%s (%s) — latest release reachable from a branch" ,
154+ nwo , f .RecommendedTag , sha )
144155 }
145156 if f .Category == checks .ImpostorCommit {
146- out .Detail (" %s %s" , out . Yellow ( "!" ) , pipeline .ImpostorCommitContext )
147- out .Detail (" %s %s" , out . Bold ( "→" ) , pipeline .PublisherEscalationCopy )
157+ out .Detail (" %s %s" , ui . IconWarning , pipeline .ImpostorCommitContext )
158+ out .Detail (" ↳ %s" , pipeline .PublisherEscalationCopy )
148159 out .Detail (" see: %s" , out .DocLink (pipeline .PublisherTagReleasesDocURL ))
149160 }
150161 if f .DocURL != "" {
151162 out .Detail (" see: %s" , out .DocLink (f .DocURL ))
152163 }
153164}
154165
155- // warningGroup deduplicates warnings by dep key across workflows.
166+ // renderSelfHostedGroup prints a deduplicated block for self-hosted-runner
167+ // findings, listing each affected workflow and its non-hosted labels.
168+ func renderSelfHostedGroup (out * ui.UI , findings []checks.Finding ) {
169+ label := "SELF-HOSTED-RUNNER"
170+ icon := "!"
171+ if IsAlertedCategory (checks .SelfHostedRunner ) {
172+ icon = "✗"
173+ }
174+ out .Detail ("%s %s" , icon , out .Dim (label ))
175+ for _ , f := range findings {
176+ wfName := workflowName (f .WorkflowPath )
177+ out .Detail (" %s: %s" , out .Bold (wfName ), f .Detail )
178+ }
179+ if IsAlertedCategory (checks .SelfHostedRunner ) && findings [0 ].Remediation != "" {
180+ out .Detail (" %s %s" , ui .IconWarning , findings [0 ].Remediation )
181+ }
182+ labelSet := map [string ]bool {}
183+ for _ , f := range findings {
184+ for _ , l := range extractBracketedLabels (f .Detail ) {
185+ labelSet [l ] = true
186+ }
187+ }
188+ if len (labelSet ) > 0 {
189+ var labels []string
190+ for l := range labelSet {
191+ labels = append (labels , l )
192+ }
193+ sort .Strings (labels )
194+ out .Detail (" ↳ re-run with --allow-runners %s" , strings .Join (labels , "," ))
195+ }
196+ }
197+
198+ // workflowName extracts the workflow filename from a path like
199+ // ".github/workflows/ci.yml".
200+ func workflowName (path string ) string {
201+ if i := strings .LastIndex (path , "/" ); i >= 0 {
202+ return path [i + 1 :]
203+ }
204+ return path
205+ }
206+
207+ // extractBracketedLabels pulls comma-separated items from the first
208+ // [...] group in s. Returns nil if no brackets are found.
209+ func extractBracketedLabels (s string ) []string {
210+ start := strings .Index (s , "[" )
211+ end := strings .Index (s , "]" )
212+ if start < 0 || end <= start {
213+ return nil
214+ }
215+ inner := s [start + 1 : end ]
216+ var labels []string
217+ for _ , l := range strings .Split (inner , "," ) {
218+ l = strings .TrimSpace (l )
219+ if l != "" {
220+ labels = append (labels , l )
221+ }
222+ }
223+ return labels
224+ }
225+
156226type warningGroup struct {
157227 finding checks.Finding
158228 count int
@@ -229,6 +299,28 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) {
229299 out .TermCaution ("%d %s skipped — non-hosted runner labels are not supported" ,
230300 len (selfHostedRunnerWorkflows ),
231301 ui .Pluralize (len (selfHostedRunnerWorkflows ), "workflow" , "workflows" ))
302+ // Collect distinct labels from findings for the remediation hint.
303+ labelSet := map [string ]bool {}
304+ for _ , key := range warnOrder {
305+ wg := warnMap [key ]
306+ if wg .finding .Category != checks .SelfHostedRunner {
307+ continue
308+ }
309+ for _ , l := range extractBracketedLabels (wg .finding .Detail ) {
310+ labelSet [l ] = true
311+ }
312+ }
313+ if len (labelSet ) > 0 {
314+ var labels []string
315+ for l := range labelSet {
316+ labels = append (labels , l )
317+ }
318+ sort .Strings (labels )
319+ out .TermDetail ("↳ if these are org-hosted larger runners, re-run with --allow-runners %s" ,
320+ strings .Join (labels , "," ))
321+ } else {
322+ out .TermDetail ("↳ if these are org-hosted larger runners, re-run with --allow-runners <label>" )
323+ }
232324 }
233325 if len (unpinnedWorkflows ) > 0 {
234326 out .TermWarn ("%d %s not yet pinned" ,
0 commit comments