1+ #!/usr/bin/env python3
2+ """
3+ Sync-back script to automatically update action versions in source templates
4+ from the generated workflow files after Dependabot updates.
5+
6+ This script scans the generated workflow files (.github/workflows/__*.yml) to find
7+ the latest action versions used, then updates:
8+ 1. Hardcoded action versions in pr-checks/sync.py
9+ 2. Action version references in template files in pr-checks/checks/
10+ 3. Action version references in regular workflow files
11+
12+ This ensures that when Dependabot updates action versions in generated workflows,
13+ those changes are properly synced back to the source templates.
14+ """
15+
16+ import os
17+ import re
18+ import glob
19+ import argparse
20+ import sys
21+ from pathlib import Path
22+ from typing import Dict , Set , List , Tuple
23+
24+
25+ def scan_generated_workflows (workflow_dir : str ) -> Dict [str , str ]:
26+ """
27+ Scan generated workflow files to extract the latest action versions.
28+
29+ Args:
30+ workflow_dir: Path to .github/workflows directory
31+
32+ Returns:
33+ Dictionary mapping action names to their latest versions
34+ """
35+ action_versions = {}
36+ generated_files = glob .glob (os .path .join (workflow_dir , "__*.yml" ))
37+
38+ # Actions we care about syncing
39+ target_actions = {
40+ 'actions/setup-go' ,
41+ 'actions/setup-node' ,
42+ 'actions/setup-python' ,
43+ 'actions/github-script'
44+ }
45+
46+ for file_path in generated_files :
47+ with open (file_path , 'r' ) as f :
48+ content = f .read ()
49+
50+ # Find all action uses in the file
51+ pattern = r'uses:\s+(actions/[^@\s]+)@([^@\s]+)'
52+ matches = re .findall (pattern , content )
53+
54+ for action_name , version in matches :
55+ if action_name in target_actions :
56+ # Take the latest version seen (they should all be the same after Dependabot)
57+ action_versions [action_name ] = version
58+
59+ return action_versions
60+
61+
62+ def update_sync_py (sync_py_path : str , action_versions : Dict [str , str ]) -> bool :
63+ """
64+ Update hardcoded action versions in pr-checks/sync.py
65+
66+ Args:
67+ sync_py_path: Path to sync.py file
68+ action_versions: Dictionary of action names to versions
69+
70+ Returns:
71+ True if file was modified, False otherwise
72+ """
73+ if not os .path .exists (sync_py_path ):
74+ print (f"Warning: { sync_py_path } not found" )
75+ return False
76+
77+ with open (sync_py_path , 'r' ) as f :
78+ content = f .read ()
79+
80+ original_content = content
81+
82+ # Update hardcoded action versions
83+ for action_name , version in action_versions .items ():
84+ # Look for patterns like 'uses': 'actions/setup-node@v4'
85+ pattern = rf"('uses':\s*')(actions/{ action_name .split ('/' )[- 1 ]} )@([^']+)(')"
86+ replacement = rf"\1\2@{ version } \4"
87+ content = re .sub (pattern , replacement , content )
88+
89+ if content != original_content :
90+ with open (sync_py_path , 'w' ) as f :
91+ f .write (content )
92+ print (f"Updated { sync_py_path } " )
93+ return True
94+ else :
95+ print (f"No changes needed in { sync_py_path } " )
96+ return False
97+
98+
99+ def update_template_files (checks_dir : str , action_versions : Dict [str , str ]) -> List [str ]:
100+ """
101+ Update action versions in template files in pr-checks/checks/
102+
103+ Args:
104+ checks_dir: Path to pr-checks/checks directory
105+ action_versions: Dictionary of action names to versions
106+
107+ Returns:
108+ List of files that were modified
109+ """
110+ modified_files = []
111+ template_files = glob .glob (os .path .join (checks_dir , "*.yml" ))
112+
113+ for file_path in template_files :
114+ with open (file_path , 'r' ) as f :
115+ content = f .read ()
116+
117+ original_content = content
118+
119+ # Update action versions
120+ for action_name , version in action_versions .items ():
121+ # Look for patterns like 'uses: actions/setup-node@v4'
122+ pattern = rf"(uses:\s+{ re .escape (action_name )} )@([^@\s]+)"
123+ replacement = rf"\1@{ version } "
124+ content = re .sub (pattern , replacement , content )
125+
126+ if content != original_content :
127+ with open (file_path , 'w' ) as f :
128+ f .write (content )
129+ modified_files .append (file_path )
130+ print (f"Updated { file_path } " )
131+
132+ return modified_files
133+
134+
135+ def update_regular_workflows (workflow_dir : str , action_versions : Dict [str , str ]) -> List [str ]:
136+ """
137+ Update action versions in regular (non-generated) workflow files
138+
139+ Args:
140+ workflow_dir: Path to .github/workflows directory
141+ action_versions: Dictionary of action names to versions
142+
143+ Returns:
144+ List of files that were modified
145+ """
146+ modified_files = []
147+
148+ # Get all workflow files that are NOT generated (don't start with __)
149+ all_files = glob .glob (os .path .join (workflow_dir , "*.yml" ))
150+ regular_files = [f for f in all_files if not os .path .basename (f ).startswith ("__" )]
151+
152+ for file_path in regular_files :
153+ with open (file_path , 'r' ) as f :
154+ content = f .read ()
155+
156+ original_content = content
157+
158+ # Update action versions
159+ for action_name , version in action_versions .items ():
160+ # Look for patterns like 'uses: actions/setup-node@v4'
161+ pattern = rf"(uses:\s+{ re .escape (action_name )} )@([^@\s]+)"
162+ replacement = rf"\1@{ version } "
163+ content = re .sub (pattern , replacement , content )
164+
165+ if content != original_content :
166+ with open (file_path , 'w' ) as f :
167+ f .write (content )
168+ modified_files .append (file_path )
169+ print (f"Updated { file_path } " )
170+
171+ return modified_files
172+
173+
174+ def main ():
175+ parser = argparse .ArgumentParser (description = "Sync action versions from generated workflows back to templates" )
176+ parser .add_argument ("--dry-run" , action = "store_true" , help = "Show what would be changed without making changes" )
177+ parser .add_argument ("--verbose" , "-v" , action = "store_true" , help = "Enable verbose output" )
178+ args = parser .parse_args ()
179+
180+ # Get the repository root (assuming script is in pr-checks/)
181+ script_dir = Path (__file__ ).parent
182+ repo_root = script_dir .parent
183+
184+ workflow_dir = repo_root / ".github" / "workflows"
185+ checks_dir = script_dir / "checks"
186+ sync_py_path = script_dir / "sync.py"
187+
188+ print ("Scanning generated workflows for latest action versions..." )
189+ action_versions = scan_generated_workflows (str (workflow_dir ))
190+
191+ if args .verbose :
192+ print ("Found action versions:" )
193+ for action , version in action_versions .items ():
194+ print (f" { action } @{ version } " )
195+
196+ if not action_versions :
197+ print ("No action versions found in generated workflows" )
198+ return 1
199+
200+ if args .dry_run :
201+ print ("\n DRY RUN - Would make the following changes:" )
202+ print (f"Action versions to sync: { action_versions } " )
203+ return 0
204+
205+ # Update files
206+ print ("\n Updating source files..." )
207+ modified_files = []
208+
209+ # Update sync.py
210+ if update_sync_py (str (sync_py_path ), action_versions ):
211+ modified_files .append (str (sync_py_path ))
212+
213+ # Update template files
214+ template_modified = update_template_files (str (checks_dir ), action_versions )
215+ modified_files .extend (template_modified )
216+
217+ # Update regular workflow files
218+ workflow_modified = update_regular_workflows (str (workflow_dir ), action_versions )
219+ modified_files .extend (workflow_modified )
220+
221+ if modified_files :
222+ print (f"\n Sync completed. Modified { len (modified_files )} files:" )
223+ for file_path in modified_files :
224+ print (f" { file_path } " )
225+ else :
226+ print ("\n No files needed updating - all action versions are already in sync" )
227+
228+ return 0
229+
230+
231+ if __name__ == "__main__" :
232+ sys .exit (main ())
0 commit comments