diff --git a/.github/workflows/refresh-upstream.yml b/.github/workflows/refresh-upstream.yml index 5096664..065a864 100644 --- a/.github/workflows/refresh-upstream.yml +++ b/.github/workflows/refresh-upstream.yml @@ -26,16 +26,35 @@ 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 - - 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/sync_upstream.py check upstream/sources.json - python3 -m unittest discover -s tests -p 'test_*.py' + 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: steps.refresh.outcome == 'success' + 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 + if: steps.refresh.outcome == 'success' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.WORKFLOW_UPDATE_TOKEN }} @@ -45,14 +64,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/** + workflow-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 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/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: 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. diff --git a/docs/upstream-workflows.md b/docs/upstream-workflows.md index 3bdd886..ac22c42 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 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 @@ -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. diff --git a/patches/README.md b/patches/README.md index 025a896..18479fd 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 GitHub's native `workflow-templates/` directory. + +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..60e1bbc --- /dev/null +++ b/scripts/render_upstream.py @@ -0,0 +1,281 @@ +#!/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"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"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"failed to apply {patch_path}: {details}") + + return working.read_bytes() + + +def sync(templates: list[Template], root: Path) -> dict[str, object]: + results: list[dict[str, object]] = [] + + for template in templates: + destination = _safe_path(root, template.destination) + 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: + problems: list[str] = [] + for template in templates: + 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: + problems.append(f"{template.name}: rendered template is out of date") + + if problems: + raise ValueError("; ".join(problems)) + + +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 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}") + + +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", "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) + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_render_upstream.py b/tests/test_render_upstream.py new file mode 100644 index 0000000..6f017b5 --- /dev/null +++ b/tests/test_render_upstream.py @@ -0,0 +1,245 @@ +# 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, + render_pull_request_body, + sync, + write_pull_request_body, + write_report, +) + + +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.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + json.dumps( + { + "templates": [ + { + "name": "example", + "source": "upstream/vendor/example.yml", + "patches": ["patches/example.yml.patch"], + "destination": "workflow-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) + report = sync(load_templates(manifest), root) + 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) + 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": "workflow-templates/good.yml", + }, + { + "name": "broken", + "source": "upstream/vendor/broken.yml", + "patches": ["patches/broken.yml.patch"], + "destination": "workflow-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 / "workflow-templates/good.yml").read_text(encoding="utf-8"), + "name: Good\n", + ) + self.assertFalse((root / "workflow-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: + root, manifest = self.fixture(directory) + destination = root / "workflow-templates/example.yml" + destination.parent.mkdir(parents=True) + destination.write_text("name: stale\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "rendered template is 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": "workflow-templates/example.yml", + } + ] + } + ), + encoding="utf-8", + ) + + 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() diff --git a/upstream/templates.json b/upstream/templates.json new file mode 100644 index 0000000..bfca31d --- /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": "workflow-templates/appstore-build-publish.yml" + } + ] +} 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 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" +} 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 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 }}