Skip to content

Commit ea27a30

Browse files
authored
Merge pull request #4 from LibreCodeCoop/feat/automate-upstream-refresh
feat: automate pinned upstream workflow refresh
2 parents aec4f73 + 16ed46c commit ea27a30

5 files changed

Lines changed: 290 additions & 19 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
name: Refresh upstream workflows
5+
6+
on:
7+
workflow_dispatch:
8+
schedule:
9+
- cron: '17 3 * * 0'
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
refresh:
16+
name: Refresh pinned upstream sources
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 10
19+
permissions:
20+
contents: write
21+
pull-requests: write
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
25+
with:
26+
persist-credentials: false
27+
28+
- name: Refresh upstream pins
29+
env:
30+
GITHUB_TOKEN: ${{ github.token }}
31+
run: python3 scripts/sync_upstream.py refresh upstream/sources.json
32+
33+
- name: Verify refreshed sources
34+
run: |
35+
python3 scripts/sync_upstream.py check upstream/sources.json
36+
python3 -m unittest discover -s tests -p 'test_*.py'
37+
38+
- name: Create update pull request
39+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
40+
with:
41+
token: ${{ secrets.WORKFLOW_UPDATE_TOKEN }}
42+
commit-message: 'chore: refresh upstream workflow pins'
43+
committer: GitHub <noreply@github.com>
44+
author: github-workflows bot <noreply@github.com>
45+
signoff: true
46+
branch: automated/refresh-upstream-workflows
47+
delete-branch: true
48+
title: 'chore: refresh upstream workflow pins'
49+
body: |
50+
Automated refresh of tracked upstream workflow sources.
51+
52+
The committed source URLs remain pinned to immutable commit SHAs and
53+
SHA-256 hashes. Review upstream changes and any downstream patches
54+
before merging.
55+
labels: dependencies
56+
add-paths: |
57+
upstream/sources.json
58+
upstream/vendor/**

‎docs/upstream-workflows.md‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@ Upstream files are declared in `upstream/sources.json`.
1010
Each entry contains:
1111

1212
- `name`: stable local identifier;
13+
- `repository`, `ref` and `path`: optional tracking metadata used only to discover newer upstream revisions;
1314
- `url`: raw file URL pinned to an immutable upstream commit;
1415
- `sha256`: expected SHA-256 of the downloaded bytes;
15-
- `destination`: repository-relative generated destination.
16+
- `destination`: repository-relative vendored destination.
17+
18+
The tracking ref can be mutable. The effective source cannot: after refresh, the
19+
manifest is rewritten to a full commit SHA and content hash before the vendored
20+
file is accepted.
1621

1722
Example:
1823

@@ -21,30 +26,49 @@ Example:
2126
"sources": [
2227
{
2328
"name": "example",
29+
"repository": "example/project",
30+
"ref": "main",
31+
"path": ".github/workflows/example.yml",
2432
"url": "https://raw.githubusercontent.com/example/project/<commit>/.github/workflows/example.yml",
2533
"sha256": "<64 lowercase hex characters>",
26-
"destination": "templates/example.yml"
34+
"destination": "upstream/vendor/example/example.yml"
2735
}
2836
]
2937
}
3038
```
3139

3240
## Commands
3341

34-
Synchronize declared sources:
42+
Synchronize declared immutable sources:
3543

3644
```bash
3745
python3 scripts/sync_upstream.py sync upstream/sources.json
3846
```
3947

40-
Verify committed generated files without modifying them:
48+
Verify committed vendored files without modifying them:
4149

4250
```bash
4351
python3 scripts/sync_upstream.py check upstream/sources.json
4452
```
4553

46-
Both commands verify the source hash before accepting content.
54+
Resolve tracked refs to their latest commit, recompute SHA-256 and update the
55+
vendored files:
56+
57+
```bash
58+
python3 scripts/sync_upstream.py refresh upstream/sources.json
59+
```
60+
61+
The scheduled `refresh-upstream.yml` workflow runs this refresh weekly, validates
62+
the result and opens a pull request when upstream changed.
63+
64+
A dedicated `WORKFLOW_UPDATE_TOKEN` secret is required for pull-request creation.
65+
Using only the workflow's `GITHUB_TOKEN` would prevent the resulting pull request
66+
from triggering the normal CI workflows. The refresh itself only uses the
67+
read-only `GITHUB_TOKEN` to resolve public upstream commits.
68+
69+
Both `sync` and `check` verify the recorded source hash before accepting
70+
content. `refresh` only records bytes fetched from the exact commit it resolved.
4771

48-
Patch application will be introduced with the first real upstream template so
49-
the patch interface is designed against an actual workflow rather than a
50-
hypothetical format.
72+
Patch application is intentionally a separate layer: upstream bytes remain
73+
verbatim under `upstream/vendor/`, while downstream adaptations should be stored
74+
as reviewable patches and rendered into generated templates.

‎scripts/sync_upstream.py‎

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
import argparse
88
import hashlib
99
import json
10+
import os
1011
from dataclasses import dataclass
1112
from pathlib import Path
12-
from urllib.parse import urlparse
13+
from urllib.parse import quote, urlparse
1314
from urllib.request import Request, urlopen
1415

1516

@@ -19,6 +20,9 @@ class Source:
1920
url: str
2021
sha256: str
2122
destination: Path
23+
repository: str | None = None
24+
ref: str | None = None
25+
path: str | None = None
2226

2327

2428
def load_sources(manifest_path: Path) -> list[Source]:
@@ -46,23 +50,34 @@ def load_sources(manifest_path: Path) -> list[Source]:
4650
raise ValueError(f"sources[{index}].sha256 must be 64 lowercase hex characters")
4751
_validate_immutable_url(url, f"sources[{index}].url")
4852

53+
repository = _optional_string(raw.get("repository"), f"sources[{index}].repository")
54+
ref = _optional_string(raw.get("ref"), f"sources[{index}].ref")
55+
path = _optional_string(raw.get("path"), f"sources[{index}].path")
56+
tracking = (repository, ref, path)
57+
if any(value is not None for value in tracking) and not all(
58+
value is not None for value in tracking
59+
):
60+
raise ValueError(
61+
f"sources[{index}] must define repository, ref and path together"
62+
)
63+
4964
sources.append(
5065
Source(
5166
name=name,
5267
url=url,
5368
sha256=digest,
5469
destination=Path(destination),
70+
repository=repository,
71+
ref=ref,
72+
path=path,
5573
)
5674
)
5775

5876
return sources
5977

6078

6179
def fetch(source: Source) -> bytes:
62-
request = Request(source.url, headers={"User-Agent": "github-workflows-sync"})
63-
with urlopen(request, timeout=30) as response:
64-
content = response.read()
65-
80+
content = _download(source.url)
6681
actual = hashlib.sha256(content).hexdigest()
6782
if actual != source.sha256:
6883
raise ValueError(
@@ -91,6 +106,78 @@ def check(sources: list[Source], root: Path) -> None:
91106
raise ValueError("generated templates are out of date: " + ", ".join(drift))
92107

93108

109+
def refresh(manifest_path: Path, root: Path, token: str | None = None) -> None:
110+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
111+
raw_sources = payload.get("sources")
112+
if not isinstance(raw_sources, list):
113+
raise ValueError("manifest.sources must be an array")
114+
115+
# Validate the current manifest before mutating it.
116+
load_sources(manifest_path)
117+
118+
for index, raw in enumerate(raw_sources):
119+
if not isinstance(raw, dict):
120+
raise ValueError(f"manifest.sources[{index}] must be an object")
121+
122+
repository = raw.get("repository")
123+
ref = raw.get("ref")
124+
path = raw.get("path")
125+
if not all(isinstance(value, str) and value for value in (repository, ref, path)):
126+
continue
127+
128+
commit = _latest_commit(repository, ref, path, token)
129+
url = f"https://raw.githubusercontent.com/{repository}/{commit}/{path}"
130+
content = _download(url)
131+
digest = hashlib.sha256(content).hexdigest()
132+
133+
raw["url"] = url
134+
raw["sha256"] = digest
135+
136+
destination = _safe_destination(root, Path(str(raw["destination"])))
137+
destination.parent.mkdir(parents=True, exist_ok=True)
138+
destination.write_bytes(content)
139+
140+
manifest_path.write_text(
141+
json.dumps(payload, indent=2, sort_keys=False) + "\n",
142+
encoding="utf-8",
143+
)
144+
145+
146+
def _latest_commit(repository: str, ref: str, path: str, token: str | None) -> str:
147+
url = (
148+
f"https://api.github.com/repos/{repository}/commits"
149+
f"?sha={quote(ref, safe='')}&path={quote(path, safe='')}&per_page=1"
150+
)
151+
headers = {
152+
"Accept": "application/vnd.github+json",
153+
"User-Agent": "github-workflows-sync",
154+
"X-GitHub-Api-Version": "2022-11-28",
155+
}
156+
if token:
157+
headers["Authorization"] = f"Bearer {token}"
158+
159+
request = Request(url, headers=headers)
160+
with urlopen(request, timeout=30) as response:
161+
payload = json.load(response)
162+
163+
if not isinstance(payload, list) or not payload:
164+
raise ValueError(
165+
f"cannot resolve latest commit for {repository}:{ref}:{path}"
166+
)
167+
commit = payload[0].get("sha")
168+
if not isinstance(commit, str) or len(commit) != 40:
169+
raise ValueError(
170+
f"invalid commit returned for {repository}:{ref}:{path}"
171+
)
172+
return commit
173+
174+
175+
def _download(url: str) -> bytes:
176+
request = Request(url, headers={"User-Agent": "github-workflows-sync"})
177+
with urlopen(request, timeout=30) as response:
178+
return response.read()
179+
180+
94181
def _validate_immutable_url(url: str, path: str) -> None:
95182
parsed = urlparse(url)
96183
if parsed.scheme != "https":
@@ -125,20 +212,35 @@ def _non_empty_string(value: object, path: str) -> str:
125212
return value
126213

127214

215+
def _optional_string(value: object, path: str) -> str | None:
216+
if value is None:
217+
return None
218+
if not isinstance(value, str) or not value:
219+
raise ValueError(f"{path} must be a non-empty string when defined")
220+
return value
221+
222+
128223
def main() -> int:
129224
parser = argparse.ArgumentParser()
130-
parser.add_argument("command", choices=("sync", "check"))
225+
parser.add_argument("command", choices=("sync", "check", "refresh"))
131226
parser.add_argument("manifest", type=Path)
132227
args = parser.parse_args()
133228

134229
root = Path.cwd()
135-
sources = load_sources(args.manifest)
136230

137231
try:
138-
if args.command == "sync":
139-
sync(sources, root)
232+
if args.command == "refresh":
233+
refresh(
234+
args.manifest,
235+
root,
236+
token=os.environ.get("GITHUB_TOKEN"),
237+
)
140238
else:
141-
check(sources, root)
239+
sources = load_sources(args.manifest)
240+
if args.command == "sync":
241+
sync(sources, root)
242+
else:
243+
check(sources, root)
142244
except ValueError as error:
143245
parser.error(str(error))
144246

0 commit comments

Comments
 (0)