# SPDX-License-Identifier: GPL-3.0-or-later """Export and sign a public package repository.""" import hashlib import json import os import re import shutil import subprocess import tempfile from datetime import datetime, timezone from pathlib import Path import project as p def export(output, signing_home=None, key=None): output = output.resolve() if output.exists(): raise ValueError("Select a new output directory for this release.") if bool(signing_home) != bool(key): raise ValueError("Use both --signing-home and --key, or neither.") pool_artifacts = {} for module, entry in p.packages().items(): state = p.read(p.WORK / "state" / (module + ".json")) plan = p.read(p.WORK / "input/modules" / (module + ".json")) assert state["version"] == p.recipe(module)[1] assert plan["prepared"]["inputs"]["debian"] == p.tree( p.ROOT / "packages" / module / "debian" ), module + " recipe changed" assert plan["prepared"]["inputs"]["source"] == p.tree( p.ROOT / "packages" / module / "source" ), module + " native payload changed" assert plan["prepared"]["inputs"].get("materials") == p.source_materials( entry ), module + " input definitions changed" assert plan["prepared"]["inputs"].get("format") == entry["format"], ( module + " source format changed" ) assert plan["prepared"]["inputs"]["source_files"] == { f["name"]: f["sha256"] for f in entry["files"] }, module + " lock changed" assert ( state["inputs"] == hashlib.sha256(json.dumps(plan, sort_keys=True).encode()).hexdigest() ) for name, sha in state["artifacts"].items(): assert p.digest(p.WORK / "results" / name) == sha, name if not name.endswith((".buildinfo", ".changes")): pool_artifacts[name] = sha source = p.WORK / "repository" assert { path.name: p.digest(path) for path in (source / "pool").rglob("*") if path.is_file() } == pool_artifacts, "Repository pool does not match accepted build records" from audit import main as audit if audit(): raise ValueError("Package library audit failed") config = p.read(p.ROOT / "config/release.json") if key and key.upper() != config["fingerprint"]: raise ValueError( "Update the release fingerprint and client instructions before rotating signing keys." ) client = (p.ROOT / "docs/INSTALL.txt").read_text() assert config["fingerprint"] in client and config["url"] in client, ( "Client instructions do not match release configuration" ) output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".export-", dir=output.parent) as temp: target = Path(temp) / "repository" target.mkdir() for name in ["pool", "dists"]: shutil.copytree(source / name, target / name, copy_function=shutil.copy2) dist = target / "dists" / config["build_suite"] rd = target / "dists" / config["public_codename"] if dist != rd: dist.rename(rd) for path in rd.rglob("Release"): if path != rd / "Release": path.write_text( path.read_text() .replace( "Archive: " + config["build_suite"], "Archive: " + config["public_suite"], ) .replace( "Plasma Next build dependencies", "Plasma Next for Debian 13" ) ) for name in ["InRelease", "Release.gpg"]: (rd / name).unlink(missing_ok=True) text = ( f"Origin: Plasma Next\nLabel: Plasma Next for Debian 13\nSuite: {config['public_suite']}\nCodename: {config['public_codename']}\nDate: " + datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") + f"\nArchitectures: {config['architecture']}\nComponents: main\nSHA256:\n" ) for path in sorted((rd / "main").rglob("*")): if path.is_file(): text += ( f" {p.digest(path)} {path.stat().st_size} {path.relative_to(rd)}\n" ) release = rd / "Release" release.write_text(text) shutil.copy2(p.ROOT / "docs/INSTALL.txt", target / "README.txt") signed = False if key: if not re.fullmatch("[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64}", key): raise ValueError("Use the full signing fingerprint.") key = key.upper() home = signing_home.resolve() assert not home.is_relative_to(output) and not home.is_relative_to(p.ROOT) env = {**os.environ, "LC_ALL": "C"} if os.isatty(0): env["GPG_TTY"] = os.ttyname(0) gpg = ["gpg", "--no-options", "--homedir", str(home)] pub = target / "plasma-next.asc" pub.write_bytes( subprocess.check_output( gpg + [ "--armor", "--export-options", "export-minimal", "--export", key, ], env=env, ) ) assert pub.stat().st_size for name, args in [ ("InRelease", ["--clearsign"]), ("Release.gpg", ["--armor", "--detach-sign"]), ]: subprocess.run( gpg + [ "--local-user", key, "--digest-algo", "SHA256", "--output", str(rd / name), *args, str(release), ], check=True, env=env, ) with tempfile.TemporaryDirectory(prefix="gpgv-", dir=temp) as verify: publicring = Path(verify) / "public.gpg" publicring.write_bytes( subprocess.check_output( gpg + ["--export-options", "export-minimal", "--export", key], env=env, ) ) for args in [ [str(rd / "InRelease")], [str(rd / "Release.gpg"), str(release)], ]: r = subprocess.run( [ "gpgv", "--homedir", verify, "--keyring", str(publicring), "--status-fd", "1", *args, ], capture_output=True, text=True, env=env, check=True, ) assert any( line.startswith("[GNUPG:] VALIDSIG ") and key in (line.split()[2], line.split()[-1]) for line in r.stdout.splitlines() ) signed = True manifest = { str(path.relative_to(target)): p.digest(path) for path in target.rglob("*") if path.is_file() } p.write( target / "export.json", {"signed": signed, "fingerprint": key, "files": manifest}, ) target.rename(output) print( "Exported " + str(output) + (" (signed)" if signed else " (unsigned preview)") )