From 436471d2fbcc14b0e324fcfa9cd20d5d897b1626 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:16:43 +0000 Subject: [PATCH 01/21] Initial plan From 8d31b533a271fcac59e4170c0f978ec15302d4e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:39:16 +0000 Subject: [PATCH 02/21] Add sync-back automation for Dependabot action version updates Co-authored-by: henrymercer <14129055+henrymercer@users.noreply.github.com> --- pr-checks/checks/bundle-toolcache.yml | 6 +- pr-checks/checks/bundle-zstd.yml | 4 +- pr-checks/checks/config-export.yml | 2 +- pr-checks/checks/diagnostics-export.yml | 2 +- ...indirect-tracing-workaround-diagnostic.yml | 4 +- ...ect-tracing-workaround-no-file-program.yml | 2 +- pr-checks/checks/quality-queries.yml | 4 +- pr-checks/readme.md | 24 ++ pr-checks/sync-back.py | 232 ++++++++++++++++++ pr-checks/sync-back.sh | 25 ++ pr-checks/sync.py | 4 +- 11 files changed, 295 insertions(+), 14 deletions(-) create mode 100755 pr-checks/sync-back.py create mode 100755 pr-checks/sync-back.sh 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..e96949c040 100644 --- a/pr-checks/readme.md +++ b/pr-checks/readme.md @@ -12,3 +12,27 @@ to one of the files in this directory. ### If you don't want to intall `just` Manually run each step in the `justfile`. + +## Sync-back automation + +When Dependabot updates action versions in the generated workflow files (`.github/workflows/__*.yml`), +the sync-back automation ensures those changes are properly reflected in the source templates. + +### Running sync-back manually + +To sync action versions from generated workflows back to source templates: + +```bash +# Dry run to see what would be changed +./pr-checks/sync-back.sh --dry-run --verbose + +# Actually apply the changes +./pr-checks/sync-back.sh +``` + +The sync-back script (`sync-back.py`) automatically updates: +- Hardcoded action versions in `pr-checks/sync.py` +- Action version references in template files in `pr-checks/checks/` +- Action version references in regular workflow files + +This ensures that the `verify-pr-checks.sh` test always passes after Dependabot PRs. diff --git a/pr-checks/sync-back.py b/pr-checks/sync-back.py new file mode 100755 index 0000000000..45e4d333f0 --- /dev/null +++ b/pr-checks/sync-back.py @@ -0,0 +1,232 @@ +#!/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 +the latest 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/ +3. Action version references in regular workflow files + +This ensures that when Dependabot updates action versions in generated workflows, +those changes are properly synced back to the source templates. +""" + +import os +import re +import glob +import argparse +import sys +from pathlib import Path +from typing import Dict, Set, List, Tuple + + +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 + """ + action_versions = {} + generated_files = glob.glob(os.path.join(workflow_dir, "__*.yml")) + + # Actions we care about syncing + target_actions = { + 'actions/setup-go', + 'actions/setup-node', + 'actions/setup-python', + 'actions/github-script' + } + + for file_path in generated_files: + with open(file_path, 'r') as f: + content = f.read() + + # Find all action uses in the file + pattern = r'uses:\s+(actions/[^@\s]+)@([^@\s]+)' + matches = re.findall(pattern, content) + + for action_name, version in matches: + if action_name in target_actions: + # Take the latest version seen (they should all be the same after Dependabot) + action_versions[action_name] = version + + 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 + + Returns: + True if file was modified, False otherwise + """ + if not os.path.exists(sync_py_path): + print(f"Warning: {sync_py_path} not found") + return False + + with open(sync_py_path, 'r') as f: + content = f.read() + + original_content = content + + # Update hardcoded action versions + for action_name, version in action_versions.items(): + # Look for patterns like 'uses': 'actions/setup-node@v4' + pattern = rf"('uses':\s*')(actions/{action_name.split('/')[-1]})@([^']+)(')" + replacement = rf"\1\2@{version}\4" + 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 + + 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 in action_versions.items(): + # Look for patterns like 'uses: actions/setup-node@v4' + pattern = rf"(uses:\s+{re.escape(action_name)})@([^@\s]+)" + replacement = rf"\1@{version}" + 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 update_regular_workflows(workflow_dir: str, action_versions: Dict[str, str]) -> List[str]: + """ + Update action versions in regular (non-generated) workflow files + + Args: + workflow_dir: Path to .github/workflows directory + action_versions: Dictionary of action names to versions + + Returns: + List of files that were modified + """ + modified_files = [] + + # Get all workflow files that are NOT generated (don't start with __) + all_files = glob.glob(os.path.join(workflow_dir, "*.yml")) + regular_files = [f for f in all_files if not os.path.basename(f).startswith("__")] + + for file_path in regular_files: + with open(file_path, 'r') as f: + content = f.read() + + original_content = content + + # Update action versions + for action_name, version in action_versions.items(): + # Look for patterns like 'uses: actions/setup-node@v4' + pattern = rf"(uses:\s+{re.escape(action_name)})@([^@\s]+)" + replacement = rf"\1@{version}" + 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("--dry-run", action="store_true", help="Show what would be changed without making changes") + 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 + + if args.dry_run: + print("\nDRY RUN - Would make the following changes:") + print(f"Action versions to sync: {action_versions}") + return 0 + + # 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) + + # Update regular workflow files + workflow_modified = update_regular_workflows(str(workflow_dir), action_versions) + modified_files.extend(workflow_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/sync-back.sh b/pr-checks/sync-back.sh new file mode 100755 index 0000000000..7a3db61948 --- /dev/null +++ b/pr-checks/sync-back.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Sync-back wrapper script +# This script runs the sync-back.py Python script to automatically sync +# Dependabot action version updates back to the source templates. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SYNC_BACK_PY="${SCRIPT_DIR}/sync-back.py" + +# Check if Python script exists +if [[ ! -f "$SYNC_BACK_PY" ]]; then + echo "Error: sync-back.py not found at $SYNC_BACK_PY" >&2 + exit 1 +fi + +# Make sure the Python script is executable +chmod +x "$SYNC_BACK_PY" + +# Run the sync-back script with all provided arguments +echo "Running sync-back automation..." +python3 "$SYNC_BACK_PY" "$@" + +echo "Sync-back completed successfully." \ No newline at end of file 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 From f77ed607fd888340dd3b035d106b102f5033d792 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:00:52 +0000 Subject: [PATCH 03/21] Improve sync-back automation with automatic action detection, comment preservation, and tests Co-authored-by: henrymercer <14129055+henrymercer@users.noreply.github.com> --- pr-checks/.gitignore | 2 + pr-checks/readme.md | 17 +- pr-checks/sync-back.py | 56 +++--- pr-checks/sync-back.sh | 25 --- pr-checks/test_sync_back.py | 339 ++++++++++++++++++++++++++++++++++++ 5 files changed, 383 insertions(+), 56 deletions(-) delete mode 100755 pr-checks/sync-back.sh create mode 100644 pr-checks/test_sync_back.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/readme.md b/pr-checks/readme.md index e96949c040..4a0d45f704 100644 --- a/pr-checks/readme.md +++ b/pr-checks/readme.md @@ -18,21 +18,32 @@ Manually run each step in the `justfile`. When Dependabot updates action versions in the generated workflow files (`.github/workflows/__*.yml`), the sync-back automation ensures those changes are properly reflected in the source templates. +The sync-back script automatically detects all actions used in generated workflows and preserves +version comments (e.g., `# v1.2.3`) when syncing versions between files. + ### Running sync-back manually To sync action versions from generated workflows back to source templates: ```bash # Dry run to see what would be changed -./pr-checks/sync-back.sh --dry-run --verbose +python3 pr-checks/sync-back.py --dry-run --verbose # Actually apply the changes -./pr-checks/sync-back.sh +python3 pr-checks/sync-back.py ``` -The sync-back script (`sync-back.py`) automatically updates: +The sync-back script automatically updates: - Hardcoded action versions in `pr-checks/sync.py` - Action version references in template files in `pr-checks/checks/` - Action version references in regular workflow files This ensures that the `verify-pr-checks.sh` test always passes after Dependabot PRs. + +### Testing + +The sync-back script includes comprehensive tests that can be run with: + +```bash +python3 pr-checks/test_sync_back.py -v +``` diff --git a/pr-checks/sync-back.py b/pr-checks/sync-back.py index 45e4d333f0..42c57ea90f 100755 --- a/pr-checks/sync-back.py +++ b/pr-checks/sync-back.py @@ -4,11 +4,14 @@ from the generated workflow files after Dependabot updates. This script scans the generated workflow files (.github/workflows/__*.yml) to find -the latest action versions used, then updates: +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/ 3. Action version references in regular workflow files +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. """ @@ -30,31 +33,25 @@ def scan_generated_workflows(workflow_dir: str) -> Dict[str, str]: workflow_dir: Path to .github/workflows directory Returns: - Dictionary mapping action names to their latest versions + Dictionary mapping action names to their latest versions (including comments) """ action_versions = {} generated_files = glob.glob(os.path.join(workflow_dir, "__*.yml")) - # Actions we care about syncing - target_actions = { - 'actions/setup-go', - 'actions/setup-node', - 'actions/setup-python', - 'actions/github-script' - } - for file_path in generated_files: with open(file_path, 'r') as f: content = f.read() - # Find all action uses in the file - pattern = r'uses:\s+(actions/[^@\s]+)@([^@\s]+)' + # 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 in matches: - if action_name in target_actions: + for action_name, version_with_comment in matches: + # Only track non-local actions (those with / but not starting with ./) + if '/' in action_name and not action_name.startswith('./'): # Take the latest version seen (they should all be the same after Dependabot) - action_versions[action_name] = version + action_versions[action_name] = version_with_comment.rstrip() return action_versions @@ -65,7 +62,7 @@ def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: Args: sync_py_path: Path to sync.py file - action_versions: Dictionary of action names to versions + action_versions: Dictionary of action names to versions (may include comments) Returns: True if file was modified, False otherwise @@ -80,9 +77,12 @@ def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: original_content = content # Update hardcoded action versions - for action_name, version in action_versions.items(): + 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' - pattern = rf"('uses':\s*')(actions/{action_name.split('/')[-1]})@([^']+)(')" + pattern = rf"('uses':\s*')(actions/{re.escape(action_name.split('/')[-1])})@([^']+)(')" replacement = rf"\1\2@{version}\4" content = re.sub(pattern, replacement, content) @@ -102,7 +102,7 @@ def update_template_files(checks_dir: str, action_versions: Dict[str, str]) -> L Args: checks_dir: Path to pr-checks/checks directory - action_versions: Dictionary of action names to versions + action_versions: Dictionary of action names to versions (may include comments) Returns: List of files that were modified @@ -117,10 +117,10 @@ def update_template_files(checks_dir: str, action_versions: Dict[str, str]) -> L original_content = content # Update action versions - for action_name, version in action_versions.items(): - # Look for patterns like 'uses: actions/setup-node@v4' - pattern = rf"(uses:\s+{re.escape(action_name)})@([^@\s]+)" - replacement = rf"\1@{version}" + 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: @@ -138,7 +138,7 @@ def update_regular_workflows(workflow_dir: str, action_versions: Dict[str, str]) Args: workflow_dir: Path to .github/workflows directory - action_versions: Dictionary of action names to versions + action_versions: Dictionary of action names to versions (may include comments) Returns: List of files that were modified @@ -156,10 +156,10 @@ def update_regular_workflows(workflow_dir: str, action_versions: Dict[str, str]) original_content = content # Update action versions - for action_name, version in action_versions.items(): - # Look for patterns like 'uses: actions/setup-node@v4' - pattern = rf"(uses:\s+{re.escape(action_name)})@([^@\s]+)" - replacement = rf"\1@{version}" + 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: diff --git a/pr-checks/sync-back.sh b/pr-checks/sync-back.sh deleted file mode 100755 index 7a3db61948..0000000000 --- a/pr-checks/sync-back.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Sync-back wrapper script -# This script runs the sync-back.py Python script to automatically sync -# Dependabot action version updates back to the source templates. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SYNC_BACK_PY="${SCRIPT_DIR}/sync-back.py" - -# Check if Python script exists -if [[ ! -f "$SYNC_BACK_PY" ]]; then - echo "Error: sync-back.py not found at $SYNC_BACK_PY" >&2 - exit 1 -fi - -# Make sure the Python script is executable -chmod +x "$SYNC_BACK_PY" - -# Run the sync-back script with all provided arguments -echo "Running sync-back automation..." -python3 "$SYNC_BACK_PY" "$@" - -echo "Sync-back completed successfully." \ 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..3f1c9e460b --- /dev/null +++ b/pr-checks/test_sync_back.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +""" +Tests for the sync-back.py script +""" + +import os +import tempfile +import shutil +import unittest +from pathlib import Path +from unittest.mock import patch +import sys + +# Add the current directory to sys.path and import the sync_back module +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import the sync-back module +import importlib.util +spec = importlib.util.spec_from_file_location("sync_back", os.path.join(os.path.dirname(__file__), "sync-back.py")) +sync_back = importlib.util.module_from_spec(spec) +spec.loader.exec_module(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_scan_generated_workflows_skips_non_generated(self): + """Test that non-generated files are ignored""" + # Create generated file + generated_content = """ +name: Generated +jobs: + test: + steps: + - uses: actions/checkout@v4 + """ + with open(os.path.join(self.workflow_dir, "__generated.yml"), 'w') as f: + f.write(generated_content) + + # Create regular file + regular_content = """ +name: Regular +jobs: + test: + steps: + - uses: actions/checkout@v3 + """ + with open(os.path.join(self.workflow_dir, "regular.yml"), 'w') as f: + f.write(regular_content) + + result = sync_back.scan_generated_workflows(self.workflow_dir) + + # Should only see the version from the generated file + self.assertEqual(result['actions/checkout'], 'v4') + + 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_update_regular_workflows(self): + """Test updating regular workflow files""" + # Create a regular workflow file + workflow_content = """ +name: Regular Workflow +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v4 + """ + + workflow_path = os.path.join(self.workflow_dir, "regular.yml") + with open(workflow_path, 'w') as f: + f.write(workflow_content) + + # Create a generated workflow file (should be ignored) + generated_path = os.path.join(self.workflow_dir, "__generated.yml") + with open(generated_path, 'w') as f: + f.write(workflow_content) + + action_versions = { + 'actions/checkout': 'v4', + 'actions/setup-node': 'v5' + } + + result = sync_back.update_regular_workflows(self.workflow_dir, action_versions) + + # Should only update the regular file, not the generated one + self.assertEqual(len(result), 1) + self.assertIn(workflow_path, result) + self.assertNotIn(generated_path, result) + + with open(workflow_path, 'r') as f: + updated_content = f.read() + + self.assertIn("uses: actions/checkout@v4", updated_content) + self.assertIn("uses: actions/setup-node@v5", 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) + + def test_missing_sync_py_file(self): + """Test handling of missing sync.py file""" + result = sync_back.update_sync_py("/nonexistent/sync.py", {}) + self.assertFalse(result) + + def test_main_dry_run(self): + """Test that dry-run functionality works""" + # Create a test workflow + workflow_content = """ +name: Test +jobs: + test: + steps: + - uses: actions/checkout@v4 + """ + + with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: + f.write(workflow_content) + + # Test the scanning function directly since mocking main() is complex + result = sync_back.scan_generated_workflows(self.workflow_dir) + self.assertIn('actions/checkout', result) + self.assertEqual(result['actions/checkout'], 'v4') + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 5d79536231a53872a4b9a0de3411242a170997f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:53:17 +0000 Subject: [PATCH 04/21] Remove regular workflow file updates from sync-back script Co-authored-by: henrymercer <14129055+henrymercer@users.noreply.github.com> --- pr-checks/readme.md | 3 ++- pr-checks/sync-back.py | 47 ++----------------------------------- pr-checks/test_sync_back.py | 40 ------------------------------- 3 files changed, 4 insertions(+), 86 deletions(-) diff --git a/pr-checks/readme.md b/pr-checks/readme.md index 4a0d45f704..042e288e03 100644 --- a/pr-checks/readme.md +++ b/pr-checks/readme.md @@ -36,7 +36,8 @@ python3 pr-checks/sync-back.py The sync-back script automatically updates: - Hardcoded action versions in `pr-checks/sync.py` - Action version references in template files in `pr-checks/checks/` -- Action version references in regular workflow files + +Regular workflow files are updated directly by Dependabot and don't need sync-back. This ensures that the `verify-pr-checks.sh` test always passes after Dependabot PRs. diff --git a/pr-checks/sync-back.py b/pr-checks/sync-back.py index 42c57ea90f..35d36e575f 100755 --- a/pr-checks/sync-back.py +++ b/pr-checks/sync-back.py @@ -7,13 +7,13 @@ 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/ -3. Action version references in regular workflow files 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. +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 @@ -132,45 +132,6 @@ def update_template_files(checks_dir: str, action_versions: Dict[str, str]) -> L return modified_files -def update_regular_workflows(workflow_dir: str, action_versions: Dict[str, str]) -> List[str]: - """ - Update action versions in regular (non-generated) workflow files - - Args: - workflow_dir: Path to .github/workflows directory - action_versions: Dictionary of action names to versions (may include comments) - - Returns: - List of files that were modified - """ - modified_files = [] - - # Get all workflow files that are NOT generated (don't start with __) - all_files = glob.glob(os.path.join(workflow_dir, "*.yml")) - regular_files = [f for f in all_files if not os.path.basename(f).startswith("__")] - - for file_path in regular_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("--dry-run", action="store_true", help="Show what would be changed without making changes") @@ -214,10 +175,6 @@ def main(): template_modified = update_template_files(str(checks_dir), action_versions) modified_files.extend(template_modified) - # Update regular workflow files - workflow_modified = update_regular_workflows(str(workflow_dir), action_versions) - modified_files.extend(workflow_modified) - if modified_files: print(f"\nSync completed. Modified {len(modified_files)} files:") for file_path in modified_files: diff --git a/pr-checks/test_sync_back.py b/pr-checks/test_sync_back.py index 3f1c9e460b..eeb525870d 100644 --- a/pr-checks/test_sync_back.py +++ b/pr-checks/test_sync_back.py @@ -248,46 +248,6 @@ def test_update_template_files_preserves_comments(self): self.assertIn("uses: ruby/setup-ruby@55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0", updated_content) - def test_update_regular_workflows(self): - """Test updating regular workflow files""" - # Create a regular workflow file - workflow_content = """ -name: Regular Workflow -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v4 - """ - - workflow_path = os.path.join(self.workflow_dir, "regular.yml") - with open(workflow_path, 'w') as f: - f.write(workflow_content) - - # Create a generated workflow file (should be ignored) - generated_path = os.path.join(self.workflow_dir, "__generated.yml") - with open(generated_path, 'w') as f: - f.write(workflow_content) - - action_versions = { - 'actions/checkout': 'v4', - 'actions/setup-node': 'v5' - } - - result = sync_back.update_regular_workflows(self.workflow_dir, action_versions) - - # Should only update the regular file, not the generated one - self.assertEqual(len(result), 1) - self.assertIn(workflow_path, result) - self.assertNotIn(generated_path, result) - - with open(workflow_path, 'r') as f: - updated_content = f.read() - - self.assertIn("uses: actions/checkout@v4", updated_content) - self.assertIn("uses: actions/setup-node@v5", 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 From f5371102858eb38af0cdfb146b6e204bfdfc15f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:59:28 +0000 Subject: [PATCH 05/21] Add sync-back script execution to rebuild workflow Co-authored-by: henrymercer <14129055+henrymercer@users.noreply.github.com> --- .github/workflows/rebuild.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml index ec43df50e5..b3c40dd7f9 100644 --- a/.github/workflows/rebuild.yml +++ b/.github/workflows/rebuild.yml @@ -77,6 +77,7 @@ jobs: cd pr-checks python -m pip install --upgrade pip pip install ruamel.yaml==0.17.31 + python3 sync-back.py python3 sync.py - name: "Merge in progress: Finish merge and push" From d9bc711b1c9fe8f539aa0d1ae7e5e82fd807cf63 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Wed, 10 Sep 2025 18:12:20 +0100 Subject: [PATCH 06/21] Rename script for consistency --- .github/workflows/rebuild.yml | 2 +- pr-checks/readme.md | 4 ++-- pr-checks/{sync-back.py => sync_back.py} | 0 pr-checks/test_sync_back.py | 10 ++++------ 4 files changed, 7 insertions(+), 9 deletions(-) rename pr-checks/{sync-back.py => sync_back.py} (100%) diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml index b3c40dd7f9..cfce787134 100644 --- a/.github/workflows/rebuild.yml +++ b/.github/workflows/rebuild.yml @@ -77,7 +77,7 @@ jobs: cd pr-checks python -m pip install --upgrade pip pip install ruamel.yaml==0.17.31 - python3 sync-back.py + python3 sync_back.py python3 sync.py - name: "Merge in progress: Finish merge and push" diff --git a/pr-checks/readme.md b/pr-checks/readme.md index 042e288e03..58c6b94129 100644 --- a/pr-checks/readme.md +++ b/pr-checks/readme.md @@ -27,10 +27,10 @@ To sync action versions from generated workflows back to source templates: ```bash # Dry run to see what would be changed -python3 pr-checks/sync-back.py --dry-run --verbose +python3 pr-checks/sync_back.py --dry-run --verbose # Actually apply the changes -python3 pr-checks/sync-back.py +python3 pr-checks/sync_back.py ``` The sync-back script automatically updates: diff --git a/pr-checks/sync-back.py b/pr-checks/sync_back.py similarity index 100% rename from pr-checks/sync-back.py rename to pr-checks/sync_back.py diff --git a/pr-checks/test_sync_back.py b/pr-checks/test_sync_back.py index eeb525870d..37fc031337 100644 --- a/pr-checks/test_sync_back.py +++ b/pr-checks/test_sync_back.py @@ -1,22 +1,20 @@ #!/usr/bin/env python3 """ -Tests for the sync-back.py script +Tests for the sync_back.py script """ import os -import tempfile import shutil -import unittest -from pathlib import Path -from unittest.mock import patch import sys +import tempfile +import unittest # Add the current directory to sys.path and import the sync_back module sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Import the sync-back module import importlib.util -spec = importlib.util.spec_from_file_location("sync_back", os.path.join(os.path.dirname(__file__), "sync-back.py")) +spec = importlib.util.spec_from_file_location("sync_back", os.path.join(os.path.dirname(__file__), "sync_back.py")) sync_back = importlib.util.module_from_spec(spec) spec.loader.exec_module(sync_back) From d08f9295109931df92ba738efdd68362d92e9436 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Wed, 10 Sep 2025 18:12:29 +0100 Subject: [PATCH 07/21] Run test script in CI --- .github/workflows/pr-checks.yml | 4 ++++ pr-checks/__init__.py | 0 2 files changed, 4 insertions(+) create mode 100644 pr-checks/__init__.py 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/pr-checks/__init__.py b/pr-checks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From cde0d796a647ee8286d3f9afcfb752c9e3415c1a Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Wed, 10 Sep 2025 18:14:08 +0100 Subject: [PATCH 08/21] Run sync back script separately --- .github/workflows/rebuild.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml index cfce787134..37e768c66b 100644 --- a/.github/workflows/rebuild.yml +++ b/.github/workflows/rebuild.yml @@ -72,12 +72,16 @@ jobs: with: python-version: 3.11 + - name: Sync back version updates to generated workflows + 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_back.py python3 sync.py - name: "Merge in progress: Finish merge and push" From 1343eba2d06f8e026a7bd25e9c4e15f1c0c44698 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Wed, 10 Sep 2025 18:14:20 +0100 Subject: [PATCH 09/21] Remove unused imports --- pr-checks/sync_back.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py index 35d36e575f..52b390c20f 100755 --- a/pr-checks/sync_back.py +++ b/pr-checks/sync_back.py @@ -22,7 +22,7 @@ import argparse import sys from pathlib import Path -from typing import Dict, Set, List, Tuple +from typing import Dict, List def scan_generated_workflows(workflow_dir: str) -> Dict[str, str]: From d0f02ad6837ae6f14f131fba9795e124c5888675 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Wed, 10 Sep 2025 18:24:38 +0100 Subject: [PATCH 10/21] Simplify import --- pr-checks/test_sync_back.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/pr-checks/test_sync_back.py b/pr-checks/test_sync_back.py index 37fc031337..b25a6bbc41 100644 --- a/pr-checks/test_sync_back.py +++ b/pr-checks/test_sync_back.py @@ -5,18 +5,10 @@ import os import shutil -import sys import tempfile import unittest -# Add the current directory to sys.path and import the sync_back module -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -# Import the sync-back module -import importlib.util -spec = importlib.util.spec_from_file_location("sync_back", os.path.join(os.path.dirname(__file__), "sync_back.py")) -sync_back = importlib.util.module_from_spec(spec) -spec.loader.exec_module(sync_back) +import sync_back class TestSyncBack(unittest.TestCase): From c9d2739db2a0060cb585dcc353ce91887ddfa0ec Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Wed, 10 Sep 2025 18:24:51 +0100 Subject: [PATCH 11/21] Use more generic regexp for `sync.py` changes --- pr-checks/sync_back.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py index 52b390c20f..7bcfead857 100755 --- a/pr-checks/sync_back.py +++ b/pr-checks/sync_back.py @@ -82,8 +82,8 @@ def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: 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' - pattern = rf"('uses':\s*')(actions/{re.escape(action_name.split('/')[-1])})@([^']+)(')" - replacement = rf"\1\2@{version}\4" + 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: @@ -119,7 +119,7 @@ def update_template_files(checks_dir: str, action_versions: Dict[str, str]) -> L # 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]+)" + pattern = rf"(uses:\s+{re.escape(action_name)})@(?:[^@\n]+)" replacement = rf"\1@{version_with_comment}" content = re.sub(pattern, replacement, content) From 5df1d6e0dbe6997a791b3a6700cf2b98fea535ce Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 15:39:05 +0100 Subject: [PATCH 12/21] Remove redundant check --- pr-checks/sync_back.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py index 7bcfead857..d588efc7f6 100755 --- a/pr-checks/sync_back.py +++ b/pr-checks/sync_back.py @@ -49,7 +49,7 @@ def scan_generated_workflows(workflow_dir: str) -> Dict[str, str]: for action_name, version_with_comment in matches: # Only track non-local actions (those with / but not starting with ./) - if '/' in action_name and not action_name.startswith('./'): + if not action_name.startswith('./'): # Take the latest version seen (they should all be the same after Dependabot) action_versions[action_name] = version_with_comment.rstrip() From ee37081d031a4f59701d3eabb6677858b20cd575 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 16:34:10 +0200 Subject: [PATCH 13/21] Remove docs about sync back workflow In favour of docs in the script itself --- pr-checks/readme.md | 38 +------------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/pr-checks/readme.md b/pr-checks/readme.md index 58c6b94129..283ed35993 100644 --- a/pr-checks/readme.md +++ b/pr-checks/readme.md @@ -9,42 +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`. - -## Sync-back automation - -When Dependabot updates action versions in the generated workflow files (`.github/workflows/__*.yml`), -the sync-back automation ensures those changes are properly reflected in the source templates. - -The sync-back script automatically detects all actions used in generated workflows and preserves -version comments (e.g., `# v1.2.3`) when syncing versions between files. - -### Running sync-back manually - -To sync action versions from generated workflows back to source templates: - -```bash -# Dry run to see what would be changed -python3 pr-checks/sync_back.py --dry-run --verbose - -# Actually apply the changes -python3 pr-checks/sync_back.py -``` - -The sync-back script automatically updates: -- Hardcoded action versions in `pr-checks/sync.py` -- Action version references in template files in `pr-checks/checks/` - -Regular workflow files are updated directly by Dependabot and don't need sync-back. - -This ensures that the `verify-pr-checks.sh` test always passes after Dependabot PRs. - -### Testing - -The sync-back script includes comprehensive tests that can be run with: - -```bash -python3 pr-checks/test_sync_back.py -v -``` From 5065ea8eef517afdf6f7e41413ff347bac2896a1 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 16:41:25 +0200 Subject: [PATCH 14/21] Improve comment --- pr-checks/sync_back.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py index d588efc7f6..76202cc78c 100755 --- a/pr-checks/sync_back.py +++ b/pr-checks/sync_back.py @@ -50,7 +50,7 @@ def scan_generated_workflows(workflow_dir: str) -> Dict[str, str]: for action_name, version_with_comment in matches: # Only track non-local actions (those with / but not starting with ./) if not action_name.startswith('./'): - # Take the latest version seen (they should all be the same after Dependabot) + # 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 From 86ed2117d5126886b07d25a0a5db0ee16734d81b Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 16:42:56 +0200 Subject: [PATCH 15/21] Note limitation of looking for `uses: ` in pattern --- pr-checks/sync_back.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py index 76202cc78c..ffc18f3fc7 100755 --- a/pr-checks/sync_back.py +++ b/pr-checks/sync_back.py @@ -82,6 +82,9 @@ def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: 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) From bb07e07aff4d81b455b93dbecda1510346e2cbf4 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 16:43:58 +0200 Subject: [PATCH 16/21] Remove trailing whitespace --- pr-checks/sync_back.py | 66 +++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py index ffc18f3fc7..e4fe0bfa4a 100755 --- a/pr-checks/sync_back.py +++ b/pr-checks/sync_back.py @@ -28,59 +28,59 @@ 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): print(f"Warning: {sync_py_path} not found") return False - + with open(sync_py_path, 'r') as f: content = f.read() - + original_content = content - - # Update hardcoded action versions + + # 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 @@ -88,7 +88,7 @@ def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: 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) @@ -102,36 +102,36 @@ def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: 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 @@ -140,51 +140,51 @@ def main(): parser.add_argument("--dry-run", action="store_true", help="Show what would be changed without making changes") 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 - + if args.dry_run: print("\nDRY RUN - Would make the following changes:") print(f"Action versions to sync: {action_versions}") return 0 - + # 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 + + # 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 From 8df00436ea90278318ee4935ea97eac7c726fd3e Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 16:44:42 +0200 Subject: [PATCH 17/21] Remove half baked dry run functionality --- pr-checks/sync_back.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py index e4fe0bfa4a..9cf25b20bf 100755 --- a/pr-checks/sync_back.py +++ b/pr-checks/sync_back.py @@ -137,7 +137,6 @@ def update_template_files(checks_dir: str, action_versions: Dict[str, str]) -> L def main(): parser = argparse.ArgumentParser(description="Sync action versions from generated workflows back to templates") - parser.add_argument("--dry-run", action="store_true", help="Show what would be changed without making changes") parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output") args = parser.parse_args() @@ -161,11 +160,6 @@ def main(): print("No action versions found in generated workflows") return 1 - if args.dry_run: - print("\nDRY RUN - Would make the following changes:") - print(f"Action versions to sync: {action_versions}") - return 0 - # Update files print("\nUpdating source files...") modified_files = [] From fbe415d86f05c296e3d42f5ca949b95d8cca4fec Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 16:46:57 +0200 Subject: [PATCH 18/21] Remove misleading test case --- pr-checks/test_sync_back.py | 126 ++++++++++++++---------------------- 1 file changed, 49 insertions(+), 77 deletions(-) diff --git a/pr-checks/test_sync_back.py b/pr-checks/test_sync_back.py index b25a6bbc41..7801670883 100644 --- a/pr-checks/test_sync_back.py +++ b/pr-checks/test_sync_back.py @@ -12,7 +12,7 @@ class TestSyncBack(unittest.TestCase): - + def setUp(self): """Set up temporary directories and files for testing""" self.test_dir = tempfile.mkdtemp() @@ -20,14 +20,14 @@ def setUp(self): 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 @@ -41,16 +41,16 @@ def test_scan_generated_workflows_basic(self): - 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 = """ @@ -63,16 +63,16 @@ def test_scan_generated_workflows_with_comments(self): - 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 = """ @@ -85,45 +85,17 @@ def test_scan_generated_workflows_ignores_local_actions(self): - 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_scan_generated_workflows_skips_non_generated(self): - """Test that non-generated files are ignored""" - # Create generated file - generated_content = """ -name: Generated -jobs: - test: - steps: - - uses: actions/checkout@v4 - """ - with open(os.path.join(self.workflow_dir, "__generated.yml"), 'w') as f: - f.write(generated_content) - - # Create regular file - regular_content = """ -name: Regular -jobs: - test: - steps: - - uses: actions/checkout@v3 - """ - with open(os.path.join(self.workflow_dir, "regular.yml"), 'w') as f: - f.write(regular_content) - - result = sync_back.scan_generated_workflows(self.workflow_dir) - - # Should only see the version from the generated file - self.assertEqual(result['actions/checkout'], 'v4') - + + def test_update_sync_py(self): """Test updating sync.py file""" sync_py_content = """ @@ -133,29 +105,29 @@ def test_update_sync_py(self): 'with': {'node-version': '16'} }, { - 'uses': 'actions/setup-go@v5', + '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 = """ @@ -166,24 +138,24 @@ def test_update_sync_py_with_comments(self): } ] """ - + 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 = """ @@ -194,26 +166,26 @@ def test_update_template_files(self): 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 = """ @@ -221,23 +193,23 @@ def test_update_template_files_preserves_comments(self): 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 @@ -249,22 +221,22 @@ def test_no_changes_needed(self): } ] """ - + 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) - + def test_missing_sync_py_file(self): """Test handling of missing sync.py file""" result = sync_back.update_sync_py("/nonexistent/sync.py", {}) self.assertFalse(result) - + def test_main_dry_run(self): """Test that dry-run functionality works""" # Create a test workflow @@ -275,10 +247,10 @@ def test_main_dry_run(self): steps: - uses: actions/checkout@v4 """ - + with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: f.write(workflow_content) - + # Test the scanning function directly since mocking main() is complex result = sync_back.scan_generated_workflows(self.workflow_dir) self.assertIn('actions/checkout', result) From d9ad6a31c352ca76f113f08199289d8e75d790a7 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 16:47:56 +0200 Subject: [PATCH 19/21] Error if `sync.py` not found --- pr-checks/sync_back.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py index 9cf25b20bf..1474b455e6 100755 --- a/pr-checks/sync_back.py +++ b/pr-checks/sync_back.py @@ -68,8 +68,7 @@ def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: True if file was modified, False otherwise """ if not os.path.exists(sync_py_path): - print(f"Warning: {sync_py_path} not found") - return False + raise FileNotFoundError(f"Could not find {sync_py_path}") with open(sync_py_path, 'r') as f: content = f.read() From e9d7b2dd99b5817e200b76f144dd73a448e2dd22 Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Mon, 22 Sep 2025 16:48:35 +0200 Subject: [PATCH 20/21] Remove unnecessary test cases --- pr-checks/test_sync_back.py | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/pr-checks/test_sync_back.py b/pr-checks/test_sync_back.py index 7801670883..de2e42d733 100644 --- a/pr-checks/test_sync_back.py +++ b/pr-checks/test_sync_back.py @@ -232,30 +232,6 @@ def test_no_changes_needed(self): result = sync_back.update_sync_py(self.sync_py_path, action_versions) self.assertFalse(result) - def test_missing_sync_py_file(self): - """Test handling of missing sync.py file""" - result = sync_back.update_sync_py("/nonexistent/sync.py", {}) - self.assertFalse(result) - - def test_main_dry_run(self): - """Test that dry-run functionality works""" - # Create a test workflow - workflow_content = """ -name: Test -jobs: - test: - steps: - - uses: actions/checkout@v4 - """ - - with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: - f.write(workflow_content) - - # Test the scanning function directly since mocking main() is complex - result = sync_back.scan_generated_workflows(self.workflow_dir) - self.assertIn('actions/checkout', result) - self.assertEqual(result['actions/checkout'], 'v4') - if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From 2885255647f76aa6b9e8786437481ed061e240dc Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Tue, 23 Sep 2025 11:29:23 +0200 Subject: [PATCH 21/21] Only sync back versions on Dependabot update PRs --- .github/workflows/rebuild.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml index 37e768c66b..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" @@ -73,6 +75,8 @@ jobs: 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