From 1395371b011eeaa57e5c08879689388710fac79f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 18:38:21 -0300 Subject: [PATCH 1/3] feat: record workflow catalog provenance --- actions/sync-workflows/action.yml | 18 +++ actions/sync-workflows/sync.py | 196 ++++++++++++++++++++++++---- tests/test_sync_workflows_action.py | 97 +++++++++++++- 3 files changed, 277 insertions(+), 34 deletions(-) diff --git a/actions/sync-workflows/action.yml b/actions/sync-workflows/action.yml index 7ada083..8f43374 100644 --- a/actions/sync-workflows/action.yml +++ b/actions/sync-workflows/action.yml @@ -15,6 +15,18 @@ inputs: description: Lock file path relative to the consumer repository. required: false default: .github/actions-lock.txt + platform-version: + description: Released github-workflows platform version that produced the catalog. + required: false + default: '' + source-commit: + description: Immutable github-workflows source commit used by the synchronization run. + required: false + default: '' + catalog-commit: + description: Immutable workflow catalog commit being materialized. + required: false + default: '' outputs: changed: @@ -50,8 +62,14 @@ runs: SYNC_SOURCE: ${{ inputs.source }} SYNC_TARGET: ${{ inputs.target }} SYNC_LOCK_FILE: ${{ inputs.lock-file }} + SYNC_PLATFORM_VERSION: ${{ inputs.platform-version }} + SYNC_SOURCE_COMMIT: ${{ inputs.source-commit }} + SYNC_CATALOG_COMMIT: ${{ inputs.catalog-commit }} run: >- python3 "$SYNC_ACTION_PATH/sync.py" --source "$SYNC_SOURCE" --target "$SYNC_TARGET" --lock-file "$SYNC_LOCK_FILE" + --platform-version "$SYNC_PLATFORM_VERSION" + --source-commit "$SYNC_SOURCE_COMMIT" + --catalog-commit "$SYNC_CATALOG_COMMIT" diff --git a/actions/sync-workflows/sync.py b/actions/sync-workflows/sync.py index e228edb..f9f9527 100644 --- a/actions/sync-workflows/sync.py +++ b/actions/sync-workflows/sync.py @@ -11,23 +11,55 @@ import shutil import subprocess import tempfile +from dataclasses import dataclass from pathlib import Path LOCK_HEADER = ( "# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n" "# SPDX-" + "License-Identifier: MIT\n" ) +LOCK_SCHEMA_HEADER = "# workflow-lock-schema: 2\n" + + +@dataclass(frozen=True) +class LockEntry: + workflow: str + algorithm: str + digest: str + platform_version: str = "" + source_commit: str = "" + catalog_commit: str = "" + + def to_json(self) -> str: + payload = { + "workflow": self.workflow, + "sha256": self.digest, + "platform_version": self.platform_version, + "source_commit": self.source_commit, + "catalog_commit": self.catalog_commit, + } + return json.dumps(payload, separators=(",", ":"), ensure_ascii=True) def md5(path: Path) -> str: return hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest() -def parse_lock(path: Path) -> dict[str, str]: +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _valid_hex(value: str, length: int) -> bool: + return len(value) == length and all( + character in "0123456789abcdef" for character in value + ) + + +def parse_lock_records(path: Path) -> dict[str, LockEntry]: if not path.is_file(): return {} - entries: dict[str, str] = {} + entries: dict[str, LockEntry] = {} for line_number, raw_line in enumerate( path.read_text(encoding="utf-8").splitlines(), start=1, @@ -36,31 +68,109 @@ def parse_lock(path: Path) -> dict[str, str]: if not line or line.startswith("#"): continue - parts = line.split() - if len(parts) != 2: - raise ValueError(f"invalid lock entry at line {line_number}") - - digest, workflow = parts - if ( - len(digest) != 32 - or any(character not in "0123456789abcdef" for character in digest) - ): - raise ValueError(f"invalid MD5 at line {line_number}") + if line.startswith("{"): + try: + payload = json.loads(line) + except json.JSONDecodeError as error: + raise ValueError( + f"invalid lock JSON at line {line_number}" + ) from error + + if not isinstance(payload, dict): + raise ValueError(f"invalid lock entry at line {line_number}") + + workflow = payload.get("workflow") + digest = payload.get("sha256") + if not isinstance(workflow, str) or not workflow: + raise ValueError(f"invalid workflow at line {line_number}") + if not isinstance(digest, str) or not _valid_hex(digest, 64): + raise ValueError(f"invalid SHA-256 at line {line_number}") + + entry = LockEntry( + workflow=workflow, + algorithm="sha256", + digest=digest, + platform_version=str(payload.get("platform_version", "")), + source_commit=str(payload.get("source_commit", "")), + catalog_commit=str(payload.get("catalog_commit", "")), + ) + else: + parts = line.split() + if len(parts) != 2: + raise ValueError(f"invalid lock entry at line {line_number}") + + digest, workflow = parts + if not _valid_hex(digest, 32): + raise ValueError(f"invalid MD5 at line {line_number}") + entry = LockEntry( + workflow=workflow, + algorithm="md5", + digest=digest, + ) - if workflow in entries: - raise ValueError(f"duplicate lock entry: {workflow}") - entries[workflow] = digest + if entry.workflow in entries: + raise ValueError(f"duplicate lock entry: {entry.workflow}") + entries[entry.workflow] = entry return entries -def write_lock(path: Path, entries: dict[str, str]) -> None: +def parse_lock(path: Path) -> dict[str, str]: + return { + name: entry.digest + for name, entry in parse_lock_records(path).items() + } + + +def write_lock( + path: Path, + entries: dict[str, LockEntry | str], +) -> None: lines = [LOCK_HEADER.rstrip("\n"), ""] - lines.extend(f"{entries[name]} {name}" for name in sorted(entries)) + + if entries and all(isinstance(value, str) for value in entries.values()): + lines.extend( + f"{entries[name]} {name}" + for name in sorted(entries) + ) + else: + lines.extend([LOCK_SCHEMA_HEADER.rstrip("\n"), ""]) + for name in sorted(entries): + value = entries[name] + if isinstance(value, str): + raise ValueError("cannot mix legacy and v2 lock entries") + lines.append(value.to_json()) + path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(lines) + "\n", encoding="utf-8") +def matches_source(entry: LockEntry, source_file: Path) -> bool: + if entry.algorithm == "md5": + return entry.digest == md5(source_file) + if entry.algorithm == "sha256": + return entry.digest == sha256(source_file) + raise ValueError(f"unsupported lock digest algorithm: {entry.algorithm}") + + +def desired_entry( + workflow: str, + source_file: Path, + *, + platform_version: str, + source_commit: str, + catalog_commit: str, +) -> LockEntry: + return LockEntry( + workflow=workflow, + algorithm="sha256", + digest=sha256(source_file), + platform_version=platform_version, + source_commit=source_commit, + catalog_commit=catalog_commit, + ) + + def apply_patch(target_root: Path, target_file: Path) -> tuple[bool, str]: patch_file = Path(f"{target_file}.patch") if not patch_file.is_file(): @@ -115,19 +225,24 @@ def sync( source: Path, target: Path, lock_path: Path, + *, + platform_version: str = "", + source_commit: str = "", + catalog_commit: str = "", ) -> dict[str, object]: if not source.is_dir(): raise ValueError(f"source directory does not exist: {source}") if not target.is_dir(): raise ValueError(f"target directory does not exist: {target}") - entries = parse_lock(lock_path) + entries = parse_lock_records(lock_path) updated: list[str] = [] adopted: list[str] = [] unchanged: list[str] = [] skipped: list[str] = [] failed: list[str] = [] diverged: list[str] = [] + provenance_updated: list[str] = [] details: list[str] = [] source_by_name = {path.name: path for path in workflow_files(source)} @@ -154,10 +269,16 @@ def sync( skipped.append(name) continue - new_version = md5(source_file) - locked_version = entries.get(name, "") + locked = entries.get(name) + desired = desired_entry( + name, + source_file, + platform_version=platform_version, + source_commit=source_commit, + catalog_commit=catalog_commit, + ) - if not locked_version: + if locked is None: expected, patch_ok, patch_message = render_expected( source_file, target, target_file ) @@ -174,13 +295,13 @@ def sync( ) continue - entries[name] = new_version + entries[name] = desired adopted.append(name) if patch_message: details.append(f"- {name}: adopted; {patch_message}") continue - if locked_version == new_version: + if matches_source(locked, source_file): expected, patch_ok, patch_message = render_expected( source_file, target, target_file ) @@ -197,12 +318,19 @@ def sync( ) continue - unchanged.append(name) + if locked != desired: + entries[name] = desired + provenance_updated.append(name) + details.append( + f"- {name}: lock provenance migrated/updated without rewriting workflow" + ) + else: + unchanged.append(name) continue shutil.copyfile(source_file, target_file) patch_ok, patch_message = apply_patch(target, target_file) - entries[name] = new_version + entries[name] = desired updated.append(name) if patch_message: @@ -210,7 +338,9 @@ def sync( if not patch_ok: failed.append(name) - lock_changed = bool(updated or adopted or stale_entries) + lock_changed = bool( + updated or adopted or stale_entries or provenance_updated + ) if lock_changed: write_lock(lock_path, entries) @@ -224,6 +354,7 @@ def sync( "skipped": skipped, "failed": failed, "diverged": diverged, + "provenance_updated": provenance_updated, "details": details, "removed_from_lock": stale_entries, } @@ -235,6 +366,7 @@ def render_summary(report: dict[str, object]) -> str: "", f"- Updated: {len(report['updated'])}", f"- Adopted: {len(report['adopted'])}", + f"- Provenance updated: {len(report['provenance_updated'])}", f"- Unchanged: {len(report['unchanged'])}", f"- Skipped: {len(report['skipped'])}", f"- Failed: {len(report['failed'])}", @@ -266,6 +398,9 @@ def main() -> int: parser.add_argument("--source", required=True, type=Path) parser.add_argument("--target", required=True, type=Path) parser.add_argument("--lock-file", default=".github/actions-lock.txt") + parser.add_argument("--platform-version", default="") + parser.add_argument("--source-commit", default="") + parser.add_argument("--catalog-commit", default="") args = parser.parse_args() try: @@ -273,7 +408,14 @@ def main() -> int: target = args.target.resolve() lock_path = target / args.lock_file - report = sync(source, target, lock_path) + report = sync( + source, + target, + lock_path, + platform_version=args.platform_version, + source_commit=args.source_commit, + catalog_commit=args.catalog_commit, + ) summary = render_summary(report) summary_root = Path(os.environ.get("RUNNER_TEMP", target / ".github")) diff --git a/tests/test_sync_workflows_action.py b/tests/test_sync_workflows_action.py index 9604072..4af2cd7 100644 --- a/tests/test_sync_workflows_action.py +++ b/tests/test_sync_workflows_action.py @@ -40,14 +40,19 @@ def test_adopts_matching_existing_workflow(self) -> None: (target / ".github/workflows/lint.yml").write_text(content, encoding="utf-8") report = sync_module.sync( - source, target, target / ".github/actions-lock.txt" + source, + target, + target / ".github/actions-lock.txt", + platform_version="v0.4.0", + source_commit="a" * 40, + catalog_commit="b" * 40, ) self.assertTrue(report["changed"]) self.assertEqual(report["adopted"], ["lint.yml"]) self.assertEqual( sync_module.parse_lock(target / ".github/actions-lock.txt")["lint.yml"], - sync_module.md5(source_file), + sync_module.sha256(source_file), ) def test_refuses_initial_local_divergence(self) -> None: @@ -88,7 +93,7 @@ def test_updates_managed_workflow_and_records_catalog_hash(self) -> None: 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"], - sync_module.md5(source_file), + sync_module.sha256(source_file), ) def test_skips_workflow_not_installed_in_consumer(self) -> None: @@ -121,8 +126,12 @@ def test_reports_unchanged_when_lock_matches_catalog(self) -> None: source, target, target / ".github/actions-lock.txt" ) - self.assertFalse(report["changed"]) - self.assertEqual(report["unchanged"], ["lint.yml"]) + self.assertTrue(report["changed"]) + self.assertEqual(report["provenance_updated"], ["lint.yml"]) + records = sync_module.parse_lock_records( + target / ".github/actions-lock.txt" + ) + self.assertEqual(records["lint.yml"].algorithm, "sha256") def test_applies_consumer_local_patch(self) -> None: temporary, source, target = self.fixture() @@ -158,7 +167,7 @@ def test_applies_consumer_local_patch(self) -> None: ) self.assertEqual( sync_module.parse_lock(target / ".github/actions-lock.txt")["sync.yml"], - sync_module.md5(source_file), + sync_module.sha256(source_file), ) def test_broken_patch_sets_draft_signal_and_keeps_catalog_lock(self) -> None: @@ -226,13 +235,86 @@ def test_removes_lock_entries_missing_from_catalog(self) -> None: self.assertEqual(report["removed_from_lock"], ["old.yml"]) self.assertEqual( sync_module.parse_lock(target / ".github/actions-lock.txt"), - {"lint.yml": sync_module.md5(current)}, + {"lint.yml": sync_module.sha256(current)}, ) self.assertEqual( stale.read_text(encoding="utf-8"), "name: Local old workflow\n", ) + def test_v2_lock_records_provenance_and_sha256(self) -> None: + temporary, source, target = self.fixture() + with temporary: + content = "name: Same\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", + platform_version="v0.4.0", + source_commit="a" * 40, + catalog_commit="b" * 40, + ) + + self.assertTrue(report["changed"]) + records = sync_module.parse_lock_records( + target / ".github/actions-lock.txt" + ) + record = records["lint.yml"] + self.assertEqual(record.algorithm, "sha256") + self.assertEqual(record.digest, sync_module.sha256(source_file)) + self.assertEqual(record.platform_version, "v0.4.0") + self.assertEqual(record.source_commit, "a" * 40) + self.assertEqual(record.catalog_commit, "b" * 40) + + def test_v2_provenance_change_does_not_rewrite_workflow(self) -> None: + temporary, source, target = self.fixture() + with temporary: + content = "name: Same\n" + source_file = source / "lint.yml" + source_file.write_text(content, encoding="utf-8") + target_file = target / ".github/workflows/lint.yml" + target_file.write_text(content, encoding="utf-8") + sync_module.write_lock( + target / ".github/actions-lock.txt", + { + "lint.yml": sync_module.LockEntry( + workflow="lint.yml", + algorithm="sha256", + digest=sync_module.sha256(source_file), + platform_version="v0.3.0", + source_commit="a" * 40, + catalog_commit="b" * 40, + ) + }, + ) + before = target_file.stat().st_mtime_ns + + report = sync_module.sync( + source, + target, + target / ".github/actions-lock.txt", + platform_version="v0.4.0", + source_commit="c" * 40, + catalog_commit="d" * 40, + ) + + self.assertTrue(report["changed"]) + self.assertEqual(report["provenance_updated"], ["lint.yml"]) + self.assertEqual(target_file.stat().st_mtime_ns, before) + record = sync_module.parse_lock_records( + target / ".github/actions-lock.txt" + )["lint.yml"] + self.assertEqual(record.platform_version, "v0.4.0") + self.assertEqual(record.source_commit, "c" * 40) + self.assertEqual(record.catalog_commit, "d" * 40) + + def test_writes_single_line_github_output(self) -> None: with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "output" @@ -266,6 +348,7 @@ def test_mixed_result_summary_is_deterministic(self) -> None: "unchanged": ["b.yml"], "skipped": ["c.yml"], "failed": ["a.yml"], + "provenance_updated": [], "details": ["- a.yml: Patch failed"], } ) From ccc2d888fb17fc4885e2eb827ac7102a2eedc36e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 18:38:54 -0300 Subject: [PATCH 2/3] fix: keep lock entries importlib-safe --- actions/sync-workflows/sync.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/actions/sync-workflows/sync.py b/actions/sync-workflows/sync.py index f9f9527..d52f4d1 100644 --- a/actions/sync-workflows/sync.py +++ b/actions/sync-workflows/sync.py @@ -11,7 +11,7 @@ import shutil import subprocess import tempfile -from dataclasses import dataclass +from typing import NamedTuple from pathlib import Path LOCK_HEADER = ( @@ -21,8 +21,7 @@ LOCK_SCHEMA_HEADER = "# workflow-lock-schema: 2\n" -@dataclass(frozen=True) -class LockEntry: +class LockEntry(NamedTuple): workflow: str algorithm: str digest: str From b87361c0c87d659e28c242b6f7ef86a581e0a7e0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 18:41:28 -0300 Subject: [PATCH 3/3] test: expect SHA-256 lock after patch failure --- tests/test_sync_workflows_action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sync_workflows_action.py b/tests/test_sync_workflows_action.py index 4af2cd7..a5b962e 100644 --- a/tests/test_sync_workflows_action.py +++ b/tests/test_sync_workflows_action.py @@ -202,7 +202,7 @@ def test_broken_patch_sets_draft_signal_and_keeps_catalog_lock(self) -> None: self.assertEqual(report["failed"], ["sync.yml"]) self.assertEqual( sync_module.parse_lock(target / ".github/actions-lock.txt")["sync.yml"], - sync_module.md5(source_file), + sync_module.sha256(source_file), ) def test_removes_lock_entries_missing_from_catalog(self) -> None: