Files
shacraft-launcher/scripts/release.py
T

416 lines
16 KiB
Python

#!/usr/bin/env python3
"""Assemble/verify release assets. No network or publication in this module."""
import argparse
import base64
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
from datetime import datetime
from pathlib import Path
import release_formats
REPOSITORY = "emil28092005/shacraft-launcher"
ORIGIN = f"https://github.com/{REPOSITORY}/releases/download"
PLATFORMS = {
"windows-x86_64": ("nsis/*.exe", "-setup.exe"),
"linux-x86_64": ("appimage/*.AppImage", ".AppImage"),
"darwin-aarch64": ("macos/*.app.tar.gz", ".app.tar.gz"),
"darwin-x86_64": ("macos/*.app.tar.gz", ".app.tar.gz"),
}
MANUAL = {
"windows-x86_64-msi": ("windows-x86_64", "msi/*.msi", ".msi"),
"linux-x86_64-deb": ("linux-x86_64", "deb/*.deb", ".deb"),
"darwin-aarch64-dmg": ("darwin-aarch64", "dmg/*.dmg", ".dmg"),
"darwin-x86_64-dmg": ("darwin-x86_64", "dmg/*.dmg", ".dmg"),
}
FIELDS = {"url", "signature", "sha256", "size"}
MAX_SIZE = 1024**3
def require(condition, message):
if not condition:
raise ValueError(message)
def version_tag(version, tag):
require(
re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version),
"release version must be stable MAJOR.MINOR.PATCH",
)
major, minor, patch = map(int, version.split("."))
require(major <= 255 and minor <= 255 and patch <= 65535, "version exceeds MSI limits")
require(tag == f"v{version}", "tag/version mismatch")
def filename(version, platform, suffix):
return f"shacraft-launcher_{version}_{platform}{suffix}"
def expected_names(version):
return {filename(version, key, value[1]) for key, value in PLATFORMS.items()} | {
filename(version, platform, suffix) for platform, _, suffix in MANUAL.values()
}
def canonical(data):
return (json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
def unique_object(pairs):
result = {}
for key, value in pairs:
require(key not in result, "duplicate JSON field")
result[key] = value
return result
def read_json(path):
return json.loads(path.read_bytes(), object_pairs_hook=unique_object)
def public_key():
value = os.environ.get("SHACRAFT_UPDATER_PUBLIC_KEY", "").strip()
require(bool(value), "SHACRAFT_UPDATER_PUBLIC_KEY is missing")
try:
decoded = base64.b64decode(value, validate=True).decode("utf-8")
lines = decoded.splitlines()
raw = base64.b64decode(lines[1], validate=True)
require(
lines[0].startswith("untrusted comment:") and len(raw) == 42 and raw[:2] == b"Ed",
"invalid Tauri public key",
)
except (ValueError, IndexError, UnicodeError) as error:
raise ValueError("invalid Tauri public key") from error
return value
def verifier_path(root):
return os.environ.get(
"RELEASE_VERIFIER",
str(
root
/ "scripts/release-verifier/target/release"
/ ("shacraft-release-verifier.exe" if os.name == "nt" else "shacraft-release-verifier")
),
)
def verify_signature(root, data, signature):
with tempfile.TemporaryDirectory(prefix="shacraft-verify-") as temporary:
key = Path(temporary) / "public-key"
key.write_text(public_key(), encoding="utf-8")
result = subprocess.run(
[verifier_path(root), str(key), str(data), str(signature)],
capture_output=True,
timeout=120,
check=False,
)
require(result.returncode == 0, f"invalid updater signature: {data.name}")
def signer(root, data):
private = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "")
require(bool(private.strip()), "TAURI_SIGNING_PRIVATE_KEY is missing")
environment = dict(os.environ)
# An omitted password means an unencrypted key. Never allow a CI prompt;
# encrypted keys without the correct password fail in the signer.
environment.setdefault("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "")
# Builds accept a key path; signer sign accepts the encoded key contents.
try:
is_key_path = len(private) < 4096 and "\n" not in private and Path(private).is_file()
except OSError:
is_key_path = False
if is_key_path:
environment["TAURI_SIGNING_PRIVATE_KEY"] = Path(private).read_text().strip()
environment.pop("TAURI_SIGNING_PRIVATE_KEY_PATH", None)
result = subprocess.run(
["node", str(root / "node_modules/@tauri-apps/cli/tauri.js"), "signer", "sign", str(data)],
env=environment,
capture_output=True,
timeout=120,
check=False,
)
require(result.returncode == 0, "Tauri signing failed (check protected signing credentials)")
def preflight(root, version, tag, check_git=False, signing=False):
version_tag(version, tag)
versions = [
read_json(root / "package.json")["version"],
read_json(root / "package-lock.json")["version"],
read_json(root / "package-lock.json")["packages"][""]["version"],
read_json(root / "src-tauri/tauri.conf.json")["version"],
tomllib.loads((root / "src-tauri/Cargo.toml").read_text())["package"]["version"],
]
require(all(value == version for value in versions), "source versions disagree with release")
if check_git:
def git(*args):
return subprocess.check_output(["git", *args], cwd=root, text=True).strip()
require(
git("rev-parse", "HEAD") == git("rev-parse", f"refs/tags/{tag}^{{commit}}"),
"checkout does not match existing release tag",
)
subprocess.run(
["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"], cwd=root, check=True
)
if signing:
require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI test keys cannot release")
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
require(public_key() == pinned, "release public key differs from committed updater key")
with tempfile.TemporaryDirectory(prefix="shacraft-key-check-") as temporary:
challenge = Path(temporary) / "key-check"
challenge.write_bytes(f"ShaCraft release key check {tag}\n".encode())
signer(root, challenge)
verify_signature(root, challenge, Path(str(challenge) + ".sig"))
def write_build_config(destination):
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(
canonical(
{
"bundle": {"createUpdaterArtifacts": True},
"plugins": {"updater": {"pubkey": public_key()}},
}
)
)
def ci_key(root, directory):
require(not os.environ.get("TAURI_SIGNING_PRIVATE_KEY"), "CI must not receive production key")
directory.mkdir(parents=True, exist_ok=True)
key = directory / "DISPOSABLE-CI-ONLY.key"
result = subprocess.run(
[
"node",
str(root / "node_modules/@tauri-apps/cli/tauri.js"),
"signer",
"generate",
"--ci",
"--password",
"",
"--write-keys",
str(key),
],
capture_output=True,
timeout=120,
check=False,
)
require(result.returncode == 0, "disposable CI key generation failed")
key.chmod(0o600)
values = {
"TAURI_SIGNING_PRIVATE_KEY": str(key),
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(key) + ".pub").read_text().strip(),
"SHACRAFT_UPDATER_TEST_BUILD": "1",
}
os.environ.update(values)
write_build_config(directory / "updater-build.json")
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as stream:
for name, value in values.items():
require("\n" not in value and "\r" not in value, "invalid CI environment value")
stream.write(f"{name}={value}\n")
def collect(root, bundle, destination, platform, version):
version_tag(version, f"v{version}")
destination.mkdir(parents=True, exist_ok=True)
require(not list(destination.iterdir()), "collection destination must be empty")
entries = [(PLATFORMS[platform][0], PLATFORMS[platform][1], True)]
entries += [
(glob, suffix, suffix == ".msi") for key, glob, suffix in MANUAL.values() if key == platform
]
for glob, suffix, required_signature in entries:
matches = list(bundle.glob(glob))
require(
len(matches) == 1 and matches[0].is_file() and not matches[0].is_symlink(),
f"expected exactly one {platform} {glob}",
)
source = matches[0]
main = bundle.parent / "shacraft-launcher.exe" if platform == "windows-x86_64" else None
release_formats.validate(source, platform, suffix, version, main)
target = destination / filename(version, platform, suffix)
shutil.copyfile(source, target)
source_signature = Path(str(source) + ".sig")
signature = Path(str(target) + ".sig")
if required_signature:
require(
source_signature.is_file() and not source_signature.is_symlink(),
f"missing generated updater signature: {source.name}",
)
shutil.copyfile(source_signature, signature)
else:
signer(root, target) # Sign manual packages explicitly, independent of bundler output.
verify_signature(root, target, signature)
if os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") == "1":
(destination / "CI_NOT_FOR_RELEASE.txt").write_text(
"DISPOSABLE TEST KEY. These CI artifacts are not deployable releases.\n",
encoding="utf-8",
)
def descriptor(root, directory, version, tag, platform, suffix):
name = filename(version, platform, suffix)
path, sig = directory / name, directory / (name + ".sig")
require(
path.is_file() and not path.is_symlink() and sig.is_file() and not sig.is_symlink(),
f"missing or unsafe release asset: {name}",
)
size = path.stat().st_size
require(0 < size <= MAX_SIZE, "invalid package size")
verify_signature(root, path, sig)
release_formats.validate(path, platform, suffix, version)
with path.open("rb") as stream:
digest = hashlib.file_digest(stream, "sha256").hexdigest()
signature = sig.read_text(encoding="utf-8").strip()
require(0 < len(signature) <= 2048, "invalid signature length")
return {"url": f"{ORIGIN}/{tag}/{name}", "signature": signature, "sha256": digest, "size": size}
def metadata(root, directory, version, tag, notes, date):
version_tag(version, tag)
require(len(notes.encode()) <= 4096, "release notes too long")
require(
re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", date), "expected UTC publication date"
)
datetime.fromisoformat(date.replace("Z", "+00:00"))
return {
"schemaVersion": 1,
"version": version,
"tag": tag,
"notes": notes,
"pub_date": date,
"platforms": {
key: descriptor(root, directory, version, tag, key, suffix)
for key, (_, suffix) in PLATFORMS.items()
},
"manualPackages": {
key: descriptor(root, directory, version, tag, platform, suffix)
for key, (platform, _, suffix) in MANUAL.items()
},
}
def verify_release(root, directory, version, tag):
version_tag(version, tag)
names = expected_names(version)
expected = names | {name + ".sig" for name in names} | {"latest.json", "latest.json.sig"}
require(
{path.name for path in directory.iterdir()} == expected,
"release asset set is incomplete or unexpected",
)
path = directory / "latest.json"
require(path.stat().st_size <= 32768, "metadata exceeds limit")
require(
not path.is_symlink() and not (directory / "latest.json.sig").is_symlink(),
"unsafe metadata",
)
verify_signature(
root, path, directory / "latest.json.sig"
) # Verify exact bytes BEFORE parsing.
actual = read_json(path)
require(
isinstance(actual, dict)
and set(actual)
== {"schemaVersion", "version", "tag", "notes", "pub_date", "platforms", "manualPackages"},
"unexpected metadata fields",
)
require(
type(actual["schemaVersion"]) is int and actual["schemaVersion"] == 1,
"unsupported metadata schema",
)
require(actual["version"] == version and actual["tag"] == tag, "signed version/tag mismatch")
require(
isinstance(actual["notes"], str) and isinstance(actual["pub_date"], str),
"invalid metadata text",
)
for section, keys in (("platforms", PLATFORMS), ("manualPackages", MANUAL)):
require(
isinstance(actual[section], dict) and set(actual[section]) == set(keys),
"unexpected platform/package set",
)
for item in actual[section].values():
require(isinstance(item, dict) and set(item) == FIELDS, "unexpected descriptor fields")
require(
type(item["size"]) is int and 0 < item["size"] <= MAX_SIZE,
"invalid descriptor size",
)
require(
all(isinstance(item[name], str) for name in ("url", "signature", "sha256")),
"invalid descriptor text",
)
expected_metadata = metadata(root, directory, version, tag, actual["notes"], actual["pub_date"])
require(
actual == expected_metadata,
"metadata does not match exact platforms, URLs, hashes, sizes or signatures",
)
require(path.read_bytes() == canonical(expected_metadata), "metadata is not canonical")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
sub = parser.add_subparsers(dest="command", required=True)
check = sub.add_parser("preflight")
check.add_argument("--version", required=True)
check.add_argument("--tag", required=True)
check.add_argument("--check-git", action="store_true")
check.add_argument("--signing", action="store_true")
check.add_argument("--config", type=Path)
ci = sub.add_parser("ci-key")
ci.add_argument("--directory", required=True, type=Path)
gather = sub.add_parser("collect")
gather.add_argument("--bundle", required=True, type=Path)
gather.add_argument("--directory", required=True, type=Path)
gather.add_argument("--platform", required=True, choices=PLATFORMS)
gather.add_argument("--version", required=True)
for name in ("metadata", "verify"):
item = sub.add_parser(name)
item.add_argument("--directory", required=True, type=Path)
item.add_argument("--version", required=True)
item.add_argument("--tag", required=True)
if name == "metadata":
item.add_argument("--date", required=True)
item.add_argument("--notes", default="")
args = parser.parse_args()
root = args.root.resolve()
if args.command == "preflight":
preflight(root, args.version, args.tag, args.check_git, args.signing)
if args.config:
write_build_config(args.config)
elif args.command == "ci-key":
ci_key(root, args.directory)
elif args.command == "collect":
collect(root, args.bundle, args.directory, args.platform, args.version)
elif args.command == "metadata":
require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI artifacts cannot release")
data = metadata(root, args.directory, args.version, args.tag, args.notes, args.date)
path = args.directory / "latest.json"
require(
not path.exists() and not (args.directory / "latest.json.sig").exists(),
"metadata already exists",
)
path.write_bytes(canonical(data))
signer(root, path)
verify_release(root, args.directory, args.version, args.tag)
else:
verify_release(root, args.directory, args.version, args.tag)
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
print(f"Release validation failed: {error}", file=sys.stderr)
sys.exit(1)