Files

199 lines
7.8 KiB
Python

# 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)")
)