Skip to content

Commit 27715f7

Browse files
committed
ci: update fork sync workflow with safety checks
1 parent e7a4d80 commit 27715f7

1 file changed

Lines changed: 191 additions & 0 deletions

File tree

.github/workflows/sync-fork.yml

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,194 @@ jobs:
139139
run: |
140140
set -euo pipefail
141141
gh api repos/${{ github.repository }}/merge-upstream -X POST -f branch=${{ github.event.repository.default_branch }}
142+
143+
- name: Release retention policy
144+
if: steps.upstream.outputs.skip != 'true'
145+
env:
146+
GH_TOKEN: ${{ secrets.SYNC_PAT }}
147+
SELF_USE_RELEASE_REPOS: "sciman-top/cockpit-tools-local"
148+
run: |
149+
set -euo pipefail
150+
python3 - <<'PY'
151+
import hashlib
152+
import json
153+
import os
154+
import pathlib
155+
import re
156+
import shutil
157+
import subprocess
158+
import sys
159+
import tempfile
160+
161+
repo = os.environ["GITHUB_REPOSITORY"]
162+
self_use_repos = {
163+
item.strip().lower()
164+
for item in os.environ.get("SELF_USE_RELEASE_REPOS", "").split(",")
165+
if item.strip()
166+
}
167+
if repo.lower() in self_use_repos:
168+
print("::notice::self-use local releases policy is managed manually; weekly release retention is skipped.")
169+
raise SystemExit(0)
170+
171+
installable_re = re.compile(r"\.(?:exe|msi|deb|rpm|appimage|dmg|pkg|zip|tar\.gz)$", re.IGNORECASE)
172+
signed_re = re.compile(r"\.(?:sig|minisig|sigstore)$", re.IGNORECASE)
173+
174+
def gh_json(args):
175+
cmd = ["gh", *args]
176+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
177+
if result.returncode != 0:
178+
raise RuntimeError(result.stderr.strip() or "gh command failed")
179+
return json.loads(result.stdout)
180+
181+
def gh_run(args):
182+
cmd = ["gh", *args]
183+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
184+
if result.returncode != 0:
185+
raise RuntimeError(result.stderr.strip() or "gh command failed")
186+
187+
def stable(releases):
188+
return [
189+
release for release in releases
190+
if isinstance(release, dict)
191+
and not release.get("draft")
192+
and not release.get("prerelease")
193+
]
194+
195+
def required_asset_names(release):
196+
assets = release.get("assets") or []
197+
names = []
198+
for asset in assets:
199+
name = asset.get("name")
200+
if not isinstance(name, str):
201+
continue
202+
lowered = name.lower()
203+
if (
204+
installable_re.search(name)
205+
or signed_re.search(name)
206+
or lowered in {
207+
"latest.json",
208+
"sha256sums",
209+
"sha256sums.txt",
210+
"sha512sums",
211+
"sha512sums.txt",
212+
}
213+
):
214+
names.append(name)
215+
return names
216+
217+
repo_meta = gh_json(["api", f"repos/{repo}"])
218+
upstream = ((repo_meta.get("parent") or {}).get("full_name") or "").strip()
219+
if not upstream:
220+
print("::notice::Upstream repo metadata is missing; release retention skipped.")
221+
raise SystemExit(0)
222+
223+
upstream_releases = gh_json(["api", f"repos/{upstream}/releases?per_page=100"])
224+
fork_releases = gh_json(["api", f"repos/{repo}/releases?per_page=100"])
225+
upstream_stable = stable(upstream_releases)
226+
if not upstream_stable:
227+
print("::notice::source-only release notes policy: upstream has no stable releases.")
228+
raise SystemExit(0)
229+
230+
latest = upstream_stable[0]
231+
tag = latest.get("tag_name")
232+
if not tag:
233+
print("::notice::Latest stable release has no tag; release retention skipped.")
234+
raise SystemExit(0)
235+
236+
required = required_asset_names(latest)
237+
if not required:
238+
print("::notice::source-only release notes policy: upstream latest stable release has no mirrored assets.")
239+
raise SystemExit(0)
240+
241+
latest_fork = None
242+
for release in fork_releases:
243+
if isinstance(release, dict) and release.get("tag_name") == tag:
244+
latest_fork = release
245+
break
246+
247+
if latest_fork is None:
248+
payload_path = None
249+
try:
250+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
251+
json.dump({
252+
"tag_name": tag,
253+
"target_commitish": os.environ["GITHUB_REF_NAME"],
254+
"name": latest.get("name") or tag,
255+
"body": latest.get("body") or "",
256+
"draft": False,
257+
"prerelease": False,
258+
}, handle, ensure_ascii=False)
259+
payload_path = handle.name
260+
gh_run(["api", f"repos/{repo}/releases", "-X", "POST", "--input", payload_path])
261+
latest_fork = {"tag_name": tag, "assets": []}
262+
finally:
263+
if payload_path and os.path.exists(payload_path):
264+
os.unlink(payload_path)
265+
266+
fork_asset_names = {
267+
asset.get("name")
268+
for asset in (latest_fork.get("assets") or [])
269+
if isinstance(asset, dict) and isinstance(asset.get("name"), str)
270+
}
271+
missing = [name for name in required if name not in fork_asset_names]
272+
273+
if missing:
274+
with tempfile.TemporaryDirectory() as tmpdir:
275+
for asset_name in missing:
276+
gh_run([
277+
"release", "download", tag,
278+
"-R", upstream,
279+
"-D", tmpdir,
280+
"-p", asset_name,
281+
"--clobber",
282+
])
283+
284+
latest_json = pathlib.Path(tmpdir) / "latest.json"
285+
if latest_json.exists():
286+
upstream_prefix = f"https://github.com/{upstream}/releases/download/{tag}/"
287+
fork_prefix = f"https://github.com/{repo}/releases/download/{tag}/"
288+
text = latest_json.read_text(encoding="utf-8")
289+
latest_json.write_text(text.replace(upstream_prefix, fork_prefix), encoding="utf-8")
290+
291+
sha_sums = pathlib.Path(tmpdir) / "SHA256SUMS.txt"
292+
if sha_sums.exists() and latest_json.exists():
293+
latest_hash = hashlib.sha256(latest_json.read_bytes()).hexdigest()
294+
updated = []
295+
for line in sha_sums.read_text(encoding="utf-8").splitlines():
296+
stripped = line.rstrip("\n")
297+
if stripped.strip().endswith("latest.json"):
298+
filename = stripped.split()[-1]
299+
updated.append(f"{latest_hash} {filename}")
300+
else:
301+
updated.append(stripped)
302+
sha_sums.write_text("\n".join(updated) + "\n", encoding="utf-8")
303+
304+
upload_files = [str(pathlib.Path(tmpdir) / asset_name) for asset_name in missing]
305+
gh_run(["release", "upload", tag, *upload_files, "-R", repo])
306+
307+
old_stable = [
308+
release for release in stable(fork_releases)
309+
if release.get("tag_name") != tag
310+
]
311+
for release in old_stable:
312+
old_tag = release.get("tag_name")
313+
if not old_tag:
314+
continue
315+
for asset in release.get("assets") or []:
316+
name = asset.get("name")
317+
if not isinstance(name, str):
318+
continue
319+
lowered = name.lower()
320+
if (
321+
installable_re.search(name)
322+
or signed_re.search(name)
323+
or lowered in {
324+
"latest.json",
325+
"sha256sums",
326+
"sha256sums.txt",
327+
"sha512sums",
328+
"sha512sums.txt",
329+
}
330+
):
331+
gh_run(["release", "delete-asset", old_tag, name, "-R", repo, "--yes"])
332+
PY

0 commit comments

Comments
 (0)