Skip to content

Commit bb543bd

Browse files
authored
Merge pull request #48 from github/nodeselector/add-no-narrow-flag
Tag narrowing, dependabot contract, and integration hardening
2 parents 5321b12 + d743004 commit bb543bd

23 files changed

Lines changed: 3041 additions & 171 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
# VHS demo recordings
55
/demo/vhs/out/
66
profiles/
7+
dist/

cmd/gh-actions-pin/check.go

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import (
1313

1414
"github.com/MakeNowJust/heredoc"
1515
"github.com/cli/go-gh/v2/pkg/repository"
16+
parserlock "github.com/github/actions-lockfile/go/pkg/lockfile"
1617
"github.com/github/gh-actions-pin/cmd/gh-actions-pin/format"
1718
"github.com/github/gh-actions-pin/internal/config"
1819
"github.com/github/gh-actions-pin/internal/pin"
1920
"github.com/github/gh-actions-pin/internal/pinpool"
2021
"github.com/github/gh-actions-pin/internal/pipeline"
22+
"github.com/github/gh-actions-pin/internal/pipeline/checks"
2123
"github.com/github/gh-actions-pin/internal/profile"
2224
"github.com/github/gh-actions-pin/internal/resolve"
2325
"github.com/github/gh-actions-pin/internal/tag"
@@ -40,6 +42,10 @@ type checkOptions struct {
4042
// rewriting workflows or updating the lockfile. Orthogonal to the
4143
// renderer choice (--json).
4244
noFix bool
45+
// noNarrow disables tag narrowing: mutable version refs like "v4"
46+
// are kept as-is in the lock comment instead of being resolved to
47+
// the full patch tag (e.g. "v4.2.1").
48+
noNarrow bool
4349
}
4450

4551
func newCheckCmd(newResolver resolverFunc) *cobra.Command {
@@ -124,6 +130,7 @@ func bindCheckFlags(cmd *cobra.Command, opts *checkOptions) {
124130
cmd.Flags().StringVar(&opts.hostname, "hostname", "", "GitHub hostname to query (defaults to GH_HOST, current repo host, or github.com)")
125131
cmd.Flags().BoolVar(&opts.rescan, "rescan", false, "Re-verify reachability for every recorded pin (bypasses the lockfile fast path)")
126132
cmd.Flags().BoolVar(&opts.noFix, "no-fix", false, "Read-only: report findings without modifying workflows or the lockfile")
133+
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)")
127134
cmd.Flags().StringVar(&opts.profileDir, "profile", "", "Enable profiling: write trace, CPU profile, and HTTP log to `dir`")
128135
}
129136

@@ -173,7 +180,11 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
173180
}
174181

175182
endSetup := prof.Phase("setup (discover + lockfile)")
176-
paths, r, store, err := newRun(opts.workflowPaths, opts.hostname, pool, newResolver)
183+
// check fix mode can rebuild a deleted lockfile, so interactive sessions
184+
// may delete-and-recreate an unreadable one. --no-fix is read-only and
185+
// must not delete; it fails instead.
186+
recoverLock := newLockRecovery(noInteractiveFlag(cmd), console, confirmFactoryHook, !opts.noFix)
187+
paths, r, store, err := newRun(opts.workflowPaths, opts.hostname, pool, newResolver, recoverLock)
177188
if err != nil {
178189
return err
179190
}
@@ -250,6 +261,20 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
250261
valid := result.Valid
251262
skippedRescan := result.SkippedRescan
252263

264+
// --no-onboard: refuse to onboard new workflows or actions. Rewrite the
265+
// relevant not-pinned findings to onboarding-required and drop their refs
266+
// so Plan/Commit never pins them; already-tracked refs that were bumped
267+
// (ref-changed) are left to re-pin as usual.
268+
onboardingRefused := 0
269+
var refusedLabels []string
270+
if noOnboardFlag(cmd) {
271+
refusedLabels = gateNoOnboard(report)
272+
onboardingRefused = len(refusedLabels)
273+
if onboardingRefused > 0 {
274+
valid = report.IsValid()
275+
}
276+
}
277+
253278
// Render the read-only diagnosis. --json selects the renderer; it does
254279
// not decide whether fixes are applied. Terminal output is shown up front
255280
// (the human narrative). JSON is emitted later, after any fixes land, so
@@ -274,6 +299,14 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
274299
return err
275300
}
276301
}
302+
// Surface SSO URL even in read-only mode — it's the actionable fix
303+
// for SAML-gated repos and shouldn't require a --fix run to see.
304+
if gc := r.GHClient(); gc != nil {
305+
if ssoURL := gc.SSOURL(); ssoURL != "" {
306+
console.TermBlank()
307+
console.TermDetail("Authorize in your web browser: %s", ssoURL)
308+
}
309+
}
277310
if !valid {
278311
if opts.jsonFields == "" {
279312
console.TermDetail("Re-run without --no-fix to apply fixes.")
@@ -303,6 +336,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
303336
RepoOwner: repoOwner,
304337
RepoName: repoName,
305338
Version: cliVersion(),
339+
NoNarrow: opts.noNarrow,
306340
})
307341
endPlan()
308342
if planErr != nil {
@@ -321,6 +355,13 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
321355

322356
console.StopProgress()
323357

358+
// Inject info-severity findings for non-semver refs so they appear
359+
// in --json output. Suppressed when --no-narrow is set (user chose
360+
// this deliberately).
361+
if !opts.noNarrow {
362+
injectVersionRefFindings(report, record)
363+
}
364+
324365
// Write the run log.
325366
record.Repo = &pin.RepoInfo{Owner: repoOwner, Name: repoName, Host: resolveHostname(opts.hostname)}
326367
if path, werr := record.WriteJSON(); werr == nil {
@@ -347,7 +388,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
347388

348389
// Terminal summary.
349390
hasInconclusive := opts.rescan && report.HasInconclusive()
350-
summaryErr := renderPinSummary(console, record, report, r, skippedRescan, hasInconclusive)
391+
summaryErr := renderPinSummary(console, record, report, r, skippedRescan, hasInconclusive, refusedLabels, opts.noNarrow)
351392

352393
// Surface the SAML SSO authorization URL if one was captured during
353394
// the run, matching cli/cli's "Authorize in your web browser:" line.
@@ -374,6 +415,60 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc)
374415
return nil
375416
}
376417

418+
// injectVersionRefFindings appends info-severity findings for entries pinned
419+
// with a non-full-semver ref (v4, v3.1, main, etc.). These surface in --json
420+
// output so machine consumers can detect imprecise refs.
421+
func injectVersionRefFindings(report *checks.Report, record *pin.Record) {
422+
// Index which workflows each non-semver dep appears in.
423+
type depInfo struct {
424+
nwo string
425+
ref string
426+
wfs map[string]bool
427+
}
428+
seen := map[string]*depInfo{} // NWO@Ref → info
429+
for _, e := range record.Entries {
430+
if e.Resolution != pin.Pinned && e.Resolution != pin.Verified {
431+
continue
432+
}
433+
sv, ok := parserlock.ParseSemVer(e.Ref)
434+
if ok && sv.IsFull() {
435+
continue
436+
}
437+
key := e.NWO + "@" + e.Ref
438+
di, exists := seen[key]
439+
if !exists {
440+
di = &depInfo{nwo: e.NWO, ref: e.Ref, wfs: map[string]bool{}}
441+
seen[key] = di
442+
}
443+
for _, wf := range e.Workflows {
444+
di.wfs[wf] = true
445+
}
446+
}
447+
if len(seen) == 0 {
448+
return
449+
}
450+
451+
// Append a finding to each affected workflow report.
452+
for i := range report.Workflows {
453+
wr := &report.Workflows[i]
454+
for _, di := range seen {
455+
if !di.wfs[wr.Path] {
456+
continue
457+
}
458+
wr.Findings = append(wr.Findings, checks.Finding{
459+
WorkflowPath: wr.Path,
460+
Category: checks.VersionRef,
461+
Severity: checks.SeverityInfo,
462+
Confidence: checks.ConfidenceHigh,
463+
Detail: fmt.Sprintf(
464+
"%s@%s: prefer a full semver ref (e.g. v4.2.1) — each patch tag resolves to exactly one commit",
465+
di.nwo, di.ref,
466+
),
467+
})
468+
}
469+
}
470+
}
471+
377472
// cliVersion returns the gh-actions-pin extension version embedded by the Go
378473
// build system. Returns "(devel)" for local `go build` and a real version
379474
// like "v0.1.2" when installed via `gh extension install`.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func renderErrorFindings(out *ui.UI, report *checks.Report, failedCount, checked
9595
parts := []string{}
9696
for _, cat := range []checks.Category{
9797
checks.LockfileForgery,
98-
checks.RefChanged, checks.NotPinned,
98+
checks.RefChanged, checks.NotPinned, checks.OnboardingRequired,
9999
checks.Stale, checks.MisleadingSHA, checks.ImpostorCommit,
100100
} {
101101
if n, ok := catCounts[cat]; ok {
@@ -252,7 +252,7 @@ func renderWarnings(out *ui.UI, report *checks.Report, willRemediate bool) {
252252
// remediator should not re-print it in non-interactive mode).
253253
func IsAlertedCategory(c checks.Category) bool {
254254
switch c {
255-
case checks.ImpostorCommit, checks.LockfileForgery, checks.MisleadingSHA:
255+
case checks.ImpostorCommit, checks.LockfileForgery, checks.MisleadingSHA, checks.OnboardingRequired:
256256
return true
257257
}
258258
return false

cmd/gh-actions-pin/lockrecovery.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/cli/go-gh/v2/pkg/prompter"
8+
"github.com/github/gh-actions-pin/internal/ui"
9+
"github.com/spf13/cobra"
10+
"golang.org/x/term"
11+
)
12+
13+
// noInteractiveFlag reports the value of the persistent --no-interactive flag,
14+
// defaulting to false when it is not registered.
15+
func noInteractiveFlag(cmd *cobra.Command) bool {
16+
v, _ := cmd.Flags().GetBool("no-interactive")
17+
return v
18+
}
19+
20+
// confirmer asks a yes/no question. Satisfied by *prompter.Prompter; an
21+
// interface so tests inject a fake without a TTY.
22+
type confirmer interface {
23+
Confirm(prompt string, defaultValue bool) (bool, error)
24+
}
25+
26+
// confirmFactory returns a confirmer and whether the session can prompt.
27+
// canPrompt is false in any non-interactive context (no TTY, CI), so the
28+
// recovery policy fails closed instead of blocking on input that never comes.
29+
type confirmFactory func() (confirmer, bool)
30+
31+
// confirmFactoryHook is the confirm factory commands use to build the
32+
// corrupt-lockfile recovery policy. Production points at defaultConfirmFactory
33+
// (real terminal). Tests override it to drive the interactive delete-and-
34+
// recreate path without a TTY; the command tests run serially (t.Chdir) so a
35+
// package-level override with cleanup is safe.
36+
var confirmFactoryHook confirmFactory = defaultConfirmFactory
37+
38+
// defaultConfirmFactory binds to the real terminal and renders to stderr so
39+
// `--json` stdout stays clean. It reports canPrompt only when both stdin and
40+
// stderr are TTYs and CI is unset — a CI runner with a stray TTY must never
41+
// be prompted.
42+
func defaultConfirmFactory() (confirmer, bool) {
43+
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) || ciEnabled() {
44+
return nil, false
45+
}
46+
return prompter.New(os.Stdin, os.Stderr, os.Stderr), true
47+
}
48+
49+
// ciEnabled mirrors the CI convention used by internal/ui: most providers set
50+
// CI=true. A truthy CI value means no interactive prompts.
51+
func ciEnabled() bool {
52+
v := os.Getenv("CI")
53+
return v != "" && v != "0" && v != "false"
54+
}
55+
56+
// lockRecovery decides what to do when the on-disk lockfile can't be parsed.
57+
// It returns (true, nil) when the lockfile was removed and loading should be
58+
// retried (the empty-lockfile path then recreates it), or a non-nil error to
59+
// abort the run (exit 2). It never silently accepts an unreadable lockfile.
60+
type lockRecovery func(lockPath string, parseErr error) (recovered bool, err error)
61+
62+
// newLockRecovery builds the recovery policy. allowDelete is false for
63+
// read-only or relock commands that cannot rebuild a deleted lockfile
64+
// (`check --no-fix`, `update`); those always fail with a clear pointer. When
65+
// allowDelete is true (`check` fix mode), an interactive session is offered a
66+
// delete-and-recreate; non-interactive sessions (CI, --no-interactive) fail.
67+
func newLockRecovery(noInteractive bool, console *ui.UI, newConfirm confirmFactory, allowDelete bool) lockRecovery {
68+
return func(lockPath string, parseErr error) (bool, error) {
69+
if !allowDelete {
70+
return false, fmt.Errorf("%w; run `gh actions-pin check` to rebuild it, or delete it by hand", parseErr)
71+
}
72+
var (
73+
confirm confirmer
74+
canPrompt bool
75+
)
76+
if newConfirm != nil {
77+
confirm, canPrompt = newConfirm()
78+
}
79+
if noInteractive || !canPrompt {
80+
return false, fmt.Errorf("%w; delete it and re-run to recreate it, or fix it by hand", parseErr)
81+
}
82+
// Release the terminal so the prompt renders cleanly over any spinner.
83+
console.StopProgress()
84+
ok, err := confirm.Confirm(fmt.Sprintf("Lockfile %s is unreadable (%v). Delete and recreate it?", lockPath, parseErr), false)
85+
if err != nil {
86+
return false, err
87+
}
88+
if !ok {
89+
return false, fmt.Errorf("%w; left in place", parseErr)
90+
}
91+
if err := os.Remove(lockPath); err != nil {
92+
return false, fmt.Errorf("deleting unreadable lockfile %s: %w", lockPath, err)
93+
}
94+
console.TermNeutral("Deleted unreadable lockfile %s; it will be recreated.", lockPath)
95+
return true, nil
96+
}
97+
}

0 commit comments

Comments
 (0)