1+ #!/usr/bin/env node
2+
3+ /**
4+ * Update GitHub Actions in .github/workflows/*.ya?ml to the latest suitable release:
5+ * - Finds lines with `uses: owner/repo[/path]@ref`
6+ * - Queries GitHub Releases (first page only) for `owner/repo`
7+ * - Picks the most advanced semver whose major >= currently-installed major (if known)
8+ * - Resolves the selected release tag to a commit SHA
9+ * - Replaces the ref with the commit SHA and appends `# vX[.Y[.Z]]` comment
10+ * - Handles release names/tags like `codeql-bundle-v2.23.2` by extracting `v2.23.2`.
11+ * - No third-party modules; Node.js 22 standard library only.
12+ *
13+ * Notes:
14+ * - Supports optional GITHUB_TOKEN to raise rate limit: grant classic `public_repo` permission
15+ */
16+
17+ const fs = require ( 'fs' ) ;
18+ const fsp = require ( 'fs/promises' ) ;
19+ const path = require ( 'path' ) ;
20+ const https = require ( 'https' ) ;
21+
22+ const WORKFLOWS_DIR = path . join ( process . cwd ( ) , '.github' , 'workflows' ) ;
23+ const RELEASES_PER_PAGE = 100 ;
24+
25+ const TOKEN = process . env . GITHUB_TOKEN || null ;
26+
27+ // Simple cache maps
28+ const releasesCache = new Map ( ) ; // key: 'owner/repo' => array of release objects with extracted versions
29+ const commitShaCache = new Map ( ) ; // key: 'owner/repo@tag' => sha
30+
31+ async function main ( ) {
32+ const files = await findWorkflowFiles ( WORKFLOWS_DIR ) ;
33+ if ( files . length === 0 ) {
34+ console . log ( 'No workflow files found.' ) ;
35+ return ;
36+ }
37+
38+ let totalUpdates = 0 ;
39+
40+ for ( const file of files ) {
41+ const original = await fsp . readFile ( file , 'utf8' ) ;
42+ const lines = original . split ( / \r ? \n / ) ;
43+
44+ let updated = false ;
45+ for ( let i = 0 ; i < lines . length ; i ++ ) {
46+ const parsed = parseUsesLine ( lines [ i ] ) ;
47+ if ( ! parsed ) continue ;
48+
49+ // Ignore local or docker actions
50+ const value = parsed . valueStr ;
51+ if ( ! value || ! value . includes ( '@' ) ) continue ;
52+ if ( value . startsWith ( '.' ) || value . startsWith ( 'docker://' ) ) continue ;
53+
54+ const { beforeAt, ref, baseRepo, subPath, quote } = parseUsesValue ( value ) ;
55+ if ( ! baseRepo ) continue ;
56+ console . log ( `${ file } :${ i + 1 } checking ${ beforeAt } ...` )
57+
58+ try {
59+ const currentVersion = extractVersionFromCommentOrRef ( parsed . comment , ref ) ;
60+ const releases = await getReleasesForRepo ( baseRepo ) ;
61+ if ( ! releases || releases . length === 0 ) continue ;
62+
63+ const candidate = pickBestRelease ( releases , currentVersion ) ;
64+ if ( ! candidate ) continue ;
65+
66+ const tag = candidate . tag ;
67+ const sha = await getCommitShaForTag ( baseRepo , tag ) ;
68+
69+ if ( ! sha ) continue ;
70+
71+ const newValueStr = `${ beforeAt } @${ sha } ` ;
72+ const newComment = `# ${ candidate . versionText } ` ;
73+
74+ // Only update if change is needed
75+ const currentShaPinned = isSha ( ref ) ? ref : null ;
76+ const needsChange = currentShaPinned !== sha || ! commentContainsVersion ( parsed . comment , candidate . versionText ) ;
77+ if ( needsChange ) {
78+ const newLine = rebuildUsesLine ( parsed , newValueStr , newComment , quote ) ;
79+ lines [ i ] = newLine ;
80+ updated = true ;
81+ totalUpdates ++ ;
82+ console . log ( `${ file } :${ i + 1 } -> ${ baseRepo } ${ subPath || '' } @ ${ sha } (${ candidate . versionText } )` ) ;
83+ }
84+ } catch ( err ) {
85+ console . warn ( `Warning: Failed to update ${ baseRepo } in ${ file } : ${ err . message } ` ) ;
86+ }
87+ }
88+
89+ if ( updated ) {
90+ const content = lines . join ( '\n' ) ;
91+ if ( content !== original ) {
92+ await fsp . writeFile ( file , content , 'utf8' ) ;
93+ }
94+ }
95+ }
96+
97+ console . log ( `Done. ${ totalUpdates } update(s) applied.` ) ;
98+ }
99+
100+ function commentContainsVersion ( comment , versionText ) {
101+ if ( ! comment || ! versionText ) return false ;
102+ return comment . includes ( versionText ) ;
103+ }
104+
105+ function isSha ( s ) {
106+ return / ^ [ 0 - 9 a - f ] { 40 } $ / i. test ( s ) ;
107+ }
108+
109+ function rebuildUsesLine ( parsed , newValueStr , newComment , quote ) {
110+ const { indent, beforeKey, keyAndSep, trailing } = parsed ;
111+ const quotedValue = quote ? `${ quote } ${ newValueStr } ${ quote } ` : newValueStr ;
112+ // Always replace any existing comment with our version comment
113+ return `${ indent } ${ beforeKey } ${ keyAndSep } ${ quotedValue } ${ newComment } ${ trailing ? '' : '' } ` ;
114+ }
115+
116+ function parseUsesValue ( valueStr ) {
117+ // valueStr like: owner/repo[/path]@ref
118+ const atIndex = valueStr . lastIndexOf ( '@' ) ;
119+ if ( atIndex <= 0 ) return { } ;
120+ const beforeAt = valueStr . slice ( 0 , atIndex ) ;
121+ const ref = valueStr . slice ( atIndex + 1 ) ;
122+
123+ const parts = beforeAt . split ( '/' ) ;
124+ if ( parts . length < 2 ) return { } ;
125+
126+ const baseRepo = `${ parts [ 0 ] } /${ parts [ 1 ] } ` ;
127+ const subPath = parts . length > 2 ? `/${ parts . slice ( 2 ) . join ( '/' ) } ` : '' ;
128+ return { beforeAt, ref, baseRepo, subPath, quote : detectQuoteChar ( valueStr ) } ;
129+ }
130+
131+ function detectQuoteChar ( s ) {
132+ if ( ! s ) return null ;
133+ const first = s [ 0 ] ;
134+ if ( first === '"' || first === "'" ) return first ;
135+ return null ;
136+ }
137+
138+ function extractVersionFromCommentOrRef ( comment , ref ) {
139+ // Prefer version from inline comment like '# v2.3.4'
140+ const fromComment = extractVersionInfo ( comment ) ;
141+ if ( fromComment ) return fromComment ;
142+
143+ // Then try the ref itself if it is a version tag like 'v2.3.4' or 'v3'
144+ const fromRef = extractVersionInfo ( ref ) ;
145+ if ( fromRef ) return fromRef ;
146+
147+ return null ; // Unknown currently installed version
148+ }
149+
150+ function extractVersionInfo ( str ) {
151+ if ( ! str ) return null ;
152+ // Find first occurrence of v<major>[.<minor>][.<patch>], ignoring letters/digits before v
153+ const re = / (?: ^ | [ ^ A - Z a - z 0 - 9 ] ) v ( \d + ) (?: \. ( \d + ) ) ? (?: \. ( \d + ) ) ? / i;
154+ const m = re . exec ( str ) ;
155+ if ( ! m ) return null ;
156+ const major = parseInt ( m [ 1 ] , 10 ) ;
157+ const minor = m [ 2 ] != null ? parseInt ( m [ 2 ] , 10 ) : 0 ;
158+ const patch = m [ 3 ] != null ? parseInt ( m [ 3 ] , 10 ) : 0 ;
159+ const versionText = `v${ major } ${ m [ 2 ] != null ? `.${ minor } ` : '' } ${ m [ 3 ] != null ? `.${ patch } ` : '' } ` ;
160+ return { major, minor, patch, versionText } ;
161+ }
162+
163+ function compareSemver ( a , b ) {
164+ if ( a . major !== b . major ) return a . major - b . major ;
165+ if ( a . minor !== b . minor ) return a . minor - b . minor ;
166+ if ( a . patch !== b . patch ) return a . patch - b . patch ;
167+ return 0 ;
168+ }
169+
170+ function pickBestRelease ( releases , currentVersion ) {
171+ // Filter out drafts/prereleases and releases without detectable version
172+ const candidates = releases
173+ . filter ( r => ! r . draft && ! r . prerelease && r . versionInfo )
174+ . map ( r => ( { tag : r . tag_name , versionInfo : r . versionInfo , versionText : r . versionInfo . versionText } ) ) ;
175+
176+ let filtered = candidates ;
177+ if ( currentVersion && Number . isFinite ( currentVersion . major ) ) {
178+ filtered = candidates . filter ( c => c . versionInfo . major >= currentVersion . major ) ;
179+ }
180+ if ( filtered . length === 0 ) {
181+ return null ;
182+ }
183+ // Pick the highest by semver
184+ filtered . sort ( ( a , b ) => {
185+ const cmp = compareSemver ( a . versionInfo , b . versionInfo ) ;
186+ return cmp !== 0 ? cmp : 0 ;
187+ } ) ;
188+ return filtered [ filtered . length - 1 ] ;
189+ }
190+
191+ async function getReleasesForRepo ( repo ) {
192+ if ( releasesCache . has ( repo ) ) {
193+ return releasesCache . get ( repo ) ;
194+ }
195+ const [ owner , name ] = repo . split ( '/' ) ;
196+ const path = `/repos/${ encodeURIComponent ( owner ) } /${ encodeURIComponent ( name ) } /releases?per_page=${ RELEASES_PER_PAGE } ` ;
197+ const releases = await ghGetJson ( path ) ;
198+ if ( ! Array . isArray ( releases ) ) {
199+ releasesCache . set ( repo , [ ] ) ;
200+ return [ ] ;
201+ }
202+ // Attach extracted version info, try tag_name then name
203+ const withVersions = releases . map ( r => {
204+ const vi = extractVersionInfo ( r . tag_name ) || extractVersionInfo ( r . name ) ;
205+ return { ...r , versionInfo : vi } ;
206+ } ) ;
207+ releasesCache . set ( repo , withVersions ) ;
208+ return withVersions ;
209+ }
210+
211+ async function getCommitShaForTag ( repo , tag ) {
212+ const key = `${ repo } @${ tag } ` ;
213+ if ( commitShaCache . has ( key ) ) return commitShaCache . get ( key ) ;
214+
215+ const [ owner , name ] = repo . split ( '/' ) ;
216+ // Use the commits endpoint which resolves tag -> commit
217+ const path = `/repos/${ encodeURIComponent ( owner ) } /${ encodeURIComponent ( name ) } /commits/${ encodeURIComponent ( tag ) } ` ;
218+ const commit = await ghGetJson ( path ) ;
219+ const sha = commit && typeof commit . sha === 'string' ? commit . sha : null ;
220+ if ( sha ) commitShaCache . set ( key , sha ) ;
221+ return sha ;
222+ }
223+
224+ function ghGetJson ( apiPath ) {
225+ const options = {
226+ hostname : 'api.github.com' ,
227+ path : apiPath ,
228+ method : 'GET' ,
229+ headers : {
230+ 'Accept' : 'application/vnd.github+json' ,
231+ 'User-Agent' : 'gh-actions-updater-script' ,
232+ } ,
233+ } ;
234+ if ( TOKEN ) {
235+ options . headers . Authorization = `Bearer ${ TOKEN } ` ;
236+ }
237+
238+ return new Promise ( ( resolve , reject ) => {
239+ const req = https . request ( options , ( res ) => {
240+ let data = '' ;
241+ res . setEncoding ( 'utf8' ) ;
242+ res . on ( 'data' , chunk => { data += chunk } ) ;
243+ res . on ( 'end' , ( ) => {
244+ if ( res . statusCode && res . statusCode >= 200 && res . statusCode < 300 ) {
245+ try {
246+ resolve ( JSON . parse ( data ) ) ;
247+ } catch ( e ) {
248+ reject ( new Error ( `Failed to parse JSON from ${ apiPath } : ${ e . message } ` ) ) ;
249+ }
250+ } else {
251+ // Return empty array for 404 on releases to avoid throwing on repos without releases
252+ if ( res . statusCode === 404 && apiPath . includes ( '/releases' ) ) {
253+ resolve ( [ ] ) ;
254+ return ;
255+ }
256+ reject ( new Error ( `GitHub API ${ apiPath } failed: ${ res . statusCode } ${ res . statusMessage } - ${ data . slice ( 0 , 200 ) } ` ) ) ;
257+ }
258+ } ) ;
259+ } ) ;
260+ req . on ( 'error' , reject ) ;
261+ req . end ( ) ;
262+ } ) ;
263+ }
264+
265+ async function findWorkflowFiles ( dir ) {
266+ let entries ;
267+ try {
268+ entries = await fsp . readdir ( dir , { withFileTypes : true } ) ;
269+ } catch ( e ) {
270+ return [ ] ;
271+ }
272+ const files = [ ] ;
273+ for ( const ent of entries ) {
274+ if ( ent . isFile ( ) ) {
275+ if ( / \. y a ? m l $ / i. test ( ent . name ) ) {
276+ files . push ( path . join ( dir , ent . name ) ) ;
277+ }
278+ }
279+ }
280+ return files ;
281+ }
282+
283+ function parseUsesLine ( line ) {
284+ // Capture indentation and the entirety after 'uses:'
285+ // Supports lines like:
286+ // uses: owner/repo@ref # comment
287+ // uses: "owner/repo@ref" # comment
288+ // uses: 'owner/repo@ref' # comment
289+ const m = / ^ ( \s * ) ( - ? \s * ) ? ( u s e s : \s * ) ( .* ) $ / . exec ( line ) ;
290+ if ( ! m ) return null ;
291+
292+ const indent = m [ 1 ] || '' ;
293+ const beforeKey = m [ 2 ] || '' ;
294+ const keyAndSep = m [ 3 ] ;
295+ const rest = m [ 4 ] || '' ;
296+
297+ // Parse value and comment from rest while respecting optional quotes
298+ let i = 0 ;
299+ while ( i < rest . length && / \s / . test ( rest [ i ] ) ) i ++ ;
300+
301+ if ( i >= rest . length ) {
302+ return {
303+ indent,
304+ beforeKey,
305+ keyAndSep,
306+ valueStr : '' ,
307+ comment : '' ,
308+ trailing : '' ,
309+ originalLine : line ,
310+ } ;
311+ }
312+
313+ let quote = null ;
314+ let value = '' ;
315+ let j = i ;
316+ if ( rest [ j ] === '"' || rest [ j ] === "'" ) {
317+ quote = rest [ j ] ;
318+ j ++ ;
319+ const start = j ;
320+ while ( j < rest . length ) {
321+ if ( rest [ j ] === quote && rest [ j - 1 ] !== '\\' ) break ;
322+ j ++ ;
323+ }
324+ value = rest . slice ( start , j ) ;
325+ // Move past closing quote if present
326+ if ( j < rest . length && rest [ j ] === quote ) j ++ ;
327+ } else {
328+ const start = j ;
329+ while ( j < rest . length && ! / \s / . test ( rest [ j ] ) && rest [ j ] !== '#' ) {
330+ j ++ ;
331+ }
332+ value = rest . slice ( start , j ) ;
333+ }
334+
335+ // Skip spaces
336+ while ( j < rest . length && / \s / . test ( rest [ j ] ) ) j ++ ;
337+
338+ let comment = '' ;
339+ if ( j < rest . length && rest [ j ] === '#' ) {
340+ comment = rest . slice ( j ) . replace ( / ^ \s * # \s ? / , '' ) . trim ( ) ;
341+ }
342+
343+ return {
344+ indent,
345+ beforeKey,
346+ keyAndSep,
347+ valueStr : value ,
348+ comment,
349+ quote,
350+ trailing : '' ,
351+ originalLine : line ,
352+ } ;
353+ }
354+
355+ ( async ( ) => {
356+ try {
357+ await main ( ) ;
358+ } catch ( e ) {
359+ console . error ( 'Error:' , e ) ;
360+ process . exit ( 1 ) ;
361+ }
362+ } ) ( ) ;
0 commit comments