diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 0b6dba8a4c..37140e4e5e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -51,6 +51,10 @@ jobs: - name: Run unit tests run: npm test + - name: Run pr-checks tests + working-directory: pr-checks + run: python -m unittest discover + - name: Lint if: matrix.os != 'windows-latest' run: npm run lint-ci diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml index ec43df50e5..c5e69bfd26 100644 --- a/.github/workflows/rebuild.yml +++ b/.github/workflows/rebuild.yml @@ -11,6 +11,10 @@ jobs: runs-on: ubuntu-latest if: github.event.label.name == 'Rebuild' || github.event_name == 'workflow_dispatch' + env: + HEAD_REF: ${{ github.event.pull_request.head.ref || github.event.ref }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref || 'main' }} + permissions: contents: write # needed to push rebuilt commit pull-requests: write # needed to comment on the PR @@ -19,7 +23,7 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 0 - ref: ${{ github.event.pull_request.head.ref || github.event.ref }} + ref: ${{ env.HEAD_REF }} - name: Remove label if: github.event_name == 'pull_request' @@ -37,8 +41,6 @@ jobs: - name: Merge in changes from base branch id: merge - env: - BASE_BRANCH: ${{ github.event.pull_request.base.ref || 'main' }} run: | git fetch origin "$BASE_BRANCH" @@ -72,9 +74,16 @@ jobs: with: python-version: 3.11 + - name: Sync back version updates to generated workflows + # Only sync back versions on Dependabot update PRs + if: startsWith(env.HEAD_REF, 'dependabot/') + working-directory: pr-checks + run: | + python3 sync_back.py -v + - name: Generate workflows + working-directory: pr-checks run: | - cd pr-checks python -m pip install --upgrade pip pip install ruamel.yaml==0.17.31 python3 sync.py diff --git a/pr-checks/.gitignore b/pr-checks/.gitignore index 0a764a4de3..979f35ea98 100644 --- a/pr-checks/.gitignore +++ b/pr-checks/.gitignore @@ -1 +1,3 @@ env +__pycache__/ +*.pyc diff --git a/pr-checks/__init__.py b/pr-checks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pr-checks/checks/bundle-toolcache.yml b/pr-checks/checks/bundle-toolcache.yml index d384cefee5..d3a15fcb41 100644 --- a/pr-checks/checks/bundle-toolcache.yml +++ b/pr-checks/checks/bundle-toolcache.yml @@ -8,7 +8,7 @@ operatingSystems: - windows steps: - name: Remove CodeQL from toolcache - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); @@ -18,7 +18,7 @@ steps: - name: Install @actions/tool-cache run: npm install @actions/tool-cache - name: Check toolcache does not contain CodeQL - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const toolcache = require('@actions/tool-cache'); @@ -37,7 +37,7 @@ steps: output: ${{ runner.temp }}/results upload-database: false - name: Check CodeQL is installed within the toolcache - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const toolcache = require('@actions/tool-cache'); diff --git a/pr-checks/checks/bundle-zstd.yml b/pr-checks/checks/bundle-zstd.yml index de83d8e923..2ec8b3b8d2 100644 --- a/pr-checks/checks/bundle-zstd.yml +++ b/pr-checks/checks/bundle-zstd.yml @@ -8,7 +8,7 @@ operatingSystems: - windows steps: - name: Remove CodeQL from toolcache - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); @@ -33,7 +33,7 @@ steps: path: ${{ runner.temp }}/results/javascript.sarif retention-days: 7 - name: Check diagnostic with expected tools URL appears in SARIF - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: SARIF_PATH: ${{ runner.temp }}/results/javascript.sarif with: diff --git a/pr-checks/checks/config-export.yml b/pr-checks/checks/config-export.yml index ce94482567..c51ad04e26 100644 --- a/pr-checks/checks/config-export.yml +++ b/pr-checks/checks/config-export.yml @@ -18,7 +18,7 @@ steps: path: "${{ runner.temp }}/results/javascript.sarif" retention-days: 7 - name: Check config properties appear in SARIF - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: SARIF_PATH: "${{ runner.temp }}/results/javascript.sarif" with: diff --git a/pr-checks/checks/diagnostics-export.yml b/pr-checks/checks/diagnostics-export.yml index c4e4f3d458..dd41b8df9f 100644 --- a/pr-checks/checks/diagnostics-export.yml +++ b/pr-checks/checks/diagnostics-export.yml @@ -32,7 +32,7 @@ steps: path: "${{ runner.temp }}/results/javascript.sarif" retention-days: 7 - name: Check diagnostics appear in SARIF - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: SARIF_PATH: "${{ runner.temp }}/results/javascript.sarif" with: diff --git a/pr-checks/checks/go-indirect-tracing-workaround-diagnostic.yml b/pr-checks/checks/go-indirect-tracing-workaround-diagnostic.yml index bfe7afb383..0768bd58a8 100644 --- a/pr-checks/checks/go-indirect-tracing-workaround-diagnostic.yml +++ b/pr-checks/checks/go-indirect-tracing-workaround-diagnostic.yml @@ -12,7 +12,7 @@ steps: languages: go tools: ${{ steps.prepare-test.outputs.tools-url }} # Deliberately change Go after the `init` step - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: "1.20" - name: Build code @@ -23,7 +23,7 @@ steps: output: "${{ runner.temp }}/results" upload-database: false - name: Check diagnostic appears in SARIF - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: SARIF_PATH: "${{ runner.temp }}/results/go.sarif" with: diff --git a/pr-checks/checks/go-indirect-tracing-workaround-no-file-program.yml b/pr-checks/checks/go-indirect-tracing-workaround-no-file-program.yml index 9db4cad641..4f9e5eed29 100644 --- a/pr-checks/checks/go-indirect-tracing-workaround-no-file-program.yml +++ b/pr-checks/checks/go-indirect-tracing-workaround-no-file-program.yml @@ -24,7 +24,7 @@ steps: output: "${{ runner.temp }}/results" upload-database: false - name: Check diagnostic appears in SARIF - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: SARIF_PATH: "${{ runner.temp }}/results/go.sarif" with: diff --git a/pr-checks/checks/quality-queries.yml b/pr-checks/checks/quality-queries.yml index 9eb578171e..b8420ad209 100644 --- a/pr-checks/checks/quality-queries.yml +++ b/pr-checks/checks/quality-queries.yml @@ -54,7 +54,7 @@ steps: retention-days: 7 - name: Check quality query does not appear in security SARIF if: contains(matrix.analysis-kinds, 'code-scanning') - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: SARIF_PATH: "${{ runner.temp }}/results/javascript.sarif" EXPECT_PRESENT: "false" @@ -62,7 +62,7 @@ steps: script: ${{ env.CHECK_SCRIPT }} - name: Check quality query appears in quality SARIF if: contains(matrix.analysis-kinds, 'code-quality') - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: SARIF_PATH: "${{ runner.temp }}/results/javascript.quality.sarif" EXPECT_PRESENT: "true" diff --git a/pr-checks/readme.md b/pr-checks/readme.md index 618a67503d..283ed35993 100644 --- a/pr-checks/readme.md +++ b/pr-checks/readme.md @@ -9,6 +9,6 @@ to one of the files in this directory. 1. Install https://github.com/casey/just by whichever way you prefer. 2. Run `just update-pr-checks` in your terminal. -### If you don't want to intall `just` +### If you don't want to install `just` Manually run each step in the `justfile`. diff --git a/pr-checks/sync.py b/pr-checks/sync.py index 6d23cafab5..e76945cc4c 100755 --- a/pr-checks/sync.py +++ b/pr-checks/sync.py @@ -127,7 +127,7 @@ def writeHeader(checkStream): steps.extend([ { 'name': 'Install Node.js', - 'uses': 'actions/setup-node@v4', + 'uses': 'actions/setup-node@v5', 'with': { 'node-version': '20.x', 'cache': 'npm', @@ -165,7 +165,7 @@ def writeHeader(checkStream): steps.append({ 'name': 'Install Go', - 'uses': 'actions/setup-go@v5', + 'uses': 'actions/setup-go@v6', 'with': { 'go-version': '${{ inputs.go-version || \'' + baseGoVersionExpr + '\' }}', # to avoid potentially misleading autobuilder results where we expect it to download diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py new file mode 100755 index 0000000000..1474b455e6 --- /dev/null +++ b/pr-checks/sync_back.py @@ -0,0 +1,185 @@ +#!/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 new file mode 100644 index 0000000000..de2e42d733 --- /dev/null +++ b/pr-checks/test_sync_back.py @@ -0,0 +1,237 @@ +#!/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()