CI / lint (pull_request) Successful in 12s
CI / typecheck (pull_request) Successful in 9s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 11s
CI / package (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 7m26s
CI / release (pull_request) Skipped
229 lines
10 KiB
Python
Executable File
229 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate and attach one tested Signage release bundle to an existing Gitea release."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tarfile
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
|
|
ARCHIVE = re.compile(r"^signage-(\d+\.\d+\.\d+)-linux-x64-glibc\.tar\.gz$")
|
|
|
|
|
|
class Refused(Exception):
|
|
"""The release does not meet a condition that must fail closed."""
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def package_metadata(archive: Path) -> dict:
|
|
try:
|
|
with tarfile.open(archive, "r:gz") as bundle:
|
|
member = next(
|
|
entry for entry in bundle.getmembers()
|
|
if entry.name == "signage/ekklesia.package.json" and entry.isfile()
|
|
)
|
|
extracted = bundle.extractfile(member)
|
|
if extracted is None:
|
|
raise KeyError(member.name)
|
|
return json.loads(extracted.read())
|
|
except (KeyError, StopIteration, tarfile.TarError, json.JSONDecodeError) as error:
|
|
raise Refused(f"cannot read signage/ekklesia.package.json from {archive.name}") from error
|
|
|
|
|
|
def package_pair(directory: Path, tag: str, revision: str) -> list[Path]:
|
|
archives = [path for path in directory.iterdir() if path.is_file() and ARCHIVE.fullmatch(path.name)]
|
|
if len(archives) != 1:
|
|
raise Refused(f"expected one Signage Module Package, found {len(archives)}")
|
|
|
|
package = archives[0]
|
|
match = ARCHIVE.fullmatch(package.name)
|
|
assert match is not None
|
|
version = match.group(1)
|
|
if tag != f"v{version}":
|
|
raise Refused(f"tag {tag} does not match package version {version}")
|
|
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
|
raise Refused("release revision is not a full Git object ID")
|
|
|
|
checksum = directory / f"{package.name}.sha256"
|
|
expected = [package, checksum]
|
|
missing = [path.name for path in expected if not path.is_file()]
|
|
if missing:
|
|
raise Refused(f"Module Package pair is incomplete; missing={missing}")
|
|
package_digest = sha256(package)
|
|
checksum_line = checksum.read_text(encoding="utf-8")
|
|
wanted_line = f"{package_digest} {package.name}\n"
|
|
if checksum_line != wanted_line:
|
|
raise Refused(f"{checksum.name} does not match {package.name}")
|
|
built_digest = os.environ.get("EXPECTED_PACKAGE_SHA256")
|
|
if built_digest is not None and built_digest != package_digest:
|
|
raise Refused("selected Module Package differs from the package job's bytes")
|
|
metadata = package_metadata(package)
|
|
if metadata.get("module") != {"id": "signage", "version": version}:
|
|
raise Refused("package metadata does not identify the expected Signage version")
|
|
if metadata.get("provenance", {}).get("revision") != revision:
|
|
raise Refused("package provenance revision does not match the tag commit")
|
|
|
|
return expected
|
|
|
|
|
|
def expected_paths(directory: Path, tag: str, revision: str) -> list[Path]:
|
|
package, checksum = package_pair(directory, tag, revision)
|
|
package_digest = sha256(package)
|
|
match = ARCHIVE.fullmatch(package.name)
|
|
assert match is not None
|
|
version = match.group(1)
|
|
source = directory / f"signage-{version}-linux-x64-glibc-corresponding-source.tar.gz"
|
|
source_checksum = directory / f"{source.name}.sha256"
|
|
redistribution = directory / f"signage-{version}-linux-x64-glibc.redistribution.json"
|
|
notice = directory / f"signage-{version}-linux-x64-glibc.source-notice.txt"
|
|
expected = [package, checksum, source, source_checksum, redistribution, notice]
|
|
|
|
missing = [path.name for path in expected if not path.is_file()]
|
|
extras = sorted(path.name for path in directory.iterdir() if path.is_file() and path not in expected)
|
|
if missing or extras:
|
|
raise Refused(f"release bundle differs from the six required files; missing={missing}, extra={extras}")
|
|
|
|
source_line = source_checksum.read_text(encoding="utf-8")
|
|
wanted_source_line = f"{sha256(source)} {source.name}\n"
|
|
if source_line != wanted_source_line:
|
|
raise Refused(f"{source_checksum.name} does not match {source.name}")
|
|
|
|
binding = json.loads(redistribution.read_text(encoding="utf-8"))
|
|
package_binding = binding.get("package", {})
|
|
if package_binding.get("file") != package.name or package_binding.get("sha256") != package_digest:
|
|
raise Refused("redistribution metadata does not bind the selected Module Package")
|
|
corresponding = binding.get("correspondingSource", {})
|
|
if corresponding.get("file") != source.name or corresponding.get("sha256") != sha256(source):
|
|
raise Refused("redistribution metadata does not bind the selected corresponding source")
|
|
|
|
return expected
|
|
|
|
|
|
class Gitea:
|
|
def __init__(self) -> None:
|
|
self.server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
|
self.repository = os.environ.get("GITHUB_REPOSITORY", "")
|
|
self.token = os.environ.get("GITHUB_TOKEN", "")
|
|
if not self.server or not self.repository or not self.token:
|
|
raise Refused("GITHUB_SERVER_URL, GITHUB_REPOSITORY, and GITHUB_TOKEN are required")
|
|
|
|
def request(self, method: str, path: str, body: bytes | None = None,
|
|
headers: dict[str, str] | None = None, *, authenticated: bool = True) -> bytes:
|
|
target = path if path.startswith("http") else f"{self.server}/api/v1/repos/{self.repository}{path}"
|
|
request_headers = dict(headers or {})
|
|
if authenticated:
|
|
request_headers["Authorization"] = f"token {self.token}"
|
|
request = urllib.request.Request(target, data=body, method=method, headers=request_headers)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=120) as response:
|
|
return response.read()
|
|
except urllib.error.HTTPError as error:
|
|
detail = error.read()[:300].decode("utf-8", "replace")
|
|
raise Refused(f"{method} {path} answered {error.code}: {detail}") from error
|
|
|
|
def json(self, method: str, path: str) -> object:
|
|
data = self.request(method, path)
|
|
return json.loads(data) if data else None
|
|
|
|
def public_download(self, url: str) -> bytes:
|
|
return self.request("GET", url, authenticated=False)
|
|
|
|
|
|
def upload(directory: Path, tag: str, revision: str) -> None:
|
|
files = expected_paths(directory, tag, revision)
|
|
gitea = Gitea()
|
|
quoted_tag = urllib.parse.quote(tag, safe="")
|
|
release = gitea.json("GET", f"/releases/tags/{quoted_tag}")
|
|
tagged = gitea.json("GET", f"/tags/{quoted_tag}")
|
|
if not isinstance(release, dict):
|
|
raise Refused(f"release {tag} does not exist")
|
|
if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision:
|
|
raise Refused(f"tag {tag} does not name {revision}")
|
|
|
|
release_id = release.get("id")
|
|
listed = gitea.json("GET", f"/releases/{release_id}/assets")
|
|
present = {asset["name"]: asset for asset in listed or []}
|
|
unexpected = sorted(set(present) - {path.name for path in files})
|
|
if unexpected:
|
|
raise Refused(f"release already has unexpected assets: {unexpected}")
|
|
|
|
# Preflight every published name before changing the release. This prevents a later conflict
|
|
# from leaving newly uploaded earlier files beside immutable, different bytes.
|
|
verified: dict[str, str] = {}
|
|
for path in files:
|
|
asset = present.get(path.name)
|
|
if asset is None:
|
|
continue
|
|
public_digest = hashlib.sha256(gitea.public_download(asset["browser_download_url"])).hexdigest()
|
|
expected_digest = sha256(path)
|
|
if public_digest != expected_digest:
|
|
raise Refused(f"public {path.name} has sha256 {public_digest}, expected {expected_digest}")
|
|
verified[path.name] = public_digest
|
|
|
|
for path in files:
|
|
digest = sha256(path)
|
|
asset = present.get(path.name)
|
|
if asset is None:
|
|
boundary = uuid.uuid4().hex
|
|
body = (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
|
|
"Content-Type: application/octet-stream\r\n\r\n"
|
|
).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
|
gitea.request(
|
|
"POST",
|
|
f"/releases/{release_id}/assets?name={urllib.parse.quote(path.name)}",
|
|
body,
|
|
{"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
|
)
|
|
listed = gitea.json("GET", f"/releases/{release_id}/assets")
|
|
asset = next((item for item in listed or [] if item.get("name") == path.name), None)
|
|
if asset is None:
|
|
raise Refused(f"Gitea did not list {path.name} after upload")
|
|
|
|
public_digest = verified.get(path.name)
|
|
if public_digest is None:
|
|
public_digest = hashlib.sha256(gitea.public_download(asset["browser_download_url"])).hexdigest()
|
|
if public_digest != digest:
|
|
raise Refused(f"public {path.name} has sha256 {public_digest}, expected {digest}")
|
|
print(f"release-assets: public {path.name}, sha256 {digest}", flush=True)
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
try:
|
|
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "upload"}:
|
|
raise Refused("usage: release-assets.py (verify-package|verify|upload) DIRECTORY TAG REVISION")
|
|
command, directory, tag, revision = argv
|
|
selected = (package_pair(Path(directory), tag, revision)
|
|
if command == "verify-package"
|
|
else expected_paths(Path(directory), tag, revision))
|
|
if command in {"verify-package", "verify"}:
|
|
for path in selected:
|
|
print(path)
|
|
else:
|
|
upload(Path(directory), tag, revision)
|
|
return 0
|
|
except (OSError, json.JSONDecodeError, Refused) as error:
|
|
print(f"release-assets: REFUSED: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|