diff --git a/.github/workflows/plugin-checksum.yml b/.github/workflows/plugin-checksum.yml new file mode 100644 index 000000000..eafca1c08 --- /dev/null +++ b/.github/workflows/plugin-checksum.yml @@ -0,0 +1,20 @@ +name: Plugin Checksum Verification +on: + pull_request: + paths: + - 'plugins/**' + push: + branches: [main] + paths: + - 'plugins/**' +jobs: + verify-checksums: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Verify plugin checksums are up to date + run: python scripts/refresh_plugin_checksum.py --all --check diff --git a/scripts/refresh_plugin_checksum.py b/scripts/refresh_plugin_checksum.py index 7738589e8..de8147841 100644 --- a/scripts/refresh_plugin_checksum.py +++ b/scripts/refresh_plugin_checksum.py @@ -63,7 +63,7 @@ def compute_plugin_digest(metadata_file: Path, parser_file: Path) -> str: # ── Core refresh logic ──────────────────────────────────────────────────────── -def refresh_plugin(plugin_dir: Path, dry_run: bool = False) -> bool: +def refresh_plugin(plugin_dir: Path, dry_run: bool = False, check: bool = False) -> bool: """ Recalculate and write the checksum for a single plugin. @@ -107,6 +107,12 @@ def refresh_plugin(plugin_dir: Path, dry_run: bool = False) -> bool: print(f" [OK] {plugin_dir.name} — checksum already up to date") return True + if check: + print(f" [DRIFT] {plugin_dir.name}") + print(f" stored: {old_checksum}") + print(f" computed: {new_checksum}") + return False + print(f" [UPDATE] {plugin_dir.name}") print(f" old: {old_checksum}") print(f" new: {new_checksum}") @@ -125,7 +131,7 @@ def refresh_plugin(plugin_dir: Path, dry_run: bool = False) -> bool: return True -def refresh_all_plugins(plugins_dir: Path, dry_run: bool = False) -> None: +def refresh_all_plugins(plugins_dir: Path, dry_run: bool = False, check: bool = False) -> None: """Refresh checksums for every plugin found in plugins_dir.""" if not plugins_dir.exists(): print(f"[ERROR] Plugins directory not found: {plugins_dir}", file=sys.stderr) @@ -145,11 +151,18 @@ def refresh_all_plugins(plugins_dir: Path, dry_run: bool = False) -> None: error_count = 0 for plugin_dir in plugin_dirs: - if refresh_plugin(plugin_dir, dry_run=dry_run): + if refresh_plugin(plugin_dir, dry_run=dry_run, check=check): success_count += 1 else: error_count += 1 + if check: + print(f"\nDone — {success_count} up to date, {error_count} drifted.") + if error_count > 0: + print("\nRun: python scripts/refresh_plugin_checksum.py --all") + sys.exit(1) + sys.exit(0) + print(f"\nDone — {success_count} succeeded, {error_count} failed.") if error_count > 0: @@ -199,18 +212,30 @@ def main() -> None: action="store_true", help="Show what would change without writing anything", ) + parser.add_argument( + "--check", + action="store_true", + help="Verify checksums are up to date; exit 1 if any plugin has drifted (CI mode)", + ) args = parser.parse_args() + if args.check and args.dry_run: + print("[ERROR] --check and --dry-run cannot be used together.", file=sys.stderr) + sys.exit(2) + if args.dry_run: print("[DRY RUN] No files will be modified.\n") + if args.check: + print("[CHECK] Verifying checksums are up to date (no files will be modified).\n") + if args.all: - refresh_all_plugins(args.plugins_dir, dry_run=args.dry_run) + refresh_all_plugins(args.plugins_dir, dry_run=args.dry_run, check=args.check) else: plugin_dir = args.plugins_dir / args.plugin print(f"Refreshing checksum for plugin: {args.plugin}\n") - success = refresh_plugin(plugin_dir, dry_run=args.dry_run) + success = refresh_plugin(plugin_dir, dry_run=args.dry_run, check=args.check) if not success: sys.exit(1) print("\nDone.") diff --git a/testing/backend/unit/test_refresh_plugin_checksum.py b/testing/backend/unit/test_refresh_plugin_checksum.py index bed8db6e4..cbafcc0e8 100644 --- a/testing/backend/unit/test_refresh_plugin_checksum.py +++ b/testing/backend/unit/test_refresh_plugin_checksum.py @@ -276,4 +276,71 @@ def test_exits_when_plugins_dir_missing(self, tmp_path): missing_dir = tmp_path / "no-such-dir" with pytest.raises(SystemExit): - refresh_all_plugins(missing_dir) \ No newline at end of file + refresh_all_plugins(missing_dir) + +# ── check mode ───────────────────────────────────────────────────────────────── + +class TestRefreshPluginCheckMode: + + def test_check_returns_true_when_up_to_date(self, tmp_path): + """check mode should return True and not write when checksum already matches.""" + plugin_dir = make_plugin(tmp_path, "test-plugin", checksum="placeholder") + # First bring it up to date normally + refresh_plugin(plugin_dir) + # Now check mode should report it as current + result = refresh_plugin(plugin_dir, check=True) + assert result is True + + def test_check_returns_false_on_drift(self, tmp_path): + """check mode should return False when stored checksum does not match computed digest.""" + plugin_dir = make_plugin(tmp_path, "test-plugin", checksum="stale-checksum") + result = refresh_plugin(plugin_dir, check=True) + assert result is False + + def test_check_does_not_write_on_drift(self, tmp_path): + """check mode must never modify metadata.json, even when drift is detected.""" + plugin_dir = make_plugin(tmp_path, "test-plugin", checksum="stale-checksum") + refresh_plugin(plugin_dir, check=True) + metadata = json.loads((plugin_dir / "metadata.json").read_text()) + assert metadata["checksum"] == "stale-checksum" + + def test_check_does_not_write_when_up_to_date(self, tmp_path): + """check mode must never modify metadata.json when already current.""" + plugin_dir = make_plugin(tmp_path, "test-plugin", checksum="placeholder") + refresh_plugin(plugin_dir) + before = json.loads((plugin_dir / "metadata.json").read_text()) + refresh_plugin(plugin_dir, check=True) + after = json.loads((plugin_dir / "metadata.json").read_text()) + assert before == after + + +class TestRefreshAllPluginsCheckMode: + + def test_check_exits_zero_when_all_up_to_date(self, tmp_path): + """refresh_all_plugins with check=True should exit 0 when nothing has drifted.""" + for plugin_id in ["plugin-a", "plugin-b"]: + plugin_dir = make_plugin(tmp_path, plugin_id, checksum="placeholder") + refresh_plugin(plugin_dir) + with pytest.raises(SystemExit) as exc_info: + refresh_all_plugins(tmp_path, check=True) + assert exc_info.value.code == 0 + + def test_check_exits_one_when_any_plugin_drifted(self, tmp_path): + """refresh_all_plugins with check=True should exit 1 if any plugin has drifted.""" + plugin_a = make_plugin(tmp_path, "plugin-a", checksum="placeholder") + refresh_plugin(plugin_a) + make_plugin(tmp_path, "plugin-b", checksum="stale-checksum") + with pytest.raises(SystemExit) as exc_info: + refresh_all_plugins(tmp_path, check=True) + assert exc_info.value.code == 1 + + def test_check_does_not_write_any_plugin(self, tmp_path): + """check mode across all plugins must not modify any metadata.json.""" + for plugin_id in ["plugin-a", "plugin-b"]: + make_plugin(tmp_path, plugin_id, checksum="stale-checksum") + with pytest.raises(SystemExit): + refresh_all_plugins(tmp_path, check=True) + for plugin_id in ["plugin-a", "plugin-b"]: + plugin_dir = tmp_path / plugin_id + metadata = json.loads((plugin_dir / "metadata.json").read_text()) + assert metadata["checksum"] == "stale-checksum"