Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/plugin-checksum.yml
Original file line number Diff line number Diff line change
@@ -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
35 changes: 30 additions & 5 deletions scripts/refresh_plugin_checksum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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}")
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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.")
Expand Down
69 changes: 68 additions & 1 deletion testing/backend/unit/test_refresh_plugin_checksum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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"
Loading