From e02de6572a69c191f48bd9c25b1e20bdd2d3ac6c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:17:59 -0300 Subject: [PATCH 01/15] feat: add workflow sync action Signed-off-by: Vitor Mattos --- actions/sync-workflows/action.yml | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 actions/sync-workflows/action.yml diff --git a/actions/sync-workflows/action.yml b/actions/sync-workflows/action.yml new file mode 100644 index 0000000..f6e4db2 --- /dev/null +++ b/actions/sync-workflows/action.yml @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Sync workflow templates +description: Synchronize materialized workflow templates into a consumer repository and apply local patches. + +inputs: + source: + description: Directory containing organization workflow templates. + required: true + target: + description: Consumer repository checkout directory. + required: true + lock-file: + description: Lock file path relative to the consumer repository. + required: false + default: .github/actions-lock.txt + +outputs: + changed: + description: Whether at least one workflow was updated. + value: ${{ steps.sync.outputs.changed }} + patch-failed: + description: Whether at least one local workflow patch failed to apply. + value: ${{ steps.sync.outputs.patch_failed }} + updated: + description: JSON array of updated workflow names. + value: ${{ steps.sync.outputs.updated }} + failed: + description: JSON array of workflows whose local patch failed. + value: ${{ steps.sync.outputs.failed }} + summary-file: + description: Path to the generated Markdown summary file. + value: ${{ steps.sync.outputs.summary_file }} + +runs: + using: composite + steps: + - name: Synchronize workflows + id: sync + shell: bash + run: >- + python3 "${{ github.action_path }}/sync.py" + --source "${{ inputs.source }}" + --target "${{ inputs.target }}" + --lock-file "${{ inputs.lock-file }}" From a8e46206a348fe60fef4973281c93cf9f4c2404b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:18:02 -0300 Subject: [PATCH 02/15] feat: add workflow sync action Signed-off-by: Vitor Mattos --- actions/sync-workflows/sync.py | 206 +++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 actions/sync-workflows/sync.py diff --git a/actions/sync-workflows/sync.py b/actions/sync-workflows/sync.py new file mode 100644 index 0000000..4c4eabe --- /dev/null +++ b/actions/sync-workflows/sync.py @@ -0,0 +1,206 @@ +#!/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 +import os +import shutil +import subprocess +from pathlib import Path + +LOCK_HEADER = ( + "# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n" + "# SPDX-License-Identifier: MIT\n" +) + + +def md5(path: Path) -> str: + return hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest() + + +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) != 32 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError(f"invalid MD5 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 apply_patch(target_root: Path, target_file: Path) -> tuple[bool, str]: + patch_file = Path(f"{target_file}.patch") + if not patch_file.is_file(): + return True, "" + + relative_patch = patch_file.relative_to(target_root) + result = subprocess.run( + ["patch", "--batch", "--forward", "-p1"], + cwd=target_root, + input=patch_file.read_bytes(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + output = result.stdout.decode("utf-8", errors="replace").strip() + if result.returncode == 0: + return True, f"Patch applied: {relative_patch}" + + return False, f"Patch failed: {relative_patch}\n{output}" + + +def workflow_files(source: Path) -> list[Path]: + return sorted( + path + for path in source.iterdir() + if path.is_file() and path.suffix in {".yml", ".yaml"} + ) + + +def sync( + source: Path, + target: Path, + lock_path: Path, +) -> 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) + updated: list[str] = [] + unchanged: list[str] = [] + skipped: list[str] = [] + failed: list[str] = [] + details: list[str] = [] + + for source_file in workflow_files(source): + name = source_file.name + target_file = target / ".github/workflows" / name + + if not target_file.is_file(): + skipped.append(name) + continue + + new_version = md5(source_file) + locked_version = entries.get(name, "") + + if locked_version == new_version: + unchanged.append(name) + continue + + shutil.copyfile(source_file, target_file) + patch_ok, patch_message = apply_patch(target, target_file) + entries[name] = new_version + updated.append(name) + + if patch_message: + details.append(f"- {name}: {patch_message}") + if not patch_ok: + failed.append(name) + + if updated or not lock_path.is_file(): + write_lock(lock_path, entries) + + return { + "changed": bool(updated), + "patch_failed": bool(failed), + "updated": updated, + "unchanged": unchanged, + "skipped": skipped, + "failed": failed, + "details": details, + } + + +def render_summary(report: dict[str, object]) -> str: + lines = [ + "## Workflow synchronization", + "", + f"- Updated: {len(report['updated'])}", + f"- Unchanged: {len(report['unchanged'])}", + f"- Skipped: {len(report['skipped'])}", + f"- Patch failures: {len(report['failed'])}", + ] + + details = report["details"] + if details: + lines.extend(["", "### Details", "", *details]) + + lines.append("") + return "\n".join(lines) + + +def write_output(name: str, value: str) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if output: + with Path(output).open("a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def main() -> int: + parser = argparse.ArgumentParser() + 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") + args = parser.parse_args() + + try: + source = args.source.resolve() + target = args.target.resolve() + lock_path = target / args.lock_file + + report = sync(source, target, lock_path) + + summary_path = target / ".github/workflow-sync-summary.md" + summary_path.parent.mkdir(parents=True, exist_ok=True) + summary_path.write_text(render_summary(report), encoding="utf-8") + + write_output("changed", str(report["changed"]).lower()) + write_output("patch_failed", str(report["patch_failed"]).lower()) + write_output("updated", json.dumps(report["updated"], separators=(",", ":"))) + write_output("failed", json.dumps(report["failed"], separators=(",", ":"))) + write_output("summary_file", str(summary_path)) + + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + except (OSError, ValueError) as error: + parser.error(str(error)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 0a34de24c14bb60f5232d30c9e9afcff0f8c9457 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:18:05 -0300 Subject: [PATCH 03/15] feat: add workflow sync action Signed-off-by: Vitor Mattos --- tests/test_sync_workflows_action.py | 173 ++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/test_sync_workflows_action.py diff --git a/tests/test_sync_workflows_action.py b/tests/test_sync_workflows_action.py new file mode 100644 index 0000000..e056767 --- /dev/null +++ b/tests/test_sync_workflows_action.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import hashlib +import importlib.util +import tempfile +import unittest +from pathlib import Path + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "actions" + / "sync-workflows" + / "sync.py" +) +SPEC = importlib.util.spec_from_file_location("sync_workflows_action", MODULE_PATH) +sync_module = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(sync_module) + + +class SyncWorkflowsActionTest(unittest.TestCase): + def fixture(self) -> tuple[tempfile.TemporaryDirectory, Path, Path]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + source = root / "source" + target = root / "target" + (target / ".github/workflows").mkdir(parents=True) + source.mkdir() + return temporary, source, target + + def test_updates_existing_workflow_and_records_catalog_hash(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" + ) + + report = sync_module.sync( + source, target, target / ".github/actions-lock.txt" + ) + + self.assertTrue(report["changed"]) + self.assertFalse(report["patch_failed"]) + self.assertEqual( + (target / ".github/workflows/lint.yml").read_text(encoding="utf-8"), + "name: New\n", + ) + expected = hashlib.md5( + b"name: New\n", usedforsecurity=False + ).hexdigest() + self.assertEqual( + sync_module.parse_lock(target / ".github/actions-lock.txt")["lint.yml"], + expected, + ) + + def test_skips_workflow_not_installed_in_consumer(self) -> None: + temporary, source, target = self.fixture() + with temporary: + (source / "unused.yml").write_text("name: Unused\n", encoding="utf-8") + + report = sync_module.sync( + source, target, target / ".github/actions-lock.txt" + ) + + self.assertFalse(report["changed"]) + self.assertEqual(report["skipped"], ["unused.yml"]) + + def test_reports_unchanged_when_lock_matches_catalog(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" + ) + digest = sync_module.md5(source_file) + sync_module.write_lock( + target / ".github/actions-lock.txt", {"lint.yml": digest} + ) + + report = sync_module.sync( + source, target, target / ".github/actions-lock.txt" + ) + + self.assertFalse(report["changed"]) + self.assertEqual(report["unchanged"], ["lint.yml"]) + + def test_applies_consumer_local_patch(self) -> None: + temporary, source, target = self.fixture() + with temporary: + source_file = source / "sync.yml" + source_file.write_text( + "branches:\n - default\n", encoding="utf-8" + ) + target_file = target / ".github/workflows/sync.yml" + target_file.write_text("old\n", encoding="utf-8") + patch_file = target / ".github/workflows/sync.yml.patch" + patch_file.write_text( + "--- .github/workflows/sync.yml\n" + "+++ .github/workflows/sync.yml\n" + "@@ -1,2 +1,3 @@\n" + " branches:\n" + " - default\n" + "+ - stable32\n", + encoding="utf-8", + ) + + report = sync_module.sync( + source, target, target / ".github/actions-lock.txt" + ) + + self.assertFalse(report["patch_failed"]) + self.assertEqual( + target_file.read_text(encoding="utf-8"), + "branches:\n - default\n - stable32\n", + ) + self.assertEqual( + sync_module.parse_lock(target / ".github/actions-lock.txt")["sync.yml"], + sync_module.md5(source_file), + ) + + def test_broken_patch_sets_draft_signal_and_keeps_catalog_lock(self) -> None: + temporary, source, target = self.fixture() + with temporary: + source_file = source / "sync.yml" + source_file.write_text("name: New\n", encoding="utf-8") + target_file = target / ".github/workflows/sync.yml" + target_file.write_text("name: Old\n", encoding="utf-8") + patch_file = target / ".github/workflows/sync.yml.patch" + patch_file.write_text( + "--- .github/workflows/sync.yml\n" + "+++ .github/workflows/sync.yml\n" + "@@ -1 +1 @@\n" + "-name: Missing\n" + "+name: Patched\n", + encoding="utf-8", + ) + + report = sync_module.sync( + source, target, target / ".github/actions-lock.txt" + ) + + self.assertTrue(report["changed"]) + self.assertTrue(report["patch_failed"]) + self.assertEqual(report["failed"], ["sync.yml"]) + self.assertEqual( + sync_module.parse_lock(target / ".github/actions-lock.txt")["sync.yml"], + sync_module.md5(source_file), + ) + + def test_mixed_result_summary_is_deterministic(self) -> None: + summary = sync_module.render_summary( + { + "updated": ["a.yml"], + "unchanged": ["b.yml"], + "skipped": ["c.yml"], + "failed": ["a.yml"], + "details": ["- a.yml: Patch failed"], + } + ) + + self.assertIn("- Updated: 1", summary) + self.assertIn("- Unchanged: 1", summary) + self.assertIn("- Skipped: 1", summary) + self.assertIn("- Patch failures: 1", summary) + self.assertIn("- a.yml: Patch failed", summary) + + +if __name__ == "__main__": + unittest.main() From 6f29442120d7b0904738ffca8bbde30fae13502e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:18:45 -0300 Subject: [PATCH 04/15] feat: expose workflow sync report outputs Signed-off-by: Vitor Mattos --- actions/sync-workflows/action.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/actions/sync-workflows/action.yml b/actions/sync-workflows/action.yml index f6e4db2..4be0fc5 100644 --- a/actions/sync-workflows/action.yml +++ b/actions/sync-workflows/action.yml @@ -20,7 +20,7 @@ outputs: changed: description: Whether at least one workflow was updated. value: ${{ steps.sync.outputs.changed }} - patch-failed: + patch_failed: description: Whether at least one local workflow patch failed to apply. value: ${{ steps.sync.outputs.patch_failed }} updated: @@ -29,7 +29,10 @@ outputs: failed: description: JSON array of workflows whose local patch failed. value: ${{ steps.sync.outputs.failed }} - summary-file: + summary: + description: Markdown summary for the pull request body. + value: ${{ steps.sync.outputs.summary }} + summary_file: description: Path to the generated Markdown summary file. value: ${{ steps.sync.outputs.summary_file }} From 999be909e0a8f39959cf1a91313de37cbc441ba2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:18:48 -0300 Subject: [PATCH 05/15] feat: expose workflow sync summary Signed-off-by: Vitor Mattos --- actions/sync-workflows/sync.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/actions/sync-workflows/sync.py b/actions/sync-workflows/sync.py index 4c4eabe..3edffb7 100644 --- a/actions/sync-workflows/sync.py +++ b/actions/sync-workflows/sync.py @@ -184,14 +184,17 @@ def main() -> int: report = sync(source, target, lock_path) - summary_path = target / ".github/workflow-sync-summary.md" + summary = render_summary(report) + summary_root = Path(os.environ.get("RUNNER_TEMP", target / ".github")) + summary_path = summary_root / "workflow-sync-summary.md" summary_path.parent.mkdir(parents=True, exist_ok=True) - summary_path.write_text(render_summary(report), encoding="utf-8") + summary_path.write_text(summary, encoding="utf-8") write_output("changed", str(report["changed"]).lower()) write_output("patch_failed", str(report["patch_failed"]).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)) From 286a919133cf4c65f6750a1791f12aa249be12d5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:19:14 -0300 Subject: [PATCH 06/15] feat: use sync action from workflow template Signed-off-by: Vitor Mattos --- .../sync-workflow-templates.yml | 77 +++---------------- 1 file changed, 9 insertions(+), 68 deletions(-) diff --git a/workflow-templates/sync-workflow-templates.yml b/workflow-templates/sync-workflow-templates.yml index 2f7cc01..ac7a19c 100644 --- a/workflow-templates/sync-workflow-templates.yml +++ b/workflow-templates/sync-workflow-templates.yml @@ -26,9 +26,6 @@ jobs: matrix: branches: - ${{ github.event.repository.default_branch }} - - 'stable35' - - 'stable34' - - 'stable33' name: Update workflows in ${{ matrix.branches }} @@ -68,71 +65,15 @@ jobs: path: target ref: ${{ matrix.branches }} - - name: Copy all workflow templates - run: | - echo 'SUMMARY<> $GITHUB_ENV - draft_only=0 - for workflow in ./source/workflow-templates/*.yml; do - echo "❓ Looking for $workflow" - if [ -f "$workflow" ]; then - filename=$(basename "$workflow") - target_file="./target/.github/workflows/$filename" - - # Only copy if the file exists in the target repository - if [ -f "$target_file" ]; then - if [ -f "./target/.github/actions-lock.txt" ]; then - locked_version=$(grep " $filename" ./target/.github/actions-lock.txt | cat) - else - echo "# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors" >> ./target/.github/actions-lock.txt - echo "# SPDX-License""-Identifier: MIT" >> ./target/.github/actions-lock.txt - locked_version="" - fi - locked_version=$(echo $locked_version | cut -f 1 -d " ") - new_version=$(md5sum $workflow | cut -f 1 -d " ") - - # Only update if the action changes - if [[ "$locked_version" != "$new_version" ]]; then - echo "ℹ️ Locked version: $locked_version" - echo "ℹ️ Current version: $new_version" - echo "🆙 Updating existing workflow: $filename" - echo "- 🆙 Updated [$filename](https://github.com/LibreCodeCoop/.github/commits/main/workflow-templates/$filename)" >> $GITHUB_ENV - - cp "$workflow" "$target_file" - - # Apply patch if one exists - if [ -f "$target_file.patch" ]; then - echo "🩹 Applying patch" - cd ./target - set +e - patch -p1 < ".github/workflows/$filename.patch" - patch_worked=$? - set -e - cd - - if [[ "$patch_worked" == "0" ]]; then - echo " - Patch applied" >> $GITHUB_ENV - else - echo " - [ ] ❌ Patch failed" >> $GITHUB_ENV - draft_only=1 - fi - fi - - if [[ "$locked_version" != "" ]]; then - sed -i "s/$locked_version $filename/$new_version $filename/" ./target/.github/actions-lock.txt - else - echo "$new_version $filename" >> ./target/.github/actions-lock.txt - fi - else - echo "✅ Skipping $filename: already up to date" - fi - else - echo "⏭️ Skipping $filename: does not exist in target repository" - fi - fi - done - echo 'EOF' >> $GITHUB_ENV - echo "DRAFT_ONLY=${draft_only}" >> $GITHUB_ENV + - name: Synchronize workflow templates + id: sync + uses: LibreCodeCoop/github-workflows/actions/sync-workflows@999be909e0a8f39959cf1a91313de37cbc441ba2 + with: + source: source/workflow-templates + target: target - name: Create Pull Request + if: ${{ steps.sync.outputs.changed == 'true' }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ steps.app-token.outputs.token }} @@ -143,11 +84,11 @@ jobs: signoff: true branch: 'automated/noid/${{ matrix.branches }}-update-workflows' title: '[${{ matrix.branches }}] ci(actions): Update workflow templates from organization template repository' - draft: ${{ env.DRAFT_ONLY == 1 }} + draft: ${{ steps.sync.outputs.patch_failed == 'true' }} add-paths: .github/workflows/*.yml,.github/actions-lock.txt body: | Automated update of all workflow templates from [LibreCodeCoop/.github](https://github.com/LibreCodeCoop/.github) - ${{ env.SUMMARY }} + ${{ steps.sync.outputs.summary }} labels: | dependencies 3. to review From 3df004fb8c9e6c51f55944d1c052a742048d4b08 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:19:17 -0300 Subject: [PATCH 07/15] feat: use sync action from workflow template Signed-off-by: Vitor Mattos --- .../sync-workflow-templates.yml.patch | 106 +++++++++++++++--- 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/patches/nextcloud/sync-workflow-templates.yml.patch b/patches/nextcloud/sync-workflow-templates.yml.patch index 683aec8..eb1bcc7 100644 --- a/patches/nextcloud/sync-workflow-templates.yml.patch +++ b/patches/nextcloud/sync-workflow-templates.yml.patch @@ -1,6 +1,6 @@ --- upstream/vendor/nextcloud/sync-workflow-templates.yml +++ workflow-templates/sync-workflow-templates.yml -@@ -1,9 +1,9 @@ +@@ -1,6 +1,6 @@ # This workflow is provided via the organization template repository # -# https://github.com/nextcloud/.github @@ -8,10 +8,18 @@ # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization # # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - # SPDX-License-Identifier: MIT +@@ -24,10 +24,7 @@ + fail-fast: false + matrix: + branches: + - ${{ github.event.repository.default_branch }} +- - 'stable35' +- - 'stable34' +- - 'stable33' - # This workflow will update all workflow templates -@@ -42,12 +42,24 @@ + name: Update workflows in ${{ matrix.branches }} + +@@ -42,12 +39,24 @@ with: require: admin @@ -37,17 +45,82 @@ - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 -@@ -78,7 +90,7 @@ - echo "ℹ️ Locked version: $locked_version" - echo "ℹ️ Current version: $new_version" - echo "🆙 Updating existing workflow: $filename" -- echo "- 🆙 Updated [$filename](https://github.com/nextcloud/.github/commits/master/workflow-templates/$filename)" >> $GITHUB_ENV -+ echo "- 🆙 Updated [$filename](https://github.com/LibreCodeCoop/.github/commits/main/workflow-templates/$filename)" >> $GITHUB_ENV +@@ -56,69 +65,13 @@ + path: target + ref: ${{ matrix.branches }} - cp "$workflow" "$target_file" +- - name: Copy all workflow templates +- run: | +- echo 'SUMMARY<> $GITHUB_ENV +- draft_only=0 +- for workflow in ./source/workflow-templates/*.yml; do +- echo "❓ Looking for $workflow" +- if [ -f "$workflow" ]; then +- filename=$(basename "$workflow") +- target_file="./target/.github/workflows/$filename" +- +- # Only copy if the file exists in the target repository +- if [ -f "$target_file" ]; then +- if [ -f "./target/.github/actions-lock.txt" ]; then +- locked_version=$(grep " $filename" ./target/.github/actions-lock.txt | cat) +- else +- echo "# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors" >> ./target/.github/actions-lock.txt +- echo "# SPDX-License""-Identifier: MIT" >> ./target/.github/actions-lock.txt +- locked_version="" +- fi +- locked_version=$(echo $locked_version | cut -f 1 -d " ") +- new_version=$(md5sum $workflow | cut -f 1 -d " ") +- +- # Only update if the action changes +- if [[ "$locked_version" != "$new_version" ]]; then +- echo "ℹ️ Locked version: $locked_version" +- echo "ℹ️ Current version: $new_version" +- echo "🆙 Updating existing workflow: $filename" +- echo "- 🆙 Updated [$filename](https://github.com/nextcloud/.github/commits/master/workflow-templates/$filename)" >> $GITHUB_ENV +- +- cp "$workflow" "$target_file" +- +- # Apply patch if one exists +- if [ -f "$target_file.patch" ]; then +- echo "🩹 Applying patch" +- cd ./target +- set +e +- patch -p1 < ".github/workflows/$filename.patch" +- patch_worked=$? +- set -e +- cd - +- if [[ "$patch_worked" == "0" ]]; then +- echo " - Patch applied" >> $GITHUB_ENV +- else +- echo " - [ ] ❌ Patch failed" >> $GITHUB_ENV +- draft_only=1 +- fi +- fi +- +- if [[ "$locked_version" != "" ]]; then +- sed -i "s/$locked_version $filename/$new_version $filename/" ./target/.github/actions-lock.txt +- else +- echo "$new_version $filename" >> ./target/.github/actions-lock.txt +- fi +- else +- echo "✅ Skipping $filename: already up to date" +- fi +- else +- echo "⏭️ Skipping $filename: does not exist in target repository" +- fi +- fi +- done +- echo 'EOF' >> $GITHUB_ENV +- echo "DRAFT_ONLY=${draft_only}" >> $GITHUB_ENV ++ - name: Synchronize workflow templates ++ id: sync ++ uses: LibreCodeCoop/github-workflows/actions/sync-workflows@999be909e0a8f39959cf1a91313de37cbc441ba2 ++ with: ++ source: source/workflow-templates ++ target: target -@@ -123,10 +135,10 @@ - name: Create Pull Request ++ if: ${{ steps.sync.outputs.changed == 'true' }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - token: ${{ secrets.COMMAND_BOT_WORKFLOWS }} @@ -59,12 +132,15 @@ path: target signoff: true branch: 'automated/noid/${{ matrix.branches }}-update-workflows' -@@ -134,7 +146,7 @@ - draft: ${{ env.DRAFT_ONLY == 1 }} + title: '[${{ matrix.branches }}] ci(actions): Update workflow templates from organization template repository' +- draft: ${{ env.DRAFT_ONLY == 1 }} ++ draft: ${{ steps.sync.outputs.patch_failed == 'true' }} add-paths: .github/workflows/*.yml,.github/actions-lock.txt body: | - Automated update of all workflow templates from [nextcloud/.github](https://github.com/nextcloud/.github) +- ${{ env.SUMMARY }} + Automated update of all workflow templates from [LibreCodeCoop/.github](https://github.com/LibreCodeCoop/.github) - ${{ env.SUMMARY }} ++ ${{ steps.sync.outputs.summary }} labels: | dependencies + 3. to review From 0a14210300a20b808fcb14863aa9a0dcccaaf14e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:20:12 -0300 Subject: [PATCH 08/15] test: use git-style consumer patches Signed-off-by: Vitor Mattos --- tests/test_sync_workflows_action.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_sync_workflows_action.py b/tests/test_sync_workflows_action.py index e056767..a70e249 100644 --- a/tests/test_sync_workflows_action.py +++ b/tests/test_sync_workflows_action.py @@ -99,8 +99,8 @@ def test_applies_consumer_local_patch(self) -> None: target_file.write_text("old\n", encoding="utf-8") patch_file = target / ".github/workflows/sync.yml.patch" patch_file.write_text( - "--- .github/workflows/sync.yml\n" - "+++ .github/workflows/sync.yml\n" + "--- a/.github/workflows/sync.yml\n" + "+++ b/.github/workflows/sync.yml\n" "@@ -1,2 +1,3 @@\n" " branches:\n" " - default\n" @@ -131,8 +131,8 @@ def test_broken_patch_sets_draft_signal_and_keeps_catalog_lock(self) -> None: target_file.write_text("name: Old\n", encoding="utf-8") patch_file = target / ".github/workflows/sync.yml.patch" patch_file.write_text( - "--- .github/workflows/sync.yml\n" - "+++ .github/workflows/sync.yml\n" + "--- a/.github/workflows/sync.yml\n" + "+++ b/.github/workflows/sync.yml\n" "@@ -1 +1 @@\n" "-name: Missing\n" "+name: Patched\n", From 95edaffd3b3dcbfa6bf40dc39762fbcd626346f6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:20:41 -0300 Subject: [PATCH 09/15] fix: avoid action input template injection Signed-off-by: Vitor Mattos --- actions/sync-workflows/action.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/actions/sync-workflows/action.yml b/actions/sync-workflows/action.yml index 4be0fc5..9be0729 100644 --- a/actions/sync-workflows/action.yml +++ b/actions/sync-workflows/action.yml @@ -42,8 +42,13 @@ runs: - name: Synchronize workflows id: sync shell: bash + env: + SYNC_ACTION_PATH: ${{ github.action_path }} + SYNC_SOURCE: ${{ inputs.source }} + SYNC_TARGET: ${{ inputs.target }} + SYNC_LOCK_FILE: ${{ inputs.lock-file }} run: >- - python3 "${{ github.action_path }}/sync.py" - --source "${{ inputs.source }}" - --target "${{ inputs.target }}" - --lock-file "${{ inputs.lock-file }}" + python3 "$SYNC_ACTION_PATH/sync.py" + --source "$SYNC_SOURCE" + --target "$SYNC_TARGET" + --lock-file "$SYNC_LOCK_FILE" From b7160450f734f833ca8d674f99ff929aaba71189 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:20:44 -0300 Subject: [PATCH 10/15] fix: keep generated lock SPDX REUSE-safe Signed-off-by: Vitor Mattos From 7bafa3f230ba66547b181a41ba68b88cfb48d332 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:20:46 -0300 Subject: [PATCH 11/15] chore: license sync workflow patch Signed-off-by: Vitor Mattos --- patches/nextcloud/sync-workflow-templates.yml.patch.license | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 patches/nextcloud/sync-workflow-templates.yml.patch.license diff --git a/patches/nextcloud/sync-workflow-templates.yml.patch.license b/patches/nextcloud/sync-workflow-templates.yml.patch.license new file mode 100644 index 0000000..1ce4e0c --- /dev/null +++ b/patches/nextcloud/sync-workflow-templates.yml.patch.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +SPDX-License-Identifier: AGPL-3.0-or-later From 57e644fe4882e942ac19bbe99729b4b7e3c9014e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:20:56 -0300 Subject: [PATCH 12/15] fix: avoid parsing generated SPDX literal Signed-off-by: Vitor Mattos --- actions/sync-workflows/sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/sync-workflows/sync.py b/actions/sync-workflows/sync.py index 3edffb7..561bf25 100644 --- a/actions/sync-workflows/sync.py +++ b/actions/sync-workflows/sync.py @@ -14,7 +14,7 @@ LOCK_HEADER = ( "# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n" - "# SPDX-License-Identifier: MIT\n" + "# SPDX-" + "License-Identifier: MIT\n" ) From 095dd80f17305be3cd9a04b771cdc065856302b7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:21:54 -0300 Subject: [PATCH 13/15] fix: regenerate sync workflow patch Signed-off-by: Vitor Mattos --- .../sync-workflow-templates.yml.patch | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/patches/nextcloud/sync-workflow-templates.yml.patch b/patches/nextcloud/sync-workflow-templates.yml.patch index eb1bcc7..facbdb6 100644 --- a/patches/nextcloud/sync-workflow-templates.yml.patch +++ b/patches/nextcloud/sync-workflow-templates.yml.patch @@ -3,13 +3,12 @@ @@ -1,6 +1,6 @@ # This workflow is provided via the organization template repository # --# https://github.com/nextcloud/.github +# https://github.com/LibreCodeCoop/.github +-# https://github.com/nextcloud/.github # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization # # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -@@ -24,10 +24,7 @@ - fail-fast: false +@@ -26,9 +26,6 @@ matrix: branches: - ${{ github.event.repository.default_branch }} @@ -40,15 +39,21 @@ with: persist-credentials: false path: source -- repository: nextcloud/.github + repository: LibreCodeCoop/.github +- repository: nextcloud/.github - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 -@@ -56,69 +65,13 @@ +@@ -56,86 +65,30 @@ path: target ref: ${{ matrix.branches }} ++ - name: Synchronize workflow templates ++ id: sync ++ uses: LibreCodeCoop/github-workflows/actions/sync-workflows@999be909e0a8f39959cf1a91313de37cbc441ba2 ++ with: ++ source: source/workflow-templates ++ target: target - - name: Copy all workflow templates - run: | - echo 'SUMMARY<> $GITHUB_ENV @@ -58,7 +63,7 @@ - if [ -f "$workflow" ]; then - filename=$(basename "$workflow") - target_file="./target/.github/workflows/$filename" -- + - # Only copy if the file exists in the target repository - if [ -f "$target_file" ]; then - if [ -f "./target/.github/actions-lock.txt" ]; then @@ -112,35 +117,29 @@ - done - echo 'EOF' >> $GITHUB_ENV - echo "DRAFT_ONLY=${draft_only}" >> $GITHUB_ENV -+ - name: Synchronize workflow templates -+ id: sync -+ uses: LibreCodeCoop/github-workflows/actions/sync-workflows@999be909e0a8f39959cf1a91313de37cbc441ba2 -+ with: -+ source: source/workflow-templates -+ target: target - +- - name: Create Pull Request + if: ${{ steps.sync.outputs.changed == 'true' }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: -- token: ${{ secrets.COMMAND_BOT_WORKFLOWS }} + token: ${{ steps.app-token.outputs.token }} +- token: ${{ secrets.COMMAND_BOT_WORKFLOWS }} commit-message: 'ci(actions): Update workflow templates from organization template repository' committer: GitHub -- author: nextcloud-command + author: librecode-workflow-automation[bot] <331658022+librecode-workflow-automation[bot]@users.noreply.github.com> +- author: nextcloud-command path: target signoff: true branch: 'automated/noid/${{ matrix.branches }}-update-workflows' title: '[${{ matrix.branches }}] ci(actions): Update workflow templates from organization template repository' -- draft: ${{ env.DRAFT_ONLY == 1 }} + draft: ${{ steps.sync.outputs.patch_failed == 'true' }} +- draft: ${{ env.DRAFT_ONLY == 1 }} add-paths: .github/workflows/*.yml,.github/actions-lock.txt body: | -- Automated update of all workflow templates from [nextcloud/.github](https://github.com/nextcloud/.github) -- ${{ env.SUMMARY }} + Automated update of all workflow templates from [LibreCodeCoop/.github](https://github.com/LibreCodeCoop/.github) + ${{ steps.sync.outputs.summary }} +- Automated update of all workflow templates from [nextcloud/.github](https://github.com/nextcloud/.github) +- ${{ env.SUMMARY }} labels: | dependencies 3. to review From e183fe44703c3bd67d7fa6aab074af9299fb28d3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:22:07 -0300 Subject: [PATCH 14/15] fix: pin sync action after validation fixes Signed-off-by: Vitor Mattos --- workflow-templates/sync-workflow-templates.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflow-templates/sync-workflow-templates.yml b/workflow-templates/sync-workflow-templates.yml index ac7a19c..d6fbc8e 100644 --- a/workflow-templates/sync-workflow-templates.yml +++ b/workflow-templates/sync-workflow-templates.yml @@ -67,7 +67,7 @@ jobs: - name: Synchronize workflow templates id: sync - uses: LibreCodeCoop/github-workflows/actions/sync-workflows@999be909e0a8f39959cf1a91313de37cbc441ba2 + uses: LibreCodeCoop/github-workflows/actions/sync-workflows@57e644fe4882e942ac19bbe99729b4b7e3c9014e with: source: source/workflow-templates target: target From 21b634c67f41ffc08c360cc05d64ab1e1a2cda51 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 18:22:11 -0300 Subject: [PATCH 15/15] fix: regenerate sync template patch after action pin Signed-off-by: Vitor Mattos --- .../sync-workflow-templates.yml.patch | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/patches/nextcloud/sync-workflow-templates.yml.patch b/patches/nextcloud/sync-workflow-templates.yml.patch index facbdb6..c3724ad 100644 --- a/patches/nextcloud/sync-workflow-templates.yml.patch +++ b/patches/nextcloud/sync-workflow-templates.yml.patch @@ -3,8 +3,8 @@ @@ -1,6 +1,6 @@ # This workflow is provided via the organization template repository # -+# https://github.com/LibreCodeCoop/.github -# https://github.com/nextcloud/.github ++# https://github.com/LibreCodeCoop/.github # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization # # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors @@ -39,8 +39,8 @@ with: persist-credentials: false path: source -+ repository: LibreCodeCoop/.github - repository: nextcloud/.github ++ repository: LibreCodeCoop/.github - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -48,12 +48,6 @@ path: target ref: ${{ matrix.branches }} -+ - name: Synchronize workflow templates -+ id: sync -+ uses: LibreCodeCoop/github-workflows/actions/sync-workflows@999be909e0a8f39959cf1a91313de37cbc441ba2 -+ with: -+ source: source/workflow-templates -+ target: target - - name: Copy all workflow templates - run: | - echo 'SUMMARY<> $GITHUB_ENV @@ -63,7 +57,7 @@ - if [ -f "$workflow" ]; then - filename=$(basename "$workflow") - target_file="./target/.github/workflows/$filename" - +- - # Only copy if the file exists in the target repository - if [ -f "$target_file" ]; then - if [ -f "./target/.github/actions-lock.txt" ]; then @@ -117,29 +111,35 @@ - done - echo 'EOF' >> $GITHUB_ENV - echo "DRAFT_ONLY=${draft_only}" >> $GITHUB_ENV -- ++ - name: Synchronize workflow templates ++ id: sync ++ uses: LibreCodeCoop/github-workflows/actions/sync-workflows@57e644fe4882e942ac19bbe99729b4b7e3c9014e ++ with: ++ source: source/workflow-templates ++ target: target + - name: Create Pull Request + if: ${{ steps.sync.outputs.changed == 'true' }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: -+ token: ${{ steps.app-token.outputs.token }} - token: ${{ secrets.COMMAND_BOT_WORKFLOWS }} ++ token: ${{ steps.app-token.outputs.token }} commit-message: 'ci(actions): Update workflow templates from organization template repository' committer: GitHub -+ author: librecode-workflow-automation[bot] <331658022+librecode-workflow-automation[bot]@users.noreply.github.com> - author: nextcloud-command ++ author: librecode-workflow-automation[bot] <331658022+librecode-workflow-automation[bot]@users.noreply.github.com> path: target signoff: true branch: 'automated/noid/${{ matrix.branches }}-update-workflows' title: '[${{ matrix.branches }}] ci(actions): Update workflow templates from organization template repository' -+ draft: ${{ steps.sync.outputs.patch_failed == 'true' }} - draft: ${{ env.DRAFT_ONLY == 1 }} ++ draft: ${{ steps.sync.outputs.patch_failed == 'true' }} add-paths: .github/workflows/*.yml,.github/actions-lock.txt body: | -+ Automated update of all workflow templates from [LibreCodeCoop/.github](https://github.com/LibreCodeCoop/.github) -+ ${{ steps.sync.outputs.summary }} - Automated update of all workflow templates from [nextcloud/.github](https://github.com/nextcloud/.github) - ${{ env.SUMMARY }} ++ Automated update of all workflow templates from [LibreCodeCoop/.github](https://github.com/LibreCodeCoop/.github) ++ ${{ steps.sync.outputs.summary }} labels: | dependencies 3. to review