Skip to content

Commit 66c31fd

Browse files
authored
Merge pull request #58 from github/nodeselector/improve-self-hosted-runner-errors
Improve self-hosted-runner errors: show context, deduplicate, add --allow-runners
2 parents 78a656a + 160d570 commit 66c31fd

10 files changed

Lines changed: 356 additions & 32 deletions

File tree

cmd/gh-actions-lock/check.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/github/gh-actions-lock/internal/resolve"
2525
"github.com/github/gh-actions-lock/internal/tag"
2626
"github.com/github/gh-actions-lock/internal/ui"
27+
"github.com/github/gh-actions-lock/internal/workflowfile"
2728
"github.com/spf13/cobra"
2829
)
2930

@@ -46,6 +47,10 @@ type checkOptions struct {
4647
// are kept as-is in the lock comment instead of being resolved to
4748
// the full patch tag (e.g. "v4.2.1").
4849
noNarrow bool
50+
// allowRunners lists additional runner labels to treat as hosted.
51+
// Use when org-provisioned larger runners (e.g. ubuntu-latest-xl)
52+
// are flagged as self-hosted but you know they are GitHub-hosted.
53+
allowRunners []string
4954
}
5055

5156
func newCheckCmd(newResolver resolverFunc) *cobra.Command {
@@ -103,6 +108,9 @@ func newCheckCmd(newResolver resolverFunc) *cobra.Command {
103108
# Read-only check for CI (writes nothing, exits 1 if invalid)
104109
$ gh actions-lock check --no-fix --json=valid,findings
105110
111+
# Treat org larger runners as hosted
112+
$ gh actions-lock --allow-runners ubuntu-latest-xl,ubuntu-latest-2xl
113+
106114
# All fields as JSON
107115
$ gh actions-lock check --json
108116
`),
@@ -131,6 +139,7 @@ func bindCheckFlags(cmd *cobra.Command, opts *checkOptions) {
131139
cmd.Flags().BoolVar(&opts.rescan, "rescan", false, "Re-verify reachability for every recorded pin (bypasses the lockfile fast path)")
132140
cmd.Flags().BoolVar(&opts.noFix, "no-fix", false, "Read-only: report findings without modifying workflows or the lockfile")
133141
cmd.Flags().BoolVar(&opts.noNarrow, "no-narrow", false, "Keep mutable version refs (e.g. v4) instead of narrowing to full patch tags (e.g. v4.2.1)")
142+
cmd.Flags().StringSliceVar(&opts.allowRunners, "allow-runners", nil, "Additional runner `labels` to treat as GitHub-hosted (e.g. ubuntu-latest-xl)")
134143
cmd.Flags().StringVar(&opts.profileDir, "profile", "", "Enable profiling: write trace, CPU profile, and HTTP log to `dir`")
135144
}
136145

@@ -172,6 +181,11 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
172181
// Shared worker pool for all concurrent phases.
173182
pool := pinpool.New(0, console) // 0 → DefaultWorkers
174183

184+
// Register user-supplied runner labels as hosted before parsing.
185+
if len(opts.allowRunners) > 0 {
186+
workflowfile.RegisterOrgHostedLabels(opts.allowRunners)
187+
}
188+
175189
// If profiling, use a profiled resolver that logs HTTP calls.
176190
if prof != nil && newResolver == nil {
177191
newResolver = func(hostname string, pool *pinpool.Pool) (*resolve.Resolver, error) {
@@ -197,6 +211,17 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
197211
r.SeedFromLockfile(store.AllDeps())
198212
}
199213
endSetup()
214+
215+
// Fetch org-level hosted runner labels so larger runners aren't flagged
216+
// as self-hosted. Best-effort: 403 is silently ignored.
217+
if gc := r.GHClient(); gc != nil {
218+
if currentRepo, err := repository.Current(); err == nil {
219+
if labels, err := gc.OrgHostedRunnerNames(ctx, currentRepo.Owner); err == nil && len(labels) > 0 {
220+
workflowfile.RegisterOrgHostedLabels(labels)
221+
}
222+
}
223+
}
224+
200225
opts.workflowPaths = paths
201226

202227
// Detailed narration is suppressed from the terminal during the run so the

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

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package format
22

33
import (
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+
156226
type 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",

internal/ghapi/hosted_runners.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package ghapi
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
)
9+
10+
// OrgHostedRunnerNames fetches runs-on labels of org-hosted runners.
11+
// Returns nil on permission errors (401/403) or 404 (user-owned repos)
12+
// so callers fall back to the static list.
13+
func (c *Client) OrgHostedRunnerNames(ctx context.Context, org string) ([]string, error) {
14+
var names []string
15+
page := 1
16+
for {
17+
path := fmt.Sprintf("orgs/%s/actions/hosted-runners?per_page=100&page=%d", url.PathEscape(org), page)
18+
var resp struct {
19+
TotalCount int `json:"total_count"`
20+
Runners []struct {
21+
Name string `json:"name"`
22+
} `json:"runners"`
23+
}
24+
if err := c.rest.DoWithContext(ctx, http.MethodGet, path, nil, &resp); err != nil {
25+
if IsPermissionDenied(err) || IsNotFound(err) {
26+
return nil, nil
27+
}
28+
return nil, fmt.Errorf("listing org hosted runners: %w", err)
29+
}
30+
for _, r := range resp.Runners {
31+
names = append(names, r.Name)
32+
}
33+
if len(names) >= resp.TotalCount {
34+
break
35+
}
36+
page++
37+
}
38+
return names, nil
39+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package ghapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/github/gh-actions-lock/internal/ghapi/httpmock"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestOrgHostedRunnerNames_Success(t *testing.T) {
13+
reg := &httpmock.Registry{}
14+
reg.Register(
15+
httpmock.REST("GET", "orgs/my-org/actions/hosted-runners"),
16+
httpmock.JSONResponse(map[string]any{
17+
"total_count": 2,
18+
"runners": []map[string]any{
19+
{"name": "ubuntu-latest-xl"},
20+
{"name": "ubuntu-latest-2xl"},
21+
},
22+
}),
23+
)
24+
c, err := New("github.com", WithClientTransport(reg))
25+
require.NoError(t, err)
26+
27+
names, err := c.OrgHostedRunnerNames(context.Background(), "my-org")
28+
require.NoError(t, err)
29+
assert.Equal(t, []string{"ubuntu-latest-xl", "ubuntu-latest-2xl"}, names)
30+
}
31+
32+
func TestOrgHostedRunnerNames_403_ReturnsNil(t *testing.T) {
33+
reg := &httpmock.Registry{}
34+
reg.Register(
35+
httpmock.REST("GET", "orgs/my-org/actions/hosted-runners"),
36+
httpmock.StatusResponse(403),
37+
)
38+
c, err := New("github.com", WithClientTransport(reg))
39+
require.NoError(t, err)
40+
41+
names, err := c.OrgHostedRunnerNames(context.Background(), "my-org")
42+
assert.NoError(t, err)
43+
assert.Nil(t, names)
44+
}

internal/pipeline/checks/parsed.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ type ParsedWorkflow struct {
4242
// (self-hosted, custom label, runner group, or expression). These
4343
// workflows are skipped from onboarding.
4444
NonHostedRunner bool
45+
// NonHostedLabels holds the specific non-hosted runner labels found
46+
// in the workflow (populated only when NonHostedRunner is true).
47+
NonHostedLabels []string
4548
}
4649

4750
// PartitionRefs splits refs into recorded (matching a lockfile entry by

internal/pipeline/diagnose.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package pipeline
55
import (
66
"context"
77
"fmt"
8+
"strings"
89

910
"github.com/github/gh-actions-lock/internal/dep"
1011
"github.com/github/gh-actions-lock/internal/ghapi"
@@ -89,14 +90,15 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve
8990
}
9091

9192
if pw.NonHostedRunner {
93+
labelList := strings.Join(pw.NonHostedLabels, ", ")
9294
wfKey := workflowfile.KeyFromPath(pw.Path)
9395
if store != nil && store.HasWorkflow(wfKey) {
9496
wr.Findings = append(wr.Findings, checks.Finding{
9597
WorkflowPath: pw.Path,
9698
Category: checks.SelfHostedRunner,
9799
Severity: checks.SeverityError,
98100
Confidence: checks.ConfidenceHigh,
99-
Detail: "workflow uses non-hosted runner labels which are not supported; use GitHub-hosted runners to continue using the lockfile",
101+
Detail: fmt.Sprintf("uses non-hosted runner labels [%s]; use GitHub-hosted runners to continue using the lockfile", labelList),
100102
Remediation: "switch to GitHub-hosted runner labels or move self-hosted jobs to a separate workflow",
101103
})
102104
} else {
@@ -105,7 +107,7 @@ func diagnoseOneParsed(ctx context.Context, pw checks.ParsedWorkflow, r *resolve
105107
Category: checks.SelfHostedRunner,
106108
Severity: checks.SeverityWarning,
107109
Confidence: checks.ConfidenceHigh,
108-
Detail: "workflow uses non-hosted runner labels; lockfile onboarding is not supported",
110+
Detail: fmt.Sprintf("uses non-hosted runner labels [%s]; lockfile onboarding is not supported", labelList),
109111
})
110112
}
111113
return wr

internal/pipeline/diagnose_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,14 @@ func TestDiagnoseOneParsed_SelfHostedRunner_NotOnboarded(t *testing.T) {
5252
pw := checks.ParsedWorkflow{
5353
Path: ".github/workflows/ci.yml",
5454
NonHostedRunner: true,
55+
NonHostedLabels: []string{"self-hosted", "linux"},
5556
}
5657
wr := diagnoseOneParsed(context.Background(), pw, nil, nil, nil)
5758

5859
assert.Len(t, wr.Findings, 1)
5960
assert.Equal(t, checks.SelfHostedRunner, wr.Findings[0].Category)
6061
assert.Equal(t, checks.SeverityWarning, wr.Findings[0].Severity)
62+
assert.Contains(t, wr.Findings[0].Detail, "self-hosted, linux")
6163
}
6264

6365
func TestDiagnoseOneParsed_SelfHostedRunner_AlreadyOnboarded(t *testing.T) {
@@ -71,6 +73,7 @@ func TestDiagnoseOneParsed_SelfHostedRunner_AlreadyOnboarded(t *testing.T) {
7173
pw := checks.ParsedWorkflow{
7274
Path: wfKey,
7375
NonHostedRunner: true,
76+
NonHostedLabels: []string{"self-hosted"},
7477
}
7578
wr := diagnoseOneParsed(context.Background(), pw, nil, store, nil)
7679

internal/pipeline/parse.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ func ParseAll(paths []string, store *lockfile.State) []checks.ParsedWorkflow {
4848
continue
4949
}
5050
pw.Refs, pw.LocalPaths, pw.ParseWarnings = wf.ExtractActionRefs()
51-
pw.NonHostedRunner = wf.HasNonHostedRunnerLabels()
51+
pw.NonHostedLabels = wf.NonHostedRunnerLabels()
52+
pw.NonHostedRunner = len(pw.NonHostedLabels) > 0
5253
if len(pw.Refs) > 0 {
5354
wfKey := workflowfile.KeyFromPath(path)
5455
deps, depsErr := store.Get(wfKey)

0 commit comments

Comments
 (0)