Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 37 additions & 5 deletions build/windows/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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
48 changes: 30 additions & 18 deletions build/windows/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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

Expand All @@ -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 = []
Expand All @@ -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

Expand All @@ -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 = {}
Expand Down Expand Up @@ -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.")
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
13 changes: 10 additions & 3 deletions build/windows/gui_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,3 +56,7 @@ def main() -> int:
traceback.format_exc(), encoding="utf-8",
)
raise SystemExit(1) from None


if __name__ == "__main__":
run()
23 changes: 23 additions & 0 deletions build/windows/offloader.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions build/windows/portable_entry.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions build/windows/smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 22 additions & 4 deletions docs/build-windows.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/release-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading