11import { readFileSync } from "node:fs" ;
22
3- /** Known latest major versions of popular GitHub Actions (verified via gh api 2026-04) */
4- const LATEST_ACTIONS : Record < string , string > = {
5- // Official GitHub Actions
6- "actions/checkout" : "v6" ,
7- "actions/setup-node" : "v6" ,
8- "actions/setup-python" : "v6" ,
9- "actions/setup-java" : "v5" ,
10- "actions/setup-go" : "v6" ,
11- "actions/setup-dotnet" : "v5" ,
12- "actions/cache" : "v5" ,
13- "actions/upload-artifact" : "v7" ,
14- "actions/download-artifact" : "v8" ,
15- "actions/github-script" : "v8" ,
16- "actions/dependency-review-action" : "v4" ,
17- "actions/configure-pages" : "v6" ,
18- "actions/deploy-pages" : "v5" ,
19- "actions/upload-pages-artifact" : "v4" ,
20- "actions/attest-build-provenance" : "v4" ,
21-
22- // Docker
23- "docker/build-push-action" : "v7" ,
24- "docker/setup-buildx-action" : "v4" ,
25- "docker/login-action" : "v4" ,
26- "docker/setup-qemu-action" : "v4" ,
27- "docker/metadata-action" : "v6" ,
28-
29- // Popular third-party
30- "codecov/codecov-action" : "v6" ,
31- "softprops/action-gh-release" : "v2" ,
32- "peter-evans/create-pull-request" : "v8" ,
33- "dorny/paths-filter" : "v4" ,
34- "peaceiris/actions-gh-pages" : "v4" ,
3+ type ActionRef = {
4+ path : string ;
5+ repo : string ;
6+ current : string ;
357} ;
368
379type OutdatedAction = {
@@ -57,34 +29,89 @@ function readWorkflowContent(filePath: string): string | undefined {
5729 }
5830}
5931
60- function findOutdatedActions ( content : string ) : OutdatedAction [ ] {
61- const outdated = new Map < string , OutdatedAction > ( ) ;
32+ function extractActionRefs ( content : string ) : ActionRef [ ] {
33+ const seen = new Set < string > ( ) ;
34+ const refs : ActionRef [ ] = [ ] ;
6235 const usesPattern = / u s e s : \s * ( [ ^ @ \s ] + ) @ ( v \d + (?: \. \d + ) * ) / g;
6336
6437 for ( const line of content . split ( "\n" ) ) {
6538 if ( line . trim ( ) . startsWith ( "#" ) ) continue ;
6639
6740 for ( const match of line . matchAll ( usesPattern ) ) {
68- const name = match [ 1 ] ! ;
41+ const path = match [ 1 ] ! ;
6942 const current = match [ 2 ] ! ;
43+ const key = `${ path } @${ current } ` ;
44+ if ( seen . has ( key ) ) continue ;
45+ seen . add ( key ) ;
7046
71- const latest = LATEST_ACTIONS [ name ] ;
72- if ( ! latest ) continue ;
47+ const parts = path . split ( "/" ) ;
48+ if ( parts . length < 2 ) continue ;
49+ const repo = `${ parts [ 0 ] } /${ parts [ 1 ] } ` ;
7350
74- const currentMajor = parseMajorVersion ( current ) ;
75- const latestMajor = parseMajorVersion ( latest ) ;
51+ refs . push ( { path, repo, current } ) ;
52+ }
53+ }
7654
77- if ( currentMajor === null || latestMajor === null ) continue ;
78- if ( currentMajor >= latestMajor ) continue ;
55+ return refs ;
56+ }
7957
80- const key = `${ name } @${ current } ` ;
81- if ( ! outdated . has ( key ) ) {
82- outdated . set ( key , { name, current, latest } ) ;
83- }
58+ function pickMaxMajorVersion ( tags : string [ ] ) : string | null {
59+ let maxMajor = - 1 ;
60+ for ( const tag of tags ) {
61+ const major = parseMajorVersion ( tag ) ;
62+ if ( major !== null && major > maxMajor ) {
63+ maxMajor = major ;
8464 }
8565 }
66+ return maxMajor >= 0 ? `v${ maxMajor } ` : null ;
67+ }
68+
69+ async function fetchLatestMajorVersion ( repo : string ) : Promise < string | null > {
70+ try {
71+ const proc = Bun . spawn ( [ "gh" , "api" , `repos/${ repo } /tags?per_page=50` , "--jq" , ".[].name" ] , {
72+ stdout : "pipe" ,
73+ stderr : "pipe" ,
74+ } ) ;
75+ await proc . exited ;
76+
77+ const stdout = await new Response ( proc . stdout ) . text ( ) ;
78+ const tags = stdout . trim ( ) . split ( "\n" ) . filter ( Boolean ) ;
79+
80+ return pickMaxMajorVersion ( tags ) ;
81+ } catch {
82+ return null ;
83+ }
84+ }
85+
86+ async function findOutdatedActions ( refs : ActionRef [ ] ) : Promise < OutdatedAction [ ] > {
87+ const uniqueRepos = [ ...new Set ( refs . map ( ( r ) => r . repo ) ) ] ;
88+
89+ const latestByRepo = new Map < string , string > ( ) ;
90+ const results = await Promise . all (
91+ uniqueRepos . map ( async ( repo ) => {
92+ const latest = await fetchLatestMajorVersion ( repo ) ;
93+ return { repo, latest } ;
94+ } ) ,
95+ ) ;
96+ for ( const { repo, latest } of results ) {
97+ if ( latest ) latestByRepo . set ( repo , latest ) ;
98+ }
99+
100+ const outdated : OutdatedAction [ ] = [ ] ;
101+ for ( const ref of refs ) {
102+ const latest = latestByRepo . get ( ref . repo ) ;
103+ if ( ! latest ) continue ;
104+
105+ const currentMajor = parseMajorVersion ( ref . current ) ;
106+ const latestMajor = parseMajorVersion ( latest ) ;
107+
108+ if ( currentMajor === null || latestMajor === null ) continue ;
109+ if ( currentMajor >= latestMajor ) continue ;
110+
111+ outdated . push ( { name : ref . path , current : ref . current , latest } ) ;
112+ }
86113
87- return [ ... outdated . values ( ) ] ;
114+ return outdated ;
88115}
89116
90117function formatSuggestions ( outdated : OutdatedAction [ ] ) : string {
@@ -101,11 +128,13 @@ function formatSuggestions(outdated: OutdatedAction[]): string {
101128}
102129
103130export {
104- LATEST_ACTIONS ,
131+ extractActionRefs ,
132+ fetchLatestMajorVersion ,
105133 findOutdatedActions ,
106134 formatSuggestions ,
107135 isGitHubWorkflowFile ,
108136 parseMajorVersion ,
137+ pickMaxMajorVersion ,
109138 readWorkflowContent ,
110139} ;
111- export type { OutdatedAction } ;
140+ export type { ActionRef , OutdatedAction } ;
0 commit comments