diff --git a/pr-checks/.gitignore b/pr-checks/.gitignore index c06afdaf9..c2658d7d1 100644 --- a/pr-checks/.gitignore +++ b/pr-checks/.gitignore @@ -1,4 +1 @@ -env -__pycache__/ -*.pyc node_modules/ diff --git a/pr-checks/__init__.py b/pr-checks/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py deleted file mode 100755 index 1474b455e..000000000 --- a/pr-checks/sync_back.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -""" -Sync-back script to automatically update action versions in source templates -from the generated workflow files after Dependabot updates. - -This script scans the generated workflow files (.github/workflows/__*.yml) to find -all external action versions used, then updates: -1. Hardcoded action versions in pr-checks/sync.py -2. Action version references in template files in pr-checks/checks/ - -The script automatically detects all actions used in generated workflows and -preserves version comments (e.g., # v1.2.3) when syncing versions. - -This ensures that when Dependabot updates action versions in generated workflows, -those changes are properly synced back to the source templates. Regular workflow -files are updated directly by Dependabot and don't need sync-back. -""" - -import os -import re -import glob -import argparse -import sys -from pathlib import Path -from typing import Dict, List - - -def scan_generated_workflows(workflow_dir: str) -> Dict[str, str]: - """ - Scan generated workflow files to extract the latest action versions. - - Args: - workflow_dir: Path to .github/workflows directory - - Returns: - Dictionary mapping action names to their latest versions (including comments) - """ - action_versions = {} - generated_files = glob.glob(os.path.join(workflow_dir, "__*.yml")) - - for file_path in generated_files: - with open(file_path, 'r') as f: - content = f.read() - - # Find all action uses in the file, including potential comments - # This pattern captures: action_name@version_with_possible_comment - pattern = r'uses:\s+([^/\s]+/[^@\s]+)@([^@\n]+)' - matches = re.findall(pattern, content) - - for action_name, version_with_comment in matches: - # Only track non-local actions (those with / but not starting with ./) - if not action_name.startswith('./'): - # Assume that version numbers are consistent (this should be the case on a Dependabot update PR) - action_versions[action_name] = version_with_comment.rstrip() - - return action_versions - - -def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: - """ - Update hardcoded action versions in pr-checks/sync.py - - Args: - sync_py_path: Path to sync.py file - action_versions: Dictionary of action names to versions (may include comments) - - Returns: - True if file was modified, False otherwise - """ - if not os.path.exists(sync_py_path): - raise FileNotFoundError(f"Could not find {sync_py_path}") - - with open(sync_py_path, 'r') as f: - content = f.read() - - original_content = content - - # Update hardcoded action versions - for action_name, version_with_comment in action_versions.items(): - # Extract just the version part (before any comment) for sync.py - version = version_with_comment.split('#')[0].strip() if '#' in version_with_comment else version_with_comment.strip() - - # Look for patterns like 'uses': 'actions/setup-node@v4' - # Note that this will break if we store an Action uses reference in a - # variable - that's a risk we're happy to take since in that case the - # PR checks will just fail. - pattern = rf"('uses':\s*'){re.escape(action_name)}@(?:[^']+)(')" - replacement = rf"\1{action_name}@{version}\2" - content = re.sub(pattern, replacement, content) - - if content != original_content: - with open(sync_py_path, 'w') as f: - f.write(content) - print(f"Updated {sync_py_path}") - return True - else: - print(f"No changes needed in {sync_py_path}") - return False - - -def update_template_files(checks_dir: str, action_versions: Dict[str, str]) -> List[str]: - """ - Update action versions in template files in pr-checks/checks/ - - Args: - checks_dir: Path to pr-checks/checks directory - action_versions: Dictionary of action names to versions (may include comments) - - Returns: - List of files that were modified - """ - modified_files = [] - template_files = glob.glob(os.path.join(checks_dir, "*.yml")) - - for file_path in template_files: - with open(file_path, 'r') as f: - content = f.read() - - original_content = content - - # Update action versions - for action_name, version_with_comment in action_versions.items(): - # Look for patterns like 'uses: actions/setup-node@v4' or 'uses: actions/setup-node@sha # comment' - pattern = rf"(uses:\s+{re.escape(action_name)})@(?:[^@\n]+)" - replacement = rf"\1@{version_with_comment}" - content = re.sub(pattern, replacement, content) - - if content != original_content: - with open(file_path, 'w') as f: - f.write(content) - modified_files.append(file_path) - print(f"Updated {file_path}") - - return modified_files - - -def main(): - parser = argparse.ArgumentParser(description="Sync action versions from generated workflows back to templates") - parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output") - args = parser.parse_args() - - # Get the repository root (assuming script is in pr-checks/) - script_dir = Path(__file__).parent - repo_root = script_dir.parent - - workflow_dir = repo_root / ".github" / "workflows" - checks_dir = script_dir / "checks" - sync_py_path = script_dir / "sync.py" - - print("Scanning generated workflows for latest action versions...") - action_versions = scan_generated_workflows(str(workflow_dir)) - - if args.verbose: - print("Found action versions:") - for action, version in action_versions.items(): - print(f" {action}@{version}") - - if not action_versions: - print("No action versions found in generated workflows") - return 1 - - # Update files - print("\nUpdating source files...") - modified_files = [] - - # Update sync.py - if update_sync_py(str(sync_py_path), action_versions): - modified_files.append(str(sync_py_path)) - - # Update template files - template_modified = update_template_files(str(checks_dir), action_versions) - modified_files.extend(template_modified) - - if modified_files: - print(f"\nSync completed. Modified {len(modified_files)} files:") - for file_path in modified_files: - print(f" {file_path}") - else: - print("\nNo files needed updating - all action versions are already in sync") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/pr-checks/test_sync_back.py b/pr-checks/test_sync_back.py deleted file mode 100644 index de2e42d73..000000000 --- a/pr-checks/test_sync_back.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for the sync_back.py script -""" - -import os -import shutil -import tempfile -import unittest - -import sync_back - - -class TestSyncBack(unittest.TestCase): - - def setUp(self): - """Set up temporary directories and files for testing""" - self.test_dir = tempfile.mkdtemp() - self.workflow_dir = os.path.join(self.test_dir, ".github", "workflows") - self.checks_dir = os.path.join(self.test_dir, "pr-checks", "checks") - os.makedirs(self.workflow_dir) - os.makedirs(self.checks_dir) - - # Create sync.py file - self.sync_py_path = os.path.join(self.test_dir, "pr-checks", "sync.py") - - def tearDown(self): - """Clean up temporary directories""" - shutil.rmtree(self.test_dir) - - def test_scan_generated_workflows_basic(self): - """Test basic workflow scanning functionality""" - # Create a test generated workflow file - workflow_content = """ -name: Test Workflow -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v5 - - uses: actions/setup-go@v6 - """ - - with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: - f.write(workflow_content) - - result = sync_back.scan_generated_workflows(self.workflow_dir) - - self.assertEqual(result['actions/checkout'], 'v4') - self.assertEqual(result['actions/setup-node'], 'v5') - self.assertEqual(result['actions/setup-go'], 'v6') - - def test_scan_generated_workflows_with_comments(self): - """Test scanning workflows with version comments""" - workflow_content = """ -name: Test Workflow -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0 - - uses: actions/setup-python@v6 # Latest Python - """ - - with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: - f.write(workflow_content) - - result = sync_back.scan_generated_workflows(self.workflow_dir) - - self.assertEqual(result['actions/checkout'], 'v4') - self.assertEqual(result['ruby/setup-ruby'], '44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0') - self.assertEqual(result['actions/setup-python'], 'v6 # Latest Python') - - def test_scan_generated_workflows_ignores_local_actions(self): - """Test that local actions (starting with ./) are ignored""" - workflow_content = """ -name: Test Workflow -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/local-action - - uses: ./another-local-action@v1 - """ - - with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: - f.write(workflow_content) - - result = sync_back.scan_generated_workflows(self.workflow_dir) - - self.assertEqual(result['actions/checkout'], 'v4') - self.assertNotIn('./.github/actions/local-action', result) - self.assertNotIn('./another-local-action', result) - - - def test_update_sync_py(self): - """Test updating sync.py file""" - sync_py_content = """ -steps = [ - { - 'uses': 'actions/setup-node@v4', - 'with': {'node-version': '16'} - }, - { - 'uses': 'actions/setup-go@v5', - 'with': {'go-version': '1.19'} - } -] - """ - - with open(self.sync_py_path, 'w') as f: - f.write(sync_py_content) - - action_versions = { - 'actions/setup-node': 'v5', - 'actions/setup-go': 'v6' - } - - result = sync_back.update_sync_py(self.sync_py_path, action_versions) - self.assertTrue(result) - - with open(self.sync_py_path, 'r') as f: - updated_content = f.read() - - self.assertIn("'uses': 'actions/setup-node@v5'", updated_content) - self.assertIn("'uses': 'actions/setup-go@v6'", updated_content) - - def test_update_sync_py_with_comments(self): - """Test updating sync.py file when versions have comments""" - sync_py_content = """ -steps = [ - { - 'uses': 'actions/setup-node@v4', - 'with': {'node-version': '16'} - } -] - """ - - with open(self.sync_py_path, 'w') as f: - f.write(sync_py_content) - - action_versions = { - 'actions/setup-node': 'v5 # Latest version' - } - - result = sync_back.update_sync_py(self.sync_py_path, action_versions) - self.assertTrue(result) - - with open(self.sync_py_path, 'r') as f: - updated_content = f.read() - - # sync.py should get the version without comment - self.assertIn("'uses': 'actions/setup-node@v5'", updated_content) - self.assertNotIn("# Latest version", updated_content) - - def test_update_template_files(self): - """Test updating template files""" - template_content = """ -name: Test Template -steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v4 - with: - node-version: 16 - """ - - template_path = os.path.join(self.checks_dir, "test.yml") - with open(template_path, 'w') as f: - f.write(template_content) - - action_versions = { - 'actions/checkout': 'v4', - 'actions/setup-node': 'v5 # Latest' - } - - result = sync_back.update_template_files(self.checks_dir, action_versions) - self.assertEqual(len(result), 1) - self.assertIn(template_path, result) - - with open(template_path, 'r') as f: - updated_content = f.read() - - self.assertIn("uses: actions/checkout@v4", updated_content) - self.assertIn("uses: actions/setup-node@v5 # Latest", updated_content) - - def test_update_template_files_preserves_comments(self): - """Test that updating template files preserves version comments""" - template_content = """ -name: Test Template -steps: - - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.256.0 - """ - - template_path = os.path.join(self.checks_dir, "test.yml") - with open(template_path, 'w') as f: - f.write(template_content) - - action_versions = { - 'ruby/setup-ruby': '55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0' - } - - result = sync_back.update_template_files(self.checks_dir, action_versions) - self.assertEqual(len(result), 1) - - with open(template_path, 'r') as f: - updated_content = f.read() - - self.assertIn("uses: ruby/setup-ruby@55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0", updated_content) - - def test_no_changes_needed(self): - """Test that functions return False/empty when no changes are needed""" - # Test sync.py with no changes needed - sync_py_content = """ -steps = [ - { - 'uses': 'actions/setup-node@v5', - 'with': {'node-version': '16'} - } -] - """ - - with open(self.sync_py_path, 'w') as f: - f.write(sync_py_content) - - action_versions = { - 'actions/setup-node': 'v5' - } - - result = sync_back.update_sync_py(self.sync_py_path, action_versions) - self.assertFalse(result) - - -if __name__ == '__main__': - unittest.main()