@@ -48,11 +48,12 @@ type upgradeTarget struct {
4848}
4949
5050type validationError struct {
51- Type string `json:"type"`
52- Dependency string `json:"dependency"`
53- Details string `json:"details"`
54- CompareURL string `json:"compare_url,omitempty"`
55- ReleasesURL string `json:"releases_url,omitempty"`
51+ Type string `json:"type"`
52+ Dependency string `json:"dependency"`
53+ Details string `json:"details"`
54+ CompareURL string `json:"compare_url,omitempty"`
55+ ReleasesURL string `json:"releases_url,omitempty"`
56+ UnreachableDetail string `json:"unreachable_detail,omitempty"`
5657}
5758
5859type validationWarning struct {
@@ -659,10 +660,67 @@ func runCheck(opts *checkOptions) error {
659660 if aggregate .Valid && checked > 0 {
660661 output .Success ("All %d %s valid" , checked , ui .Pluralize (checked , "workflow" , "workflows" ))
661662 } else if checked > 0 {
662- typeCounts := map [string ]int {}
663+ // Group errors by dependency so we can merge related findings
664+ // (e.g. TAMPERED + UNREACHABLE for the same dep).
665+ type depFindings struct {
666+ dep string
667+ errors []validationError
668+ }
669+ var depOrder []string
670+ depMap := map [string ]* depFindings {}
663671 for _ , e := range aggregate .Errors {
664- typeCounts [e .Type ]++
672+ if df , ok := depMap [e .Dependency ]; ok {
673+ df .errors = append (df .errors , e )
674+ } else {
675+ depOrder = append (depOrder , e .Dependency )
676+ depMap [e .Dependency ] = & depFindings {dep : e .Dependency , errors : []validationError {e }}
677+ }
665678 }
679+
680+ // Merge TAMPERED+UNREACHABLE: when both exist for a dep, fold
681+ // the unreachable detail into the tampered entry.
682+ typeCounts := map [string ]int {}
683+ for _ , dep := range depOrder {
684+ df := depMap [dep ]
685+ hasTampered := false
686+ for _ , e := range df .errors {
687+ if e .Type == "TAMPERED" {
688+ hasTampered = true
689+ break
690+ }
691+ }
692+ if hasTampered {
693+ // Absorb UNREACHABLE into TAMPERED — keep unreachable
694+ // detail as supplementary info but don't double-count.
695+ var merged []validationError
696+ var unreachableDetail string
697+ for _ , e := range df .errors {
698+ if e .Type == "UNREACHABLE" && hasTampered {
699+ unreachableDetail = e .Details
700+ continue
701+ }
702+ if e .Type == "TAMPERED" && unreachableDetail == "" {
703+ // Haven't seen UNREACHABLE yet; scan ahead.
704+ for _ , e2 := range df .errors {
705+ if e2 .Type == "UNREACHABLE" {
706+ unreachableDetail = e2 .Details
707+ break
708+ }
709+ }
710+ }
711+ me := e
712+ if me .Type == "TAMPERED" && unreachableDetail != "" {
713+ me .UnreachableDetail = unreachableDetail
714+ }
715+ merged = append (merged , me )
716+ }
717+ df .errors = merged
718+ }
719+ for _ , e := range df .errors {
720+ typeCounts [e .Type ]++
721+ }
722+ }
723+
666724 parts := []string {}
667725 for _ , t := range []string {"TAMPERED" , "MISSING" , "STALE" , "SHA_MISMATCH" , "UNREACHABLE" , "ERROR" } {
668726 if n , ok := typeCounts [t ]; ok {
@@ -673,18 +731,29 @@ func runCheck(opts *checkOptions) error {
673731 failed , checked ,
674732 ui .Pluralize (checked , "workflow" , "workflows" ),
675733 strings .Join (parts , ", " ))
676-
677- // Show each error inline so users don't have to dig through the log.
678- for _ , e := range aggregate .Errors {
679- label := output .Dim ("[" + e .Type + "]" )
680- output .Detail (" %s %s: %s" , label , e .Dependency , e .Details )
681- if e .CompareURL != "" {
682- output .Detail (" → Compare: %s" , e .CompareURL )
734+ fmt .Fprintln (os .Stderr )
735+
736+ // Show grouped errors with doctor-style formatting.
737+ for i , dep := range depOrder {
738+ df := depMap [dep ]
739+ for _ , e := range df .errors {
740+ fmt .Fprintf (os .Stderr , " ! %s %s\n " , output .Dim (e .Type ), e .Dependency )
741+ fmt .Fprintf (os .Stderr , " %s\n " , e .Details )
742+ if e .UnreachableDetail != "" {
743+ fmt .Fprintf (os .Stderr , " %s\n " , e .UnreachableDetail )
744+ }
745+ if e .CompareURL != "" {
746+ fmt .Fprintf (os .Stderr , " → %s\n " , output .Dim (e .CompareURL ))
747+ }
748+ if e .ReleasesURL != "" {
749+ fmt .Fprintf (os .Stderr , " → %s\n " , output .Dim (e .ReleasesURL ))
750+ }
683751 }
684- if e . ReleasesURL != "" {
685- output . Detail ( " → Releases: %s" , e . ReleasesURL )
752+ if i < len ( depOrder ) - 1 {
753+ fmt . Fprintln ( os . Stderr )
686754 }
687755 }
756+ fmt .Fprintln (os .Stderr )
688757 }
689758 if len (aggregate .Warnings ) > 0 {
690759 // Group warnings by key (same dependency+details) and collect workflow files.
@@ -711,9 +780,27 @@ func runCheck(opts *checkOptions) error {
711780 }
712781 for _ , key := range order {
713782 g := groups [key ]
714- output .Warning ("%s" , g .warning .String ())
783+ w := g .warning
784+ if w .Transitive {
785+ // Extract owner/repo from dep key for a cleaner message.
786+ nwo := w .Dependency
787+ if idx := strings .Index (nwo , "@" ); idx > 0 {
788+ nwo = nwo [:idx ]
789+ }
790+ // Strip sub-path from NWO (e.g. owner/repo/subpath → owner/repo).
791+ nwoParts := strings .SplitN (nwo , "/" , 3 )
792+ repoNWO := nwo
793+ if len (nwoParts ) >= 2 {
794+ repoNWO = nwoParts [0 ] + "/" + nwoParts [1 ]
795+ }
796+ output .Warning ("%s: transitive dependency pinned to a bare SHA — reachability cannot be verified" , w .Dependency )
797+ output .Detail (" ↳ this comes from a composite action's internal dependency" )
798+ output .Detail (" ↳ ask the maintainer of %s to onboard to dependency pinning" , output .Bold (repoNWO ))
799+ } else {
800+ output .Warning ("%s" , w .String ())
801+ }
715802 for _ , f := range g .files {
716- output .Detail ("in %s" , f )
803+ output .Detail (" in %s" , f )
717804 }
718805 }
719806 }
0 commit comments