From ee2cf2d5a52c9275877a2c1160701a938e7ec997 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:54:24 -0300 Subject: [PATCH 1/4] chore: remove legacy central consumer sync Signed-off-by: Vitor Mattos --- consumers.json | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 consumers.json diff --git a/consumers.json b/consumers.json deleted file mode 100644 index 8717ebc..0000000 --- a/consumers.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "consumers": [ - { - "repository": "LibreCodeCoop/profile_fields", - "workflows": [ - "lint-info-xml.yml", - "lint-php-cs.yml", - "lint-php.yml", - "openapi.yml", - "psalm.yml" - ] - } - ] -} From 1284d65adfc10f287675045e18748d92587ecbf2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:54:26 -0300 Subject: [PATCH 2/4] chore: remove legacy central consumer sync Signed-off-by: Vitor Mattos --- .github/workflows/sync-consumers.yml | 125 --------------------------- 1 file changed, 125 deletions(-) delete mode 100644 .github/workflows/sync-consumers.yml diff --git a/.github/workflows/sync-consumers.yml b/.github/workflows/sync-consumers.yml deleted file mode 100644 index e3a85f7..0000000 --- a/.github/workflows/sync-consumers.yml +++ /dev/null @@ -1,125 +0,0 @@ -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -name: Sync consumer workflows - -on: - workflow_dispatch: - push: - branches: - - main - paths: - - 'workflow-templates/**' - - 'consumers.json' - - 'scripts/sync_consumer.py' - - '.github/workflows/sync-consumers.yml' - schedule: - - cron: '41 4 * * 0' - -permissions: - contents: read - -jobs: - consumers: - name: Build consumer matrix - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.matrix.outputs.matrix }} - steps: - - name: Checkout workflow source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build matrix - id: matrix - run: echo "matrix=$(python3 scripts/sync_consumer.py matrix consumers.json)" >> "$GITHUB_OUTPUT" - - sync: - name: Sync ${{ matrix.repository }} - needs: consumers - if: ${{ needs.consumers.outputs.matrix != '{"include":[]}' }} - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.consumers.outputs.matrix) }} - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Validate GitHub App configuration - env: - WORKFLOW_APP_ID: ${{ vars.LIBRECODE_WORKFLOW_APP_ID }} - WORKFLOW_APP_PRIVATE_KEY: ${{ secrets.LIBRECODE_WORKFLOW_APP_PRIVATE_KEY }} - run: | - if [ -z "$WORKFLOW_APP_ID" ]; then - echo "::error::LIBRECODE_WORKFLOW_APP_ID is not configured." - exit 1 - fi - if [ -z "$WORKFLOW_APP_PRIVATE_KEY" ]; then - echo "::error::LIBRECODE_WORKFLOW_APP_PRIVATE_KEY is not configured." - exit 1 - fi - - - name: Create GitHub App token - id: app-token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 - with: - app-id: ${{ vars.LIBRECODE_WORKFLOW_APP_ID }} - private-key: ${{ secrets.LIBRECODE_WORKFLOW_APP_PRIVATE_KEY }} - owner: LibreCodeCoop - repositories: ${{ matrix.repository_name }} - permission-contents: write - permission-pull-requests: write - permission-workflows: write - - - name: Checkout workflow source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - path: source - - - name: Checkout consumer - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ matrix.repository }} - token: ${{ steps.app-token.outputs.token }} - persist-credentials: false - path: target - - - name: Synchronize managed workflows - id: sync - continue-on-error: true - working-directory: source - env: - CONSUMER_REPOSITORY: ${{ matrix.repository }} - run: >- - python3 scripts/sync_consumer.py sync - consumers.json - "$CONSUMER_REPOSITORY" - workflow-templates - ../target - --report "../consumer-sync-report.json" - --body "../consumer-sync-pr.md" - - - name: Create consumer update pull request - if: ${{ steps.sync.outcome == 'success' }} - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - token: ${{ steps.app-token.outputs.token }} - path: target - commit-message: 'ci: sync LibreCode workflow templates' - committer: GitHub - author: github-workflows bot - signoff: true - branch: 'automated/sync-librcode-workflows' - delete-branch: true - title: 'ci: sync LibreCode workflow templates' - body-path: consumer-sync-pr.md - add-paths: | - .github/workflows/** - .github/librecode-workflows.lock - - - name: Fail on consumer divergence - if: ${{ steps.sync.outcome == 'failure' }} - run: | - cat consumer-sync-pr.md - exit 1 From 818bb01f9decc9f94b72ebcf5bfc9d01b213fed7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:54:29 -0300 Subject: [PATCH 3/4] chore: remove legacy central consumer sync Signed-off-by: Vitor Mattos --- scripts/sync_consumer.py | 335 --------------------------------------- 1 file changed, 335 deletions(-) delete mode 100644 scripts/sync_consumer.py diff --git a/scripts/sync_consumer.py b/scripts/sync_consumer.py deleted file mode 100644 index 92ac504..0000000 --- a/scripts/sync_consumer.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -from __future__ import annotations - -import argparse -import hashlib -import json -from dataclasses import dataclass -from pathlib import Path - -LOCK_HEADER = ( - "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" - "# SPDX-" + "License-Identifier: MIT\n" -) - - -@dataclass(frozen=True) -class Consumer: - repository: str - workflows: tuple[str, ...] - - -def sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def load_consumers(path: Path) -> list[Consumer]: - payload = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError("consumer manifest must be a JSON object") - - raw_consumers = payload.get("consumers") - if not isinstance(raw_consumers, list): - raise ValueError("consumer manifest must contain a consumers array") - - consumers: list[Consumer] = [] - seen: set[str] = set() - - for index, raw in enumerate(raw_consumers): - if not isinstance(raw, dict): - raise ValueError(f"consumers[{index}] must be an object") - - repository = raw.get("repository") - workflows = raw.get("workflows") - - if not isinstance(repository, str) or "/" not in repository: - raise ValueError(f"consumers[{index}].repository must be owner/name") - if repository in seen: - raise ValueError(f"duplicate consumer repository: {repository}") - seen.add(repository) - - if ( - not isinstance(workflows, list) - or not workflows - or not all(isinstance(item, str) and item for item in workflows) - ): - raise ValueError( - f"consumers[{index}].workflows must be a non-empty array of names" - ) - - if len(set(workflows)) != len(workflows): - raise ValueError(f"duplicate workflow in consumer {repository}") - - for workflow in workflows: - workflow_path = Path(workflow) - if ( - workflow_path.name != workflow - or workflow_path.suffix not in {".yml", ".yaml"} - ): - raise ValueError( - f"invalid workflow name for {repository}: {workflow}" - ) - - consumers.append( - Consumer(repository=repository, workflows=tuple(sorted(workflows))) - ) - - return consumers - - -def matrix(consumers: list[Consumer]) -> dict[str, list[dict[str, object]]]: - return { - "include": [ - { - "repository": consumer.repository, - "repository_name": consumer.repository.split("/", 1)[1], - "workflows": list(consumer.workflows), - } - for consumer in consumers - ] - } - - -def parse_lock(path: Path) -> dict[str, str]: - if not path.is_file(): - return {} - - entries: dict[str, str] = {} - for line_number, raw_line in enumerate( - path.read_text(encoding="utf-8").splitlines(), - start=1, - ): - line = raw_line.strip() - 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) != 64 - or any(character not in "0123456789abcdef" for character in digest) - ): - raise ValueError(f"invalid SHA-256 at line {line_number}") - - if workflow in entries: - raise ValueError(f"duplicate lock entry: {workflow}") - entries[workflow] = digest - - return entries - - -def write_lock(path: Path, entries: dict[str, str]) -> None: - lines = [LOCK_HEADER.rstrip("\n"), ""] - lines.extend(f"{entries[name]} {name}" for name in sorted(entries)) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def sync_consumer( - source_dir: Path, - target_dir: Path, - workflows: tuple[str, ...], - lock_path: Path, -) -> dict[str, object]: - lock_entries = parse_lock(lock_path) - results: list[dict[str, str]] = [] - failed = False - - for workflow in workflows: - source = source_dir / workflow - target = target_dir / ".github/workflows" / workflow - - if not source.is_file(): - results.append( - { - "workflow": workflow, - "status": "failed", - "message": "source template does not exist", - } - ) - failed = True - continue - - source_hash = sha256(source) - locked_hash = lock_entries.get(workflow) - - if locked_hash is None: - if target.is_file() and sha256(target) != source_hash: - results.append( - { - "workflow": workflow, - "status": "failed", - "message": ( - "existing workflow is not managed yet and differs " - "from the current template" - ), - } - ) - failed = True - continue - - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(source.read_bytes()) - lock_entries[workflow] = source_hash - results.append( - { - "workflow": workflow, - "status": "adopted", - "message": "workflow adopted into managed synchronization", - } - ) - continue - - if not target.is_file(): - results.append( - { - "workflow": workflow, - "status": "failed", - "message": "managed workflow was deleted locally", - } - ) - failed = True - continue - - current_hash = sha256(target) - if current_hash != locked_hash: - results.append( - { - "workflow": workflow, - "status": "failed", - "message": "local workflow diverged from its managed lock", - } - ) - failed = True - continue - - if source_hash == locked_hash: - results.append( - { - "workflow": workflow, - "status": "unchanged", - "message": "workflow is already current", - } - ) - continue - - target.write_bytes(source.read_bytes()) - lock_entries[workflow] = source_hash - results.append( - { - "workflow": workflow, - "status": "updated", - "message": "workflow updated to the current template", - } - ) - - write_lock(lock_path, lock_entries) - - return { - "ok": not failed, - "results": results, - } - - -def render_pull_request_body(repository: str, report: dict[str, object]) -> str: - lines = [ - "Automated synchronization from LibreCodeCoop/github-workflows.", - "", - f"Consumer: {repository}", - "", - "## Workflow status", - "", - ] - - icons = { - "adopted": "OK", - "updated": "UPDATED", - "unchanged": "UNCHANGED", - "failed": "FAILED", - } - - for item in report["results"]: - status = item["status"] - lines.append( - f"- {icons[status]} {item['workflow']} - {status}: {item['message']}" - ) - - lines.extend( - [ - "", - "Managed workflow files are updated only when their current SHA-256 " - "matches the previously recorded lock. Local divergence is never " - "overwritten silently.", - "", - ] - ) - return "\n".join(lines) - - -def main() -> int: - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(dest="command", required=True) - - matrix_parser = subparsers.add_parser("matrix") - matrix_parser.add_argument("manifest", type=Path) - - sync_parser = subparsers.add_parser("sync") - sync_parser.add_argument("manifest", type=Path) - sync_parser.add_argument("repository") - sync_parser.add_argument("source_dir", type=Path) - sync_parser.add_argument("target_dir", type=Path) - sync_parser.add_argument("--report", type=Path) - sync_parser.add_argument("--body", type=Path) - - args = parser.parse_args() - - try: - consumers = load_consumers(args.manifest) - - if args.command == "matrix": - print(json.dumps(matrix(consumers), separators=(",", ":"))) - return 0 - - consumer = next( - (item for item in consumers if item.repository == args.repository), - None, - ) - if consumer is None: - raise ValueError(f"consumer is not declared: {args.repository}") - - lock_path = args.target_dir / ".github/librecode-workflows.lock" - report = sync_consumer( - args.source_dir, - args.target_dir, - consumer.workflows, - lock_path, - ) - - if args.report: - args.report.write_text( - json.dumps(report, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - if args.body: - args.body.write_text( - render_pull_request_body(args.repository, report), - encoding="utf-8", - ) - - print(json.dumps(report, indent=2, sort_keys=True)) - return 0 if report["ok"] else 1 - - except (OSError, ValueError) as error: - parser.error(str(error)) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From c236acac0205c93eccd2a0ed7f123e8a0f627090 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:54:31 -0300 Subject: [PATCH 4/4] chore: remove legacy central consumer sync Signed-off-by: Vitor Mattos --- tests/test_sync_consumer.py | 207 ------------------------------------ 1 file changed, 207 deletions(-) delete mode 100644 tests/test_sync_consumer.py diff --git a/tests/test_sync_consumer.py b/tests/test_sync_consumer.py deleted file mode 100644 index f3501f1..0000000 --- a/tests/test_sync_consumer.py +++ /dev/null @@ -1,207 +0,0 @@ -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -import hashlib -import json -import tempfile -import unittest -from pathlib import Path - -from scripts.sync_consumer import ( - LOCK_HEADER, - load_consumers, - matrix, - parse_lock, - render_pull_request_body, - sha256, - sync_consumer, -) - - -class SyncConsumerTest(unittest.TestCase): - def create_source(self, root: Path, content: str = "name: REUSE\n") -> Path: - source = root / "source" - source.mkdir(parents=True) - (source / "reuse.yml").write_text(content, encoding="utf-8") - return source - - def target_workflow(self, root: Path) -> Path: - return root / "target/.github/workflows/reuse.yml" - - def lock_path(self, root: Path) -> Path: - return root / "target/.github/librecode-workflows.lock" - - def test_load_consumers_and_matrix(self) -> None: - with tempfile.TemporaryDirectory() as directory: - manifest = Path(directory) / "consumers.json" - manifest.write_text( - json.dumps( - { - "consumers": [ - { - "repository": "LibreCodeCoop/extract", - "workflows": ["reuse.yml"], - } - ] - } - ), - encoding="utf-8", - ) - - consumers = load_consumers(manifest) - - self.assertEqual(consumers[0].repository, "LibreCodeCoop/extract") - self.assertEqual(consumers[0].workflows, ("reuse.yml",)) - self.assertEqual( - matrix(consumers), - { - "include": [ - { - "repository": "LibreCodeCoop/extract", - "repository_name": "extract", - "workflows": ["reuse.yml"], - } - ] - }, - ) - - def test_adopts_matching_existing_workflow(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = self.create_source(root) - target = self.target_workflow(root) - target.parent.mkdir(parents=True) - target.write_bytes((source / "reuse.yml").read_bytes()) - - report = sync_consumer( - source, - root / "target", - ("reuse.yml",), - self.lock_path(root), - ) - - self.assertTrue(report["ok"]) - self.assertEqual(report["results"][0]["status"], "adopted") - lock = parse_lock(self.lock_path(root)) - self.assertEqual(lock["reuse.yml"], sha256(source / "reuse.yml")) - - def test_refuses_to_adopt_different_existing_workflow(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = self.create_source(root) - target = self.target_workflow(root) - target.parent.mkdir(parents=True) - target.write_text("name: Local\n", encoding="utf-8") - - report = sync_consumer( - source, - root / "target", - ("reuse.yml",), - self.lock_path(root), - ) - - self.assertFalse(report["ok"]) - self.assertEqual(report["results"][0]["status"], "failed") - self.assertEqual(target.read_text(encoding="utf-8"), "name: Local\n") - - def test_updates_when_target_matches_lock(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = self.create_source(root, "name: New\n") - target = self.target_workflow(root) - target.parent.mkdir(parents=True) - target.write_text("name: Old\n", encoding="utf-8") - old_hash = hashlib.sha256(b"name: Old\n").hexdigest() - lock = self.lock_path(root) - lock.parent.mkdir(parents=True, exist_ok=True) - lock.write_text( - LOCK_HEADER + "\n" + f"{old_hash} reuse.yml\n", - encoding="utf-8", - ) - - report = sync_consumer( - source, - root / "target", - ("reuse.yml",), - lock, - ) - - self.assertTrue(report["ok"]) - self.assertEqual(report["results"][0]["status"], "updated") - self.assertEqual(target.read_text(encoding="utf-8"), "name: New\n") - self.assertEqual(parse_lock(lock)["reuse.yml"], sha256(source / "reuse.yml")) - - def test_refuses_local_divergence(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = self.create_source(root, "name: New\n") - target = self.target_workflow(root) - target.parent.mkdir(parents=True) - target.write_text("name: Local\n", encoding="utf-8") - old_hash = hashlib.sha256(b"name: Old\n").hexdigest() - lock = self.lock_path(root) - lock.parent.mkdir(parents=True, exist_ok=True) - lock.write_text( - LOCK_HEADER + "\n" + f"{old_hash} reuse.yml\n", - encoding="utf-8", - ) - - report = sync_consumer( - source, - root / "target", - ("reuse.yml",), - lock, - ) - - self.assertFalse(report["ok"]) - self.assertEqual(report["results"][0]["status"], "failed") - self.assertEqual(target.read_text(encoding="utf-8"), "name: Local\n") - self.assertEqual(parse_lock(lock)["reuse.yml"], old_hash) - - def test_reports_unchanged(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = self.create_source(root) - target = self.target_workflow(root) - target.parent.mkdir(parents=True) - target.write_bytes((source / "reuse.yml").read_bytes()) - digest = sha256(source / "reuse.yml") - lock = self.lock_path(root) - lock.parent.mkdir(parents=True, exist_ok=True) - lock.write_text( - LOCK_HEADER + "\n" + f"{digest} reuse.yml\n", - encoding="utf-8", - ) - - report = sync_consumer( - source, - root / "target", - ("reuse.yml",), - lock, - ) - - self.assertTrue(report["ok"]) - self.assertEqual(report["results"][0]["status"], "unchanged") - - def test_pull_request_body_explains_divergence_protection(self) -> None: - body = render_pull_request_body( - "LibreCodeCoop/extract", - { - "ok": True, - "results": [ - { - "workflow": "reuse.yml", - "status": "updated", - "message": "workflow updated to the current template", - } - ], - }, - ) - - self.assertIn("LibreCodeCoop/extract", body) - self.assertIn("SHA-256", body) - self.assertIn("never overwritten silently", body) - - -if __name__ == "__main__": - unittest.main()