@@ -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
4551func 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`.
0 commit comments