Files
aego/tools/ci/mirror_releases.py
sanspie ee235bbf14
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 6m1s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m35s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: success
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
ci: mirror verified Gitea releases to GitHub
2026-11-07 01:12:53 +03:00

332 lines
15 KiB
Python

#!/usr/bin/env python3
"""Mirror published Gitea releases to GitHub without changing git refs."""
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
from urllib.parse import quote, urlsplit
from urllib.request import Request, urlopen
REPOSITORY = "Alexander-D-Karpov/aego"
GITEA = "https://git.akarpov.ru/api/v1/repos/sanspie/aego"
GITEA_HOST = "git.akarpov.ru"
NATIVE_JOBS = {
"windows amd64 native", "darwin arm64 native",
"darwin amd64 native", "linux amd64 native",
}
TAG = re.compile(r"(?:nightly|stable|v[0-9A-Za-z][0-9A-Za-z.+_-]*)\Z")
STABLE = re.compile(r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z")
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
SUM = re.compile(r"([0-9a-fA-F]{64}) [ *]([^\r\n]+)\Z")
MAX_ASSET = 1024 * 1024 * 1024
def gh(*args):
result = subprocess.run(["gh", *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if result.returncode:
raise RuntimeError("GitHub CLI failed: " + " ".join(args[:2]))
return result.stdout
def github(path):
return json.loads(gh("api", "repos/" + REPOSITORY + "/" + path))
def source(path):
req = Request(GITEA + "/" + path, headers={"Accept": "application/json"})
with urlopen(req, timeout=30) as response:
return json.load(response)
def pages(fetch, path, key=None):
items = []
for page in range(1, 21):
data = fetch(path + ("&" if "?" in path else "?") + f"per_page=100&limit=100&page={page}")
batch = data[key] if key else data
if not isinstance(batch, list):
raise ValueError("invalid API page")
items.extend(batch)
if len(batch) < 100:
return items
raise ValueError("too many API pages")
def ci_ready():
source_sha = source("branches/master")["commit"]["id"]
github_sha = github("branches/master")["commit"]["sha"]
if source_sha != github_sha or not re.fullmatch(r"[0-9a-f]{40}", source_sha):
print("Release mirror paused: master refs have not synced")
return False
runs = pages(github, "actions/workflows/github-native.yml/runs?branch=master", "workflow_runs")
runs = [r for r in runs if r.get("head_sha") == source_sha and r.get("event") in ("push", "workflow_dispatch")]
if not runs or runs[0].get("status") != "completed" or runs[0].get("conclusion") != "success":
print("Release mirror paused: current GitHub native CI has not passed")
return False
run = runs[0]
jobs = pages(github, f"actions/runs/{run['id']}/jobs?filter=all", "jobs")
latest = {}
attempt = run.get("run_attempt", 1)
for job in jobs:
name, number = job.get("name"), job.get("run_attempt", 1)
if name in NATIVE_JOBS and isinstance(number, int) and number <= attempt:
if name not in latest or number > latest[name].get("run_attempt", 1):
latest[name] = job
if set(latest) != NATIVE_JOBS or any(j.get("status") != "completed" or j.get("conclusion") != "success" for j in latest.values()):
print("Release mirror paused: not all four native jobs passed")
return False
statuses = source("commits/" + source_sha + "/status").get("statuses", [])
states = {s.get("context"): s.get("status") for s in statuses}
checks = states.get("ci / checks (push)") == "success"
builds = [v for k, v in states.items() if isinstance(k, str) and k.startswith("ci / build (") and k.endswith("(push)")]
if not checks or not builds or any(state != "success" for state in builds):
print("Release mirror paused: Gitea Linux CI has not passed")
return False
return True
def stable_version(release):
match = STABLE.fullmatch(release["tag_name"])
if not match or release.get("prerelease"):
return None
return tuple(int(x) for x in match.groups())
def is_prerelease(release):
return bool(release.get("prerelease")) or stable_version(release) is None
def inventory(release):
assets = release.get("assets", [])
names = set()
entries = []
for a in assets:
name, size, identity = a.get("name"), a.get("size"), a.get("id")
if not isinstance(name, str) or not ASSET.fullmatch(name) or name in names:
raise ValueError("unsafe or duplicate release asset name")
if not isinstance(size, int) or size < 0 or size > MAX_ASSET or not isinstance(identity, int):
raise ValueError("invalid release asset metadata")
names.add(name)
entries.append([name, size, identity])
raw = json.dumps(sorted(entries), separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def marker(release, ref):
return f"<!-- aego-gitea-mirror:v1 tag={release['tag_name']} ref={ref} assets={inventory(release)} -->"
def refs(tag):
escaped = quote(tag, safe="")
src = source("git/refs/tags/" + escaped)
if not isinstance(src, list) or len(src) != 1:
raise ValueError("missing or ambiguous Gitea tag " + tag)
dst = github("git/ref/tags/" + escaped)
if src[0].get("ref") != "refs/tags/" + tag or dst.get("ref") != "refs/tags/" + tag:
raise ValueError("unexpected tag reference: " + tag)
a, b = src[0].get("object", {}), dst.get("object", {})
if a.get("sha") != b.get("sha") or a.get("type") != b.get("type") or not re.fullmatch(r"[0-9a-f]{40}", a.get("sha", "")):
raise ValueError("mirrored tag differs: " + tag)
return a["sha"]
def download(asset, dest):
url = asset.get("browser_download_url", "")
parsed = urlsplit(url)
if (parsed.scheme != "https" or parsed.netloc != GITEA_HOST or parsed.username or parsed.password
or parsed.query or parsed.fragment or not parsed.path.startswith("/sanspie/aego/releases/download/")):
raise ValueError("unsafe Gitea asset URL")
total = 0
digest = hashlib.sha256()
with urlopen(Request(url, headers={"Accept": "application/octet-stream"}), timeout=120) as response, open(dest, "xb") as f:
while True:
part = response.read(1024 * 1024)
if not part:
break
total += len(part)
if total > asset["size"] or total > MAX_ASSET:
raise ValueError("asset is larger than advertised")
digest.update(part)
f.write(part)
if total != asset["size"]:
raise ValueError("asset is shorter than advertised")
return digest.hexdigest()
def checksums(path):
sums = {}
for line in path.read_text(encoding="utf-8").splitlines():
match = SUM.fullmatch(line)
if not match or not ASSET.fullmatch(match[2]) or match[2] in sums:
raise ValueError("invalid SHA256SUMS.txt")
sums[match[2]] = match[1].lower()
return sums
def existing_hash(tag, asset, temp):
digest = asset.get("digest")
if isinstance(digest, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
return digest[7:]
with tempfile.TemporaryDirectory(prefix="github-asset-", dir=temp) as directory:
dest = Path(directory)
gh("release", "download", tag, "--repo", REPOSITORY, "--pattern", asset["name"], "--dir", str(dest))
digest = hashlib.sha256()
with (dest / asset["name"]).open("rb") as f:
for part in iter(lambda: f.read(1024 * 1024), b""):
digest.update(part)
return digest.hexdigest()
def github_release(tag):
# The /releases/tags endpoint only returns published releases; list also
# includes our draft when authenticated with repository write access.
matches = [r for r in pages(github, "releases") if r.get("tag_name") == tag]
if len(matches) != 1:
raise ValueError("missing or ambiguous GitHub release: " + tag)
return matches[0]
def sync_release(release, existing, latest):
tag = release["tag_name"]
if not TAG.fullmatch(tag):
raise ValueError("unsafe release tag")
ref = refs(tag)
expected_marker = marker(release, ref)
prerelease = is_prerelease(release)
if existing and expected_marker not in (existing.get("body") or ""):
# A user-owned release or a changed nightly must never be replaced.
if "<!-- aego-gitea-mirror:v1 " in (existing.get("body") or ""):
raise ValueError("published source snapshot changed; refusing to overwrite " + tag)
if existing.get("draft"):
print("Leaving user-owned draft unchanged:", tag)
return False
if bool(existing.get("prerelease")) != prerelease:
gh("release", "edit", tag, "--repo", REPOSITORY, "--prerelease=" + str(prerelease).lower())
print("Leaving user-owned release and its assets unchanged:", tag)
return True
with tempfile.TemporaryDirectory(prefix="aego-release-mirror-") as directory:
temp = Path(directory)
source_files = temp / "source"
source_files.mkdir()
assets = release.get("assets", [])
current = {a["name"]: a for a in (existing or {}).get("assets", [])}
if len(current) != len((existing or {}).get("assets", [])):
raise ValueError("duplicate GitHub assets")
sums_asset = next((a for a in assets if a["name"] == "SHA256SUMS.txt"), None)
sums = {}
downloaded = {}
if sums_asset:
downloaded[sums_asset["name"]] = download(sums_asset, source_files / sums_asset["name"])
sums = checksums(source_files / sums_asset["name"])
if tag == "stable" and not {"release.json", "release.json.sig"}.issubset({a["name"] for a in assets}):
raise ValueError("stable channel manifest or signature is missing")
if tag != "stable" and (not sums or not set(sums).issubset({a["name"] for a in assets})):
raise ValueError("release assets or checksums are incomplete: " + tag)
to_upload = []
expected_hashes = {}
for asset in assets:
name = asset["name"]
expected = sums.get(name)
if expected is None and name not in ("SHA256SUMS.txt", "release.json", "release.json.sig"):
raise ValueError("asset is absent from SHA256SUMS.txt: " + name)
old = current.get(name)
if old and old.get("size") != asset["size"]:
raise ValueError("GitHub asset has a different size: " + name)
if name not in downloaded and (not old or expected is None):
downloaded[name] = download(asset, source_files / name)
actual = downloaded.get(name, expected)
if expected and actual != expected:
raise ValueError("Gitea asset checksum mismatch: " + name)
expected_hashes[name] = actual
if old:
if existing_hash(tag, old, temp) != actual:
raise ValueError("GitHub asset differs; refusing to replace: " + name)
else:
to_upload.append(str(source_files / name))
if not existing:
notes = temp / "notes.md"
notes.write_text((release.get("body") or "") + "\n\nMirrored from Gitea.\n\n" + expected_marker + "\n", encoding="utf-8")
# The tag already exists. Omitting --target avoids GitHub's
# workflow-scope restriction for historical target commits.
args = ["release", "create", tag, "--repo", REPOSITORY, "--verify-tag", "--draft", "--latest=false", "--title", release.get("name") or tag, "--notes-file", str(notes)]
if prerelease:
args.append("--prerelease")
gh(*args)
if to_upload:
gh("release", "upload", tag, *to_upload, "--repo", REPOSITORY)
uploaded = github_release(tag)
if expected_marker not in (uploaded.get("body") or ""):
raise ValueError("GitHub release changed during upload: " + tag)
if bool(uploaded.get("draft")) != (not existing or bool(existing.get("draft"))):
raise ValueError("GitHub draft state changed during upload: " + tag)
uploaded_assets = {a["name"]: a for a in uploaded.get("assets", [])}
if len(uploaded_assets) != len(uploaded.get("assets", [])):
raise ValueError("duplicate uploaded assets: " + tag)
for asset in assets:
name = asset["name"]
remote = uploaded_assets.get(name)
if (not remote or remote.get("size") != asset["size"] or remote.get("state") != "uploaded"
or existing_hash(tag, remote, temp) != expected_hashes[name]):
raise ValueError("GitHub asset verification failed: " + name)
current_source = source("releases/tags/" + quote(tag, safe=""))
if current_source.get("draft") or inventory(current_source) != inventory(release) or refs(tag) != ref:
raise ValueError("source release changed during upload: " + tag)
if not existing or existing.get("draft"):
if not ci_ready():
raise ValueError("CI or master refs changed during release upload")
gh("release", "edit", tag, "--repo", REPOSITORY, "--verify-tag", "--draft=false", "--prerelease=" + str(prerelease).lower(), "--latest=" + str(tag == latest).lower())
elif bool(existing.get("prerelease")) != prerelease:
gh("release", "edit", tag, "--repo", REPOSITORY, "--prerelease=" + str(prerelease).lower())
print("Mirrored release:", tag)
return True
def main(args):
if args not in ([], ["--apply"]):
raise ValueError("expected no arguments or --apply")
releases = [r for r in pages(source, "releases") if not r.get("draft")]
for release in releases:
if not TAG.fullmatch(release.get("tag_name", "")):
raise ValueError("unsupported Gitea release tag")
inventory(release)
versions = [(stable_version(r), r["tag_name"]) for r in releases if stable_version(r) is not None]
latest = max(versions)[1] if versions else None
print("Gitea releases:", ", ".join(r["tag_name"] for r in releases), "latest:", latest)
if not args:
print("Plan only; no GitHub releases were changed")
return 0
if (os.getenv("GITHUB_SERVER_URL") != "https://github.com" or os.getenv("GITHUB_REPOSITORY") != REPOSITORY
or os.getenv("GITHUB_REF") != "refs/heads/master" or os.getenv("GITHUB_EVENT_NAME") not in ("workflow_run", "schedule", "workflow_dispatch")
or not os.getenv("GH_TOKEN")):
raise ValueError("release writes require the trusted GitHub master workflow")
if not ci_ready():
return 0
existing = {r["tag_name"]: r for r in pages(github, "releases")}
latest_ready = False
failures = []
for release in sorted(releases, key=lambda r: r["tag_name"] == latest):
try:
ready = sync_release(release, existing.get(release["tag_name"]), latest)
except (OSError, ValueError, RuntimeError, KeyError) as exc:
failures.append(release["tag_name"])
print(f"::error::Release {release['tag_name']} not mirrored: {type(exc).__name__}: {exc}", file=sys.stderr)
continue
if release["tag_name"] == latest:
latest_ready = ready
if latest and latest_ready and ci_ready():
gh("release", "edit", latest, "--repo", REPOSITORY, "--verify-tag", "--latest=true")
return 1 if failures else 0
if __name__ == "__main__":
try:
sys.exit(main(sys.argv[1:]))
except (OSError, ValueError, RuntimeError, KeyError, json.JSONDecodeError) as exc:
print("::error::Release mirror failed:", type(exc).__name__, str(exc), file=sys.stderr)
sys.exit(1)