Skip to content

Commit 4d51e0d

Browse files
committed
feat: render patched upstream workflow templates
Signed-off-by: Vitor Mattos <vitor@php.rio>
1 parent 16ed46c commit 4d51e0d

8 files changed

Lines changed: 502 additions & 4 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@ jobs:
2626
- name: Run unit tests
2727
run: python3 -m unittest discover -s tests -p 'test_*.py'
2828

29-
- name: Verify generated templates
29+
- name: Verify vendored upstream sources
3030
run: python3 scripts/sync_upstream.py check upstream/sources.json
31+
32+
- name: Verify rendered upstream templates
33+
run: python3 scripts/render_upstream.py check upstream/templates.json

‎patches/README.md‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ SPDX-License-Identifier: AGPL-3.0-or-later
77

88
This directory contains explicit patches applied to imported upstream workflows.
99

10-
The patch format and application contract will be introduced with the first
11-
upstream workflow import and covered by tests. Generated templates must not hide
12-
manual downstream edits.
10+
Each rendered template is declared in `upstream/templates.json` with:
11+
12+
- an immutable vendored source under `upstream/vendor/`;
13+
- zero or more ordered unified-diff patches from this directory;
14+
- a generated destination under `templates/`.
15+
16+
Render all declared templates with:
17+
18+
```bash
19+
python3 scripts/render_upstream.py sync upstream/templates.json
20+
```
21+
22+
CI runs the corresponding `check` command and fails when a committed generated
23+
template does not match its vendored source plus patches.
24+
25+
Patches should stay minimal. Product-specific behavior belongs in consumer
26+
configuration unless the difference is required by the shared downstream
27+
workflow contract.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
--- appstore-build-publish.yml
2+
+++ appstore-build-publish.yml
3+
@@ -18,8 +18,8 @@ jobs:
4+
build_and_publish:
5+
runs-on: ubuntu-latest
6+
7+
- # Only allowed to be run on nextcloud-releases repositories
8+
- if: ${{ github.repository_owner == 'nextcloud-releases' }}
9+
+ # Downstream consumers publish from their own repositories.
10+
+ # Repository policy is enforced by the consumer.
11+
12+
steps:
13+
- name: Check actor permission
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
2+
SPDX-License-Identifier: AGPL-3.0-or-later

‎scripts/render_upstream.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
from __future__ import annotations
6+
7+
import argparse
8+
import json
9+
import shutil
10+
import subprocess
11+
import tempfile
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
15+
16+
@dataclass(frozen=True)
17+
class Template:
18+
name: str
19+
source: Path
20+
patches: tuple[Path, ...]
21+
destination: Path
22+
23+
24+
def load_templates(manifest_path: Path) -> list[Template]:
25+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
26+
if not isinstance(payload, dict):
27+
raise ValueError("manifest must be a JSON object")
28+
29+
raw_templates = payload.get("templates")
30+
if not isinstance(raw_templates, list):
31+
raise ValueError("manifest.templates must be an array")
32+
33+
templates: list[Template] = []
34+
for index, raw in enumerate(raw_templates):
35+
if not isinstance(raw, dict):
36+
raise ValueError(f"manifest.templates[{index}] must be an object")
37+
38+
name = _non_empty_string(raw.get("name"), f"templates[{index}].name")
39+
source = Path(_non_empty_string(raw.get("source"), f"templates[{index}].source"))
40+
destination = Path(
41+
_non_empty_string(raw.get("destination"), f"templates[{index}].destination")
42+
)
43+
44+
raw_patches = raw.get("patches", [])
45+
if not isinstance(raw_patches, list) or not all(
46+
isinstance(item, str) and item for item in raw_patches
47+
):
48+
raise ValueError(f"templates[{index}].patches must be an array of paths")
49+
50+
for path in (source, destination, *(Path(item) for item in raw_patches)):
51+
_validate_relative_path(path)
52+
53+
templates.append(
54+
Template(
55+
name=name,
56+
source=source,
57+
patches=tuple(Path(item) for item in raw_patches),
58+
destination=destination,
59+
)
60+
)
61+
62+
return templates
63+
64+
65+
def render(template: Template, root: Path) -> bytes:
66+
source = _safe_path(root, template.source)
67+
if not source.is_file():
68+
raise ValueError(f"{template.name}: source does not exist: {template.source}")
69+
70+
with tempfile.TemporaryDirectory() as directory:
71+
working = Path(directory) / source.name
72+
shutil.copyfile(source, working)
73+
74+
for patch_path in template.patches:
75+
patch = _safe_path(root, patch_path)
76+
if not patch.is_file():
77+
raise ValueError(f"{template.name}: patch does not exist: {patch_path}")
78+
79+
result = subprocess.run(
80+
["patch", "--batch", "--forward", str(working), str(patch)],
81+
capture_output=True,
82+
text=True,
83+
check=False,
84+
)
85+
if result.returncode != 0:
86+
details = (result.stderr or result.stdout).strip()
87+
raise ValueError(
88+
f"{template.name}: failed to apply {patch_path}: {details}"
89+
)
90+
91+
return working.read_bytes()
92+
93+
94+
def sync(templates: list[Template], root: Path) -> None:
95+
for template in templates:
96+
content = render(template, root)
97+
destination = _safe_path(root, template.destination)
98+
destination.parent.mkdir(parents=True, exist_ok=True)
99+
destination.write_bytes(content)
100+
101+
102+
def check(templates: list[Template], root: Path) -> None:
103+
drift: list[str] = []
104+
for template in templates:
105+
expected = render(template, root)
106+
destination = _safe_path(root, template.destination)
107+
if not destination.is_file() or destination.read_bytes() != expected:
108+
drift.append(template.name)
109+
110+
if drift:
111+
raise ValueError("rendered templates are out of date: " + ", ".join(drift))
112+
113+
114+
def _validate_relative_path(path: Path) -> None:
115+
if path.is_absolute() or ".." in path.parts:
116+
raise ValueError(f"unsafe path: {path}")
117+
118+
119+
def _safe_path(root: Path, path: Path) -> Path:
120+
_validate_relative_path(path)
121+
resolved = (root / path).resolve()
122+
root_resolved = root.resolve()
123+
if resolved != root_resolved and root_resolved not in resolved.parents:
124+
raise ValueError(f"path escapes repository root: {path}")
125+
return resolved
126+
127+
128+
def _non_empty_string(value: object, path: str) -> str:
129+
if not isinstance(value, str) or not value:
130+
raise ValueError(f"{path} must be a non-empty string")
131+
return value
132+
133+
134+
def main() -> int:
135+
parser = argparse.ArgumentParser()
136+
parser.add_argument("command", choices=("sync", "check"))
137+
parser.add_argument("manifest", type=Path)
138+
args = parser.parse_args()
139+
140+
root = Path.cwd()
141+
142+
try:
143+
templates = load_templates(args.manifest)
144+
if args.command == "sync":
145+
sync(templates, root)
146+
else:
147+
check(templates, root)
148+
except ValueError as error:
149+
parser.error(str(error))
150+
151+
return 0
152+
153+
154+
if __name__ == "__main__":
155+
raise SystemExit(main())

0 commit comments

Comments
 (0)