199 lines
7.8 KiB
Python
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)")
|
|
)
|