Skip to content
Merged
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
3 changes: 3 additions & 0 deletions actions/sync-workflows/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
97 changes: 89 additions & 8 deletions actions/sync-workflows/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import os
import shutil
import subprocess
import tempfile
from pathlib import Path

LOCK_HEADER = (
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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,
}

Expand All @@ -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"]
Expand Down Expand Up @@ -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))

Expand Down
72 changes: 58 additions & 14 deletions tests/test_sync_workflows_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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"
Expand All @@ -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"],
Expand All @@ -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)


Expand Down
Loading