diff --git a/actions/sync-workflows/action.yml b/actions/sync-workflows/action.yml index 9be0729..7ada083 100644 --- a/actions/sync-workflows/action.yml +++ b/actions/sync-workflows/action.yml @@ -23,6 +23,9 @@ outputs: patch_failed: description: Whether at least one local workflow patch failed to apply. value: ${{ steps.sync.outputs.patch_failed }} + blocked: + description: Whether synchronization was blocked by unmanaged local divergence. + value: ${{ steps.sync.outputs.blocked }} updated: description: JSON array of updated workflow names. value: ${{ steps.sync.outputs.updated }} diff --git a/actions/sync-workflows/sync.py b/actions/sync-workflows/sync.py index 561bf25..e7effba 100644 --- a/actions/sync-workflows/sync.py +++ b/actions/sync-workflows/sync.py @@ -10,6 +10,7 @@ import os import shutil import subprocess +import tempfile from pathlib import Path LOCK_HEADER = ( @@ -81,6 +82,27 @@ def apply_patch(target_root: Path, target_file: Path) -> tuple[bool, str]: return False, f"Patch failed: {relative_patch}\n{output}" +def render_expected( + source_file: Path, + target_root: Path, + target_file: Path, +) -> tuple[bytes | None, bool, str]: + patch_file = Path(f"{target_file}.patch") + if not patch_file.is_file(): + return source_file.read_bytes(), True, "" + + with tempfile.TemporaryDirectory() as directory: + candidate_root = Path(directory) + candidate_file = candidate_root / ".github/workflows" / source_file.name + candidate_file.parent.mkdir(parents=True) + shutil.copyfile(source_file, candidate_file) + shutil.copyfile(patch_file, Path(f"{candidate_file}.patch")) + patch_ok, patch_message = apply_patch(candidate_root, candidate_file) + if not patch_ok: + return None, False, patch_message + return candidate_file.read_bytes(), True, patch_message + + def workflow_files(source: Path) -> list[Path]: return sorted( path @@ -101,23 +123,76 @@ def sync( entries = parse_lock(lock_path) updated: list[str] = [] + adopted: list[str] = [] unchanged: list[str] = [] skipped: list[str] = [] failed: list[str] = [] + diverged: list[str] = [] details: list[str] = [] - for source_file in workflow_files(source): - name = source_file.name + source_by_name = {path.name: path for path in workflow_files(source)} + + for name in sorted(entries): + if name in source_by_name: + target_file = target / ".github/workflows" / name + if not target_file.is_file(): + failed.append(name) + diverged.append(name) + details.append( + f"- {name}: managed workflow is missing from the consumer repository" + ) + + for name, source_file in source_by_name.items(): target_file = target / ".github/workflows" / name if not target_file.is_file(): - skipped.append(name) + if name not in entries: + skipped.append(name) continue new_version = md5(source_file) locked_version = entries.get(name, "") + if not locked_version: + expected, patch_ok, patch_message = render_expected( + source_file, target, target_file + ) + if not patch_ok: + failed.append(name) + details.append(f"- {name}: {patch_message}") + continue + if target_file.read_bytes() != expected: + failed.append(name) + diverged.append(name) + details.append( + f"- {name}: local workflow differs from catalog + local patch; " + "add or update a .patch file before adopting it" + ) + continue + + entries[name] = new_version + adopted.append(name) + if patch_message: + details.append(f"- {name}: adopted; {patch_message}") + continue + if locked_version == new_version: + expected, patch_ok, patch_message = render_expected( + source_file, target, target_file + ) + if not patch_ok: + failed.append(name) + details.append(f"- {name}: {patch_message}") + continue + if target_file.read_bytes() != expected: + failed.append(name) + diverged.append(name) + details.append( + f"- {name}: local workflow diverged from the locked catalog " + "version and local patch" + ) + continue + unchanged.append(name) continue @@ -131,16 +206,20 @@ def sync( if not patch_ok: failed.append(name) - if updated or not lock_path.is_file(): + lock_changed = bool(updated or adopted) + if lock_changed: write_lock(lock_path, entries) return { - "changed": bool(updated), - "patch_failed": bool(failed), + "changed": lock_changed, + "patch_failed": bool(set(failed) - set(diverged)), + "blocked": bool(diverged), "updated": updated, + "adopted": adopted, "unchanged": unchanged, "skipped": skipped, "failed": failed, + "diverged": diverged, "details": details, } @@ -150,9 +229,10 @@ def render_summary(report: dict[str, object]) -> str: "## Workflow synchronization", "", f"- Updated: {len(report['updated'])}", + f"- Adopted: {len(report['adopted'])}", f"- Unchanged: {len(report['unchanged'])}", f"- Skipped: {len(report['skipped'])}", - f"- Patch failures: {len(report['failed'])}", + f"- Failed: {len(report['failed'])}", ] details = report["details"] @@ -192,13 +272,14 @@ def main() -> int: write_output("changed", str(report["changed"]).lower()) write_output("patch_failed", str(report["patch_failed"]).lower()) + write_output("blocked", str(report["blocked"]).lower()) write_output("updated", json.dumps(report["updated"], separators=(",", ":"))) write_output("failed", json.dumps(report["failed"], separators=(",", ":"))) write_output("summary", summary) write_output("summary_file", str(summary_path)) print(json.dumps(report, indent=2, sort_keys=True)) - return 0 + return 1 if report["blocked"] else 0 except (OSError, ValueError) as error: parser.error(str(error)) diff --git a/tests/test_sync_workflows_action.py b/tests/test_sync_workflows_action.py index a70e249..77adf89 100644 --- a/tests/test_sync_workflows_action.py +++ b/tests/test_sync_workflows_action.py @@ -29,30 +29,64 @@ def fixture(self) -> tuple[tempfile.TemporaryDirectory, Path, Path]: source.mkdir() return temporary, source, target - def test_updates_existing_workflow_and_records_catalog_hash(self) -> None: + def test_adopts_matching_existing_workflow(self) -> None: temporary, source, target = self.fixture() with temporary: - (source / "lint.yml").write_text("name: New\n", encoding="utf-8") - (target / ".github/workflows/lint.yml").write_text( - "name: Old\n", encoding="utf-8" - ) + content = "name: Current\n" + source_file = source / "lint.yml" + source_file.write_text(content, encoding="utf-8") + (target / ".github/workflows/lint.yml").write_text(content, encoding="utf-8") report = sync_module.sync( source, target, target / ".github/actions-lock.txt" ) self.assertTrue(report["changed"]) - self.assertFalse(report["patch_failed"]) + self.assertEqual(report["adopted"], ["lint.yml"]) self.assertEqual( - (target / ".github/workflows/lint.yml").read_text(encoding="utf-8"), - "name: New\n", + sync_module.parse_lock(target / ".github/actions-lock.txt")["lint.yml"], + sync_module.md5(source_file), ) - expected = hashlib.md5( - b"name: New\n", usedforsecurity=False - ).hexdigest() + + def test_refuses_initial_local_divergence(self) -> None: + temporary, source, target = self.fixture() + with temporary: + (source / "lint.yml").write_text("name: Catalog\n", encoding="utf-8") + target_file = target / ".github/workflows/lint.yml" + target_file.write_text("name: Local\n", encoding="utf-8") + + report = sync_module.sync( + source, target, target / ".github/actions-lock.txt" + ) + + self.assertFalse(report["changed"]) + self.assertTrue(report["blocked"]) + self.assertEqual(report["diverged"], ["lint.yml"]) + self.assertEqual(target_file.read_text(encoding="utf-8"), "name: Local\n") + self.assertFalse((target / ".github/actions-lock.txt").exists()) + + def test_updates_managed_workflow_and_records_catalog_hash(self) -> None: + temporary, source, target = self.fixture() + with temporary: + source_file = source / "lint.yml" + source_file.write_text("name: New\n", encoding="utf-8") + target_file = target / ".github/workflows/lint.yml" + target_file.write_text("name: Old\n", encoding="utf-8") + old_hash = hashlib.md5(b"name: Old\n", usedforsecurity=False).hexdigest() + sync_module.write_lock( + target / ".github/actions-lock.txt", {"lint.yml": old_hash} + ) + + report = sync_module.sync( + source, target, target / ".github/actions-lock.txt" + ) + + self.assertTrue(report["changed"]) + self.assertEqual(report["updated"], ["lint.yml"]) + self.assertEqual(target_file.read_text(encoding="utf-8"), "name: New\n") self.assertEqual( sync_module.parse_lock(target / ".github/actions-lock.txt")["lint.yml"], - expected, + sync_module.md5(source_file), ) def test_skips_workflow_not_installed_in_consumer(self) -> None: @@ -96,7 +130,9 @@ def test_applies_consumer_local_patch(self) -> None: "branches:\n - default\n", encoding="utf-8" ) target_file = target / ".github/workflows/sync.yml" - target_file.write_text("old\n", encoding="utf-8") + target_file.write_text( + "branches:\n - default\n - stable32\n", encoding="utf-8" + ) patch_file = target / ".github/workflows/sync.yml.patch" patch_file.write_text( "--- a/.github/workflows/sync.yml\n" @@ -113,6 +149,7 @@ def test_applies_consumer_local_patch(self) -> None: ) self.assertFalse(report["patch_failed"]) + self.assertEqual(report["adopted"], ["sync.yml"]) self.assertEqual( target_file.read_text(encoding="utf-8"), "branches:\n - default\n - stable32\n", @@ -138,6 +175,12 @@ def test_broken_patch_sets_draft_signal_and_keeps_catalog_lock(self) -> None: "+name: Patched\n", encoding="utf-8", ) + old_hash = hashlib.md5( + b"name: Old\n", usedforsecurity=False + ).hexdigest() + sync_module.write_lock( + target / ".github/actions-lock.txt", {"sync.yml": old_hash} + ) report = sync_module.sync( source, target, target / ".github/actions-lock.txt" @@ -155,6 +198,7 @@ def test_mixed_result_summary_is_deterministic(self) -> None: summary = sync_module.render_summary( { "updated": ["a.yml"], + "adopted": [], "unchanged": ["b.yml"], "skipped": ["c.yml"], "failed": ["a.yml"], @@ -165,7 +209,7 @@ def test_mixed_result_summary_is_deterministic(self) -> None: self.assertIn("- Updated: 1", summary) self.assertIn("- Unchanged: 1", summary) self.assertIn("- Skipped: 1", summary) - self.assertIn("- Patch failures: 1", summary) + self.assertIn("- Failed: 1", summary) self.assertIn("- a.yml: Patch failed", summary)