From d458fcbc590f8fdfdad9700ee194068a146c2e26 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:01:37 -0400 Subject: [PATCH] Build a single-file portable desktop app The ZIP is portable only as a folder: Offloader.exe needs _internal beside it and refuses to start without the installation lock file. This adds Offloader-{version}-portable.exe, the desktop app as one self-extracting file that runs from any folder. It skips the installation lock. No installer or maintenance helper manages the folder it sits in, so requiring a lock file would stop it starting and creating one would litter the user's folder. The installed programs still take the lock; the portable path goes through its own entry point rather than a runtime guess about how the executable was frozen. The builder treats it like the other release outputs: it signs and verifies the outer executable, checks its embedded version, records its digest in the build record so --skip-build cannot reuse a swapped file, and lists it in SHA256SUMS.txt. The smoke runner starts a copy from an empty folder with a minimal PATH and fails if it writes anything beside itself. It has no CLI and no in-place update. The ZIP and installer still provide those. The native modules it unpacks at launch keep the signatures they were frozen with; only the outer executable is signed by this pipeline. --- CHANGELOG.md | 6 +++++ build/windows/artifacts.py | 42 +++++++++++++++++++++++++---- build/windows/build.py | 48 ++++++++++++++++++++------------- build/windows/gui_entry.py | 13 ++++++--- build/windows/offloader.spec | 23 ++++++++++++++++ build/windows/portable_entry.py | 15 +++++++++++ build/windows/smoke.py | 25 +++++++++++++++++ docs/build-windows.md | 26 +++++++++++++++--- docs/release-plan.md | 2 +- tests/test_windows_artifacts.py | 23 ++++++++++++++++ tests/test_windows_entry.py | 43 +++++++++++++++++++++++++++++ tests/test_windows_pipeline.py | 37 ++++++++++++++++++++++--- 12 files changed, 268 insertions(+), 35 deletions(-) create mode 100644 build/windows/portable_entry.py create mode 100644 tests/test_windows_entry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5944fed..74f12bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ project uses [semantic versioning][semver]. ### Added +- **A single-file portable desktop app, `Offloader-{version}-portable.exe`.** + It runs from any folder without installing and leaves nothing beside itself. + It takes no installation lock because no installer manages it. The build + signs and smoke-tests it, records its digest in the build record so + `--skip-build` cannot reuse a swapped file, and lists it in `SHA256SUMS.txt`. + It has no CLI or in-place update; the ZIP and installer still provide those. - **`offloader update` finds, verifies and applies a newer release.** GitHub Releases is the feed, so there is no manifest server and no second place a version is written down. Before anything runs: HTTPS with a host allowlist diff --git a/build/windows/artifacts.py b/build/windows/artifacts.py index 8ada11b..37daac1 100644 --- a/build/windows/artifacts.py +++ b/build/windows/artifacts.py @@ -149,11 +149,27 @@ def bundle_inventory(bundle: Path) -> dict[str, str]: return dict(sorted(names.items(), key=lambda item: item[0].casefold())) -def write_build_record(bundle: Path, identity: dict[str, str]) -> dict[str, Any]: - """Write the versioned build record atomically and return its contents.""" +def _portable_entry(portable: Path) -> dict[str, str]: + portable = Path(portable) + _reject_path_links(portable) + if not portable.is_file(): + raise RuntimeError(f"portable executable is missing: {portable}") + return {"name": portable.name, "sha256": _sha256(portable)} + + +def write_build_record( + bundle: Path, identity: dict[str, str], portable: Path | None = None, +) -> dict[str, Any]: + """Write the versioned build record atomically and return its contents. + + The portable executable sits outside the bundle, so its name and digest + are recorded separately when one was built. + """ bundle = Path(bundle) files = bundle_inventory(bundle) record: dict[str, Any] = {"schema": 1, **identity, "files": files} + if portable is not None: + record["portable"] = _portable_entry(portable) target = bundle / _RECORD_NAME _reject_path_links(bundle) fd, temporary_name = tempfile.mkstemp(prefix=".offloader-build-", suffix=".tmp", dir=bundle) @@ -170,8 +186,10 @@ def write_build_record(bundle: Path, identity: dict[str, str]) -> dict[str, Any] return record -def validate_build_record(bundle: Path, identity: dict[str, str]) -> dict[str, Any]: - """Validate a build record and the complete current bundle contents.""" +def validate_build_record( + bundle: Path, identity: dict[str, str], portable: Path | None = None, +) -> dict[str, Any]: + """Validate a build record, the bundle contents, and any portable executable.""" bundle = Path(bundle) target = bundle / _RECORD_NAME _reject_path_links(bundle) @@ -186,7 +204,10 @@ def validate_build_record(bundle: Path, identity: dict[str, str]) -> dict[str, A raise RuntimeError("could not read build record") from exc if not isinstance(record, dict) or record.get("schema") != 1: raise RuntimeError("unsupported or malformed build record schema") - if set(record) != {"schema", *identity, "files"}: + expected_fields = {"schema", *identity, "files"} + if portable is not None: + expected_fields.add("portable") + if set(record) != expected_fields: raise RuntimeError("build record identity fields differ") for key, value in identity.items(): if record.get(key) != value: @@ -203,4 +224,15 @@ def validate_build_record(bundle: Path, identity: dict[str, str]) -> dict[str, A raise RuntimeError("bundle file set differs from build record") if any(recorded[name].lower() != actual[name].lower() for name in actual): raise RuntimeError("bundle file hash differs from build record") + if portable is not None: + recorded_portable = record["portable"] + actual_portable = _portable_entry(portable) + if ( + not isinstance(recorded_portable, dict) + or set(recorded_portable) != {"name", "sha256"} + or recorded_portable["name"] != actual_portable["name"] + ): + raise RuntimeError("malformed or mismatched portable executable record") + if str(recorded_portable["sha256"]).lower() != actual_portable["sha256"]: + raise RuntimeError("portable executable hash differs from build record") return record diff --git a/build/windows/build.py b/build/windows/build.py index 3b2ff8d..b1c7d4f 100644 --- a/build/windows/build.py +++ b/build/windows/build.py @@ -26,12 +26,16 @@ def run(command: list[str]) -> None: subprocess.run(command, cwd=REPO, check=True) -def check_versions(bundle: Path, version: str) -> None: +def portable_path(version: str) -> Path: + return DIST / f"Offloader-{version}-portable.exe" + + +def check_versions(bundle: Path, version: str, portable: Path) -> None: from smoke import file_version - for name in sorted(OWN_EXECUTABLES): - if file_version(bundle / name) != version: - raise RuntimeError(f"Wrong embedded version on {name}") + for path in [*(bundle / name for name in sorted(OWN_EXECUTABLES)), portable]: + if file_version(path) != version: + raise RuntimeError(f"Wrong embedded version on {path.name}") def check_signatures(bundle: Path, *, signing: bool, version: str) -> list[dict]: @@ -57,7 +61,7 @@ def check_signatures(bundle: Path, *, signing: bool, version: str) -> list[dict] return records -def save_outputs(bundle: Path, setup: Path | None, identity: dict, +def save_outputs(bundle: Path, setup: Path | None, portable: Path, identity: dict, signatures: list[dict], signed: bool) -> None: from artifacts import bundle_inventory @@ -77,7 +81,7 @@ def save_outputs(bundle: Path, setup: Path | None, identity: dict, for path in sorted(bundle.rglob("*")): if path.is_file(): output.write(path, f"Offloader/{path.relative_to(bundle).as_posix()}") - outputs = [archive, inventory] + outputs = [archive, portable, inventory] if setup is not None: outputs.append(setup) lines = [] @@ -90,7 +94,7 @@ def save_outputs(bundle: Path, setup: Path | None, identity: dict, (DIST / "SHA256SUMS.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") -def validate_outputs(bundle: Path, setup: Path | None, identity: dict) -> None: +def validate_outputs(bundle: Path, setup: Path | None, portable: Path, identity: dict) -> None: """Require checksummed release outputs from the same completed build.""" from artifacts import bundle_inventory @@ -101,7 +105,7 @@ def validate_outputs(bundle: Path, setup: Path | None, identity: dict) -> None: raise RuntimeError("Output inventory is unsigned or belongs to different sources") if record.get("files") != bundle_inventory(bundle): raise RuntimeError("Output inventory no longer matches the bundle") - expected = {inventory.name, f"Offloader-{version}-windows-x64.zip"} + expected = {inventory.name, f"Offloader-{version}-windows-x64.zip", portable.name} if setup is not None: expected.add(setup.name) checksums = {} @@ -146,13 +150,15 @@ def main(argv: list[str] | None = None) -> int: identity = artifacts.source_identity(REPO) version = identity["version"] setup = None if args.no_installer else DIST / f"Offloader-Setup-{version}.exe" + portable = portable_path(version) if args.verify_only: if incomplete.exists(): raise RuntimeError("The last build did not finish successfully") - artifacts.validate_build_record(bundle, identity) - validate_outputs(bundle, setup, identity) - check_versions(bundle, version) + artifacts.validate_build_record(bundle, identity, portable) + validate_outputs(bundle, setup, portable, identity) + check_versions(bundle, version, portable) check_signatures(bundle, signing=False, version=version) + sign.verify_file(portable, expected_version=version) if setup is not None: sign.verify_file(setup, expected_version=version) print("Artifact signatures, source identity, file hashes, and versions verified.") @@ -170,7 +176,7 @@ def main(argv: list[str] | None = None) -> int: if setup is not None: installer.find_makensis() if args.skip_build: - artifacts.validate_build_record(bundle, identity) + artifacts.validate_build_record(bundle, identity, portable) DIST.mkdir(parents=True, exist_ok=True) incomplete.write_text("Build has not completed verification.\n", encoding="utf-8") if not args.skip_build: @@ -181,14 +187,20 @@ def main(argv: list[str] | None = None) -> int: run([*command, str(SPEC)]) (bundle / ".offloader-install.lock").touch() shutil.copyfile(REPO / "LICENSE", bundle / "LICENSE") - artifacts.write_build_record(bundle, identity) - check_versions(bundle, version) + artifacts.write_build_record(bundle, identity, portable) + check_versions(bundle, version, portable) signatures = [] if not args.no_sign: signatures = check_signatures(bundle, signing=True, version=version) + # Only the outer executable is signed here. The native modules it + # unpacks at launch keep the signatures they were frozen with. + if sign.inspect_file(portable)["signature_status"] == "NotSigned": + sign.sign_file(portable) + signatures.append({**sign.verify_file(portable, expected_version=version), + "path": portable.name}) # The record describes the actual bytes that enter the installer, # including any new Authenticode signatures. - artifacts.write_build_record(bundle, identity) + artifacts.write_build_record(bundle, identity, portable) if setup is not None: uninstaller_record = DIST / ".offloader-uninstaller-signature.json" if not args.no_sign: @@ -206,11 +218,11 @@ def main(argv: list[str] | None = None) -> int: sign.sign_file(setup) signatures.append({**sign.verify_file(setup, expected_version=version), "path": setup.name}) - run([sys.executable, str(HERE / "smoke.py"), str(bundle)]) + run([sys.executable, str(HERE / "smoke.py"), str(bundle), "--portable", str(portable)]) if artifacts.source_identity(REPO) != identity: raise RuntimeError("Sources changed while building; rebuild the candidate") - artifacts.validate_build_record(bundle, identity) - save_outputs(bundle, setup, identity, signatures, signed=not args.no_sign) + artifacts.validate_build_record(bundle, identity, portable) + save_outputs(bundle, setup, portable, identity, signatures, signed=not args.no_sign) incomplete.unlink() print(f"{'Unsigned development' if args.no_sign else 'Signed'} artifacts: {DIST}") return 0 diff --git a/build/windows/gui_entry.py b/build/windows/gui_entry.py index b16b8a0..7de2f30 100644 --- a/build/windows/gui_entry.py +++ b/build/windows/gui_entry.py @@ -34,16 +34,19 @@ def _main() -> int: return gui_main(sys.argv) -def main() -> int: +def main(*, lock: bool = True) -> int: + if not lock: + return _main() + from offloader.installation_lock import frozen_installation_lock with frozen_installation_lock(): return _main() -if __name__ == "__main__": +def run(*, lock: bool = True) -> None: try: - raise SystemExit(main()) + raise SystemExit(main(lock=lock)) except Exception: if os.environ.get("OFFLOADER_GUI_SMOKE") != "1": raise @@ -53,3 +56,7 @@ def main() -> int: traceback.format_exc(), encoding="utf-8", ) raise SystemExit(1) from None + + +if __name__ == "__main__": + run() diff --git a/build/windows/offloader.spec b/build/windows/offloader.spec index 98ffa75..7796c19 100644 --- a/build/windows/offloader.spec +++ b/build/windows/offloader.spec @@ -136,6 +136,29 @@ maintenance_exe = EXE( version=version_resource(VERSION, "offloader-maintenance.exe", "Offloader maintenance"), ) +# The portable desktop app is one self-extracting file outside the bundle. It +# takes no installation lock; see portable_entry.py. +portable_analysis = Analysis( + [str(REPO / "build/windows/portable_entry.py")], + **{**COMMON, "pathex": [str(SRC), str(REPO / "build/windows")]}, +) +PORTABLE_NAME = f"Offloader-{VERSION}-portable" +portable_exe = EXE( + PYZ(portable_analysis.pure), + portable_analysis.scripts, + portable_analysis.binaries, + portable_analysis.datas, + name=PORTABLE_NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + version=version_resource( + VERSION, f"{PORTABLE_NAME}.exe", "Offloader portable desktop application", + ), +) + COLLECT( gui_exe, cli_exe, diff --git a/build/windows/portable_entry.py b/build/windows/portable_entry.py new file mode 100644 index 0000000..a8db465 --- /dev/null +++ b/build/windows/portable_entry.py @@ -0,0 +1,15 @@ +"""Frozen single-file graphical entry point for Offloader. + +The portable executable unpacks itself to a temporary directory on each +launch. It is not an installation: no lock file, maintenance helper, or +installer manages the folder it was copied to. It therefore skips the +installation lock the bundled programs hold, which would otherwise refuse to +start without a lock file beside the executable. +""" + +from __future__ import annotations + +from gui_entry import run + +if __name__ == "__main__": + run(lock=False) diff --git a/build/windows/smoke.py b/build/windows/smoke.py index ba00b06..832f60e 100644 --- a/build/windows/smoke.py +++ b/build/windows/smoke.py @@ -65,11 +65,14 @@ def file_version(path: Path) -> str: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("bundle", nargs="?", type=Path, default=DEFAULT_BUNDLE) + parser.add_argument("--portable", type=Path, help="single-file desktop executable to check") args = parser.parse_args() cli = args.bundle.resolve() / "offloader-cli.exe" gui = args.bundle.resolve() / "Offloader.exe" if not cli.is_file() or not gui.is_file(): parser.error(f"bundle executables not found under {args.bundle}") + if args.portable is not None and not args.portable.is_file(): + parser.error(f"portable executable not found: {args.portable}") with tempfile.TemporaryDirectory(prefix="offloader-smoke-") as temporary: root = Path(temporary) @@ -102,6 +105,28 @@ def main() -> int: print(gui_log.read_text(encoding="utf-8")) raise + if args.portable is not None: + # Run a copy from a folder of its own, as a user would from a + # download or USB stick. It must start without installation files + # and must not leave anything beside itself. + portable_dir = root / "Portable Offloader" + portable_dir.mkdir() + portable = portable_dir / args.portable.name + shutil.copyfile(args.portable, portable) + if file_version(portable) != expected_version: + raise RuntimeError(f"wrong FileVersion metadata on {portable.name}") + portable_env = gui_env.copy() + portable_env["PATH"] = str(system_root / "System32") + try: + run([str(portable)], portable_env, root) + except RuntimeError: + if gui_log.exists(): + print(gui_log.read_text(encoding="utf-8")) + raise + left_behind = sorted(p.name for p in portable_dir.iterdir() if p != portable) + if left_behind: + raise RuntimeError(f"portable executable wrote beside itself: {left_behind}") + source = root / "source" source.mkdir() (source / "clip.bin").write_bytes(bytes(range(256)) * 32) diff --git a/docs/build-windows.md b/docs/build-windows.md index b944711..d1a8bbf 100644 --- a/docs/build-windows.md +++ b/docs/build-windows.md @@ -1,11 +1,24 @@ # Windows builds -The Windows builder produces a directory bundle, a portable ZIP, and an NSIS +The Windows builder produces a directory bundle, a portable ZIP, a single-file +portable desktop app named `Offloader-{version}-portable.exe`, and an NSIS installer named `Offloader-Setup-{version}.exe`. `Offloader.exe` (desktop) and `offloader-cli.exe` (console) need the adjacent `_internal` directory and `.offloader-install.lock`. Copy the entire bundle. The standalone `offloader-maintenance.exe` manages installation and removal. +## Portable executable + +`Offloader-{version}-portable.exe` is the desktop app in one file that runs +from any folder without installation. Each launch unpacks it to a temporary +directory, so it starts more slowly than the installed app. It writes nothing +beside itself; configuration, presets, and history use the same per-user +directory as the installed app. It has no CLI and no in-place updater: use the +ZIP for the CLI, and download a newer file to update. It takes no installation +lock, so the installer does not know it is running, which does not matter +because the installer never changes it. Signing covers the outer executable; +the native modules it unpacks keep the signatures they were built with. + Signing defaults on. Use `--no-sign` for development and hosted CI. Unsigned artifacts are not release downloads. The signing and installer code is implemented; hardware-token signing and independent clean-machine @@ -45,13 +58,15 @@ bundle, so do not build over executables currently in use. | --- | --- | | Default | Require clean sources, sign the bundle, assemble/sign the installer, verify, smoke-test, and write inventories/checksums | | `--no-sign` | Explicit unsigned development output; never accesses the signing key | -| `--no-installer` | Build only the portable bundle; signing still defaults on | +| `--no-installer` | Build only the portable bundle, ZIP, and single-file executable; signing still defaults on | | `--skip-build` | Reuse only a bundle with matching source commit, source digest, version, file set, and hashes | | `--verify-only` | Check signed artifacts, source identity, versions, and final checksums without rebuilding or accessing the key | -The builder writes `.offloader-build.json` inside the bundle, an external +The builder writes `.offloader-build.json` inside the bundle, also recording +the portable executable's digest, an external `Offloader-{version}-inventory.json` with dependency versions and signature -coverage, and `SHA256SUMS.txt` for the final installer, ZIP, and inventory. +coverage, and `SHA256SUMS.txt` for the final installer, ZIP, portable +executable, and inventory. A failed build leaves `.offloader-build-incomplete`; it must not be promoted. Source changes during a build invalidate the candidate. These inventories are provenance and tamper checks, not a complete third-party license inventory or SBOM. @@ -170,6 +185,9 @@ PATH. It then copies disposable data to two destinations, requests all five report formats, re-verifies the copies, and checks that a flipped byte fails verification. Re-verification uses `--allow-cache` so this check exercises packaging and checksum behavior without claiming physical-drive qualification. +Given `--portable PATH`, as the builder does, it also starts a copy of the +single-file executable from an empty folder with a minimal PATH and fails if +the executable leaves any file beside itself. The smoke runner also uses the frozen standalone maintenance helper to install, reinstall, and uninstall in a temporary directory. It checks the installed CLI, diff --git a/docs/release-plan.md b/docs/release-plan.md index 346c5a2..134c847 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -244,7 +244,7 @@ Implemented interface for `build/windows/build.py`: | `--no-sign` | Explicit unsigned development build; required in hosted PR CI; not eligible for publication | | `--skip-build` | Repackage and sign the existing bundle after confirming its version and source identity | | `--verify-only` | Verify existing application/installer signatures without signing or rebuilding | -| `--no-installer` | Produce the portable bundle; signing still defaults on | +| `--no-installer` | Produce the portable bundle, ZIP, and single-file executable; signing still defaults on | These modes are implemented; signed release use still requires the hardware key, signature qualification, and the release gates below. diff --git a/tests/test_windows_artifacts.py b/tests/test_windows_artifacts.py index 22b846b..ca0487b 100644 --- a/tests/test_windows_artifacts.py +++ b/tests/test_windows_artifacts.py @@ -46,6 +46,29 @@ def test_build_record_round_trip_and_tamper_detection(tmp_path: Path): artifacts.validate_build_record(bundle, identity) +def test_build_record_covers_portable_executable(tmp_path: Path): + bundle = _bundle(tmp_path) + portable = tmp_path / "Offloader-0.1.0-portable.exe" + portable.write_bytes(b"portable") + identity = {"version": "0.1.0", "source_commit": "abc", "source_digest": "d" * 64} + record = artifacts.write_build_record(bundle, identity, portable) + assert record["portable"]["name"] == portable.name + assert artifacts.validate_build_record(bundle, identity, portable) == record + # A record written for the portable build cannot be reused without it, + # and one written without it cannot vouch for a portable file. + with pytest.raises(RuntimeError, match="identity fields"): + artifacts.validate_build_record(bundle, identity) + portable.write_bytes(b"swapped") + with pytest.raises(RuntimeError, match="portable executable hash"): + artifacts.validate_build_record(bundle, identity, portable) + artifacts.write_build_record(bundle, identity) + with pytest.raises(RuntimeError, match="identity fields"): + artifacts.validate_build_record(bundle, identity, portable) + portable.unlink() + with pytest.raises(RuntimeError, match="missing"): + artifacts.write_build_record(bundle, identity, portable) + + def test_record_rejects_wrong_identity_and_unexpected_file(tmp_path: Path): bundle = _bundle(tmp_path) identity = {"version": "0.1.0", "source_commit": "abc", "source_digest": "d" * 64} diff --git a/tests/test_windows_entry.py b/tests/test_windows_entry.py new file mode 100644 index 0000000..8327a91 --- /dev/null +++ b/tests/test_windows_entry.py @@ -0,0 +1,43 @@ +"""The portable desktop entry starts without installation files.""" + +from __future__ import annotations + +import importlib.util +import runpy +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +BUILD = Path(__file__).resolve().parents[1] / "build/windows" + + +@pytest.fixture +def gui_entry(tmp_path, monkeypatch): + spec = importlib.util.spec_from_file_location("gui_entry", BUILD / "gui_entry.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + # A frozen executable in a folder with no lock file, as after a download. + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(tmp_path / "Offloader-portable.exe")) + monkeypatch.setattr(module, "_main", lambda: 0) + return module + + +def test_installed_entry_requires_installation_lock(gui_entry): + with pytest.raises(FileNotFoundError): + gui_entry.main() + + +def test_portable_entry_skips_installation_lock(gui_entry, tmp_path): + assert gui_entry.main(lock=False) == 0 + assert list(tmp_path.iterdir()) == [] + + +def test_portable_entry_runs_without_lock(monkeypatch): + calls = [] + monkeypatch.setitem(sys.modules, "gui_entry", + SimpleNamespace(run=lambda **kwargs: calls.append(kwargs))) + runpy.run_path(str(BUILD / "portable_entry.py"), run_name="__main__") + assert calls == [{"lock": False}] diff --git a/tests/test_windows_pipeline.py b/tests/test_windows_pipeline.py index 1ba9809..e8ea91c 100644 --- a/tests/test_windows_pipeline.py +++ b/tests/test_windows_pipeline.py @@ -23,7 +23,7 @@ def pipeline(tmp_path, monkeypatch): artifacts = SimpleNamespace( source_identity=lambda _: identity, validate_build_record=lambda *args: calls.append("validate"), - write_build_record=lambda *args: calls.append("record"), + write_build_record=lambda *args: calls.append(("record", args[2:])), ) def build_installer(*args, **kwargs): calls.append(("installer", kwargs)) @@ -33,10 +33,19 @@ def build_installer(*args, **kwargs): find_makensis=lambda: calls.append("nsis"), build_installer=build_installer, ) + def sign_file(path): + calls.append("sign portable" if Path(path).name.endswith("-portable.exe") + else "sign installer") + + def verify_file(path, **kwargs): + calls.append(("verify", Path(path).name)) + return {"verified": True} + sign = SimpleNamespace( preflight=lambda: calls.append("preflight"), - sign_file=lambda *args: calls.append("sign installer"), - verify_file=lambda *args, **kwargs: {"verified": True}, + inspect_file=lambda path: {"signature_status": "NotSigned"}, + sign_file=sign_file, + verify_file=verify_file, ) for name, value in (("artifacts", artifacts), ("installer", installer), ("sign", sign)): monkeypatch.setitem(sys.modules, name, value) @@ -55,6 +64,7 @@ def test_unsigned_build_never_accesses_signing_key(pipeline): assert "preflight" not in calls assert "sign bundle" not in calls assert "sign installer" not in calls + assert "sign portable" not in calls assert ("installer", {"sign_command": None}) in calls assert "smoke" in calls assert not (module.DIST / ".offloader-build-incomplete").exists() @@ -68,6 +78,25 @@ def test_signed_pipeline_orders_signing_before_assembly(pipeline): assert calls.index("sign installer") < calls.index("smoke") +def test_portable_is_signed_verified_and_recorded(pipeline): + module, calls, _, _ = pipeline + assert module.main([]) == 0 + portable = module.portable_path("0.1.0") + records = [i for i, value in enumerate(calls) if isinstance(value, tuple) and value[0] == "record"] + assert all(calls[i] == ("record", (portable,)) for i in records) + signed = calls.index("sign portable") + assert signed < calls.index(("verify", portable.name)) < records[-1] + outputs = next(value for value in calls if isinstance(value, tuple) and value[0] == "outputs") + assert outputs[1]["signed"] is True + + +def test_verify_only_checks_portable_signature(pipeline): + module, calls, _, _ = pipeline + assert module.main(["--verify-only"]) == 0 + assert ("verify", module.portable_path("0.1.0").name) in calls + assert "sign portable" not in calls + + def test_signing_failure_leaves_candidate_incomplete(pipeline): module, calls, _, sign = pipeline @@ -98,7 +127,7 @@ def test_verify_only_does_not_sign_rebuild_or_publish(pipeline): assert "validate" in calls assert "preflight" not in calls assert "freeze" not in calls - assert "record" not in calls + assert not any(isinstance(value, tuple) and value[0] == "record" for value in calls) assert "sign installer" not in calls