|
| 1 | +"""Check an approved release without rebuilding it or uploading to CurseForge.""" |
| 2 | + |
| 3 | +import hashlib |
| 4 | +import json |
| 5 | +import os |
| 6 | +from pathlib import Path |
| 7 | +import re |
| 8 | +import subprocess |
| 9 | +import sys |
| 10 | +import time |
| 11 | +from urllib.error import HTTPError |
| 12 | +from urllib.request import Request, urlopen |
| 13 | +from xml.etree import ElementTree |
| 14 | +from zipfile import ZipFile |
| 15 | + |
| 16 | + |
| 17 | +MAVEN = "https://maven.moddev.zone/releases" |
| 18 | +MANIFEST = Path(__file__).with_name("releases.json") |
| 19 | + |
| 20 | + |
| 21 | +def require(condition, message): |
| 22 | + if not condition: |
| 23 | + raise ValueError(message) |
| 24 | + |
| 25 | + |
| 26 | +def digest(data): |
| 27 | + return hashlib.sha256(data).hexdigest() |
| 28 | + |
| 29 | + |
| 30 | +def read_url(url, allow_absent=False): |
| 31 | + try: |
| 32 | + request = Request(url, headers={"User-Agent": "MMD-Maven-Release/1.0"}) |
| 33 | + with urlopen(request, timeout=30) as response: |
| 34 | + require(response.status == 200, f"Unexpected HTTP status for {url}") |
| 35 | + return response.read() |
| 36 | + except HTTPError as error: |
| 37 | + if error.code == 404 and allow_absent: |
| 38 | + return None |
| 39 | + raise |
| 40 | + |
| 41 | + |
| 42 | +def github(path): |
| 43 | + return subprocess.check_output(["gh", "api", path]) |
| 44 | + |
| 45 | + |
| 46 | +def stage(manifest, version, release, work): |
| 47 | + repo = manifest["repository"] |
| 48 | + require(os.environ.get("GITHUB_REPOSITORY", repo) == repo, "Wrong source repository") |
| 49 | + artifact_id = release["artifactId"] |
| 50 | + ci = json.loads(github(f"repos/{repo}/actions/runs/{release['runId']}")) |
| 51 | + require(ci["head_sha"] == release["commit"] and ci["conclusion"] == "success" |
| 52 | + and ci["path"] == ".github/workflows/ci.yml" and ci["event"] == "push" |
| 53 | + and ci["head_branch"] == release["branch"], "CI provenance changed") |
| 54 | + if release.get("tag"): |
| 55 | + ref = json.loads(github(f"repos/{repo}/commits/{release['tag']}")) |
| 56 | + require(ref["sha"] == release["commit"], "Release tag moved") |
| 57 | + details = json.loads(github(f"repos/{repo}/actions/artifacts/{artifact_id}")) |
| 58 | + require(details["workflow_run"]["id"] == release["runId"] |
| 59 | + and details["digest"] == "sha256:" + release["archiveSha256"] |
| 60 | + and not details["expired"], "Expected unexpired, checksum-sealed CI artifact") |
| 61 | + work.mkdir(parents=True, exist_ok=False) |
| 62 | + archive = work / "ci.zip" |
| 63 | + with archive.open("xb") as out: |
| 64 | + subprocess.run(["gh", "api", f"repos/{repo}/actions/artifacts/{artifact_id}/zip"], stdout=out, check=True) |
| 65 | + require(digest(archive.read_bytes()) == release["archiveSha256"], "CI archive hash changed") |
| 66 | + |
| 67 | + jars = jar_names(manifest, version) |
| 68 | + with ZipFile(archive) as bundle: |
| 69 | + sums = {} |
| 70 | + for line in bundle.read("build/release/SHA256SUMS").decode("ascii").splitlines(): |
| 71 | + sha, name = line.split(maxsplit=1) |
| 72 | + require(name not in sums and re.fullmatch(r"[a-fA-F0-9]{64}", sha), "Malformed CI checksums") |
| 73 | + sums[name] = sha.lower() |
| 74 | + require(set(sums) == set(jars), "CI did not seal exactly the three release jars") |
| 75 | + require(sums[jars[0]] == release["mainSha256"], "CI main jar does not match the published release") |
| 76 | + for name in jars: |
| 77 | + member = "build/libs/" + name |
| 78 | + require(bundle.namelist().count(member) == 1, f"Missing or duplicate CI jar: {name}") |
| 79 | + data = bundle.read(member) |
| 80 | + require(digest(data) == sums[name], f"Bad CI checksum: {name}") |
| 81 | + (work / name).write_bytes(data) |
| 82 | + file_id = release["curseFileId"] |
| 83 | + public = read_url(f"https://edge.forgecdn.net/files/{file_id // 1000}/{file_id % 1000}/{jars[0]}") |
| 84 | + require(digest(public) == release["mainSha256"], "CurseForge bytes changed") |
| 85 | + (work / "verified.json").write_text(json.dumps(sums), encoding="ascii") |
| 86 | + print(f"CurseForge file {file_id} matches {jars[0]}: {release['mainSha256']}") |
| 87 | + |
| 88 | + |
| 89 | +def jar_names(manifest, version): |
| 90 | + stem = manifest["artifactId"] + "-" + version |
| 91 | + return [stem + suffix for suffix in (".jar", "-sources.jar", "-javadoc.jar")] |
| 92 | + |
| 93 | + |
| 94 | +def destination_state(manifest, version, release, work, verify_metadata=False): |
| 95 | + jars = jar_names(manifest, version) |
| 96 | + sums = json.loads((work / "verified.json").read_text(encoding="ascii")) |
| 97 | + require(sums[jars[0]] == release["mainSha256"], "Prepared release does not match the manifest") |
| 98 | + pom = jars[0].removesuffix(".jar") + ".pom" |
| 99 | + sums[pom] = digest((work / "gradle/publications/mavenJava/pom-default.xml").read_bytes()) |
| 100 | + directory = f"{MAVEN}/{manifest['groupId'].replace('.', '/')}/{manifest['artifactId']}" |
| 101 | + present = 0 |
| 102 | + for name, sha in sums.items(): |
| 103 | + if name in jars: |
| 104 | + require(digest((work / name).read_bytes()) == sha, f"Prepared file changed: {name}") |
| 105 | + remote = read_url(f"{directory}/{version}/{name}", allow_absent=True) |
| 106 | + if remote is not None: |
| 107 | + require(digest(remote) == sha, f"Maven already contains different bytes: {name}") |
| 108 | + present += 1 |
| 109 | + require(present in (0, len(sums)), "Maven version is partially populated; refusing automatic overwrite") |
| 110 | + if verify_metadata and present: |
| 111 | + metadata = ElementTree.fromstring(read_url(f"{directory}/maven-metadata.xml")) |
| 112 | + require(version in [node.text for node in metadata.findall("versioning/versions/version")], |
| 113 | + "Published version is missing from Maven metadata") |
| 114 | + return "present" if present else "absent" |
| 115 | + |
| 116 | + |
| 117 | +def main(): |
| 118 | + mode, version, folder = sys.argv[1:] |
| 119 | + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) |
| 120 | + require(version in manifest["releases"], f"Version is not approved: {version}") |
| 121 | + release, work = manifest["releases"][version], Path(folder) |
| 122 | + if mode == "stage": |
| 123 | + stage(manifest, version, release, work) |
| 124 | + elif mode in ("check", "require-absent"): |
| 125 | + state = destination_state(manifest, version, release, work) |
| 126 | + require(mode == "check" or state == "absent", "Destination is not empty") |
| 127 | + print(state) |
| 128 | + elif mode == "verify": |
| 129 | + for delay in (0, 2, 5, 10): |
| 130 | + time.sleep(delay) |
| 131 | + if destination_state(manifest, version, release, work, verify_metadata=True) == "present": |
| 132 | + print(f"MMD Maven verified: {manifest['groupId']}:{manifest['artifactId']}:{version}") |
| 133 | + print(f"Main jar SHA-256 matches CurseForge: {release['mainSha256']}") |
| 134 | + return |
| 135 | + raise ValueError("Publication is not publicly readable") |
| 136 | + else: |
| 137 | + raise ValueError(f"Unknown mode: {mode}") |
| 138 | + |
| 139 | + |
| 140 | +if __name__ == "__main__": |
| 141 | + main() |
0 commit comments