Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions .github/scripts/tauri-release.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@
"nsis": ("-setup.exe",),
"msi": (".msi",),
}
# Bundle kind -> the installer tauri-plugin-updater names in its `{os}-{arch}-{installer}` platform
# key (read from the bundle type the bundler patches into each package's binary). A dmg is not an
# updater format.
UPDATER_INSTALLERS = {
"deb": "deb",
"rpm": "rpm",
"appimage": "appimage",
"nsis": "nsis",
"msi": "msi",
"app": "app",
}
# Arch token Tauri itself uses in dmg/nsis/msi file names, reused when a bundle
# name would otherwise collide across targets (macOS `.app.tar.gz`).
ARCH_FILE_TOKEN = {"x86_64": "x64", "aarch64": "aarch64", "i686": "x86", "armv7": "armv7"}
Expand Down Expand Up @@ -607,6 +618,17 @@ def cmd_updater_json(args: argparse.Namespace) -> None:
if args.targets_file is not None:
expected_platforms = {entry["updater_platform"] for entry in load_targets(args.targets_file)}

def platform_entry(key: str, name: str) -> dict:
signature_path = args.dist / f"{name}.sig"
if not signature_path.is_file():
raise Failure(f"{key}: signature file {name}.sig is missing")
decode_signature(signature_path)
return {
"signature": signature_path.read_text(encoding="utf-8").strip(),
"url": f"https://github.com/{args.repo}/releases/download/{args.tag}/"
+ quote(name, safe=""),
}

platforms: dict[str, dict] = {}
for record in evidence:
updater = record["updater"]
Expand All @@ -619,15 +641,26 @@ def cmd_updater_json(args: argparse.Namespace) -> None:
raise Failure(f"duplicate updater platform {key}")
if key != updater_platform_for(record["target"]):
raise Failure(f"{record['target']}: evidence updater_platform {key} is wrong")
signature_path = args.dist / updater["signature"]
if not signature_path.is_file():
raise Failure(f"{key}: signature file {updater['signature']} is missing")
decode_signature(signature_path)
platforms[key] = {
"signature": signature_path.read_text(encoding="utf-8").strip(),
"url": f"https://github.com/{args.repo}/releases/download/{args.tag}/"
+ quote(updater["name"], safe=""),
}
if updater["signature"] != f"{updater['name']}.sig":
raise Failure(f"{key}: updater signature {updater['signature']} does not belong to {updater['name']}")
platforms[key] = platform_entry(key, updater["name"])
# tauri-plugin-updater asks for `{os}-{arch}-{installer}` first, where the installer is the
# one the bundler wrote into this package's binary, and falls back to `{os}-{arch}`: without
# these keys a copy installed from the .deb, the .rpm or the .msi would download the
# updater bundle above and refuse it as the wrong format.
per_kind: dict[str, str] = {}
for item in record["files"]:
installer = UPDATER_INSTALLERS.get(item["kind"])
if installer is None or not item["signature"]:
continue
if installer in per_kind:
raise Failure(
f"{record['target']}: two signed {item['kind']} packages "
f"({per_kind[installer]}, {item['name']}); an installed copy could be either"
)
per_kind[installer] = item["name"]
for installer, name in sorted(per_kind.items()):
platforms[f"{key}-{installer}"] = platform_entry(f"{key}-{installer}", name)

if not platforms:
raise Failure("no updater platforms collected; refusing to write an empty latest.json")
Expand Down
139 changes: 100 additions & 39 deletions .github/scripts/test-tauri-release.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
#!/usr/bin/env python3
"""Offline test of `tauri-release.py collect` against Lockra's own target manifest with the updater
off (Lockra sets no bundle.createUpdaterArtifacts): every leg collects what Tauri writes then, the
macOS legs their dmg (the .app stays a directory, which only an updater build archives), and a leg
whose bundles are missing fails. It also checks that deny.toml's [graph] targets are exactly the
shipped targets. Run by scripts/verify-all.sh and the CI config job.
"""Offline test of `tauri-release.py collect` and `updater-json` against Lockra's own target
manifest and its real updater configuration: every package of every leg is signed and collected,
the macOS legs archive the .app for the updater beside their dmg, a leg without a signature or an
archive fails, and latest.json carries one key per target plus one per installer, so a copy
installed from the .deb, the .rpm or the .msi updates through its own package. It also checks the
update settings in tauri.conf.json and that deny.toml's [graph] targets are exactly the shipped
targets. Run by scripts/verify-all.sh and the CI config job.

Stdlib only and Python 3.9 or later, like tauri-release.py, so it runs wherever `make check` does
(macOS ships Python 3.9); it needs no jq, GNU tool or TOML library.
"""

from __future__ import annotations

import base64
import json
import re
import shutil
import subprocess
import sys
import tempfile
Expand All @@ -22,33 +24,66 @@
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / ".github" / "scripts" / "tauri-release.py"
MANIFEST = ROOT / ".github" / "release-targets.json"
TAURI_CONF = ROOT / "apps" / "desktop" / "src-tauri" / "tauri.conf.json"
DENY = ROOT / "deny.toml"
REPO = "sunerpy/lockra"
ENDPOINT = f"https://github.com/{REPO}/releases/latest/download/latest.json"
VERSION = "1.2.3"


def fail(message: str) -> None:
sys.exit(f"test-tauri-release: {message}")


def signature(name: str) -> str:
"""A `.sig` as the Tauri CLI writes it: base64 of minisign text with a trusted comment."""
text = (
"untrusted comment: signature from tauri secret key\nRUQfixture\n"
f"trusted comment: timestamp:1790000000\tfile:{name}\tversion:{VERSION}\nZml4dHVyZQ==\n"
)
return base64.b64encode(text.encode()).decode()


def check_update_config(config: dict) -> None:
"""The settings the in-app update relies on (docs/release.md, "In-app updates")."""
if config.get("bundle", {}).get("createUpdaterArtifacts") is not True:
fail("tauri.conf.json: bundle.createUpdaterArtifacts must be true")
updater = config.get("plugins", {}).get("updater", {})
try:
pubkey = base64.b64decode(updater.get("pubkey", ""), validate=True).decode()
except ValueError:
fail("tauri.conf.json: plugins.updater.pubkey is not base64")
if not pubkey.startswith("untrusted comment: minisign public key"):
fail("tauri.conf.json: plugins.updater.pubkey is not a minisign public key")
if updater.get("endpoints") != [ENDPOINT]:
fail(f"tauri.conf.json: plugins.updater.endpoints must be [{ENDPOINT}]")
if updater.get("requireSignedVersion") is not True:
fail("tauri.conf.json: plugins.updater.requireSignedVersion must be true")
if updater.get("windows", {}).get("installMode") != "passive":
fail("tauri.conf.json: plugins.updater.windows.installMode must be passive")


class Fixture:
"""A Tauri project directory with the bundles each target's `tauri bundle` writes."""

def __init__(self, root: Path) -> None:
def __init__(self, root: Path, config: dict) -> None:
self.root = root
repo = root / "repo"
self.project = repo / "src-tauri"
self.project.mkdir(parents=True)
(repo / "package.json").write_text(json.dumps({"name": "lockra", "version": VERSION}), encoding="utf-8")
# The shape of Lockra's tauri.conf.json that collect reads: no updater artifacts, no plugin.
config = {"productName": "Lockra", "version": "../package.json", "identifier": "dev.lockra.desktop",
"bundle": {"active": True, "targets": "all"}}
(self.project / "tauri.conf.json").write_text(json.dumps(config), encoding="utf-8")
# The parts of Lockra's tauri.conf.json that collect reads, the updater block included.
fixture = {"productName": "Lockra", "version": "../package.json", "identifier": "dev.lockra.desktop",
"bundle": config["bundle"], "plugins": config["plugins"]}
(self.project / "tauri.conf.json").write_text(json.dumps(fixture), encoding="utf-8")
self.runs = 0

def bundle(self, target: str, directory: str, name: str) -> Path:
def bundle(self, target: str, directory: str, name: str, signed: bool = True) -> Path:
path = self.project / "target" / target / "release" / "bundle" / directory / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"bundle {target} {name}\n", encoding="utf-8")
if signed:
path.with_name(f"{name}.sig").write_text(signature(name), encoding="utf-8")
return path

def app(self, target: str) -> Path:
Expand Down Expand Up @@ -89,24 +124,29 @@ def main() -> None:
if sys.version_info < (3, 9):
fail("needs Python 3.9 or later")
targets = [entry["target"] for entry in json.loads(MANIFEST.read_text(encoding="utf-8"))["targets"]]
config = json.loads(TAURI_CONF.read_text(encoding="utf-8"))
check_update_config(config)

with tempfile.TemporaryDirectory() as tmp:
fx = Fixture(Path(tmp))
arm_dmg = fx.bundle("aarch64-apple-darwin", "dmg", f"Lockra_{VERSION}_aarch64.dmg")
fx = Fixture(Path(tmp), config)
arm_archive = fx.bundle("aarch64-apple-darwin", "macos", "Lockra.app.tar.gz")
x64_msi = fx.bundle("x86_64-pc-windows-msvc", "msi", f"Lockra_{VERSION}_x64_en-US.msi")
expected = [
arm_dmg,
x64_msi,
fx.bundle("x86_64-unknown-linux-gnu", "deb", f"Lockra_{VERSION}_amd64.deb"),
fx.bundle("x86_64-unknown-linux-gnu", "rpm", f"Lockra-{VERSION}-1.x86_64.rpm"),
fx.bundle("x86_64-unknown-linux-gnu", "appimage", f"Lockra_{VERSION}_amd64.AppImage"),
fx.bundle("aarch64-unknown-linux-gnu", "deb", f"Lockra_{VERSION}_arm64.deb"),
fx.bundle("aarch64-unknown-linux-gnu", "rpm", f"Lockra-{VERSION}-1.aarch64.rpm"),
fx.bundle("aarch64-unknown-linux-gnu", "appimage", f"Lockra_{VERSION}_aarch64.AppImage"),
fx.bundle("x86_64-apple-darwin", "dmg", f"Lockra_{VERSION}_x64.dmg"),
fx.bundle("x86_64-pc-windows-msvc", "nsis", f"Lockra_{VERSION}_x64-setup.exe"),
fx.bundle("aarch64-pc-windows-msvc", "nsis", f"Lockra_{VERSION}_arm64-setup.exe"),
]
packages = {
"linux-x86_64-deb": fx.bundle("x86_64-unknown-linux-gnu", "deb", f"Lockra_{VERSION}_amd64.deb").name,
"linux-x86_64-rpm": fx.bundle("x86_64-unknown-linux-gnu", "rpm", f"Lockra-{VERSION}-1.x86_64.rpm").name,
"linux-x86_64-appimage": fx.bundle("x86_64-unknown-linux-gnu", "appimage", f"Lockra_{VERSION}_amd64.AppImage").name,
"linux-aarch64-deb": fx.bundle("aarch64-unknown-linux-gnu", "deb", f"Lockra_{VERSION}_arm64.deb").name,
"linux-aarch64-rpm": fx.bundle("aarch64-unknown-linux-gnu", "rpm", f"Lockra-{VERSION}-1.aarch64.rpm").name,
"linux-aarch64-appimage": fx.bundle("aarch64-unknown-linux-gnu", "appimage", f"Lockra_{VERSION}_aarch64.AppImage").name,
"darwin-aarch64-app": "Lockra_aarch64.app.tar.gz",
"darwin-x86_64-app": "Lockra_x64.app.tar.gz",
"windows-x86_64-nsis": fx.bundle("x86_64-pc-windows-msvc", "nsis", f"Lockra_{VERSION}_x64-setup.exe").name,
"windows-x86_64-msi": x64_msi.name,
"windows-aarch64-nsis": fx.bundle("aarch64-pc-windows-msvc", "nsis", f"Lockra_{VERSION}_arm64-setup.exe").name,
}
fx.bundle("x86_64-apple-darwin", "macos", "Lockra.app.tar.gz")
dmgs = [fx.bundle("aarch64-apple-darwin", "dmg", f"Lockra_{VERSION}_aarch64.dmg", signed=False).name,
fx.bundle("x86_64-apple-darwin", "dmg", f"Lockra_{VERSION}_x64.dmg", signed=False).name]
fx.app("aarch64-apple-darwin")
fx.app("x86_64-apple-darwin")

Expand All @@ -116,26 +156,47 @@ def main() -> None:
if run.returncode != 0:
fail(f"collect failed for {target}: {run.stderr.strip()}")
actual = sorted(path.name for path in out.iterdir())
wanted = sorted(path.name for path in expected)
wanted = sorted([*packages.values(), *(f"{name}.sig" for name in packages.values()), *dmgs])
if actual != wanted:
fail(f"collected {actual}, expected {wanted}")
for target in ("aarch64-apple-darwin", "x86_64-apple-darwin"):
for target in targets:
record = json.loads((evidence / f"{target}.json").read_text(encoding="utf-8"))
if record["updater_enabled"] or record["updater"] is not None or [f["kind"] for f in record["files"]] != ["dmg"]:
fail(f"{target}: evidence is not the dmg alone: {record}")
if not record["updater_enabled"] or record["updater"] is None:
fail(f"{target}: the updater bundle was not recorded: {record}")
if len(list(evidence.glob("*.json"))) != len(targets):
fail("one evidence file per target expected")

# A macOS leg whose bundle step produced no .app, or no dmg, fails; so does any other leg
# with a bundle missing.
shutil.rmtree(fx.app("aarch64-apple-darwin"))
fx.expect_failure("macOS leg without its .app", "aarch64-apple-darwin")
fx.app("aarch64-apple-darwin")
arm_dmg.unlink()
fx.expect_failure("macOS leg without its dmg", "aarch64-apple-darwin")
latest = out / "latest.json"
run = subprocess.run([sys.executable, str(SCRIPT), "updater-json", "--dist", str(out), "--evidence-dir", str(evidence),
"--repo", REPO, "--tag", f"v{VERSION}", "--version", VERSION, "--targets-file", str(MANIFEST),
"--out", str(latest)], capture_output=True, text=True)
if run.returncode != 0:
fail(f"updater-json failed: {run.stderr.strip()}")
platforms = json.loads(latest.read_text(encoding="utf-8"))["platforms"]
generic = {"linux-x86_64": "linux-x86_64-appimage", "linux-aarch64": "linux-aarch64-appimage",
"darwin-aarch64": "darwin-aarch64-app", "darwin-x86_64": "darwin-x86_64-app",
"windows-x86_64": "windows-x86_64-nsis", "windows-aarch64": "windows-aarch64-nsis"}
if sorted(platforms) != sorted([*packages, *generic]):
fail(f"latest.json platforms {sorted(platforms)}, expected {sorted([*packages, *generic])}")
base = f"https://github.com/{REPO}/releases/download/v{VERSION}/"
for key, name in packages.items():
entry = platforms[key]
if entry["url"] != base + name or entry["signature"] != (out / f"{name}.sig").read_text(encoding="utf-8").strip():
fail(f"latest.json {key} does not point at {name} and its signature: {entry}")
for key, specific in generic.items():
if platforms[key] != platforms[specific]:
fail(f"latest.json {key} is not its updater bundle {specific}")

# A leg whose updater bundle has no signature fails, and so does a macOS leg whose bundle
# step archived no .app or produced no dmg.
(fx.project / "target/x86_64-unknown-linux-gnu/release/bundle/appimage" / f"Lockra_{VERSION}_amd64.AppImage.sig").unlink()
fx.expect_failure("Linux leg without the AppImage signature", "x86_64-unknown-linux-gnu")
arm_archive.unlink()
fx.expect_failure("macOS leg without its .app archive", "aarch64-apple-darwin")
x64_msi.unlink()
fx.expect_failure("Windows leg without its msi", "x86_64-pc-windows-msvc")
print(f"test-tauri-release: collect passed for {len(targets)} targets with the updater off")
print(f"test-tauri-release: collect and latest.json passed for {len(targets)} targets "
f"({len(packages) + len(generic)} updater platforms)")

# cargo-deny checks the graph of exactly the targets that ship, so a target added to the
# manifest cannot escape the licence, ban and advisory policy.
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,14 @@ jobs:
- name: shellcheck
run: shellcheck scripts/*.sh .github/scripts/*.sh

# The one-line installers, offline: a fake GitHub and fake package managers for install.sh,
# and PowerShell 7 with the cmdlets that reach the network or the registry replaced for
# install.ps1. install-scripts.yml runs them for real on each system.
- name: Install scripts
run: |
scripts/test-install.sh
pwsh -NoProfile -File scripts/test-install.ps1

deny:
name: Dependency policy
runs-on: ubuntu-latest
Expand Down
Loading
Loading