From 6a6bd778b619680f65ec523fd9ec4b14f5863ba6 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Sun, 1 Mar 2026 15:33:35 +0000 Subject: [PATCH 01/10] Add initial `sync_back.ts` script --- pr-checks/sync_back.ts | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100755 pr-checks/sync_back.ts diff --git a/pr-checks/sync_back.ts b/pr-checks/sync_back.ts new file mode 100755 index 0000000000..e42934f7a1 --- /dev/null +++ b/pr-checks/sync_back.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env npx tsx + +/* +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.ts +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 { parseArgs } from "node:util"; + +import * as path from "path"; + +const THIS_DIR = __dirname; +const CHECKS_DIR = path.join(THIS_DIR, "checks"); +const WORKFLOW_DIR = path.join(THIS_DIR, "..", ".github", "workflows"); +const SYNC_TS_PATH = path.join(THIS_DIR, "sync.ts"); + +function main(): number { + const { values } = parseArgs({ + options: { + verbose: { + type: "boolean", + short: "v", + default: false, + }, + }, + strict: true, + }); + + const verbose = values.verbose ?? false; + + console.log(verbose); + + return 0; +} + +process.exit(main()); From e1b83ccb749c0bbcc0f023d8d33dbc6efcbf200b Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Sun, 1 Mar 2026 15:37:11 +0000 Subject: [PATCH 02/10] Add `scanGeneratedWorkflows` --- pr-checks/sync_back.ts | 53 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/pr-checks/sync_back.ts b/pr-checks/sync_back.ts index e42934f7a1..8cb973b84c 100755 --- a/pr-checks/sync_back.ts +++ b/pr-checks/sync_back.ts @@ -19,6 +19,7 @@ files are updated directly by Dependabot and don't need sync-back. import { parseArgs } from "node:util"; +import * as fs from "fs"; import * as path from "path"; const THIS_DIR = __dirname; @@ -26,6 +27,43 @@ const CHECKS_DIR = path.join(THIS_DIR, "checks"); const WORKFLOW_DIR = path.join(THIS_DIR, "..", ".github", "workflows"); const SYNC_TS_PATH = path.join(THIS_DIR, "sync.ts"); +/** + * Scan generated workflow files to extract the latest action versions. + * + * @param workflowDir - Path to .github/workflows directory + * @returns Map from action names to their latest versions (including comments) + */ +function scanGeneratedWorkflows(workflowDir: string): Record { + const actionVersions: Record = {}; + + const generatedFiles = fs + .readdirSync(workflowDir) + .filter((f) => f.startsWith("__") && f.endsWith(".yml")) + .map((f) => path.join(workflowDir, f)); + + for (const filePath of generatedFiles) { + const content = fs.readFileSync(filePath, "utf8"); + + // Find all action uses in the file, including potential comments + // This pattern captures: action_name@version_with_possible_comment + const pattern = /uses:\s+([^/\s]+\/[^@\s]+)@([^@\n]+)/g; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(content)) !== null) { + const actionName = match[1]; + const versionWithComment = match[2].trimEnd(); + + // Only track non-local actions (those with / but not starting with ./) + if (!actionName.startsWith("./")) { + // Assume that version numbers are consistent (this should be the case on a Dependabot update PR) + actionVersions[actionName] = versionWithComment; + } + } + } + + return actionVersions; +} + function main(): number { const { values } = parseArgs({ options: { @@ -40,7 +78,20 @@ function main(): number { const verbose = values.verbose ?? false; - console.log(verbose); + console.info("Scanning generated workflows for latest action versions..."); + const actionVersions = scanGeneratedWorkflows(WORKFLOW_DIR); + + if (verbose) { + console.info("Found action versions:"); + for (const [action, version] of Object.entries(actionVersions)) { + console.info(` ${action}@${version}`); + } + } + + if (Object.keys(actionVersions).length === 0) { + console.error("No action versions found in generated workflows"); + return 1; + } return 0; } From f05cfae0182d4291d35b9ca8d61996e4127e3e22 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Sun, 1 Mar 2026 15:43:17 +0000 Subject: [PATCH 03/10] Add `updateSyncTs` --- pr-checks/sync_back.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/pr-checks/sync_back.ts b/pr-checks/sync_back.ts index 8cb973b84c..3fc12ee6ac 100755 --- a/pr-checks/sync_back.ts +++ b/pr-checks/sync_back.ts @@ -64,6 +64,55 @@ function scanGeneratedWorkflows(workflowDir: string): Record { return actionVersions; } +/** + * Update hardcoded action versions in pr-checks/sync.ts + * + * @param syncTsPath - Path to sync.ts file + * @param actionVersions - Map of action names to versions (may include comments) + * @returns True if the file was modified, false otherwise + */ +function updateSyncTs( + syncTsPath: string, + actionVersions: Record, +): boolean { + if (!fs.existsSync(syncTsPath)) { + throw new Error(`Could not find ${syncTsPath}`); + } + + let content = fs.readFileSync(syncTsPath, "utf8"); + const originalContent = content; + + // Update hardcoded action versions + for (const [actionName, versionWithComment] of Object.entries( + actionVersions, + )) { + // Extract just the version part (before any comment) for sync.ts + const version = versionWithComment.includes("#") + ? versionWithComment.split("#")[0].trim() + : versionWithComment.trim(); + + // 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. + const escaped = actionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `(uses:\\s*")${escaped}@(?:[^"]+)(")`, + "g", + ); + content = content.replace(pattern, `$1${actionName}@${version}$2`); + } + + if (content !== originalContent) { + fs.writeFileSync(syncTsPath, content, "utf8"); + console.info(`Updated ${syncTsPath}`); + return true; + } else { + console.info(`No changes needed in ${syncTsPath}`); + return false; + } +} + function main(): number { const { values } = parseArgs({ options: { @@ -93,6 +142,28 @@ function main(): number { return 1; } + // Update files + console.info("\nUpdating source files..."); + const modifiedFiles: string[] = []; + + // Update sync.ts + if (updateSyncTs(SYNC_TS_PATH, actionVersions)) { + modifiedFiles.push(SYNC_TS_PATH); + } + + // TODO: Update template files + + if (modifiedFiles.length > 0) { + console.info(`\nSync completed. Modified ${modifiedFiles.length} files:`); + for (const filePath of modifiedFiles) { + console.info(` ${filePath}`); + } + } else { + console.info( + "\nNo files needed updating - all action versions are already in sync", + ); + } + return 0; } From dd779fa7d32987b6617af33220036a1cc7262045 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Sun, 1 Mar 2026 15:46:59 +0000 Subject: [PATCH 04/10] Add `updateTemplateFiles` --- pr-checks/sync_back.ts | 49 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/pr-checks/sync_back.ts b/pr-checks/sync_back.ts index 3fc12ee6ac..ace43bc1f0 100755 --- a/pr-checks/sync_back.ts +++ b/pr-checks/sync_back.ts @@ -113,6 +113,51 @@ function updateSyncTs( } } +/** + * Update action versions in template files in pr-checks/checks/ + * + * @param checksDir - Path to pr-checks/checks directory + * @param actionVersions - Map of action names to versions (may include comments) + * @returns List of files that were modified + */ +function updateTemplateFiles( + checksDir: string, + actionVersions: Record, +): string[] { + const modifiedFiles: string[] = []; + + const templateFiles = fs + .readdirSync(checksDir) + .filter((f) => f.endsWith(".yml")) + .map((f) => path.join(checksDir, f)); + + for (const filePath of templateFiles) { + let content = fs.readFileSync(filePath, "utf8"); + const originalContent = content; + + // Update action versions + for (const [actionName, versionWithComment] of Object.entries( + actionVersions, + )) { + // Look for patterns like 'uses: actions/setup-node@v4' or 'uses: actions/setup-node@sha # comment' + const escaped = actionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `(uses:\\s+${escaped})@(?:[^@\n]+)`, + "g", + ); + content = content.replace(pattern, `$1@${versionWithComment}`); + } + + if (content !== originalContent) { + fs.writeFileSync(filePath, content, "utf8"); + modifiedFiles.push(filePath); + console.info(`Updated ${filePath}`); + } + } + + return modifiedFiles; +} + function main(): number { const { values } = parseArgs({ options: { @@ -151,7 +196,9 @@ function main(): number { modifiedFiles.push(SYNC_TS_PATH); } - // TODO: Update template files + // Update template files + const templateModified = updateTemplateFiles(CHECKS_DIR, actionVersions); + modifiedFiles.push(...templateModified); if (modifiedFiles.length > 0) { console.info(`\nSync completed. Modified ${modifiedFiles.length} files:`); From 8eb0202e9d4ba01c2d56e0ce47f216da4a368502 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Sun, 1 Mar 2026 15:54:08 +0000 Subject: [PATCH 05/10] Port tests --- pr-checks/sync_back.test.ts | 250 ++++++++++++++++++++++++++++++++++++ pr-checks/sync_back.ts | 11 +- 2 files changed, 257 insertions(+), 4 deletions(-) create mode 100755 pr-checks/sync_back.test.ts diff --git a/pr-checks/sync_back.test.ts b/pr-checks/sync_back.test.ts new file mode 100755 index 0000000000..316d2b7303 --- /dev/null +++ b/pr-checks/sync_back.test.ts @@ -0,0 +1,250 @@ +#!/usr/bin/env npx tsx + +/* +Tests for the sync_back.ts script +*/ + +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { + scanGeneratedWorkflows, + updateSyncTs, + updateTemplateFiles, +} from "./sync_back"; + +let testDir: string; +let workflowDir: string; +let checksDir: string; +let syncTsPath: string; + +beforeEach(() => { + /** Set up temporary directories and files for testing */ + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-back-test-")); + workflowDir = path.join(testDir, ".github", "workflows"); + checksDir = path.join(testDir, "pr-checks", "checks"); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.mkdirSync(checksDir, { recursive: true }); + + // Create sync.ts file path + syncTsPath = path.join(testDir, "pr-checks", "sync.ts"); +}); + +afterEach(() => { + /** Clean up temporary directories */ + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +describe("scanGeneratedWorkflows", () => { + it("basic workflow scanning", () => { + /** Test basic workflow scanning functionality */ + const workflowContent = ` +name: Test Workflow +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v5 + - uses: actions/setup-go@v6 +`; + + fs.writeFileSync(path.join(workflowDir, "__test.yml"), workflowContent); + + const result = scanGeneratedWorkflows(workflowDir); + + assert.equal(result["actions/checkout"], "v4"); + assert.equal(result["actions/setup-node"], "v5"); + assert.equal(result["actions/setup-go"], "v6"); + }); + + it("scanning workflows with version comments", () => { + /** Test scanning workflows with version comments */ + const workflowContent = ` +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 +`; + + fs.writeFileSync(path.join(workflowDir, "__test.yml"), workflowContent); + + const result = scanGeneratedWorkflows(workflowDir); + + assert.equal(result["actions/checkout"], "v4"); + assert.equal( + result["ruby/setup-ruby"], + "44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0", + ); + assert.equal(result["actions/setup-python"], "v6 # Latest Python"); + }); + + it("ignores local actions", () => { + /** Test that local actions (starting with ./) are ignored */ + const workflowContent = ` +name: Test Workflow +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/local-action + - uses: ./another-local-action@v1 +`; + + fs.writeFileSync(path.join(workflowDir, "__test.yml"), workflowContent); + + const result = scanGeneratedWorkflows(workflowDir); + + assert.equal(result["actions/checkout"], "v4"); + assert.equal("./.github/actions/local-action" in result, false); + assert.equal("./another-local-action" in result, false); + }); +}); + +describe("updateSyncTs", () => { + it("updates sync.ts file", () => { + /** Test updating sync.ts file */ + const syncTsContent = ` +const steps = [ + { + uses: "actions/setup-node@v4", + with: { "node-version": "16" }, + }, + { + uses: "actions/setup-go@v5", + with: { "go-version": "1.19" }, + }, +]; +`; + + fs.writeFileSync(syncTsPath, syncTsContent); + + const actionVersions = { + "actions/setup-node": "v5", + "actions/setup-go": "v6", + }; + + const result = updateSyncTs(syncTsPath, actionVersions); + assert.equal(result, true); + + const updatedContent = fs.readFileSync(syncTsPath, "utf8"); + + assert.ok(updatedContent.includes('uses: "actions/setup-node@v5"')); + assert.ok(updatedContent.includes('uses: "actions/setup-go@v6"')); + }); + + it("strips comments from versions", () => { + /** Test updating sync.ts file when versions have comments */ + const syncTsContent = ` +const steps = [ + { + uses: "actions/setup-node@v4", + with: { "node-version": "16" }, + }, +]; +`; + + fs.writeFileSync(syncTsPath, syncTsContent); + + const actionVersions = { + "actions/setup-node": "v5 # Latest version", + }; + + const result = updateSyncTs(syncTsPath, actionVersions); + assert.equal(result, true); + + const updatedContent = fs.readFileSync(syncTsPath, "utf8"); + + // sync.ts should get the version without comment + assert.ok(updatedContent.includes('uses: "actions/setup-node@v5"')); + assert.ok(!updatedContent.includes("# Latest version")); + }); + + it("returns false when no changes are needed", () => { + /** Test that updateSyncTs returns false when no changes are needed */ + const syncTsContent = ` +const steps = [ + { + uses: "actions/setup-node@v5", + with: { "node-version": "16" }, + }, +]; +`; + + fs.writeFileSync(syncTsPath, syncTsContent); + + const actionVersions = { + "actions/setup-node": "v5", + }; + + const result = updateSyncTs(syncTsPath, actionVersions); + assert.equal(result, false); + }); +}); + +describe("updateTemplateFiles", () => { + it("updates template files", () => { + /** Test updating template files */ + const templateContent = ` +name: Test Template +steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v4 + with: + node-version: 16 +`; + + const templatePath = path.join(checksDir, "test.yml"); + fs.writeFileSync(templatePath, templateContent); + + const actionVersions = { + "actions/checkout": "v4", + "actions/setup-node": "v5 # Latest", + }; + + const result = updateTemplateFiles(checksDir, actionVersions); + assert.equal(result.length, 1); + assert.ok(result.includes(templatePath)); + + const updatedContent = fs.readFileSync(templatePath, "utf8"); + + assert.ok(updatedContent.includes("uses: actions/checkout@v4")); + assert.ok(updatedContent.includes("uses: actions/setup-node@v5 # Latest")); + }); + + it("preserves version comments", () => { + /** Test that updating template files preserves version comments */ + const templateContent = ` +name: Test Template +steps: + - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.256.0 +`; + + const templatePath = path.join(checksDir, "test.yml"); + fs.writeFileSync(templatePath, templateContent); + + const actionVersions = { + "ruby/setup-ruby": + "55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0", + }; + + const result = updateTemplateFiles(checksDir, actionVersions); + assert.equal(result.length, 1); + + const updatedContent = fs.readFileSync(templatePath, "utf8"); + + assert.ok( + updatedContent.includes( + "uses: ruby/setup-ruby@55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0", + ), + ); + }); +}); diff --git a/pr-checks/sync_back.ts b/pr-checks/sync_back.ts index ace43bc1f0..7e1375580b 100755 --- a/pr-checks/sync_back.ts +++ b/pr-checks/sync_back.ts @@ -33,7 +33,7 @@ const SYNC_TS_PATH = path.join(THIS_DIR, "sync.ts"); * @param workflowDir - Path to .github/workflows directory * @returns Map from action names to their latest versions (including comments) */ -function scanGeneratedWorkflows(workflowDir: string): Record { +export function scanGeneratedWorkflows(workflowDir: string): Record { const actionVersions: Record = {}; const generatedFiles = fs @@ -71,7 +71,7 @@ function scanGeneratedWorkflows(workflowDir: string): Record { * @param actionVersions - Map of action names to versions (may include comments) * @returns True if the file was modified, false otherwise */ -function updateSyncTs( +export function updateSyncTs( syncTsPath: string, actionVersions: Record, ): boolean { @@ -120,7 +120,7 @@ function updateSyncTs( * @param actionVersions - Map of action names to versions (may include comments) * @returns List of files that were modified */ -function updateTemplateFiles( +export function updateTemplateFiles( checksDir: string, actionVersions: Record, ): string[] { @@ -214,4 +214,7 @@ function main(): number { return 0; } -process.exit(main()); +// Only call `main` if this script was run directly. +if (require.main === module) { + process.exit(main()); +} From a6892dcba5626e5de9004e1e6a4b59cae314ea02 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Sun, 1 Mar 2026 15:56:48 +0000 Subject: [PATCH 06/10] Use `sync_back.ts` in `rebuild` workflow --- .github/workflows/rebuild.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml index fcf6affd0e..3e06e5d687 100644 --- a/.github/workflows/rebuild.yml +++ b/.github/workflows/rebuild.yml @@ -83,7 +83,8 @@ jobs: if: startsWith(env.HEAD_REF, 'dependabot/') working-directory: pr-checks run: | - python3 sync_back.py -v + npm ci + npx tsx sync_back.ts --verbose - name: Generate workflows working-directory: pr-checks From 24fa947692d89af97428ed0eee48f1326843726f Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 3 Mar 2026 11:40:54 +0000 Subject: [PATCH 07/10] Update `pr-checks` to run new tests --- .github/workflows/pr-checks.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1c78da10f9..77a544cfa8 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -42,11 +42,6 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: 3.11 - - name: Install dependencies run: | # Use the system Bash shell to ensure we can run commands like `npm ci` @@ -68,7 +63,7 @@ jobs: - name: Run pr-checks tests if: always() working-directory: pr-checks - run: python -m unittest discover + run: npm ci && npx tsx --test - name: Lint if: always() && matrix.os != 'windows-latest' From bf9bf1c0277da3f5066fa1e2194946132aa1319e Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 3 Mar 2026 11:41:24 +0000 Subject: [PATCH 08/10] Remove python setup from `rebuild` workflow --- .github/workflows/rebuild.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml index 3e06e5d687..095c0726f2 100644 --- a/.github/workflows/rebuild.yml +++ b/.github/workflows/rebuild.yml @@ -73,11 +73,6 @@ jobs: npm run lint -- --fix npm run build - - name: Set up Python - uses: actions/setup-python@v6 - 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/') From 77fc89c78dc90433e4d9050503c82b635843b3cc Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 3 Mar 2026 11:42:49 +0000 Subject: [PATCH 09/10] Remove python files from `pr-checks` --- pr-checks/.gitignore | 3 - pr-checks/__init__.py | 0 pr-checks/sync_back.py | 185 ---------------------------- pr-checks/test_sync_back.py | 237 ------------------------------------ 4 files changed, 425 deletions(-) delete mode 100644 pr-checks/__init__.py delete mode 100755 pr-checks/sync_back.py delete mode 100644 pr-checks/test_sync_back.py diff --git a/pr-checks/.gitignore b/pr-checks/.gitignore index c06afdaf9e..c2658d7d1b 100644 --- a/pr-checks/.gitignore +++ b/pr-checks/.gitignore @@ -1,4 +1 @@ -env -__pycache__/ -*.pyc node_modules/ diff --git a/pr-checks/__init__.py b/pr-checks/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pr-checks/sync_back.py b/pr-checks/sync_back.py deleted file mode 100755 index 1474b455e6..0000000000 --- a/pr-checks/sync_back.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -""" -Sync-back script to automatically update action versions in source templates -from the generated workflow files after Dependabot updates. - -This script scans the generated workflow files (.github/workflows/__*.yml) to find -all external action versions used, then updates: -1. Hardcoded action versions in pr-checks/sync.py -2. Action version references in template files in pr-checks/checks/ - -The script automatically detects all actions used in generated workflows and -preserves version comments (e.g., # v1.2.3) when syncing versions. - -This ensures that when Dependabot updates action versions in generated workflows, -those changes are properly synced back to the source templates. Regular workflow -files are updated directly by Dependabot and don't need sync-back. -""" - -import os -import re -import glob -import argparse -import sys -from pathlib import Path -from typing import Dict, List - - -def scan_generated_workflows(workflow_dir: str) -> Dict[str, str]: - """ - Scan generated workflow files to extract the latest action versions. - - Args: - workflow_dir: Path to .github/workflows directory - - Returns: - Dictionary mapping action names to their latest versions (including comments) - """ - action_versions = {} - generated_files = glob.glob(os.path.join(workflow_dir, "__*.yml")) - - for file_path in generated_files: - with open(file_path, 'r') as f: - content = f.read() - - # Find all action uses in the file, including potential comments - # This pattern captures: action_name@version_with_possible_comment - pattern = r'uses:\s+([^/\s]+/[^@\s]+)@([^@\n]+)' - matches = re.findall(pattern, content) - - for action_name, version_with_comment in matches: - # Only track non-local actions (those with / but not starting with ./) - if not action_name.startswith('./'): - # Assume that version numbers are consistent (this should be the case on a Dependabot update PR) - action_versions[action_name] = version_with_comment.rstrip() - - return action_versions - - -def update_sync_py(sync_py_path: str, action_versions: Dict[str, str]) -> bool: - """ - Update hardcoded action versions in pr-checks/sync.py - - Args: - sync_py_path: Path to sync.py file - action_versions: Dictionary of action names to versions (may include comments) - - Returns: - True if file was modified, False otherwise - """ - if not os.path.exists(sync_py_path): - raise FileNotFoundError(f"Could not find {sync_py_path}") - - with open(sync_py_path, 'r') as f: - content = f.read() - - original_content = content - - # Update hardcoded action versions - for action_name, version_with_comment in action_versions.items(): - # Extract just the version part (before any comment) for sync.py - version = version_with_comment.split('#')[0].strip() if '#' in version_with_comment else version_with_comment.strip() - - # Look for patterns like 'uses': 'actions/setup-node@v4' - # Note that this will break if we store an Action uses reference in a - # variable - that's a risk we're happy to take since in that case the - # PR checks will just fail. - pattern = rf"('uses':\s*'){re.escape(action_name)}@(?:[^']+)(')" - replacement = rf"\1{action_name}@{version}\2" - content = re.sub(pattern, replacement, content) - - if content != original_content: - with open(sync_py_path, 'w') as f: - f.write(content) - print(f"Updated {sync_py_path}") - return True - else: - print(f"No changes needed in {sync_py_path}") - return False - - -def update_template_files(checks_dir: str, action_versions: Dict[str, str]) -> List[str]: - """ - Update action versions in template files in pr-checks/checks/ - - Args: - checks_dir: Path to pr-checks/checks directory - action_versions: Dictionary of action names to versions (may include comments) - - Returns: - List of files that were modified - """ - modified_files = [] - template_files = glob.glob(os.path.join(checks_dir, "*.yml")) - - for file_path in template_files: - with open(file_path, 'r') as f: - content = f.read() - - original_content = content - - # Update action versions - for action_name, version_with_comment in action_versions.items(): - # Look for patterns like 'uses: actions/setup-node@v4' or 'uses: actions/setup-node@sha # comment' - pattern = rf"(uses:\s+{re.escape(action_name)})@(?:[^@\n]+)" - replacement = rf"\1@{version_with_comment}" - content = re.sub(pattern, replacement, content) - - if content != original_content: - with open(file_path, 'w') as f: - f.write(content) - modified_files.append(file_path) - print(f"Updated {file_path}") - - return modified_files - - -def main(): - parser = argparse.ArgumentParser(description="Sync action versions from generated workflows back to templates") - parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output") - args = parser.parse_args() - - # Get the repository root (assuming script is in pr-checks/) - script_dir = Path(__file__).parent - repo_root = script_dir.parent - - workflow_dir = repo_root / ".github" / "workflows" - checks_dir = script_dir / "checks" - sync_py_path = script_dir / "sync.py" - - print("Scanning generated workflows for latest action versions...") - action_versions = scan_generated_workflows(str(workflow_dir)) - - if args.verbose: - print("Found action versions:") - for action, version in action_versions.items(): - print(f" {action}@{version}") - - if not action_versions: - print("No action versions found in generated workflows") - return 1 - - # Update files - print("\nUpdating source files...") - modified_files = [] - - # Update sync.py - if update_sync_py(str(sync_py_path), action_versions): - modified_files.append(str(sync_py_path)) - - # Update template files - template_modified = update_template_files(str(checks_dir), action_versions) - modified_files.extend(template_modified) - - if modified_files: - print(f"\nSync completed. Modified {len(modified_files)} files:") - for file_path in modified_files: - print(f" {file_path}") - else: - print("\nNo files needed updating - all action versions are already in sync") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/pr-checks/test_sync_back.py b/pr-checks/test_sync_back.py deleted file mode 100644 index de2e42d733..0000000000 --- a/pr-checks/test_sync_back.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for the sync_back.py script -""" - -import os -import shutil -import tempfile -import unittest - -import sync_back - - -class TestSyncBack(unittest.TestCase): - - def setUp(self): - """Set up temporary directories and files for testing""" - self.test_dir = tempfile.mkdtemp() - self.workflow_dir = os.path.join(self.test_dir, ".github", "workflows") - self.checks_dir = os.path.join(self.test_dir, "pr-checks", "checks") - os.makedirs(self.workflow_dir) - os.makedirs(self.checks_dir) - - # Create sync.py file - self.sync_py_path = os.path.join(self.test_dir, "pr-checks", "sync.py") - - def tearDown(self): - """Clean up temporary directories""" - shutil.rmtree(self.test_dir) - - def test_scan_generated_workflows_basic(self): - """Test basic workflow scanning functionality""" - # Create a test generated workflow file - workflow_content = """ -name: Test Workflow -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v5 - - uses: actions/setup-go@v6 - """ - - with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: - f.write(workflow_content) - - result = sync_back.scan_generated_workflows(self.workflow_dir) - - self.assertEqual(result['actions/checkout'], 'v4') - self.assertEqual(result['actions/setup-node'], 'v5') - self.assertEqual(result['actions/setup-go'], 'v6') - - def test_scan_generated_workflows_with_comments(self): - """Test scanning workflows with version comments""" - workflow_content = """ -name: Test Workflow -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0 - - uses: actions/setup-python@v6 # Latest Python - """ - - with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: - f.write(workflow_content) - - result = sync_back.scan_generated_workflows(self.workflow_dir) - - self.assertEqual(result['actions/checkout'], 'v4') - self.assertEqual(result['ruby/setup-ruby'], '44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0') - self.assertEqual(result['actions/setup-python'], 'v6 # Latest Python') - - def test_scan_generated_workflows_ignores_local_actions(self): - """Test that local actions (starting with ./) are ignored""" - workflow_content = """ -name: Test Workflow -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/local-action - - uses: ./another-local-action@v1 - """ - - with open(os.path.join(self.workflow_dir, "__test.yml"), 'w') as f: - f.write(workflow_content) - - result = sync_back.scan_generated_workflows(self.workflow_dir) - - self.assertEqual(result['actions/checkout'], 'v4') - self.assertNotIn('./.github/actions/local-action', result) - self.assertNotIn('./another-local-action', result) - - - def test_update_sync_py(self): - """Test updating sync.py file""" - sync_py_content = """ -steps = [ - { - 'uses': 'actions/setup-node@v4', - 'with': {'node-version': '16'} - }, - { - 'uses': 'actions/setup-go@v5', - 'with': {'go-version': '1.19'} - } -] - """ - - with open(self.sync_py_path, 'w') as f: - f.write(sync_py_content) - - action_versions = { - 'actions/setup-node': 'v5', - 'actions/setup-go': 'v6' - } - - result = sync_back.update_sync_py(self.sync_py_path, action_versions) - self.assertTrue(result) - - with open(self.sync_py_path, 'r') as f: - updated_content = f.read() - - self.assertIn("'uses': 'actions/setup-node@v5'", updated_content) - self.assertIn("'uses': 'actions/setup-go@v6'", updated_content) - - def test_update_sync_py_with_comments(self): - """Test updating sync.py file when versions have comments""" - sync_py_content = """ -steps = [ - { - 'uses': 'actions/setup-node@v4', - 'with': {'node-version': '16'} - } -] - """ - - with open(self.sync_py_path, 'w') as f: - f.write(sync_py_content) - - action_versions = { - 'actions/setup-node': 'v5 # Latest version' - } - - result = sync_back.update_sync_py(self.sync_py_path, action_versions) - self.assertTrue(result) - - with open(self.sync_py_path, 'r') as f: - updated_content = f.read() - - # sync.py should get the version without comment - self.assertIn("'uses': 'actions/setup-node@v5'", updated_content) - self.assertNotIn("# Latest version", updated_content) - - def test_update_template_files(self): - """Test updating template files""" - template_content = """ -name: Test Template -steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v4 - with: - node-version: 16 - """ - - template_path = os.path.join(self.checks_dir, "test.yml") - with open(template_path, 'w') as f: - f.write(template_content) - - action_versions = { - 'actions/checkout': 'v4', - 'actions/setup-node': 'v5 # Latest' - } - - result = sync_back.update_template_files(self.checks_dir, action_versions) - self.assertEqual(len(result), 1) - self.assertIn(template_path, result) - - with open(template_path, 'r') as f: - updated_content = f.read() - - self.assertIn("uses: actions/checkout@v4", updated_content) - self.assertIn("uses: actions/setup-node@v5 # Latest", updated_content) - - def test_update_template_files_preserves_comments(self): - """Test that updating template files preserves version comments""" - template_content = """ -name: Test Template -steps: - - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.256.0 - """ - - template_path = os.path.join(self.checks_dir, "test.yml") - with open(template_path, 'w') as f: - f.write(template_content) - - action_versions = { - 'ruby/setup-ruby': '55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0' - } - - result = sync_back.update_template_files(self.checks_dir, action_versions) - self.assertEqual(len(result), 1) - - with open(template_path, 'r') as f: - updated_content = f.read() - - self.assertIn("uses: ruby/setup-ruby@55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0", updated_content) - - def test_no_changes_needed(self): - """Test that functions return False/empty when no changes are needed""" - # Test sync.py with no changes needed - sync_py_content = """ -steps = [ - { - 'uses': 'actions/setup-node@v5', - 'with': {'node-version': '16'} - } -] - """ - - with open(self.sync_py_path, 'w') as f: - f.write(sync_py_content) - - action_versions = { - 'actions/setup-node': 'v5' - } - - result = sync_back.update_sync_py(self.sync_py_path, action_versions) - self.assertFalse(result) - - -if __name__ == '__main__': - unittest.main() From 0a5b95cdcce255451b8a129ee63c777e94ba3b3f Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 3 Mar 2026 11:45:18 +0000 Subject: [PATCH 10/10] Update `pr-checks` README --- pr-checks/readme.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pr-checks/readme.md b/pr-checks/readme.md index 283ed35993..81eff0cdaf 100644 --- a/pr-checks/readme.md +++ b/pr-checks/readme.md @@ -6,9 +6,9 @@ to one of the files in this directory. ## Updating workflows -1. Install https://github.com/casey/just by whichever way you prefer. -2. Run `just update-pr-checks` in your terminal. +Run `./sync.sh` to invoke the workflow generator and re-generate the workflow files in `.github/workflows/` based on the templates in `pr-checks/checks/`. -### If you don't want to install `just` +Alternatively, you can use `just`: -Manually run each step in the `justfile`. +1. Install https://github.com/casey/just by whichever way you prefer. +2. Run `just update-pr-checks` in your terminal.