diff --git a/.github/workflows/publish-workflow-catalog.yml b/.github/workflows/publish-workflow-catalog.yml new file mode 100644 index 0000000..71fac77 --- /dev/null +++ b/.github/workflows/publish-workflow-catalog.yml @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Publish workflow catalog + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'workflow-templates/**' + - 'scripts/sync_catalog.py' + - '.github/workflows/publish-workflow-catalog.yml' + +permissions: + contents: read + +jobs: + publish: + name: Publish organization workflow catalog + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout workflow source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Checkout organization catalog + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: LibreCodeCoop/.github + token: ${{ secrets.WORKFLOW_UPDATE_TOKEN }} + persist-credentials: false + path: catalog + + - name: Synchronize workflow catalog + run: >- + python3 scripts/sync_catalog.py sync + workflow-templates + catalog/workflow-templates + --report catalog-sync-report.json + + - name: Create catalog update pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.WORKFLOW_UPDATE_TOKEN }} + path: catalog + commit-message: 'chore: sync LibreCode workflow catalog' + committer: GitHub + author: github-workflows bot + signoff: true + branch: automated/sync-workflow-catalog + delete-branch: true + title: 'chore: sync workflow catalog' + body: | + Automated synchronization from `LibreCodeCoop/github-workflows`. + + The files in `workflow-templates/` are generated and validated in the source repository. Review this pull request before publishing the updated organization catalog. + add-paths: | + workflow-templates/** diff --git a/scripts/sync_catalog.py b/scripts/sync_catalog.py new file mode 100644 index 0000000..b3322f8 --- /dev/null +++ b/scripts/sync_catalog.py @@ -0,0 +1,166 @@ +#!/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 json +import shutil +from pathlib import Path + +PUBLISHABLE_SUFFIXES = (".yml", ".yaml", ".properties.json", ".svg") + + +def is_publishable(path: Path) -> bool: + return path.is_file() and path.name.endswith(PUBLISHABLE_SUFFIXES) + + +def collect_publishable(directory: Path) -> dict[str, Path]: + if not directory.is_dir(): + raise ValueError(f"directory does not exist: {directory}") + + files = { + path.name: path + for path in directory.iterdir() + if is_publishable(path) + } + validate_catalog(files) + return files + + +def validate_catalog(files: dict[str, Path]) -> None: + workflow_names: set[str] = set() + + for name in files: + if name.endswith(".yml"): + workflow_names.add(name[:-4]) + elif name.endswith(".yaml"): + workflow_names.add(name[:-5]) + + for workflow_name in workflow_names: + metadata_name = f"{workflow_name}.properties.json" + if metadata_name not in files: + raise ValueError(f"missing template metadata: {metadata_name}") + + for name, path in files.items(): + if not name.endswith(".properties.json"): + continue + + workflow_name = name[: -len(".properties.json")] + if ( + f"{workflow_name}.yml" not in files + and f"{workflow_name}.yaml" not in files + ): + raise ValueError(f"metadata has no matching workflow: {name}") + + try: + metadata = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise ValueError(f"invalid JSON in {name}: {error}") from error + + if not isinstance(metadata, dict): + raise ValueError(f"metadata must be a JSON object: {name}") + + icon_name = metadata.get("iconName") + if icon_name is not None: + if not isinstance(icon_name, str) or not icon_name: + raise ValueError(f"iconName must be a non-empty string: {name}") + if not icon_name.startswith("octicon "): + icon_file = f"{icon_name}.svg" + if icon_file not in files: + raise ValueError( + f"metadata references missing icon {icon_file}: {name}" + ) + + +def sync_catalog(source: Path, target: Path) -> dict[str, list[str]]: + source_files = collect_publishable(source) + target.mkdir(parents=True, exist_ok=True) + + target_files = { + path.name: path + for path in target.iterdir() + if is_publishable(path) + } + + updated: list[str] = [] + unchanged: list[str] = [] + removed: list[str] = [] + + for name, source_path in sorted(source_files.items()): + target_path = target / name + source_content = source_path.read_bytes() + + if target_path.is_file() and target_path.read_bytes() == source_content: + unchanged.append(name) + continue + + shutil.copyfile(source_path, target_path) + updated.append(name) + + for name, target_path in sorted(target_files.items()): + if name in source_files: + continue + target_path.unlink() + removed.append(name) + + return { + "updated": updated, + "unchanged": unchanged, + "removed": removed, + } + + +def check_catalog(source: Path, target: Path) -> None: + source_files = collect_publishable(source) + target_files = { + path.name: path + for path in target.iterdir() + if is_publishable(path) + } if target.is_dir() else {} + + problems: list[str] = [] + + for name, source_path in sorted(source_files.items()): + target_path = target_files.get(name) + if target_path is None: + problems.append(f"missing from catalog: {name}") + elif target_path.read_bytes() != source_path.read_bytes(): + problems.append(f"catalog file differs: {name}") + + for name in sorted(set(target_files) - set(source_files)): + problems.append(f"stale catalog file: {name}") + + if problems: + raise ValueError("; ".join(problems)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=("sync", "check")) + parser.add_argument("source", type=Path) + parser.add_argument("target", type=Path) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + + try: + if args.command == "sync": + report = sync_catalog(args.source, args.target) + if args.report: + args.report.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + check_catalog(args.source, args.target) + except (OSError, ValueError) as error: + parser.error(str(error)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sync_catalog.py b/tests/test_sync_catalog.py new file mode 100644 index 0000000..b2512e1 --- /dev/null +++ b/tests/test_sync_catalog.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import json +import tempfile +import unittest +from pathlib import Path + +from scripts.sync_catalog import check_catalog, collect_publishable, sync_catalog + + +class SyncCatalogTest(unittest.TestCase): + def write_template( + self, + directory: Path, + name: str = "reuse", + *, + icon_name: str = "octicon verified", + ) -> None: + directory.mkdir(parents=True, exist_ok=True) + (directory / f"{name}.yml").write_text( + "name: Example\n", + encoding="utf-8", + ) + (directory / f"{name}.properties.json").write_text( + json.dumps( + { + "name": "Example", + "description": "Example workflow", + "iconName": icon_name, + } + ) + + "\n", + encoding="utf-8", + ) + + def test_sync_adds_updates_and_removes_managed_files(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + target = root / "target" + self.write_template(source) + + target.mkdir() + (target / "reuse.yml").write_text("name: Old\n", encoding="utf-8") + (target / "old.yml").write_text("name: Old\n", encoding="utf-8") + (target / "old.properties.json").write_text("{}\n", encoding="utf-8") + (target / "README.md").write_text("keep me\n", encoding="utf-8") + + report = sync_catalog(source, target) + + self.assertEqual(report["updated"], ["reuse.properties.json", "reuse.yml"]) + self.assertEqual(report["removed"], ["old.properties.json", "old.yml"]) + self.assertTrue((target / "README.md").is_file()) + check_catalog(source, target) + + def test_sync_reports_unchanged_files(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + target = root / "target" + self.write_template(source) + sync_catalog(source, target) + + report = sync_catalog(source, target) + + self.assertEqual(report["updated"], []) + self.assertEqual( + report["unchanged"], + ["reuse.properties.json", "reuse.yml"], + ) + self.assertEqual(report["removed"], []) + + def test_requires_metadata_for_each_workflow(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) + (source / "reuse.yml").write_text("name: Example\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "missing template metadata"): + collect_publishable(source) + + def test_rejects_metadata_without_matching_workflow(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) + (source / "reuse.properties.json").write_text( + '{"name":"Example"}\n', + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "no matching workflow"): + collect_publishable(source) + + def test_requires_custom_svg_icon(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) + self.write_template(source, icon_name="custom-icon") + + with self.assertRaisesRegex(ValueError, "missing icon custom-icon.svg"): + collect_publishable(source) + + (source / "custom-icon.svg").write_text( + "\n", + encoding="utf-8", + ) + collect_publishable(source) + + def test_check_detects_drift(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + target = root / "target" + self.write_template(source) + sync_catalog(source, target) + (target / "reuse.yml").write_text("name: Drift\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "catalog file differs"): + check_catalog(source, target) + + +if __name__ == "__main__": + unittest.main()