ci: publish tested module release assets
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
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
This commit is contained in:
Executable
+228
@@ -0,0 +1,228 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user