ci: verify signed packages before protected draft publication

This commit is contained in:
Emil
2026-09-09 19:55:53 +03:00
parent 1d08396b80
commit b2ac174398
18 changed files with 2349 additions and 38 deletions
+1
View File
@@ -0,0 +1 @@
/target/
+23
View File
@@ -0,0 +1,23 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "shacraft-release-verifier"
version = "0.1.0"
dependencies = [
"base64",
"minisign-verify",
]
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "shacraft-release-verifier"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
base64 = "=0.22.1"
minisign-verify = "=0.2.5"
+32
View File
@@ -0,0 +1,32 @@
//! Uses precisely the Tauri updater 2.11 signature verification primitive.
//! Upstream: plugins/updater/src/updater.rs::verify_signature (MIT/Apache-2.0).
use base64::Engine;
use minisign_verify::{PublicKey, Signature};
use std::{env, fs, process::ExitCode};
fn verify() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = env::args_os().skip(1).collect();
if args.len() != 3 {
return Err("expected PUBLIC_KEY_FILE DATA_FILE SIGNATURE_FILE".into());
}
let decode = |path: &std::ffi::OsStr| -> Result<String, Box<dyn std::error::Error>> {
let encoded = fs::read_to_string(path)?;
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded.trim())?;
Ok(String::from_utf8(bytes)?)
};
let public_key = PublicKey::decode(&decode(&args[0])?)?;
let signature = Signature::decode(&decode(&args[2])?)?;
public_key.verify(&fs::read(&args[1])?, &signature, true)?;
Ok(())
}
fn main() -> ExitCode {
match verify() {
Ok(()) => ExitCode::SUCCESS,
Err(_) => {
// Never echo key material or signer diagnostics into release logs.
eprintln!("Tauri updater signature verification failed");
ExitCode::FAILURE
}
}
}
+415
View File
@@ -0,0 +1,415 @@
#!/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) # Tauri does not produce deb/DMG updater signatures.
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)
+115
View File
@@ -0,0 +1,115 @@
"""No network: reject unsafe draft listings before invoking GitHub download."""
import copy
import unittest
from pathlib import Path
from unittest.mock import patch
import release
import release_github
class DraftAssetGateTests(unittest.TestCase):
def draft(self):
packages = release.expected_names("0.3.0")
names = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
self.assertEqual(len(names), 18)
return {
"id": 123, "tag_name": "v0.3.0", "draft": True, "prerelease": False,
"assets": [{"name": name, "id": index, "state": "uploaded", "size": 1, "digest": None}
for index, name in enumerate(sorted(names), 1)],
}
def assert_no_download(self, data):
with (patch.object(release_github, "api", return_value=data),
patch.object(release_github, "gh") as github,
patch.object(release, "verify_release") as verify):
with self.assertRaises(ValueError):
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
github.assert_not_called()
verify.assert_not_called()
def fake_download(self, listing):
def download(*args):
self.assertEqual(args[:3], ("release", "download", "v0.3.0"))
destination = Path(args[args.index("--dir") + 1])
for asset in listing["assets"]:
(destination / asset["name"]).write_bytes(b"x" * asset["size"])
return download
def test_exact_complete_listing_still_downloads_and_verifies_actual_contents(self):
listing = self.draft()
with (patch.object(release_github, "api", side_effect=[listing, listing]) as api,
patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github,
patch.object(release, "verify_release") as verify):
self.assertEqual(release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0"), listing)
github.assert_called_once()
verify.assert_called_once()
self.assertEqual(api.call_count, 2)
def test_unexpected_traversal_absolute_and_excessive_names_cannot_download(self):
for name in ["extra.exe", "../latest.json", r"..\latest.json", "/tmp/latest.json",
"folder/latest.json", "latest.JSON", "x" * 4096]:
with self.subTest(name=name[:50]):
listing = self.draft()
listing["assets"][0]["name"] = name
self.assert_no_download(listing)
listing = self.draft()
listing["assets"].append({"name": "extra.txt", "id": 100, "state": "uploaded", "size": 1})
self.assert_no_download(listing)
def test_missing_duplicate_and_unfinished_files_cannot_download(self):
for index in range(18):
with self.subTest(missing=index):
listing = self.draft()
listing["assets"].pop(index)
self.assert_no_download(listing)
listing = self.draft()
listing["assets"].append(copy.deepcopy(listing["assets"][0]))
self.assert_no_download(listing)
listing = self.draft()
listing["assets"][0]["state"] = "new"
self.assert_no_download(listing)
def test_every_asset_size_must_be_a_positive_integer_within_its_limit(self):
listing = self.draft()
for index, asset in enumerate(listing["assets"]):
limit = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3
for size in [0, -1, True, False, 1.0, "1", None, limit + 1]:
with self.subTest(name=asset["name"], size=size):
altered = copy.deepcopy(listing)
altered["assets"][index]["size"] = size
self.assert_no_download(altered)
def test_declared_size_boundaries_are_accepted_without_allocating_large_files(self):
listing = self.draft()
for asset in listing["assets"]:
asset["size"] = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3
with (patch.object(release_github, "api", return_value=listing),
patch.object(release_github, "gh", side_effect=RuntimeError("download boundary reached")) as github):
with self.assertRaisesRegex(RuntimeError, "download boundary reached"):
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
github.assert_called_once()
def test_valid_listing_does_not_bypass_signature_failure_or_remote_race_checks(self):
listing = self.draft()
with (patch.object(release_github, "api", return_value=listing) as api,
patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github,
patch.object(release, "verify_release", side_effect=ValueError("invalid updater signature")) as verify):
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
github.assert_called_once()
verify.assert_called_once()
self.assertEqual(api.call_count, 1)
changed = copy.deepcopy(listing)
changed["assets"][0]["id"] += 100
with (patch.object(release_github, "api", side_effect=[listing, changed]),
patch.object(release_github, "gh", side_effect=self.fake_download(listing)),
patch.object(release, "verify_release") as verify):
with self.assertRaisesRegex(ValueError, "changed during verification"):
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
verify.assert_called_once()
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
"""Read-only package checks. Never execute or install a package under inspection."""
import io
import json
import plistlib
import shutil
import struct
import subprocess
import tarfile
from pathlib import Path, PurePosixPath
def require(condition, message):
if not condition:
raise ValueError(f"Package validation failed: {message}")
def pe_machine(data):
require(len(data) >= 64 and data[:2] == b"MZ", "invalid PE DOS header")
offset = struct.unpack_from("<I", data, 60)[0]
require(
64 <= offset <= len(data) - 26 and data[offset : offset + 4] == b"PE\0\0",
"invalid PE header",
)
return struct.unpack_from("<H", data, offset + 4)[0], struct.unpack_from(
"<H", data, offset + 24
)[0]
def pe_x64(data):
require(pe_machine(data) == (0x8664, 0x20B), "launcher executable must be AMD64 PE32+")
def appimage(path):
with path.open("rb") as stream:
data = stream.read(64)
require(
len(data) == 64 and data[:6] == b"\x7fELF\x02\x01", "AppImage must be little-endian ELF64"
)
require(data[8:11] == b"AI\x02", "AppImage must use Type 2 format")
require(struct.unpack_from("<H", data, 18)[0] == 62, "AppImage must target AMD64")
def safe_member(name):
name = name.removeprefix("./")
path = PurePosixPath(name)
require(
not path.is_absolute() and ".." not in path.parts and "\\" not in name,
"unsafe archive member",
)
return name
def mac_app(path, platform, version):
with tarfile.open(path, "r:gz") as archive:
members = {}
for index, item in enumerate(archive):
require(index < 10000, "too many app archive entries")
name = safe_member(item.name).rstrip("/")
require(name not in members, "duplicate app archive member")
members[name] = item
plists = [name for name in members if name.endswith(".app/Contents/Info.plist")]
require(len(plists) == 1, "expected exactly one app Info.plist")
info = members[plists[0]]
require(info.isfile() and 0 < info.size <= 1024 * 1024, "invalid app Info.plist")
details = plistlib.loads(archive.extractfile(info).read())
executable = details.get("CFBundleExecutable", "")
require(
isinstance(executable, str)
and executable not in {"", ".", ".."}
and "/" not in executable
and "\\" not in executable,
"invalid app executable name",
)
require(details.get("CFBundleShortVersionString") == version, "app version mismatch")
main = plists[0].removesuffix("Info.plist") + "MacOS/" + executable
require(main in members and members[main].isfile(), "app executable missing or linked")
data = archive.extractfile(members[main]).read(32)
require(
len(data) == 32 and data[:4] == b"\xcf\xfa\xed\xfe",
"expected a thin little-endian Mach-O64 executable",
)
cpu, _, kind = struct.unpack_from("<III", data, 4)
wanted = 0x0100000C if platform == "darwin-aarch64" else 0x01000007
require(cpu == wanted and kind == 2, "app Mach-O architecture/type mismatch")
def deb(path, version):
control = None
debian_binary = None
with path.open("rb") as stream:
require(stream.read(8) == b"!<arch>\n", "invalid deb ar header")
seen = set()
while header := stream.read(60):
require(len(header) == 60 and header[58:] == b"`\n", "invalid deb ar member")
name = header[:16].decode("ascii").strip().removesuffix("/")
require(name not in seen and len(seen) < 20, "duplicate/excessive deb members")
seen.add(name)
size = int(header[48:58].decode("ascii").strip())
require(
0 <= size <= 1024**3 and stream.tell() + size <= path.stat().st_size,
"invalid/truncated deb member size",
)
if name == "debian-binary":
require(size <= 16, "invalid debian-binary size")
debian_binary = stream.read(size)
elif name in {"control.tar.gz", "control.tar.xz", "control.tar"}:
require(control is None and size <= 8 * 1024**2, "invalid deb control archive")
control = stream.read(size)
require(len(control) == size, "truncated deb control archive")
else:
stream.seek(size, 1)
if size % 2:
require(stream.read(1) == b"\n", "invalid ar padding")
require(
debian_binary == b"2.0\n"
and control is not None
and any(name.startswith("data.tar") for name in seen),
"missing deb version/control/data",
)
with tarfile.open(fileobj=io.BytesIO(control), mode="r:*") as archive:
matches = [item for item in archive if safe_member(item.name) == "control"]
require(
len(matches) == 1 and matches[0].isfile() and matches[0].size <= 1024**2,
"invalid deb control file",
)
text = archive.extractfile(matches[0]).read().decode("utf-8")
fields = {}
for line in text.splitlines():
if line and not line[0].isspace():
key, separator, value = line.partition(":")
require(separator and key not in fields, "invalid/duplicate deb control field")
fields[key] = value.strip()
require(fields.get("Architecture") == "amd64", "deb Architecture must be amd64")
require(fields.get("Version") == version, "deb version mismatch")
def expected_nsis_payload(original):
# tauri-cli-v2.11.4 / tauri-bundler::patch_binary changes only the first
# complete token, then restores the original on-disk main after each bundle.
# Reproduce that exact operation; never mask PE checksums, sections,
# Authenticode certificate tables or arbitrary matching regions.
token = b"__TAURI_BUNDLE_TYPE_VAR_UNK"
patched = b"__TAURI_BUNDLE_TYPE_VAR_NSS"
offset = original.find(token)
require(offset >= 0, "built launcher lacks the expected Tauri bundle-type marker")
return original[:offset] + patched + original[offset + len(token) :]
def nsis_payload(path, built_main):
tool = shutil.which("7z") or r"C:\Program Files\7-Zip\7z.exe"
require(Path(tool).is_file(), "7-Zip is required to inspect NSIS payload")
listing = subprocess.run(
[tool, "l", "-slt", "-sccUTF-8", "--", str(path)],
capture_output=True,
check=False,
timeout=60,
)
require(listing.returncode == 0, "7-Zip cannot inspect NSIS payload")
matches = []
for line in listing.stdout.decode("utf-8", errors="strict").splitlines():
if line.startswith("Path = "):
entry = line.removeprefix("Path = ")
if PurePosixPath(entry.replace("\\", "/")).name == built_main.name:
matches.append(entry)
require(len(matches) == 1, "expected exactly one bundled launcher EXE in NSIS")
extracted = subprocess.run(
[tool, "x", "-so", "-bd", "--", str(path), matches[0]],
capture_output=True,
check=False,
timeout=60,
)
require(extracted.returncode == 0, "7-Zip cannot extract NSIS launcher for inspection")
pe_x64(extracted.stdout)
require(
extracted.stdout == expected_nsis_payload(built_main.read_bytes()),
"NSIS payload differs from the x64 build plus the exact Tauri NSIS marker patch",
)
def windows(path, suffix, version, built_main):
with path.open("rb") as stream:
header = stream.read(4096)
if suffix == ".msi":
require(
len(header) >= 512
and header[:8] == bytes.fromhex("d0cf11e0a1b11ae1")
and header[28:30] == b"\xfe\xff",
"invalid MSI compound-file header",
)
else:
# Tauri's NSIS x64 package legitimately uses an x86-unicode installer stub.
require(
pe_machine(header) in {(0x14C, 0x10B), (0x8664, 0x20B)}, "unsupported NSIS PE wrapper"
)
if built_main is None:
return # Full payload/COM checks run on the Windows collection runner.
require(built_main.is_file() and not built_main.is_symlink(), "built launcher EXE is missing")
pe_x64(built_main.read_bytes())
if suffix != ".msi":
nsis_payload(path, built_main)
else:
script = Path(__file__).with_name("release_msi.ps1")
result = subprocess.run(
[
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-File",
str(script),
"-PackagePath",
str(path.resolve()),
],
capture_output=True,
check=False,
timeout=60,
)
require(result.returncode == 0, "cannot read MSI summary/properties")
details = json.loads(result.stdout.decode("utf-8-sig"))
require(details.get("template", "").split(";")[0] == "x64", "MSI template must be x64")
require(details.get("version") == version, "MSI ProductVersion mismatch")
def validate(path, platform, suffix, version, built_main=None):
if suffix == ".AppImage":
appimage(path)
elif suffix == ".deb":
deb(path, version)
elif suffix == ".app.tar.gz":
mac_app(path, platform, version)
elif suffix == ".dmg":
with path.open("rb") as stream:
require(path.stat().st_size >= 512, "truncated DMG")
stream.seek(-512, 2)
require(stream.read(4) == b"koly", "invalid DMG UDIF trailer")
else:
windows(path, suffix, version, built_main)
+127
View File
@@ -0,0 +1,127 @@
"""Execute the actual trusted workflow gate against disposable local Git history."""
import os
import re
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1]))
WORKFLOWS = [ROOT / ".github/workflows" / name for name in ("release.yml", "release-publish.yml")]
GATE = " - name: Resolve tag using trusted workflow Git commands\n"
def workflow_gate(workflow):
after = workflow.read_text().split(GATE, 1)[1]
lines = after.split(" run: |\n", 1)[1].splitlines()
body = []
for line in lines:
if not line.startswith(" "):
break
body.append(line[10:])
return "\n".join(body) + "\n"
class ReleaseGateTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-source-gate-")
self.directory = Path(self.temporary.name)
self.environment = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
self.git("init", "--initial-branch=main")
self.git("config", "user.email", "test@example.invalid")
self.git("config", "user.name", "Release Gate Test")
(self.directory / "reviewed.txt").write_text("Reviewed source\n")
self.git("add", ".")
self.git("commit", "-m", "Reviewed main commit")
self.good = self.git("rev-parse", "HEAD")
self.git("update-ref", "refs/remotes/origin/main", self.good)
self.git("tag", "v0.2.0")
self.git("checkout", "-b", "unreviewed")
(self.directory / "scripts").mkdir()
# This validator would falsely accept its own tag if a workflow ran it.
(self.directory / "scripts/release.py").write_text(
"from pathlib import Path\nPath('untrusted-code-ran').write_text('bypassed')\n"
)
self.git("add", ".")
self.git("commit", "-m", "Unreviewed tag with false validator")
self.bad = self.git("rev-parse", "HEAD")
self.git("tag", "v9.9.9")
self.git("checkout", "main")
def tearDown(self):
self.temporary.cleanup()
def git(self, *args):
return subprocess.check_output(["git", *args], cwd=self.directory,
env=self.environment, text=True, stderr=subprocess.DEVNULL).strip()
def run_gate(self, workflow, tag):
output = self.directory / (workflow.stem + "-output")
output.unlink(missing_ok=True)
result = subprocess.run(["bash", "-c", workflow_gate(workflow)], cwd=self.directory,
env=dict(self.environment, RELEASE_TAG=tag, GITHUB_OUTPUT=str(output)),
capture_output=True, text=True, check=False)
return result, output.read_text() if output.exists() else ""
def test_main_tag_emits_immutable_commit(self):
for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v0.2.0")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(output, f"commit={self.good}\n")
def test_unreviewed_tag_cannot_replace_its_own_ancestry_validator(self):
for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v9.9.9")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(output, "")
self.assertFalse((self.directory / "untrusted-code-ran").exists())
def test_missing_or_retargeted_tag_cannot_change_validated_source(self):
for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v8.8.8")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(output, "")
self.git("tag", "-f", "v0.2.0", self.good)
result, output = self.run_gate(workflow, "v0.2.0")
self.assertEqual(result.returncode, 0, result.stderr)
self.git("tag", "-f", "v0.2.0", self.bad)
self.assertEqual(output, f"commit={self.good}\n")
result, fresh_output = self.run_gate(workflow, "v0.2.0")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(fresh_output, "")
def test_workflows_execute_candidate_code_only_after_gate_and_checkout_sha(self):
for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name):
text = workflow.read_text()
preflight = text.split(" preflight:\n", 1)[1].split("\n build:\n", 1)[0].split("\n publish:\n", 1)[0]
before, after = preflight.split(GATE, 1)
self.assertEqual(before.count("uses: actions/checkout@v4"), 1)
self.assertIn("fetch-depth: 0", before)
self.assertNotIn("ref:", before) # Initial checkout is trusted workflow main.
self.assertIn("release.version_tag", before)
self.assertLess(after.index("git merge-base --is-ancestor"), after.index("uses: actions/checkout@v4"))
self.assertIn("ref: ${{ steps.source.outputs.commit }}", after)
self.assertNotIn("ref: ${{ inputs.tag }}", text)
downstream = text[len(text.split(" preflight:\n", 1)[0]) + len(" preflight:\n") + len(preflight):]
self.assertGreater(downstream.count("ref: ${{ needs.preflight.outputs.commit }}"), 0)
self.assertEqual(downstream.count("uses: actions/checkout@v4"), downstream.count("ref: ${{ needs.preflight.outputs.commit }}"))
self.assertEqual(text.count("uses: actions/checkout@v4"), text.count("persist-credentials: false"))
def test_write_token_is_only_exposed_to_explicit_final_publish_step(self):
text = WORKFLOWS[1].read_text().split("\n publish:\n", 1)[1]
job_environment = re.search(r"(?m)^ env:\n((?: .*\n)+)", text)
self.assertIsNotNone(job_environment)
self.assertNotIn("GH_TOKEN", job_environment.group(1))
before, final = text.split(" - name: Re-download, verify signatures/metadata/assets and explicitly publish\n", 1)
self.assertNotIn("GH_TOKEN", before)
self.assertIn("GH_TOKEN: ${{ github.token }}", final)
if __name__ == "__main__":
unittest.main()
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Explicit workflow-only GitHub release operations, with fail-closed gates."""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import release
ENVIRONMENTS = {"launcher-release", "launcher-release-publish"}
def gh(*args, missing=False):
result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=900, check=False)
if result.returncode:
try:
body = json.loads(result.stdout)
except ValueError:
body = {}
if missing and str(body.get("status")) == "404":
return None
raise ValueError("GitHub operation failed; no permission or validation bypass is allowed")
return result.stdout
def api(path, missing=False):
value = gh("api", f"repos/{release.REPOSITORY}/{path}", missing=missing)
return None if value is None else json.loads(value)
def validate_environment(data):
release.require(isinstance(data, dict), "protected environment is absent")
policy = data.get("deployment_branch_policy") or {}
release.require(
policy.get("protected_branches") is True and policy.get("custom_branch_policies") is False,
"release environment must allow protected branches only",
)
rules = data.get("protection_rules") or []
reviewers = next((rule for rule in rules if rule.get("type") == "required_reviewers"), {})
release.require(
reviewers.get("prevent_self_review") is True, "environment must prevent self review"
)
allowed = reviewers.get("reviewers") or []
release.require(
any(
item.get("type") in {"User", "Team"}
and type((item.get("reviewer") or {}).get("id")) is int
and item["reviewer"]["id"] > 0
for item in allowed
),
"release environment requires an independent reviewer",
)
def workflow_guard():
release.require(
os.environ.get("GITHUB_REPOSITORY") == release.REPOSITORY, "wrong release repository"
)
release.require(
os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch",
"release must be dispatched manually",
)
release.require(
os.environ.get("GITHUB_REF") == "refs/heads/main", "release workflow must run from main"
)
def gate(environment):
workflow_guard()
release.require(environment in ENVIRONMENTS, "unexpected release environment")
release.require(
api("branches/main").get("protected") is True, "main must be a protected branch"
)
validate_environment(api(f"environments/{environment}", missing=True))
# Jobs consume this output only after validation. Never reference a missing
# environment directly: GitHub would create it without protection rules.
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream:
stream.write(f"environment={environment}\n")
def asset_snapshot(data):
release.require(
data.get("draft") is True and data.get("prerelease") is False,
"expected a stable DRAFT release",
)
assets = data.get("assets") or []
result = {}
for asset in assets:
name = asset["name"]
release.require(
name not in result and asset.get("state") == "uploaded",
"duplicate or incomplete release asset",
)
result[name] = (asset["id"], asset["size"], asset.get("digest"))
return result
def verify_draft(root, version, tag):
release.version_tag(version, tag)
before = api(f"releases/tags/{tag}")
release.require(before.get("tag_name") == tag, "draft tag mismatch")
snapshot = asset_snapshot(before)
packages = release.expected_names(version)
expected = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
release.require(set(snapshot) == expected, "draft asset set is incomplete or unexpected")
# Reject remote names and declared sizes before gh writes any asset locally.
# This is only a pre-download bound; signatures and actual bytes are still
# checked below, followed by the remote identity/race check.
for name, (_, size, _) in snapshot.items():
limit = (
32 * 1024 if name == "latest.json"
else 8 * 1024 if name.endswith(".sig")
else release.MAX_SIZE
)
release.require(type(size) is int and 0 < size <= limit, "invalid remote draft asset size")
with tempfile.TemporaryDirectory(prefix="shacraft-draft-check-") as temporary:
directory = Path(temporary)
gh("release", "download", tag, "--repo", release.REPOSITORY, "--dir", str(directory))
release.verify_release(root, directory, version, tag)
release.require(
set(snapshot) == {p.name for p in directory.iterdir()},
"draft assets changed during download",
)
for path in directory.iterdir():
release.require(snapshot[path.name][1] == path.stat().st_size, "draft size mismatch")
after = api(f"releases/tags/{tag}")
release.require(
after["id"] == before["id"] and asset_snapshot(after) == snapshot,
"draft changed during verification",
)
return after
def draft(root, directory, version, tag):
workflow_guard()
release.preflight(root, version, tag, check_git=True, signing=True)
release.verify_release(root, directory, version, tag)
with tempfile.TemporaryDirectory(prefix="shacraft-release-notes-") as temporary:
notes = Path(temporary) / "notes.md"
release_notes = release.read_json(directory / "latest.json")["notes"]
instructions = (
f"https://github.com/{release.REPOSITORY}/blob/{tag}/docs/updater-release.md"
"#installed-package-migration-and-recovery"
)
notes.write_text(
release_notes + "\n\n" +
"[Установка, переход с 0.1.1 и восстановление](" + instructions + ").\n" +
"Используйте прежний тип пакета. Обновление .deb выполняется через менеджер пакетов.\n",
encoding="utf-8",
)
gh(
"release",
"create",
tag,
*[str(path) for path in sorted(directory.iterdir())],
"--repo",
release.REPOSITORY,
"--draft",
"--verify-tag",
"--title",
f"ShaCraft Launcher {version}",
"--notes-file",
str(notes),
)
verify_draft(root, version, tag)
print(
"Complete draft uploaded and re-verified. Publication requires the separate protected workflow."
)
def publish(root, version, tag, confirmation):
workflow_guard()
release.require(
confirmation == f"publish {tag}", "explicit publication confirmation does not match tag"
)
release.preflight(root, version, tag, check_git=True)
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
release.require(
release.public_key() == pinned, "publication key differs from committed updater key"
)
gate("launcher-release-publish") # Re-check protection immediately before publication.
current = api("releases/latest", missing=True)
if current is not None:
old_tag = current.get("tag_name", "")
release.version_tag(old_tag.removeprefix("v"), old_tag)
release.require(
tuple(map(int, version.split("."))) > tuple(map(int, old_tag[1:].split("."))),
"publication must advance the stable release version",
)
verify_draft(root, version, tag)
gh("release", "edit", tag, "--repo", release.REPOSITORY, "--draft=false", "--latest")
print("Verified release published by explicit protected operator workflow.")
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)
item = sub.add_parser("gate")
item.add_argument("environment", choices=sorted(ENVIRONMENTS))
for name in ("draft", "publish"):
item = sub.add_parser(name)
item.add_argument("--version", required=True)
item.add_argument("--tag", required=True)
if name == "draft":
item.add_argument("--directory", required=True, type=Path)
else:
item.add_argument("--confirmation", required=True)
args = parser.parse_args()
if args.command == "gate":
gate(args.environment)
elif args.command == "draft":
draft(args.root.resolve(), args.directory, args.version, args.tag)
else:
publish(args.root.resolve(), args.version, args.tag, args.confirmation)
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
print(f"Release stopped: {error}", file=sys.stderr)
sys.exit(1)
+14
View File
@@ -0,0 +1,14 @@
param([Parameter(Mandatory=$true)][string]$PackagePath)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
# MSIDBOPEN_READONLY = 0. Reading COM properties never installs the package.
$installer = New-Object -ComObject WindowsInstaller.Installer
$summary = $installer.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $installer, @($PackagePath, 0))
$template = $summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7))
$database = $installer.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $installer, @($PackagePath, 0))
$view = $database.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $database, @('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductVersion'''))
$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null
$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null)
if ($null -eq $record) { throw 'Missing MSI ProductVersion' }
$version = $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @(1))
@{ template = [string]$template; version = [string]$version } | ConvertTo-Json -Compress
+531
View File
@@ -0,0 +1,531 @@
"""Real Tauri signature checks using disposable keys, never release credentials."""
import io
import json
import os
import plistlib
import shutil
import struct
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import release
from release_assets_test import DraftAssetGateTests # noqa: F401; unittest discovery
from release_gate_test import ReleaseGateTests # noqa: F401; unittest discovery
import release_formats
import release_github
ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1]))
def fixture_pe(machine=0x8664, magic=0x20B):
data = bytearray(256)
data[:2] = b"MZ"
struct.pack_into("<I", data, 60, 128)
data[128:132] = b"PE\0\0"
struct.pack_into("<H", data, 132, machine)
struct.pack_into("<H", data, 152, magic)
marker = b"__TAURI_BUNDLE_TYPE_VAR_UNK"
data[200 : 200 + len(marker)] = marker
return bytes(data)
def fixture_tar(files):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
for name, data in files.items():
info = tarfile.TarInfo(name)
info.size = len(data)
archive.addfile(info, io.BytesIO(data))
return stream.getvalue()
def fixture_deb(architecture="amd64", version="0.3.0"):
control = fixture_tar(
{
"./control": f"Package: shacraft-launcher\nVersion: {version}\nArchitecture: {architecture}\n".encode()
}
)
result = bytearray(b"!<arch>\n")
for name, data in [
("debian-binary", b"2.0\n"),
("control.tar.gz", control),
("data.tar.gz", fixture_tar({})),
]:
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{100644:<8}{len(data):<10}`\n".encode()
assert len(header) == 60
result.extend(header + data + (b"\n" if len(data) % 2 else b""))
return bytes(result)
def fixture_package(name, version="0.3.0"):
if name.endswith(".app.tar.gz"):
cpu = 0x0100000C if "aarch64" in name else 0x01000007
executable = struct.pack("<IIIIIIII", 0xFEEDFACF, cpu, 0, 2, 0, 0, 0, 0)
return fixture_tar(
{
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
{
"CFBundleExecutable": "shacraft-launcher",
"CFBundleShortVersionString": version,
}
),
"ShaCraft.app/Contents/MacOS/shacraft-launcher": executable,
}
)
if name.endswith(".AppImage"):
data = bytearray(64)
data[:6] = b"\x7fELF\x02\x01"
data[8:11] = b"AI\x02"
struct.pack_into("<H", data, 18, 62)
return bytes(data)
if name.endswith(".deb"):
return fixture_deb(version=version)
if name.endswith(".dmg"):
return b"koly" + bytes(508)
if name.endswith(".msi"):
data = bytearray(512)
data[:8] = bytes.fromhex("d0cf11e0a1b11ae1")
data[28:30] = b"\xfe\xff"
return bytes(data)
return fixture_pe(0x14C, 0x10B) # x86 NSIS wrapper is valid for an x64 payload.
class ReleaseTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="shacraft-release-tests-")
cls.base = Path(cls.temporary.name)
# A new unencrypted disposable key per test process. No static private
# key or credentials are stored in the repository or test output.
cls.key = cls.base / "test-only.key"
environment = dict(os.environ)
for name in (
"TAURI_SIGNING_PRIVATE_KEY",
"TAURI_SIGNING_PRIVATE_KEY_PATH",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
):
environment.pop(name, None)
result = subprocess.run(
[
"node",
str(ROOT / "node_modules/@tauri-apps/cli/tauri.js"),
"signer",
"generate",
"--ci",
"--password",
"",
"--write-keys",
str(cls.key),
],
env=environment,
capture_output=True,
timeout=120,
check=False,
)
if result.returncode:
raise RuntimeError("disposable Tauri signer setup failed")
cls.key.chmod(0o600)
cls.key_values = {
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(cls.key) + ".pub").read_text().strip(),
"TAURI_SIGNING_PRIVATE_KEY": cls.key.read_text().strip(),
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
"SHACRAFT_UPDATER_TEST_BUILD": "0",
}
cls.originals = cls.base / "complete"
cls.originals.mkdir()
with patch.dict(os.environ, cls.key_values):
for name in release.expected_names("0.3.0"):
path = cls.originals / name
path.write_bytes(fixture_package(name))
release.signer(ROOT, path)
data = release.metadata(
ROOT, cls.originals, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
)
(cls.originals / "latest.json").write_bytes(release.canonical(data))
release.signer(ROOT, cls.originals / "latest.json")
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def setUp(self):
self.case = tempfile.TemporaryDirectory(dir=self.base)
self.directory = Path(self.case.name) / "assets"
shutil.copytree(self.originals, self.directory)
self.environment = patch.dict(os.environ, self.key_values)
self.environment.start()
def tearDown(self):
self.environment.stop()
self.case.cleanup()
def verify(self):
release.verify_release(ROOT, self.directory, "0.3.0", "v0.3.0")
def resign_metadata(self, mutate):
path = self.directory / "latest.json"
data = json.loads(path.read_bytes())
mutate(data)
path.write_bytes(release.canonical(data))
release.signer(ROOT, path)
def test_complete_release_and_metadata_generation_are_deterministic(self):
self.verify()
first = release.metadata(
ROOT, self.directory, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
)
self.assertEqual(release.canonical(first), (self.directory / "latest.json").read_bytes())
def test_package_bit_flip_fails_actual_plugin_verification(self):
path = self.directory / release.filename("0.3.0", "linux-x86_64", ".AppImage")
path.write_bytes(path.read_bytes() + b"tampered")
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_metadata_substitution_is_rejected_before_json_parsing(self):
(self.directory / "latest.json").write_bytes(b"not even JSON")
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_valid_signature_for_other_artifact_does_not_suffice(self):
linux = release.filename("0.3.0", "linux-x86_64", ".AppImage")
windows = release.filename("0.3.0", "windows-x86_64", "-setup.exe")
shutil.copyfile(self.directory / (windows + ".sig"), self.directory / (linux + ".sig"))
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_wrong_public_key_fails_real_verifier(self):
encoded = release.public_key()
import base64
lines = base64.b64decode(encoded).decode().splitlines()
raw = bytearray(base64.b64decode(lines[1]))
raw[-1] ^= 1
lines[1] = base64.b64encode(raw).decode()
wrong = base64.b64encode(("\n".join(lines) + "\n").encode()).decode()
with patch.dict(os.environ, {"SHACRAFT_UPDATER_PUBLIC_KEY": wrong}):
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_all_platforms_and_manual_packages_are_mandatory(self):
for name in sorted(release.expected_names("0.3.0")):
path = self.directory / (name + ".sig")
original = path.read_bytes()
path.unlink()
with self.subTest(name=name), self.assertRaisesRegex(ValueError, "asset set"):
self.verify()
path.write_bytes(original)
def test_extra_ci_marker_or_file_blocks_promotion(self):
(self.directory / "CI_NOT_FOR_RELEASE.txt").write_text("test key")
with self.assertRaisesRegex(ValueError, "asset set"):
self.verify()
def test_signed_metadata_must_match_tag_and_exact_source_assets(self):
alterations = [
lambda x: x.update(version="0.2.0"),
lambda x: x.update(tag="v0.4.0"),
lambda x: x["platforms"].pop("darwin-aarch64"),
lambda x: x["platforms"].update({"linux-aarch64": x["platforms"]["linux-x86_64"]}),
lambda x: x["manualPackages"].pop("windows-x86_64-msi"),
lambda x: x["platforms"]["linux-x86_64"].update(url="https://evil.invalid/package"),
lambda x: x["platforms"]["linux-x86_64"].update(
url=x["platforms"]["linux-x86_64"]["url"].replace("v0.3.0", "v0.2.0")
),
lambda x: x["platforms"]["linux-x86_64"].update(sha256="0" * 64),
lambda x: x["platforms"]["linux-x86_64"].update(size=True),
lambda x: x.update(pub_date="2026-09-09T00:00:00+03:00"),
lambda x: x.update(extra="not allowed"),
]
for index, alter in enumerate(alterations):
shutil.copyfile(self.originals / "latest.json", self.directory / "latest.json")
self.resign_metadata(alter)
with self.subTest(index=index), self.assertRaises(ValueError):
self.verify()
def test_duplicate_json_fields_are_rejected_even_if_signed(self):
path = self.directory / "latest.json"
path.write_bytes(
path.read_bytes().replace(
b'"schemaVersion": 1,', b'"schemaVersion": 1, "schemaVersion": 1,'
)
)
release.signer(ROOT, path)
with self.assertRaisesRegex(ValueError, "duplicate JSON"):
self.verify()
def test_collect_requires_generated_updater_sig_and_signs_manual_deb(self):
bundle = Path(self.case.name) / "bundle"
(bundle / "appimage").mkdir(parents=True)
(bundle / "deb").mkdir()
app = bundle / "appimage/Test.AppImage"
app.write_bytes(fixture_package("Test.AppImage"))
(bundle / "deb/Test.deb").write_bytes(fixture_package("Test.deb"))
output = Path(self.case.name) / "collected"
with self.assertRaisesRegex(ValueError, "missing generated updater signature"):
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
shutil.rmtree(output)
release.signer(ROOT, app)
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
self.assertEqual(len(list(output.iterdir())), 4)
for suffix in (".AppImage", ".deb"):
data = output / release.filename("0.3.0", "linux-x86_64", suffix)
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
def test_missing_key_fails_and_optional_password_never_prompts(self):
data = self.directory / "latest.json"
with patch.dict(os.environ, {"TAURI_SIGNING_PRIVATE_KEY": ""}):
with self.assertRaisesRegex(ValueError, "PRIVATE_KEY is missing"):
release.signer(ROOT, data)
with patch.dict(os.environ):
os.environ.pop("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", None)
release.signer(ROOT, data)
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
def test_release_preflight_rejects_placeholder_mismatch_and_test_mode(self):
source = Path(self.case.name) / "source"
(source / "src-tauri").mkdir(parents=True)
(source / "package.json").write_text('{"version":"0.3.0"}')
(source / "package-lock.json").write_text(
'{"version":"0.3.0","packages":{"":{"version":"0.3.0"}}}'
)
(source / "src-tauri/tauri.conf.json").write_text('{"version":"0.3.0"}')
(source / "src-tauri/Cargo.toml").write_text('[package]\nversion="0.3.0"\n')
(source / "src-tauri/updater-public-key.txt").write_text("UNCONFIGURED")
with self.assertRaisesRegex(ValueError, "differs from committed"):
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
with patch.dict(os.environ, {"SHACRAFT_UPDATER_TEST_BUILD": "1"}):
with self.assertRaisesRegex(ValueError, "test keys cannot release"):
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
(source / "package.json").write_text('{"version":"0.3.1"}')
with self.assertRaisesRegex(ValueError, "source versions disagree"):
release.preflight(source, "0.3.0", "v0.3.0")
class PackageFormatTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-format-tests-")
self.root = Path(self.temporary.name)
def tearDown(self):
self.temporary.cleanup()
def package(self, name, data=None):
path = self.root / name
path.write_bytes(fixture_package(name) if data is None else data)
return path
def test_mac_archive_binds_main_executable_cpu_and_version(self):
path = self.package("darwin-aarch64.app.tar.gz")
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
for platform, version in [("darwin-x86_64", "0.3.0"), ("darwin-aarch64", "0.2.0")]:
with self.subTest(platform=platform, version=version), self.assertRaises(ValueError):
release_formats.mac_app(path, platform, version)
def test_mac_archive_rejects_traversal_and_missing_binary(self):
for files in (
{"../Bad.app/Contents/Info.plist": b"bad"},
{
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
{"CFBundleExecutable": "missing", "CFBundleShortVersionString": "0.3.0"}
)
},
):
path = self.package("darwin-aarch64.app.tar.gz", fixture_tar(files))
with self.assertRaises(ValueError):
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
def test_appimage_rejects_arm64_wrong_class_and_type1(self):
path = self.package("linux-x86_64.AppImage")
release_formats.appimage(path)
for offset, data in [(18, struct.pack("<H", 183)), (4, b"\x01"), (10, b"\x01")]:
bad = bytearray(fixture_package(path.name))
bad[offset : offset + len(data)] = data
path.write_bytes(bad)
with self.assertRaises(ValueError):
release_formats.appimage(path)
def test_deb_control_binds_architecture_and_version(self):
path = self.package("linux-x86_64.deb")
release_formats.deb(path, "0.3.0")
for architecture, version in [("arm64", "0.3.0"), ("amd64", "0.2.0")]:
path.write_bytes(fixture_deb(architecture, version))
with self.assertRaises(ValueError):
release_formats.deb(path, "0.3.0")
def test_nsis_stub_may_be_x86_but_extracted_main_must_equal_x64_build(self):
wrapper = self.package("setup.exe")
main = self.package("shacraft-launcher.exe", fixture_pe())
listing = subprocess.CompletedProcess(
[], 0, b"Path = setup.exe\nPath = $INSTDIR/shacraft-launcher.exe\n", b""
)
for extracted, success in [
(release_formats.expected_nsis_payload(fixture_pe()), True),
(fixture_pe(), False),
(release_formats.expected_nsis_payload(fixture_pe(0xAA64, 0x20B)), False),
(release_formats.expected_nsis_payload(fixture_pe()) + b"different", False),
]:
with (
patch.object(release_formats.shutil, "which", return_value=str(main)),
patch.object(
release_formats.subprocess,
"run",
side_effect=[listing, subprocess.CompletedProcess([], 0, extracted, b"")],
),
):
if success:
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
else:
with self.assertRaises(ValueError):
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
def test_nsis_identity_allows_only_the_actual_first_bundle_marker_patch(self):
original = fixture_pe() + b"__TAURI_BUNDLE_TYPE_VAR_UNK"
expected = release_formats.expected_nsis_payload(original)
self.assertEqual(
expected,
original.replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_NSS", 1),
)
self.assertEqual(expected.count(b"__TAURI_BUNDLE_TYPE_VAR_UNK"), 1)
with self.assertRaisesRegex(ValueError, "bundle-type marker"):
release_formats.expected_nsis_payload(
fixture_pe().replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_MSI")
)
# No certificate table/checksum/other byte range is ignored.
mutated = bytearray(expected)
mutated[190] ^= 1
self.assertNotEqual(bytes(mutated), release_formats.expected_nsis_payload(original))
self.assertNotEqual(
expected.replace(b"_VAR_NSS", b"_VAR_MSI"),
release_formats.expected_nsis_payload(original),
)
def test_msi_readonly_metadata_requires_x64_and_version(self):
msi = self.package("setup.msi")
main = self.package("shacraft-launcher.exe", fixture_pe())
for architecture, version, success in [
("x64;1033", "0.3.0", True),
("Intel;1033", "0.3.0", False),
("x64;1033", "0.2.0", False),
]:
result = subprocess.CompletedProcess(
[], 0, json.dumps({"template": architecture, "version": version}).encode(), b""
)
with patch.object(release_formats.subprocess, "run", return_value=result) as command:
if success:
release_formats.windows(msi, ".msi", "0.3.0", main)
else:
with self.assertRaises(ValueError):
release_formats.windows(msi, ".msi", "0.3.0", main)
self.assertIn("release_msi.ps1", command.call_args.args[0][4])
class WorkflowPolicyTests(unittest.TestCase):
def protected(self):
return {
"deployment_branch_policy": {
"protected_branches": True,
"custom_branch_policies": False,
},
"protection_rules": [
{
"type": "required_reviewers",
"prevent_self_review": True,
"reviewers": [{"type": "User", "reviewer": {"id": 123}}],
}
],
}
def test_protected_environment_requires_existing_independent_review(self):
release_github.validate_environment(self.protected())
for data in (
None,
{},
{"deployment_branch_policy": {}},
dict(self.protected(), protection_rules=[]),
):
with self.subTest(data=data), self.assertRaises(ValueError):
release_github.validate_environment(data)
for mutate in (
lambda d: d["deployment_branch_policy"].update(protected_branches=False),
lambda d: d["protection_rules"][0].update(prevent_self_review=False),
lambda d: d["protection_rules"][0].update(reviewers=[]),
):
data = self.protected()
mutate(data)
with self.assertRaises(ValueError):
release_github.validate_environment(data)
def test_gate_does_not_emit_missing_environment_name(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "output"
env = {
"GITHUB_REPOSITORY": release.REPOSITORY,
"GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REF": "refs/heads/main",
"GITHUB_OUTPUT": str(output),
}
with (
patch.dict(os.environ, env),
patch.object(release_github, "api", side_effect=[{"protected": True}, None]),
):
with self.assertRaisesRegex(ValueError, "absent"):
release_github.gate("launcher-release")
self.assertFalse(output.exists())
def test_publication_never_runs_without_exact_confirmation(self):
env = {
"GITHUB_REPOSITORY": release.REPOSITORY,
"GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REF": "refs/heads/main",
}
with patch.dict(os.environ, env), patch.object(release_github, "gh") as client:
with self.assertRaisesRegex(ValueError, "confirmation"):
release_github.publish(ROOT, "0.3.0", "v0.3.0", "publish v0.2.0")
client.assert_not_called()
def test_asset_snapshot_rejects_public_or_incomplete_draft(self):
data = {
"draft": True,
"prerelease": False,
"assets": [
{
"id": 1,
"name": "latest.json",
"size": 10,
"state": "uploaded",
"digest": "sha256:abc",
}
],
}
self.assertEqual(release_github.asset_snapshot(data)["latest.json"][0], 1)
for change in (
{"draft": False},
{"prerelease": True},
{"assets": [dict(data["assets"][0], state="new")]},
{"assets": data["assets"] * 2},
):
with self.assertRaises(ValueError):
release_github.asset_snapshot(dict(data, **change))
def test_version_and_tag_cannot_inject_paths_or_exceed_msi_limits(self):
release.version_tag("0.3.0", "v0.3.0")
for version, tag in (
("0.3.0", "main"),
("0.3.0", "v0.3.0/evil"),
("01.3.0", "v01.3.0"),
("0.3.0-beta", "v0.3.0-beta"),
("256.0.0", "v256.0.0"),
("0.0.65536", "v0.0.65536"),
):
with self.subTest(version=version, tag=tag), self.assertRaises(ValueError):
release.version_tag(version, tag)
if __name__ == "__main__":
unittest.main()