From 4d51e0d5e91bb7c2d313c8238e1a4a3e756f3461 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:32:03 -0300 Subject: [PATCH 01/21] feat: render patched upstream workflow templates Signed-off-by: Vitor Mattos --- .github/workflows/tests.yml | 5 +- patches/README.md | 21 +- .../appstore-build-publish.yml.patch | 13 ++ .../appstore-build-publish.yml.patch.license | 2 + scripts/render_upstream.py | 155 ++++++++++++++ .../nextcloud/appstore-build-publish.yml | 202 ++++++++++++++++++ tests/test_render_upstream.py | 96 +++++++++ upstream/templates.json | 12 ++ 8 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 patches/nextcloud/appstore-build-publish.yml.patch create mode 100644 patches/nextcloud/appstore-build-publish.yml.patch.license create mode 100644 scripts/render_upstream.py create mode 100644 templates/nextcloud/appstore-build-publish.yml create mode 100644 tests/test_render_upstream.py create mode 100644 upstream/templates.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4a307c..f21be76 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,5 +26,8 @@ jobs: - name: Run unit tests run: python3 -m unittest discover -s tests -p 'test_*.py' - - name: Verify generated templates + - name: Verify vendored upstream sources run: python3 scripts/sync_upstream.py check upstream/sources.json + + - name: Verify rendered upstream templates + run: python3 scripts/render_upstream.py check upstream/templates.json diff --git a/patches/README.md b/patches/README.md index 025a896..c3ebf53 100644 --- a/patches/README.md +++ b/patches/README.md @@ -7,6 +7,21 @@ SPDX-License-Identifier: AGPL-3.0-or-later This directory contains explicit patches applied to imported upstream workflows. -The patch format and application contract will be introduced with the first -upstream workflow import and covered by tests. Generated templates must not hide -manual downstream edits. +Each rendered template is declared in `upstream/templates.json` with: + +- an immutable vendored source under `upstream/vendor/`; +- zero or more ordered unified-diff patches from this directory; +- a generated destination under `templates/`. + +Render all declared templates with: + +```bash +python3 scripts/render_upstream.py sync upstream/templates.json +``` + +CI runs the corresponding `check` command and fails when a committed generated +template does not match its vendored source plus patches. + +Patches should stay minimal. Product-specific behavior belongs in consumer +configuration unless the difference is required by the shared downstream +workflow contract. diff --git a/patches/nextcloud/appstore-build-publish.yml.patch b/patches/nextcloud/appstore-build-publish.yml.patch new file mode 100644 index 0000000..55cef51 --- /dev/null +++ b/patches/nextcloud/appstore-build-publish.yml.patch @@ -0,0 +1,13 @@ +--- appstore-build-publish.yml ++++ appstore-build-publish.yml +@@ -18,8 +18,8 @@ jobs: + build_and_publish: + runs-on: ubuntu-latest + +- # Only allowed to be run on nextcloud-releases repositories +- if: ${{ github.repository_owner == 'nextcloud-releases' }} ++ # Downstream consumers publish from their own repositories. ++ # Repository policy is enforced by the consumer. + + steps: + - name: Check actor permission diff --git a/patches/nextcloud/appstore-build-publish.yml.patch.license b/patches/nextcloud/appstore-build-publish.yml.patch.license new file mode 100644 index 0000000..1ce4e0c --- /dev/null +++ b/patches/nextcloud/appstore-build-publish.yml.patch.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/scripts/render_upstream.py b/scripts/render_upstream.py new file mode 100644 index 0000000..48f3e59 --- /dev/null +++ b/scripts/render_upstream.py @@ -0,0 +1,155 @@ +#!/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 +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Template: + name: str + source: Path + patches: tuple[Path, ...] + destination: Path + + +def load_templates(manifest_path: Path) -> list[Template]: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("manifest must be a JSON object") + + raw_templates = payload.get("templates") + if not isinstance(raw_templates, list): + raise ValueError("manifest.templates must be an array") + + templates: list[Template] = [] + for index, raw in enumerate(raw_templates): + if not isinstance(raw, dict): + raise ValueError(f"manifest.templates[{index}] must be an object") + + name = _non_empty_string(raw.get("name"), f"templates[{index}].name") + source = Path(_non_empty_string(raw.get("source"), f"templates[{index}].source")) + destination = Path( + _non_empty_string(raw.get("destination"), f"templates[{index}].destination") + ) + + raw_patches = raw.get("patches", []) + if not isinstance(raw_patches, list) or not all( + isinstance(item, str) and item for item in raw_patches + ): + raise ValueError(f"templates[{index}].patches must be an array of paths") + + for path in (source, destination, *(Path(item) for item in raw_patches)): + _validate_relative_path(path) + + templates.append( + Template( + name=name, + source=source, + patches=tuple(Path(item) for item in raw_patches), + destination=destination, + ) + ) + + return templates + + +def render(template: Template, root: Path) -> bytes: + source = _safe_path(root, template.source) + if not source.is_file(): + raise ValueError(f"{template.name}: source does not exist: {template.source}") + + with tempfile.TemporaryDirectory() as directory: + working = Path(directory) / source.name + shutil.copyfile(source, working) + + for patch_path in template.patches: + patch = _safe_path(root, patch_path) + if not patch.is_file(): + raise ValueError(f"{template.name}: patch does not exist: {patch_path}") + + result = subprocess.run( + ["patch", "--batch", "--forward", str(working), str(patch)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + details = (result.stderr or result.stdout).strip() + raise ValueError( + f"{template.name}: failed to apply {patch_path}: {details}" + ) + + return working.read_bytes() + + +def sync(templates: list[Template], root: Path) -> None: + for template in templates: + content = render(template, root) + destination = _safe_path(root, template.destination) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + + +def check(templates: list[Template], root: Path) -> None: + drift: list[str] = [] + for template in templates: + expected = render(template, root) + destination = _safe_path(root, template.destination) + if not destination.is_file() or destination.read_bytes() != expected: + drift.append(template.name) + + if drift: + raise ValueError("rendered templates are out of date: " + ", ".join(drift)) + + +def _validate_relative_path(path: Path) -> None: + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"unsafe path: {path}") + + +def _safe_path(root: Path, path: Path) -> Path: + _validate_relative_path(path) + resolved = (root / path).resolve() + root_resolved = root.resolve() + if resolved != root_resolved and root_resolved not in resolved.parents: + raise ValueError(f"path escapes repository root: {path}") + return resolved + + +def _non_empty_string(value: object, path: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"{path} must be a non-empty string") + return value + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=("sync", "check")) + parser.add_argument("manifest", type=Path) + args = parser.parse_args() + + root = Path.cwd() + + try: + templates = load_templates(args.manifest) + if args.command == "sync": + sync(templates, root) + else: + check(templates, root) + except ValueError as error: + parser.error(str(error)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/nextcloud/appstore-build-publish.yml b/templates/nextcloud/appstore-build-publish.yml new file mode 100644 index 0000000..c75464f --- /dev/null +++ b/templates/nextcloud/appstore-build-publish.yml @@ -0,0 +1,202 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Build and publish app release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build_and_publish: + runs-on: ubuntu-latest + + # Downstream consumers publish from their own repositories. + # Repository policy is enforced by the consumer. + + steps: + - name: Check actor permission + uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v3.0 + with: + require: write + + - name: Set app env + run: | + # Split and keep last + echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + echo "APP_VERSION=${GITHUB_REF##*/}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: ${{ env.APP_NAME }} + + - name: Get app version number + id: app-version + uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + expression: "//info//version/text()" + + - name: Validate app version against tag + run: | + [ "${{ env.APP_VERSION }}" = "v${{ fromJSON(steps.app-version.outputs.result).version }}" ] + + - name: Get appinfo data + id: appinfo + uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + expression: "//info//dependencies//nextcloud/@min-version" + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + # Continue if no package.json + continue-on-error: true + with: + path: ${{ env.APP_NAME }} + fallbackNode: '^24' + fallbackNpm: '^11.3' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + # Skip if no package.json + if: ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + package-manager-cache: false + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + # Skip if no package.json + if: ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Get php version + id: php-versions + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + + - name: Set up php ${{ steps.php-versions.outputs.php-min }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ steps.php-versions.outputs.php-min }} + coverage: none + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check composer.json + id: check_composer + uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 + with: + files: "${{ env.APP_NAME }}/composer.json" + + - name: Install composer dependencies + if: steps.check_composer.outputs.files_exists == 'true' + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + composer-options: '--no-dev' + working-directory: ${{ env.APP_NAME }} + ignore-cache: 'yes' + + - name: Build ${{ env.APP_NAME }} + # Skip if no package.json + if: ${{ steps.versions.outputs.nodeVersion }} + env: + CYPRESS_INSTALL_BINARY: 0 + run: | + cd ${{ env.APP_NAME }} + npm ci + npm run build --if-present + + - name: Check Krankerl config + id: krankerl + uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 + with: + files: ${{ env.APP_NAME }}/krankerl.toml + + - name: Install Krankerl + if: steps.krankerl.outputs.files_exists == 'true' + run: | + wget https://github.com/ChristophWurst/krankerl/releases/download/v0.14.0/krankerl_0.14.0_amd64.deb + sudo dpkg -i krankerl_0.14.0_amd64.deb + + - name: Package ${{ env.APP_NAME }} ${{ env.APP_VERSION }} with krankerl + if: steps.krankerl.outputs.files_exists == 'true' + run: | + cd ${{ env.APP_NAME }} + krankerl package + + - name: Package ${{ env.APP_NAME }} ${{ env.APP_VERSION }} with makefile + if: steps.krankerl.outputs.files_exists != 'true' + run: | + cd ${{ env.APP_NAME }} + make appstore + + - name: Check server download link for ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + run: | + NCVERSION='${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }}' + DOWNLOAD_URL=$(curl -s "https://updates.nextcloud.com/updater_server/latest?channel=beta&version=$NCVERSION" | jq -r '.downloads.zip[0]') + echo "DOWNLOAD_URL=$DOWNLOAD_URL" >> $GITHUB_ENV + + - name: Download server ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + continue-on-error: true + id: server-download + if: ${{ env.DOWNLOAD_URL != 'null' }} + run: | + echo "Downloading release tarball from $DOWNLOAD_URL" + wget $DOWNLOAD_URL -O nextcloud.zip + unzip nextcloud.zip + + - name: Checkout server master fallback + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: ${{ steps.server-download.outcome != 'success' }} + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + path: nextcloud + + + - name: Sign app + run: | + # Extracting release + cd ${{ env.APP_NAME }}/build/artifacts + tar -xvf ${{ env.APP_NAME }}.tar.gz + cd ../../../ + # Setting up keys + echo '${{ secrets.APP_PRIVATE_KEY }}' > ${{ env.APP_NAME }}.key + wget --quiet "https://github.com/nextcloud/app-certificate-requests/raw/master/${{ env.APP_NAME }}/${{ env.APP_NAME }}.crt" + # Signing + php nextcloud/occ integrity:sign-app --privateKey=../${{ env.APP_NAME }}.key --certificate=../${{ env.APP_NAME }}.crt --path=../${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }} + # Rebuilding archive + cd ${{ env.APP_NAME }}/build/artifacts + tar -zcvf ${{ env.APP_NAME }}.tar.gz ${{ env.APP_NAME }} + + - name: Attach tarball to github release + uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # 2.11.5 + id: attach_to_release + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }}.tar.gz + asset_name: ${{ env.APP_NAME }}-${{ env.APP_VERSION }}.tar.gz + tag: ${{ github.ref }} + overwrite: true + + - name: Upload app to Nextcloud appstore + uses: nextcloud-libraries/nextcloud-appstore-push-action@a011fe619bcf6e77ddebc96f9908e1af4071b9c1 # v1.0.3 + with: + app_name: ${{ env.APP_NAME }} + appstore_token: ${{ secrets.APPSTORE_TOKEN }} + download_url: ${{ steps.attach_to_release.outputs.browser_download_url }} + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/tests/test_render_upstream.py b/tests/test_render_upstream.py new file mode 100644 index 0000000..b70f867 --- /dev/null +++ b/tests/test_render_upstream.py @@ -0,0 +1,96 @@ +# 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.render_upstream import check, load_templates, sync + + +class RenderUpstreamTest(unittest.TestCase): + def fixture(self, directory: str) -> tuple[Path, Path]: + root = Path(directory) + source = root / "upstream/vendor/example.yml" + source.parent.mkdir(parents=True) + source.write_text( + "name: Example\n\njobs:\n test:\n runs-on: ubuntu-latest\n", + encoding="utf-8", + ) + + patch = root / "patches/example.yml.patch" + patch.parent.mkdir(parents=True) + patch.write_text( + "--- example.yml\n" + "+++ example.yml\n" + "@@ -1,5 +1,5 @@\n" + "-name: Example\n" + "+name: Patched example\n" + " \n" + " jobs:\n" + " test:\n" + " runs-on: ubuntu-latest\n", + encoding="utf-8", + ) + + manifest = root / "upstream/templates.json" + manifest.write_text( + json.dumps( + { + "templates": [ + { + "name": "example", + "source": "upstream/vendor/example.yml", + "patches": ["patches/example.yml.patch"], + "destination": "templates/example.yml", + } + ] + } + ), + encoding="utf-8", + ) + return root, manifest + + def test_sync_applies_patch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root, manifest = self.fixture(directory) + sync(load_templates(manifest), root) + rendered = (root / "templates/example.yml").read_text(encoding="utf-8") + self.assertIn("name: Patched example", rendered) + + def test_check_detects_rendered_drift(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root, manifest = self.fixture(directory) + destination = root / "templates/example.yml" + destination.parent.mkdir(parents=True) + destination.write_text("name: stale\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "rendered templates are out of date"): + check(load_templates(manifest), root) + + def test_rejects_unsafe_paths(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = Path(directory) / "templates.json" + manifest.write_text( + json.dumps( + { + "templates": [ + { + "name": "example", + "source": "../example.yml", + "patches": [], + "destination": "templates/example.yml", + } + ] + } + ), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "unsafe path"): + load_templates(manifest) + + +if __name__ == "__main__": + unittest.main() diff --git a/upstream/templates.json b/upstream/templates.json new file mode 100644 index 0000000..021cc6a --- /dev/null +++ b/upstream/templates.json @@ -0,0 +1,12 @@ +{ + "templates": [ + { + "name": "nextcloud-appstore-build-publish", + "source": "upstream/vendor/nextcloud/appstore-build-publish.yml", + "patches": [ + "patches/nextcloud/appstore-build-publish.yml.patch" + ], + "destination": "templates/nextcloud/appstore-build-publish.yml" + } + ] +} From 2865eaa136b0d7e09c3d22bddf9e9a01cfb0754b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:32:26 -0300 Subject: [PATCH 02/21] feat: report all upstream patch failures --- scripts/render_upstream.py | 84 ++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/scripts/render_upstream.py b/scripts/render_upstream.py index 48f3e59..8f0251a 100644 --- a/scripts/render_upstream.py +++ b/scripts/render_upstream.py @@ -65,7 +65,7 @@ def load_templates(manifest_path: Path) -> list[Template]: def render(template: Template, root: Path) -> bytes: source = _safe_path(root, template.source) if not source.is_file(): - raise ValueError(f"{template.name}: source does not exist: {template.source}") + raise ValueError(f"source does not exist: {template.source}") with tempfile.TemporaryDirectory() as directory: working = Path(directory) / source.name @@ -74,7 +74,7 @@ def render(template: Template, root: Path) -> bytes: for patch_path in template.patches: patch = _safe_path(root, patch_path) if not patch.is_file(): - raise ValueError(f"{template.name}: patch does not exist: {patch_path}") + raise ValueError(f"patch does not exist: {patch_path}") result = subprocess.run( ["patch", "--batch", "--forward", str(working), str(patch)], @@ -84,31 +84,74 @@ def render(template: Template, root: Path) -> bytes: ) if result.returncode != 0: details = (result.stderr or result.stdout).strip() - raise ValueError( - f"{template.name}: failed to apply {patch_path}: {details}" - ) + raise ValueError(f"failed to apply {patch_path}: {details}") return working.read_bytes() -def sync(templates: list[Template], root: Path) -> None: +def sync(templates: list[Template], root: Path) -> dict[str, object]: + results: list[dict[str, object]] = [] + for template in templates: - content = render(template, root) destination = _safe_path(root, template.destination) - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_bytes(content) + try: + content = render(template, root) + previous = destination.read_bytes() if destination.is_file() else None + changed = previous != content + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + results.append( + { + "name": template.name, + "status": "updated" if changed else "unchanged", + "destination": str(template.destination), + "patches": [str(path) for path in template.patches], + } + ) + except ValueError as error: + results.append( + { + "name": template.name, + "status": "failed", + "destination": str(template.destination), + "patches": [str(path) for path in template.patches], + "error": str(error), + } + ) + + counts = { + status: sum(1 for item in results if item["status"] == status) + for status in ("updated", "unchanged", "failed") + } + return { + "ok": counts["failed"] == 0, + **counts, + "templates": results, + } def check(templates: list[Template], root: Path) -> None: - drift: list[str] = [] + problems: list[str] = [] for template in templates: - expected = render(template, root) + try: + expected = render(template, root) + except ValueError as error: + problems.append(f"{template.name}: {error}") + continue + destination = _safe_path(root, template.destination) if not destination.is_file() or destination.read_bytes() != expected: - drift.append(template.name) + problems.append(f"{template.name}: rendered template is out of date") + + if problems: + raise ValueError("; ".join(problems)) + - if drift: - raise ValueError("rendered templates are out of date: " + ", ".join(drift)) +def write_report(report: dict[str, object], path: Path) -> None: + path.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) def _validate_relative_path(path: Path) -> None: @@ -135,6 +178,7 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("command", choices=("sync", "check")) parser.add_argument("manifest", type=Path) + parser.add_argument("--report", type=Path) args = parser.parse_args() root = Path.cwd() @@ -142,10 +186,14 @@ def main() -> int: try: templates = load_templates(args.manifest) if args.command == "sync": - sync(templates, root) - else: - check(templates, root) - except ValueError as error: + report = sync(templates, root) + if args.report: + write_report(report, args.report) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["ok"] else 1 + + check(templates, root) + except (OSError, ValueError) as error: parser.error(str(error)) return 0 From 0576322dab6b8c1c480991eb9ca7da1f279b4157 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:32:38 -0300 Subject: [PATCH 03/21] test: cover partial upstream patch failures --- tests/test_render_upstream.py | 83 +++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/tests/test_render_upstream.py b/tests/test_render_upstream.py index b70f867..8b79dbc 100644 --- a/tests/test_render_upstream.py +++ b/tests/test_render_upstream.py @@ -6,7 +6,7 @@ import unittest from pathlib import Path -from scripts.render_upstream import check, load_templates, sync +from scripts.render_upstream import check, load_templates, sync, write_report class RenderUpstreamTest(unittest.TestCase): @@ -35,6 +35,7 @@ def fixture(self, directory: str) -> tuple[Path, Path]: ) manifest = root / "upstream/templates.json" + manifest.parent.mkdir(parents=True, exist_ok=True) manifest.write_text( json.dumps( { @@ -55,9 +56,85 @@ def fixture(self, directory: str) -> tuple[Path, Path]: def test_sync_applies_patch(self) -> None: with tempfile.TemporaryDirectory() as directory: root, manifest = self.fixture(directory) - sync(load_templates(manifest), root) + report = sync(load_templates(manifest), root) rendered = (root / "templates/example.yml").read_text(encoding="utf-8") self.assertIn("name: Patched example", rendered) + self.assertTrue(report["ok"]) + self.assertEqual(report["updated"], 1) + self.assertEqual(report["failed"], 0) + + def test_sync_reports_failure_and_continues(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root, manifest = self.fixture(directory) + + good_source = root / "upstream/vendor/good.yml" + good_source.write_text("name: Good\n", encoding="utf-8") + + broken_source = root / "upstream/vendor/broken.yml" + broken_source.write_text("name: Changed upstream\n", encoding="utf-8") + broken_patch = root / "patches/broken.yml.patch" + broken_patch.write_text( + "--- broken.yml\n" + "+++ broken.yml\n" + "@@ -1 +1 @@\n" + "-name: Old upstream\n" + "+name: Patched\n", + encoding="utf-8", + ) + + manifest.write_text( + json.dumps( + { + "templates": [ + { + "name": "good", + "source": "upstream/vendor/good.yml", + "patches": [], + "destination": "templates/good.yml", + }, + { + "name": "broken", + "source": "upstream/vendor/broken.yml", + "patches": ["patches/broken.yml.patch"], + "destination": "templates/broken.yml", + }, + ] + } + ), + encoding="utf-8", + ) + + report = sync(load_templates(manifest), root) + + self.assertFalse(report["ok"]) + self.assertEqual(report["updated"], 1) + self.assertEqual(report["failed"], 1) + self.assertEqual( + (root / "templates/good.yml").read_text(encoding="utf-8"), + "name: Good\n", + ) + self.assertFalse((root / "templates/broken.yml").exists()) + failed = next( + item for item in report["templates"] if item["status"] == "failed" + ) + self.assertEqual(failed["name"], "broken") + self.assertIn("failed to apply", failed["error"]) + + def test_write_report(self) -> None: + with tempfile.TemporaryDirectory() as directory: + report_path = Path(directory) / "report.json" + report = { + "ok": False, + "updated": 1, + "unchanged": 0, + "failed": 1, + "templates": [], + } + write_report(report, report_path) + self.assertEqual( + json.loads(report_path.read_text(encoding="utf-8")), + report, + ) def test_check_detects_rendered_drift(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -66,7 +143,7 @@ def test_check_detects_rendered_drift(self) -> None: destination.parent.mkdir(parents=True) destination.write_text("name: stale\n", encoding="utf-8") - with self.assertRaisesRegex(ValueError, "rendered templates are out of date"): + with self.assertRaisesRegex(ValueError, "rendered template is out of date"): check(load_templates(manifest), root) def test_rejects_unsafe_paths(self) -> None: From 3447d4eec6b8a704b927d949e7832be6be4245d6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:33:05 -0300 Subject: [PATCH 04/21] ci: report upstream patch failures in refresh PR --- .github/workflows/refresh-upstream.yml | 101 ++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 10 deletions(-) diff --git a/.github/workflows/refresh-upstream.yml b/.github/workflows/refresh-upstream.yml index 5096664..a3b1168 100644 --- a/.github/workflows/refresh-upstream.yml +++ b/.github/workflows/refresh-upstream.yml @@ -30,12 +30,90 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: python3 scripts/sync_upstream.py refresh upstream/sources.json - - name: Verify refreshed sources + - name: Verify refreshed immutable sources + run: python3 scripts/sync_upstream.py check upstream/sources.json + + - name: Apply downstream patches + id: render + continue-on-error: true + run: | + python3 scripts/render_upstream.py sync upstream/templates.json \ + --report render-report.json + + - name: Run unit tests + if: always() + run: python3 -m unittest discover -s tests -p 'test_*.py' + + - name: Build pull request report + if: always() run: | - python3 scripts/sync_upstream.py check upstream/sources.json - python3 -m unittest discover -s tests -p 'test_*.py' + python3 - <<'PY' + import json + from pathlib import Path + + report_path = Path("render-report.json") + body_path = Path("refresh-upstream-pr.md") + + lines = [ + "Automated refresh of tracked upstream workflow sources.", + "", + "The source URLs in this change are pinned to immutable commit SHAs " + "and SHA-256 hashes.", + "", + ] + + if not report_path.is_file(): + lines.extend([ + "## Patch status", + "", + "No patch report was produced. Review the workflow run before merging.", + ]) + else: + report = json.loads(report_path.read_text(encoding="utf-8")) + lines.extend([ + "## Patch status", + "", + f"- Updated templates: {report['updated']}", + f"- Unchanged templates: {report['unchanged']}", + f"- Failed templates: {report['failed']}", + "", + ]) + + for item in report["templates"]: + status = item["status"] + icon = {"updated": "✅", "unchanged": "➖", "failed": "❌"}[status] + lines.append(f"### {icon} {item['name']} — {status}") + lines.append("") + lines.append(f"Destination: {item['destination']}") + if item["patches"]: + lines.append("") + lines.append("Patches:") + for patch in item["patches"]: + lines.append(f"- {patch}") + if status == "failed": + lines.extend([ + "", + "Patch application failed:", + "", + "~~~text", + str(item["error"]), + "~~~", + "", + "The vendored upstream source was updated, but the generated " + "template was left unchanged and needs manual patch adjustment.", + ]) + lines.append("") + + lines.extend([ + "Review upstream changes and downstream patches before merging.", + "", + ]) + body_path.write_text("\n".join(lines), encoding="utf-8") + PY - name: Create update pull request + id: pull-request + if: always() uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.WORKFLOW_UPDATE_TOKEN }} @@ -45,14 +123,17 @@ jobs: signoff: true branch: automated/refresh-upstream-workflows delete-branch: true - title: 'chore: refresh upstream workflow pins' - body: | - Automated refresh of tracked upstream workflow sources. - - The committed source URLs remain pinned to immutable commit SHAs and - SHA-256 hashes. Review upstream changes and any downstream patches - before merging. + title: 'chore: refresh upstream workflows' + body-path: refresh-upstream-pr.md + draft: ${{ steps.render.outcome == 'failure' }} labels: dependencies add-paths: | upstream/sources.json upstream/vendor/** + templates/** + + - name: Fail when patches need manual updates + if: steps.render.outcome == 'failure' + run: | + echo "One or more downstream patches could not be applied." + exit 1 From 8a7a4cbb09b9703b7c8f5a5d069876929dd1890d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:33:17 -0300 Subject: [PATCH 05/21] fix: avoid partial upstream refresh pull requests --- .github/workflows/refresh-upstream.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/refresh-upstream.yml b/.github/workflows/refresh-upstream.yml index a3b1168..810c506 100644 --- a/.github/workflows/refresh-upstream.yml +++ b/.github/workflows/refresh-upstream.yml @@ -26,6 +26,7 @@ jobs: persist-credentials: false - name: Refresh upstream pins + id: refresh env: GITHUB_TOKEN: ${{ github.token }} run: python3 scripts/sync_upstream.py refresh upstream/sources.json @@ -45,7 +46,7 @@ jobs: run: python3 -m unittest discover -s tests -p 'test_*.py' - name: Build pull request report - if: always() + if: steps.refresh.outcome == 'success' run: | python3 - <<'PY' import json @@ -113,7 +114,7 @@ jobs: - name: Create update pull request id: pull-request - if: always() + if: steps.refresh.outcome == 'success' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.WORKFLOW_UPDATE_TOKEN }} From e3b19d28bb460aaaa76f0132a8d646a4ba8caf21 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:33:21 -0300 Subject: [PATCH 06/21] chore: license upstream template manifest --- upstream/templates.json.license | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 upstream/templates.json.license diff --git a/upstream/templates.json.license b/upstream/templates.json.license new file mode 100644 index 0000000..1ce4e0c --- /dev/null +++ b/upstream/templates.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +SPDX-License-Identifier: AGPL-3.0-or-later From 97fb19de3158444df84e5ede7a53b6d3a5321439 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:33:31 -0300 Subject: [PATCH 07/21] docs: describe patch-aware upstream refresh --- docs/upstream-workflows.md | 62 +++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/docs/upstream-workflows.md b/docs/upstream-workflows.md index 3bdd886..8f7dbad 100644 --- a/docs/upstream-workflows.md +++ b/docs/upstream-workflows.md @@ -19,23 +19,9 @@ The tracking ref can be mutable. The effective source cannot: after refresh, the manifest is rewritten to a full commit SHA and content hash before the vendored file is accepted. -Example: - -```json -{ - "sources": [ - { - "name": "example", - "repository": "example/project", - "ref": "main", - "path": ".github/workflows/example.yml", - "url": "https://raw.githubusercontent.com/example/project//.github/workflows/example.yml", - "sha256": "<64 lowercase hex characters>", - "destination": "upstream/vendor/example/example.yml" - } - ] -} -``` +Rendered downstream templates are declared separately in +`upstream/templates.json`. Each template points to one vendored source, an +ordered patch list and a generated destination under `templates/`. ## Commands @@ -58,17 +44,37 @@ vendored files: python3 scripts/sync_upstream.py refresh upstream/sources.json ``` -The scheduled `refresh-upstream.yml` workflow runs this refresh weekly, validates -the result and opens a pull request when upstream changed. +Render vendored workflows with downstream patches: -A dedicated `WORKFLOW_UPDATE_TOKEN` secret is required for pull-request creation. -Using only the workflow's `GITHUB_TOKEN` would prevent the resulting pull request -from triggering the normal CI workflows. The refresh itself only uses the -read-only `GITHUB_TOKEN` to resolve public upstream commits. +```bash +python3 scripts/render_upstream.py sync upstream/templates.json +``` + +The renderer processes every declared template. Successful templates are updated. +If one or more patches no longer apply, those templates are left unchanged and +the renderer returns a structured report containing every failure. -Both `sync` and `check` verify the recorded source hash before accepting -content. `refresh` only records bytes fetched from the exact commit it resolved. +## Automated refresh -Patch application is intentionally a separate layer: upstream bytes remain -verbatim under `upstream/vendor/`, while downstream adaptations should be stored -as reviewable patches and rendered into generated templates. +The scheduled `refresh-upstream.yml` workflow: + +1. resolves each tracked upstream workflow to its latest commit; +2. updates the immutable URL, SHA-256 and vendored bytes; +3. verifies the vendored sources; +4. attempts every downstream patch; +5. runs the test suite; +6. opens one pull request containing the upstream and successfully rendered changes. + +If all patches apply, the pull request is normal. If any patch fails, the pull +request is opened as draft and its body lists each failed template, patch path and +error. The generated template for a failed patch remains at its previous known-good +version. The workflow then fails after creating the pull request so the problem is +also visible in Actions. + +Failures while resolving, downloading or verifying upstream sources are treated +as fatal and do not create a partial update pull request. + +A dedicated `WORKFLOW_UPDATE_TOKEN` secret is required for pull-request creation. +Using only the workflow's `GITHUB_TOKEN` would prevent the resulting pull request +from triggering the normal CI workflows. The refresh itself uses the read-only +`GITHUB_TOKEN` to resolve public upstream commits. From aad909e64584a73615a095cbdb0b8204ac41b95f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:05 -0300 Subject: [PATCH 08/21] refactor: publish generated GitHub workflow templates --- upstream/templates.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upstream/templates.json b/upstream/templates.json index 021cc6a..bfca31d 100644 --- a/upstream/templates.json +++ b/upstream/templates.json @@ -6,7 +6,7 @@ "patches": [ "patches/nextcloud/appstore-build-publish.yml.patch" ], - "destination": "templates/nextcloud/appstore-build-publish.yml" + "destination": "workflow-templates/appstore-build-publish.yml" } ] } From 1fc9b08ff5771849a358ba422bb3c2fa23c5917f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:19 -0300 Subject: [PATCH 09/21] refactor: publish appstore workflow as organization template --- workflow-templates/appstore-build-publish.yml | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 workflow-templates/appstore-build-publish.yml diff --git a/workflow-templates/appstore-build-publish.yml b/workflow-templates/appstore-build-publish.yml new file mode 100644 index 0000000..c75464f --- /dev/null +++ b/workflow-templates/appstore-build-publish.yml @@ -0,0 +1,202 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Build and publish app release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build_and_publish: + runs-on: ubuntu-latest + + # Downstream consumers publish from their own repositories. + # Repository policy is enforced by the consumer. + + steps: + - name: Check actor permission + uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v3.0 + with: + require: write + + - name: Set app env + run: | + # Split and keep last + echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + echo "APP_VERSION=${GITHUB_REF##*/}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: ${{ env.APP_NAME }} + + - name: Get app version number + id: app-version + uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + expression: "//info//version/text()" + + - name: Validate app version against tag + run: | + [ "${{ env.APP_VERSION }}" = "v${{ fromJSON(steps.app-version.outputs.result).version }}" ] + + - name: Get appinfo data + id: appinfo + uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + expression: "//info//dependencies//nextcloud/@min-version" + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + # Continue if no package.json + continue-on-error: true + with: + path: ${{ env.APP_NAME }} + fallbackNode: '^24' + fallbackNpm: '^11.3' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + # Skip if no package.json + if: ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + package-manager-cache: false + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + # Skip if no package.json + if: ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Get php version + id: php-versions + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + with: + filename: ${{ env.APP_NAME }}/appinfo/info.xml + + - name: Set up php ${{ steps.php-versions.outputs.php-min }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ steps.php-versions.outputs.php-min }} + coverage: none + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check composer.json + id: check_composer + uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 + with: + files: "${{ env.APP_NAME }}/composer.json" + + - name: Install composer dependencies + if: steps.check_composer.outputs.files_exists == 'true' + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + composer-options: '--no-dev' + working-directory: ${{ env.APP_NAME }} + ignore-cache: 'yes' + + - name: Build ${{ env.APP_NAME }} + # Skip if no package.json + if: ${{ steps.versions.outputs.nodeVersion }} + env: + CYPRESS_INSTALL_BINARY: 0 + run: | + cd ${{ env.APP_NAME }} + npm ci + npm run build --if-present + + - name: Check Krankerl config + id: krankerl + uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 + with: + files: ${{ env.APP_NAME }}/krankerl.toml + + - name: Install Krankerl + if: steps.krankerl.outputs.files_exists == 'true' + run: | + wget https://github.com/ChristophWurst/krankerl/releases/download/v0.14.0/krankerl_0.14.0_amd64.deb + sudo dpkg -i krankerl_0.14.0_amd64.deb + + - name: Package ${{ env.APP_NAME }} ${{ env.APP_VERSION }} with krankerl + if: steps.krankerl.outputs.files_exists == 'true' + run: | + cd ${{ env.APP_NAME }} + krankerl package + + - name: Package ${{ env.APP_NAME }} ${{ env.APP_VERSION }} with makefile + if: steps.krankerl.outputs.files_exists != 'true' + run: | + cd ${{ env.APP_NAME }} + make appstore + + - name: Check server download link for ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + run: | + NCVERSION='${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }}' + DOWNLOAD_URL=$(curl -s "https://updates.nextcloud.com/updater_server/latest?channel=beta&version=$NCVERSION" | jq -r '.downloads.zip[0]') + echo "DOWNLOAD_URL=$DOWNLOAD_URL" >> $GITHUB_ENV + + - name: Download server ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + continue-on-error: true + id: server-download + if: ${{ env.DOWNLOAD_URL != 'null' }} + run: | + echo "Downloading release tarball from $DOWNLOAD_URL" + wget $DOWNLOAD_URL -O nextcloud.zip + unzip nextcloud.zip + + - name: Checkout server master fallback + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: ${{ steps.server-download.outcome != 'success' }} + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + path: nextcloud + + + - name: Sign app + run: | + # Extracting release + cd ${{ env.APP_NAME }}/build/artifacts + tar -xvf ${{ env.APP_NAME }}.tar.gz + cd ../../../ + # Setting up keys + echo '${{ secrets.APP_PRIVATE_KEY }}' > ${{ env.APP_NAME }}.key + wget --quiet "https://github.com/nextcloud/app-certificate-requests/raw/master/${{ env.APP_NAME }}/${{ env.APP_NAME }}.crt" + # Signing + php nextcloud/occ integrity:sign-app --privateKey=../${{ env.APP_NAME }}.key --certificate=../${{ env.APP_NAME }}.crt --path=../${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }} + # Rebuilding archive + cd ${{ env.APP_NAME }}/build/artifacts + tar -zcvf ${{ env.APP_NAME }}.tar.gz ${{ env.APP_NAME }} + + - name: Attach tarball to github release + uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # 2.11.5 + id: attach_to_release + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }}.tar.gz + asset_name: ${{ env.APP_NAME }}-${{ env.APP_VERSION }}.tar.gz + tag: ${{ github.ref }} + overwrite: true + + - name: Upload app to Nextcloud appstore + uses: nextcloud-libraries/nextcloud-appstore-push-action@a011fe619bcf6e77ddebc96f9908e1af4071b9c1 # v1.0.3 + with: + app_name: ${{ env.APP_NAME }} + appstore_token: ${{ secrets.APPSTORE_TOKEN }} + download_url: ${{ steps.attach_to_release.outputs.browser_download_url }} + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} From edaa74620fe1524ec1a4b554923d51feede28585 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:22 -0300 Subject: [PATCH 10/21] feat: add appstore workflow template metadata --- workflow-templates/appstore-build-publish.properties.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 workflow-templates/appstore-build-publish.properties.json diff --git a/workflow-templates/appstore-build-publish.properties.json b/workflow-templates/appstore-build-publish.properties.json new file mode 100644 index 0000000..462231e --- /dev/null +++ b/workflow-templates/appstore-build-publish.properties.json @@ -0,0 +1,5 @@ +{ + "name": "Build, sign and publish Nextcloud app", + "description": "Build a Nextcloud app release, sign the package, attach it to the GitHub release and publish it to the App Store.", + "iconName": "octicon package" +} From f4649dea68ffc49e1f7e9de8599f2301eac3b9bb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:24 -0300 Subject: [PATCH 11/21] chore: license workflow template metadata --- .../appstore-build-publish.properties.json.license | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 workflow-templates/appstore-build-publish.properties.json.license diff --git a/workflow-templates/appstore-build-publish.properties.json.license b/workflow-templates/appstore-build-publish.properties.json.license new file mode 100644 index 0000000..1ce4e0c --- /dev/null +++ b/workflow-templates/appstore-build-publish.properties.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +SPDX-License-Identifier: AGPL-3.0-or-later From 40c12f8c18d9e5e859f330035c381dbd9e728b96 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:26 -0300 Subject: [PATCH 12/21] refactor: remove nonstandard generated template path --- .../nextcloud/appstore-build-publish.yml | 202 ------------------ 1 file changed, 202 deletions(-) delete mode 100644 templates/nextcloud/appstore-build-publish.yml diff --git a/templates/nextcloud/appstore-build-publish.yml b/templates/nextcloud/appstore-build-publish.yml deleted file mode 100644 index c75464f..0000000 --- a/templates/nextcloud/appstore-build-publish.yml +++ /dev/null @@ -1,202 +0,0 @@ -# This workflow is provided via the organization template repository -# -# https://github.com/nextcloud/.github -# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization -# -# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT - -name: Build and publish app release - -on: - release: - types: [published] - -permissions: - contents: write - -jobs: - build_and_publish: - runs-on: ubuntu-latest - - # Downstream consumers publish from their own repositories. - # Repository policy is enforced by the consumer. - - steps: - - name: Check actor permission - uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v3.0 - with: - require: write - - - name: Set app env - run: | - # Split and keep last - echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV - echo "APP_VERSION=${GITHUB_REF##*/}" >> $GITHUB_ENV - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - path: ${{ env.APP_NAME }} - - - name: Get app version number - id: app-version - uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 - with: - filename: ${{ env.APP_NAME }}/appinfo/info.xml - expression: "//info//version/text()" - - - name: Validate app version against tag - run: | - [ "${{ env.APP_VERSION }}" = "v${{ fromJSON(steps.app-version.outputs.result).version }}" ] - - - name: Get appinfo data - id: appinfo - uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 - with: - filename: ${{ env.APP_NAME }}/appinfo/info.xml - expression: "//info//dependencies//nextcloud/@min-version" - - - name: Read package.json node and npm engines version - uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 - id: versions - # Continue if no package.json - continue-on-error: true - with: - path: ${{ env.APP_NAME }} - fallbackNode: '^24' - fallbackNpm: '^11.3' - - - name: Set up node ${{ steps.versions.outputs.nodeVersion }} - # Skip if no package.json - if: ${{ steps.versions.outputs.nodeVersion }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: ${{ steps.versions.outputs.nodeVersion }} - package-manager-cache: false - - - name: Set up npm ${{ steps.versions.outputs.npmVersion }} - # Skip if no package.json - if: ${{ steps.versions.outputs.npmVersion }} - run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' - - - name: Get php version - id: php-versions - uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 - with: - filename: ${{ env.APP_NAME }}/appinfo/info.xml - - - name: Set up php ${{ steps.php-versions.outputs.php-min }} - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 - with: - php-version: ${{ steps.php-versions.outputs.php-min }} - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Check composer.json - id: check_composer - uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 - with: - files: "${{ env.APP_NAME }}/composer.json" - - - name: Install composer dependencies - if: steps.check_composer.outputs.files_exists == 'true' - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - with: - composer-options: '--no-dev' - working-directory: ${{ env.APP_NAME }} - ignore-cache: 'yes' - - - name: Build ${{ env.APP_NAME }} - # Skip if no package.json - if: ${{ steps.versions.outputs.nodeVersion }} - env: - CYPRESS_INSTALL_BINARY: 0 - run: | - cd ${{ env.APP_NAME }} - npm ci - npm run build --if-present - - - name: Check Krankerl config - id: krankerl - uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 - with: - files: ${{ env.APP_NAME }}/krankerl.toml - - - name: Install Krankerl - if: steps.krankerl.outputs.files_exists == 'true' - run: | - wget https://github.com/ChristophWurst/krankerl/releases/download/v0.14.0/krankerl_0.14.0_amd64.deb - sudo dpkg -i krankerl_0.14.0_amd64.deb - - - name: Package ${{ env.APP_NAME }} ${{ env.APP_VERSION }} with krankerl - if: steps.krankerl.outputs.files_exists == 'true' - run: | - cd ${{ env.APP_NAME }} - krankerl package - - - name: Package ${{ env.APP_NAME }} ${{ env.APP_VERSION }} with makefile - if: steps.krankerl.outputs.files_exists != 'true' - run: | - cd ${{ env.APP_NAME }} - make appstore - - - name: Check server download link for ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} - run: | - NCVERSION='${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }}' - DOWNLOAD_URL=$(curl -s "https://updates.nextcloud.com/updater_server/latest?channel=beta&version=$NCVERSION" | jq -r '.downloads.zip[0]') - echo "DOWNLOAD_URL=$DOWNLOAD_URL" >> $GITHUB_ENV - - - name: Download server ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} - continue-on-error: true - id: server-download - if: ${{ env.DOWNLOAD_URL != 'null' }} - run: | - echo "Downloading release tarball from $DOWNLOAD_URL" - wget $DOWNLOAD_URL -O nextcloud.zip - unzip nextcloud.zip - - - name: Checkout server master fallback - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: ${{ steps.server-download.outcome != 'success' }} - with: - persist-credentials: false - submodules: true - repository: nextcloud/server - path: nextcloud - - - - name: Sign app - run: | - # Extracting release - cd ${{ env.APP_NAME }}/build/artifacts - tar -xvf ${{ env.APP_NAME }}.tar.gz - cd ../../../ - # Setting up keys - echo '${{ secrets.APP_PRIVATE_KEY }}' > ${{ env.APP_NAME }}.key - wget --quiet "https://github.com/nextcloud/app-certificate-requests/raw/master/${{ env.APP_NAME }}/${{ env.APP_NAME }}.crt" - # Signing - php nextcloud/occ integrity:sign-app --privateKey=../${{ env.APP_NAME }}.key --certificate=../${{ env.APP_NAME }}.crt --path=../${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }} - # Rebuilding archive - cd ${{ env.APP_NAME }}/build/artifacts - tar -zcvf ${{ env.APP_NAME }}.tar.gz ${{ env.APP_NAME }} - - - name: Attach tarball to github release - uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # 2.11.5 - id: attach_to_release - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }}.tar.gz - asset_name: ${{ env.APP_NAME }}-${{ env.APP_VERSION }}.tar.gz - tag: ${{ github.ref }} - overwrite: true - - - name: Upload app to Nextcloud appstore - uses: nextcloud-libraries/nextcloud-appstore-push-action@a011fe619bcf6e77ddebc96f9908e1af4071b9c1 # v1.0.3 - with: - app_name: ${{ env.APP_NAME }} - appstore_token: ${{ secrets.APPSTORE_TOKEN }} - download_url: ${{ steps.attach_to_release.outputs.browser_download_url }} - app_private_key: ${{ secrets.APP_PRIVATE_KEY }} From 245546c22f538c1d75ebb39eabef5f69b2893229 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:38 -0300 Subject: [PATCH 13/21] refactor: include GitHub workflow templates in refresh PRs --- .github/workflows/refresh-upstream.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/refresh-upstream.yml b/.github/workflows/refresh-upstream.yml index 810c506..782e6aa 100644 --- a/.github/workflows/refresh-upstream.yml +++ b/.github/workflows/refresh-upstream.yml @@ -131,7 +131,7 @@ jobs: add-paths: | upstream/sources.json upstream/vendor/** - templates/** + workflow-templates/** - name: Fail when patches need manual updates if: steps.render.outcome == 'failure' From ec5f68f8b5c09e748fdde8a9d4cbebccb6370916 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:40 -0300 Subject: [PATCH 14/21] docs: use native workflow template destination --- patches/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/README.md b/patches/README.md index c3ebf53..18479fd 100644 --- a/patches/README.md +++ b/patches/README.md @@ -11,7 +11,7 @@ Each rendered template is declared in `upstream/templates.json` with: - an immutable vendored source under `upstream/vendor/`; - zero or more ordered unified-diff patches from this directory; -- a generated destination under `templates/`. +- a generated destination under GitHub's native `workflow-templates/` directory. Render all declared templates with: From 2fe51c8957b6c2959d5bbdfac6a88bf341c3a3f7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:42 -0300 Subject: [PATCH 15/21] docs: describe native GitHub workflow template publishing --- docs/upstream-workflows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upstream-workflows.md b/docs/upstream-workflows.md index 8f7dbad..ac22c42 100644 --- a/docs/upstream-workflows.md +++ b/docs/upstream-workflows.md @@ -21,7 +21,7 @@ file is accepted. Rendered downstream templates are declared separately in `upstream/templates.json`. Each template points to one vendored source, an -ordered patch list and a generated destination under `templates/`. +ordered patch list and a generated workflow under GitHub's native `workflow-templates/` directory. Template metadata (`*.properties.json`) is maintained locally so LibreCode can provide its own names, descriptions, categories and icons without inheriting upstream branding. ## Commands From cd69c003469ff67ab1493a89e6516ca2b14aef7b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:04:47 -0300 Subject: [PATCH 16/21] test: cover native workflow template destinations --- tests/test_render_upstream.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_render_upstream.py b/tests/test_render_upstream.py index 8b79dbc..2b91a84 100644 --- a/tests/test_render_upstream.py +++ b/tests/test_render_upstream.py @@ -44,7 +44,7 @@ def fixture(self, directory: str) -> tuple[Path, Path]: "name": "example", "source": "upstream/vendor/example.yml", "patches": ["patches/example.yml.patch"], - "destination": "templates/example.yml", + "destination": "workflow-templates/example.yml", } ] } @@ -57,7 +57,7 @@ def test_sync_applies_patch(self) -> None: with tempfile.TemporaryDirectory() as directory: root, manifest = self.fixture(directory) report = sync(load_templates(manifest), root) - rendered = (root / "templates/example.yml").read_text(encoding="utf-8") + rendered = (root / "workflow-templates/example.yml").read_text(encoding="utf-8") self.assertIn("name: Patched example", rendered) self.assertTrue(report["ok"]) self.assertEqual(report["updated"], 1) @@ -90,13 +90,13 @@ def test_sync_reports_failure_and_continues(self) -> None: "name": "good", "source": "upstream/vendor/good.yml", "patches": [], - "destination": "templates/good.yml", + "destination": "workflow-templates/good.yml", }, { "name": "broken", "source": "upstream/vendor/broken.yml", "patches": ["patches/broken.yml.patch"], - "destination": "templates/broken.yml", + "destination": "workflow-templates/broken.yml", }, ] } @@ -110,10 +110,10 @@ def test_sync_reports_failure_and_continues(self) -> None: self.assertEqual(report["updated"], 1) self.assertEqual(report["failed"], 1) self.assertEqual( - (root / "templates/good.yml").read_text(encoding="utf-8"), + (root / "workflow-templates/good.yml").read_text(encoding="utf-8"), "name: Good\n", ) - self.assertFalse((root / "templates/broken.yml").exists()) + self.assertFalse((root / "workflow-templates/broken.yml").exists()) failed = next( item for item in report["templates"] if item["status"] == "failed" ) @@ -139,7 +139,7 @@ def test_write_report(self) -> None: def test_check_detects_rendered_drift(self) -> None: with tempfile.TemporaryDirectory() as directory: root, manifest = self.fixture(directory) - destination = root / "templates/example.yml" + destination = root / "workflow-templates/example.yml" destination.parent.mkdir(parents=True) destination.write_text("name: stale\n", encoding="utf-8") @@ -157,7 +157,7 @@ def test_rejects_unsafe_paths(self) -> None: "name": "example", "source": "../example.yml", "patches": [], - "destination": "templates/example.yml", + "destination": "workflow-templates/example.yml", } ] } From bc33da5d30598b389b739c2ef0891aaafccd2716 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:05:14 -0300 Subject: [PATCH 17/21] docs: define source and catalog responsibilities --- docs/architecture.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index e44cd67..40cfb21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,10 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later ## Responsibility boundary -`github-workflows` owns reusable CI and release automation. +`github-workflows` is the source of truth for reusable CI, imported workflow adaptations, generated workflow templates and release automation. -It does not manage repository rulesets. That responsibility belongs to -`LibreCodeCoop/github-governance`. +`LibreCodeCoop/.github` is the organization-facing catalog. Generated workflow templates can be published there so developers can discover them through GitHub's **Actions → New workflow** experience. The catalog is a distribution target, not the editing source. + +Repository rulesets remain the responsibility of `LibreCodeCoop/github-governance`. Consumer repositories own: @@ -34,11 +35,13 @@ hash verification ↓ explicit downstream patches ↓ -generated template +generated `workflow-templates/` artifact ↓ tests + actionlint + zizmor ↓ -versioned release +publish catalog copy to `LibreCodeCoop/.github` + ↓ +versioned release / consumer update ``` The source manifest is authoritative. A network response that does not match the @@ -61,3 +64,12 @@ Release automation is split into two stages: - **apply:** explicit mutation and publication. Credentials remain in the consumer repository or protected environment. + +## Developer experience + +The distribution model has two complementary entry points: + +1. **Discovery / first install:** `LibreCodeCoop/.github/workflow-templates/` provides the GitHub-native template cards, metadata and optional icons. +2. **Ongoing updates:** consumer repositories receive reviewable update pull requests generated from the tested templates in this repository. + +When a workflow can be expressed as a thin caller of a reusable workflow, prefer that model because fixes remain centralized. When GitHub Actions semantics require a full installed workflow, publish the generated workflow template and keep its downstream differences as explicit patches here. From e6bd63b0e8ccbafafe2cc1de43ee83c7b4552f17 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:05:19 -0300 Subject: [PATCH 18/21] docs: describe workflow template catalog publishing --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0feb74b..b6244bd 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,15 @@ reference consumers and upstream sources, not hard-coded engine concepts. ## Repository layout -- `templates/` — generated or maintained reusable workflow templates. +- `workflow-templates/` — generated GitHub-native organization workflow templates ready for catalog publication. - `upstream/` — immutable source manifests. - `patches/` — explicit downstream adaptations. - `scripts/` — deterministic synchronization/check tooling. - `tests/` — tests for synchronization and template behavior. - `docs/` — architecture, adoption and security guidance. +`LibreCodeCoop/.github` is the organization catalog used by GitHub's **Actions → New workflow** UI. This repository remains the source of truth; catalog publication should mirror generated templates rather than make `.github` a second editing source. + ## Development Run: From 546e1e44657a7a1be065ec32bbb93888371551bc Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:09:17 -0300 Subject: [PATCH 19/21] refactor: move refresh PR rendering into tested Python --- scripts/render_upstream.py | 82 +++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/scripts/render_upstream.py b/scripts/render_upstream.py index 8f0251a..60e1bbc 100644 --- a/scripts/render_upstream.py +++ b/scripts/render_upstream.py @@ -154,6 +154,74 @@ def write_report(report: dict[str, object], path: Path) -> None: ) +def render_pull_request_body(report: dict[str, object] | None) -> str: + lines = [ + "Automated refresh of tracked upstream workflow sources.", + "", + "The source URLs in this change are pinned to immutable commit SHAs " + "and SHA-256 hashes.", + "", + "## Patch status", + "", + ] + + if report is None: + lines.append("No patch report was produced. Review the workflow run before merging.") + else: + lines.extend( + [ + f"- Updated templates: {report['updated']}", + f"- Unchanged templates: {report['unchanged']}", + f"- Failed templates: {report['failed']}", + "", + ] + ) + + for item in report["templates"]: + status = item["status"] + icon = {"updated": "✅", "unchanged": "➖", "failed": "❌"}[status] + lines.append(f"### {icon} {item['name']} — {status}") + lines.append("") + lines.append(f"Destination: {item['destination']}") + + patches = item["patches"] + if patches: + lines.extend(["", "Patches:"]) + lines.extend(f"- {patch}" for patch in patches) + + if status == "failed": + lines.extend( + [ + "", + "Patch application failed:", + "", + "~~~text", + str(item["error"]), + "~~~", + "", + "The vendored upstream source was updated, but the generated " + "template was left unchanged and needs manual patch adjustment.", + ] + ) + + lines.append("") + + lines.extend( + [ + "Review upstream changes and downstream patches before merging.", + "", + ] + ) + return "\n".join(lines) + + +def write_pull_request_body(report_path: Path, output_path: Path) -> None: + report = None + if report_path.is_file(): + report = json.loads(report_path.read_text(encoding="utf-8")) + output_path.write_text(render_pull_request_body(report), encoding="utf-8") + + def _validate_relative_path(path: Path) -> None: if path.is_absolute() or ".." in path.parts: raise ValueError(f"unsafe path: {path}") @@ -176,14 +244,24 @@ def _non_empty_string(value: object, path: str) -> str: def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("command", choices=("sync", "check")) - parser.add_argument("manifest", type=Path) + parser.add_argument("command", choices=("sync", "check", "pr-body")) + parser.add_argument("manifest", type=Path, nargs="?") parser.add_argument("--report", type=Path) + parser.add_argument("--output", type=Path) args = parser.parse_args() root = Path.cwd() try: + if args.command == "pr-body": + if args.report is None or args.output is None: + parser.error("pr-body requires --report and --output") + write_pull_request_body(args.report, args.output) + return 0 + + if args.manifest is None: + parser.error(f"{args.command} requires a manifest") + templates = load_templates(args.manifest) if args.command == "sync": report = sync(templates, root) From c84bce3f937b0b3368c7d768bfee7da6c3b0348c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:09:25 -0300 Subject: [PATCH 20/21] refactor: keep refresh workflow orchestration-only --- .github/workflows/refresh-upstream.yml | 68 ++------------------------ 1 file changed, 4 insertions(+), 64 deletions(-) diff --git a/.github/workflows/refresh-upstream.yml b/.github/workflows/refresh-upstream.yml index 782e6aa..065a864 100644 --- a/.github/workflows/refresh-upstream.yml +++ b/.github/workflows/refresh-upstream.yml @@ -47,70 +47,10 @@ jobs: - name: Build pull request report if: steps.refresh.outcome == 'success' - run: | - python3 - <<'PY' - import json - from pathlib import Path - - report_path = Path("render-report.json") - body_path = Path("refresh-upstream-pr.md") - - lines = [ - "Automated refresh of tracked upstream workflow sources.", - "", - "The source URLs in this change are pinned to immutable commit SHAs " - "and SHA-256 hashes.", - "", - ] - - if not report_path.is_file(): - lines.extend([ - "## Patch status", - "", - "No patch report was produced. Review the workflow run before merging.", - ]) - else: - report = json.loads(report_path.read_text(encoding="utf-8")) - lines.extend([ - "## Patch status", - "", - f"- Updated templates: {report['updated']}", - f"- Unchanged templates: {report['unchanged']}", - f"- Failed templates: {report['failed']}", - "", - ]) - - for item in report["templates"]: - status = item["status"] - icon = {"updated": "✅", "unchanged": "➖", "failed": "❌"}[status] - lines.append(f"### {icon} {item['name']} — {status}") - lines.append("") - lines.append(f"Destination: {item['destination']}") - if item["patches"]: - lines.append("") - lines.append("Patches:") - for patch in item["patches"]: - lines.append(f"- {patch}") - if status == "failed": - lines.extend([ - "", - "Patch application failed:", - "", - "~~~text", - str(item["error"]), - "~~~", - "", - "The vendored upstream source was updated, but the generated " - "template was left unchanged and needs manual patch adjustment.", - ]) - lines.append("") - - lines.extend([ - "Review upstream changes and downstream patches before merging.", - "", - ]) - body_path.write_text("\n".join(lines), encoding="utf-8") - PY + run: >- + python3 scripts/render_upstream.py pr-body + --report render-report.json + --output refresh-upstream-pr.md - name: Create update pull request id: pull-request From edd9abfa277ee38bc912cde0e0e8a90f36de8188 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:09:32 -0300 Subject: [PATCH 21/21] test: cover refresh pull request report rendering --- tests/test_render_upstream.py | 74 ++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/test_render_upstream.py b/tests/test_render_upstream.py index 2b91a84..6f017b5 100644 --- a/tests/test_render_upstream.py +++ b/tests/test_render_upstream.py @@ -6,7 +6,14 @@ import unittest from pathlib import Path -from scripts.render_upstream import check, load_templates, sync, write_report +from scripts.render_upstream import ( + check, + load_templates, + render_pull_request_body, + sync, + write_pull_request_body, + write_report, +) class RenderUpstreamTest(unittest.TestCase): @@ -168,6 +175,71 @@ def test_rejects_unsafe_paths(self) -> None: with self.assertRaisesRegex(ValueError, "unsafe path"): load_templates(manifest) + def test_render_pull_request_body_for_successful_report(self) -> None: + body = render_pull_request_body( + { + "ok": True, + "updated": 1, + "unchanged": 1, + "failed": 0, + "templates": [ + { + "name": "updated", + "status": "updated", + "destination": "workflow-templates/updated.yml", + "patches": ["patches/updated.patch"], + }, + { + "name": "unchanged", + "status": "unchanged", + "destination": "workflow-templates/unchanged.yml", + "patches": [], + }, + ], + } + ) + + self.assertIn("Updated templates: 1", body) + self.assertIn("✅ updated — updated", body) + self.assertIn("➖ unchanged — unchanged", body) + self.assertIn("- patches/updated.patch", body) + + def test_render_pull_request_body_for_failed_patch(self) -> None: + body = render_pull_request_body( + { + "ok": False, + "updated": 0, + "unchanged": 0, + "failed": 1, + "templates": [ + { + "name": "broken", + "status": "failed", + "destination": "workflow-templates/broken.yml", + "patches": ["patches/broken.patch"], + "error": "failed to apply patches/broken.patch", + } + ], + } + ) + + self.assertIn("Failed templates: 1", body) + self.assertIn("❌ broken — failed", body) + self.assertIn("failed to apply patches/broken.patch", body) + self.assertIn("left unchanged", body) + + def test_write_pull_request_body_without_report(self) -> None: + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "missing.json" + output = Path(directory) / "body.md" + + write_pull_request_body(report, output) + + self.assertIn( + "No patch report was produced", + output.read_text(encoding="utf-8"), + ) + if __name__ == "__main__": unittest.main()