@@ -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