11#!/usr/bin/env npx tsx
22
3+ import * as yaml from "yaml" ;
4+
35/*
46Sync-back script to automatically update action versions in source templates
57from the generated workflow files after Dependabot updates.
@@ -27,14 +29,74 @@ const CHECKS_DIR = path.join(THIS_DIR, "checks");
2729const WORKFLOW_DIR = path . join ( THIS_DIR , ".." , ".github" , "workflows" ) ;
2830const SYNC_TS_PATH = path . join ( THIS_DIR , "sync.ts" ) ;
2931
32+ /** Records information about the version of an Action with an optional comment. */
33+ type ActionVersion = { version : string ; comment ?: string } ;
34+
35+ /** Converts `info` to a string that includes the version and comment. */
36+ function versionWithCommentStr ( info : ActionVersion ) : string {
37+ const comment = info . comment ? ` #${ info . comment } ` : "" ;
38+ return `${ info . version } ${ comment } ` ;
39+ }
40+
41+ /**
42+ * Constructs a `yaml.visitor` which calls `fn` for `yaml.Pair` nodes where the key is "uses" and
43+ * the value is a `yaml.Scalar`.
44+ */
45+ function usesVisitor (
46+ fn : (
47+ pair : yaml . Pair < yaml . Scalar , yaml . Scalar > ,
48+ actionName : string ,
49+ actionVersion : ActionVersion ,
50+ ) => void ,
51+ ) : yaml . visitor {
52+ return {
53+ Pair ( _ , pair ) {
54+ if (
55+ yaml . isScalar ( pair . key ) &&
56+ yaml . isScalar ( pair . value ) &&
57+ pair . key . value === "uses" &&
58+ typeof pair . value . value === "string"
59+ ) {
60+ const usesValue = pair . value . value ;
61+
62+ // Only track non-local actions (those with / but not starting with ./)
63+ if ( ! usesValue . startsWith ( "./" ) ) {
64+ const parts = ( pair . value . value as string ) . split ( "@" ) ;
65+
66+ if ( parts . length !== 2 ) {
67+ throw new Error ( `Unexpected 'uses' value: ${ usesValue } ` ) ;
68+ }
69+
70+ const actionName = parts [ 0 ] ;
71+ const actionVersion = parts [ 1 ] . trimEnd ( ) ;
72+ const comment = pair . value . comment ?. trimEnd ( ) ;
73+
74+ fn ( pair as yaml . Pair < yaml . Scalar , yaml . Scalar > , actionName , {
75+ version : actionVersion ,
76+ comment,
77+ } ) ;
78+ }
79+
80+ // Do not visit the children of this node.
81+ return yaml . visit . SKIP ;
82+ }
83+
84+ // Do nothing and continue.
85+ return undefined ;
86+ } ,
87+ } ;
88+ }
89+
3090/**
3191 * Scan generated workflow files to extract the latest action versions.
3292 *
3393 * @param workflowDir - Path to .github/workflows directory
3494 * @returns Map from action names to their latest versions (including comments)
3595 */
36- export function scanGeneratedWorkflows ( workflowDir : string ) : Record < string , string > {
37- const actionVersions : Record < string , string > = { } ;
96+ export function scanGeneratedWorkflows (
97+ workflowDir : string ,
98+ ) : Record < string , ActionVersion > {
99+ const actionVersions : Record < string , ActionVersion > = { } ;
38100
39101 const generatedFiles = fs
40102 . readdirSync ( workflowDir )
@@ -43,22 +105,15 @@ export function scanGeneratedWorkflows(workflowDir: string): Record<string, stri
43105
44106 for ( const filePath of generatedFiles ) {
45107 const content = fs . readFileSync ( filePath , "utf8" ) ;
108+ const doc = yaml . parseDocument ( content ) ;
46109
47- // Find all action uses in the file, including potential comments
48- // This pattern captures: action_name@version_with_possible_comment
49- const pattern = / u s e s : \s + ( [ ^ / \s ] + \/ [ ^ @ \s ] + ) @ ( [ ^ @ \n ] + ) / g;
50- let match : RegExpExecArray | null ;
51-
52- while ( ( match = pattern . exec ( content ) ) !== null ) {
53- const actionName = match [ 1 ] ;
54- const versionWithComment = match [ 2 ] . trimEnd ( ) ;
55-
56- // Only track non-local actions (those with / but not starting with ./)
57- if ( ! actionName . startsWith ( "./" ) ) {
110+ yaml . visit (
111+ doc ,
112+ usesVisitor ( ( _node , actionName , actionVersion ) => {
58113 // Assume that version numbers are consistent (this should be the case on a Dependabot update PR)
59- actionVersions [ actionName ] = versionWithComment ;
60- }
61- }
114+ actionVersions [ actionName ] = actionVersion ;
115+ } ) ,
116+ ) ;
62117 }
63118
64119 return actionVersions ;
@@ -73,7 +128,7 @@ export function scanGeneratedWorkflows(workflowDir: string): Record<string, stri
73128 */
74129export function updateSyncTs (
75130 syncTsPath : string ,
76- actionVersions : Record < string , string > ,
131+ actionVersions : Record < string , ActionVersion > ,
77132) : boolean {
78133 if ( ! fs . existsSync ( syncTsPath ) ) {
79134 throw new Error ( `Could not find ${ syncTsPath } ` ) ;
@@ -83,24 +138,16 @@ export function updateSyncTs(
83138 const originalContent = content ;
84139
85140 // Update hardcoded action versions
86- for ( const [ actionName , versionWithComment ] of Object . entries (
87- actionVersions ,
88- ) ) {
89- // Extract just the version part (before any comment) for sync.ts
90- const version = versionWithComment . includes ( "#" )
91- ? versionWithComment . split ( "#" ) [ 0 ] . trim ( )
92- : versionWithComment . trim ( ) ;
93-
94- // Look for patterns like uses: "actions/setup-node@v4"
141+ for ( const [ actionName , versionInfo ] of Object . entries ( actionVersions ) ) {
95142 // Note that this will break if we store an Action uses reference in a
96143 // variable - that's a risk we're happy to take since in that case the
97144 // PR checks will just fail.
98145 const escaped = actionName . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
99- const pattern = new RegExp (
100- `(uses:\\s*")${ escaped } @(?:[^"]+)(")` ,
101- "g" ,
146+ const pattern = new RegExp ( `(uses:\\s*")${ escaped } @(?:[^"]+)(")` , "g" ) ;
147+ content = content . replace (
148+ pattern ,
149+ `$1${ actionName } @${ versionInfo . version } $2` ,
102150 ) ;
103- content = content . replace ( pattern , `$1${ actionName } @${ version } $2` ) ;
104151 }
105152
106153 if ( content !== originalContent ) {
@@ -122,7 +169,7 @@ export function updateSyncTs(
122169 */
123170export function updateTemplateFiles (
124171 checksDir : string ,
125- actionVersions : Record < string , string > ,
172+ actionVersions : Record < string , ActionVersion > ,
126173) : string [ ] {
127174 const modifiedFiles : string [ ] = [ ] ;
128175
@@ -132,24 +179,33 @@ export function updateTemplateFiles(
132179 . map ( ( f ) => path . join ( checksDir , f ) ) ;
133180
134181 for ( const filePath of templateFiles ) {
135- let content = fs . readFileSync ( filePath , "utf8" ) ;
136- const originalContent = content ;
137-
138- // Update action versions
139- for ( const [ actionName , versionWithComment ] of Object . entries (
140- actionVersions ,
141- ) ) {
142- // Look for patterns like 'uses: actions/setup-node@v4' or 'uses: actions/setup-node@sha # comment'
143- const escaped = actionName . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
144- const pattern = new RegExp (
145- `(uses:\\s+${ escaped } )@(?:[^@\n]+)` ,
146- "g" ,
147- ) ;
148- content = content . replace ( pattern , `$1@${ versionWithComment } ` ) ;
149- }
182+ const content = fs . readFileSync ( filePath , "utf8" ) ;
183+ const doc = yaml . parseDocument ( content , { keepSourceTokens : true } ) ;
184+ let modified : boolean = false ;
185+
186+ yaml . visit (
187+ doc ,
188+ usesVisitor ( ( pair , actionName , actionVersion ) => {
189+ // Try to look up version information for this action.
190+ const versionInfo = actionVersions [ actionName ] ;
191+
192+ // If we found version information, and the version is different from that in the template,
193+ // then update the pair node accordingly.
194+ if ( versionInfo && versionInfo . version !== actionVersion . version ) {
195+ pair . value . value = `${ actionName } @${ versionInfo . version } ` ;
196+ pair . value . comment = versionInfo . comment ;
197+ modified = true ;
198+ }
199+ } ) ,
200+ ) ;
150201
151- if ( content !== originalContent ) {
152- fs . writeFileSync ( filePath , content , "utf8" ) ;
202+ // Write the YAML document back to the file if we made changes.
203+ if ( modified ) {
204+ fs . writeFileSync (
205+ filePath ,
206+ yaml . stringify ( doc , { lineWidth : 0 , flowCollectionPadding : false } ) ,
207+ "utf8" ,
208+ ) ;
153209 modifiedFiles . push ( filePath ) ;
154210 console . info ( `Updated ${ filePath } ` ) ;
155211 }
@@ -178,7 +234,7 @@ function main(): number {
178234 if ( verbose ) {
179235 console . info ( "Found action versions:" ) ;
180236 for ( const [ action , version ] of Object . entries ( actionVersions ) ) {
181- console . info ( ` ${ action } @${ version } ` ) ;
237+ console . info ( ` ${ action } @${ versionWithCommentStr ( version ) } ` ) ;
182238 }
183239 }
184240
0 commit comments