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
62 changes: 62 additions & 0 deletions .github/workflows/publish-workflow-catalog.yml
Original file line number Diff line number Diff line change
@@ -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 <noreply@github.com>
author: github-workflows bot <noreply@github.com>
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/**
166 changes: 166 additions & 0 deletions scripts/sync_catalog.py
Original file line number Diff line number Diff line change
@@ -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())
121 changes: 121 additions & 0 deletions tests/test_sync_catalog.py
Original file line number Diff line number Diff line change
@@ -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(
"<svg></svg>\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()
Loading