From c482df29fcc72fa71e81fcf6e486a7d5952caf04 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 6 Nov 2026 11:02:00 -0500 Subject: [PATCH 1/2] feat(install): add verified release wheel installer --- .gitattributes | 1 + .github/workflows/install.yml | 62 ++++++++++++ README.md | 25 +++++ docs/demos/installer/README.md | 9 ++ docs/demos/installer/demo.py | 24 +++++ docs/demos/installer/output.log | 7 ++ docs/demos/installer/timing.log | 4 + install.sh | 113 +++++++++++++++++++++ tests/test_installer.py | 167 ++++++++++++++++++++++++++++++++ 9 files changed, 412 insertions(+) create mode 100644 .github/workflows/install.yml create mode 100644 docs/demos/installer/README.md create mode 100644 docs/demos/installer/demo.py create mode 100644 docs/demos/installer/output.log create mode 100644 docs/demos/installer/timing.log create mode 100644 install.sh create mode 100644 tests/test_installer.py diff --git a/.gitattributes b/.gitattributes index b522f85..2d9fecd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # Preserve raw terminal bytes and replay timing; do not normalize the capture. docs/demos/output.log -text -diff +docs/demos/installer/output.log -text whitespace=-blank-at-eol diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 0000000..0cf70fa --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,62 @@ +name: Release wheel installer + +on: + pull_request: + paths: [install.sh, tests/test_installer.py, .github/workflows/install.yml] + push: + branches: [main] + paths: [install.sh, tests/test_installer.py, .github/workflows/install.yml] + +permissions: + contents: read + +jobs: + install: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 + with: + version: '0.12.0' + enable-cache: false + - name: Actual installer controls + run: python3 tests/test_installer.py + - name: Published release private install and retained-destination refusal + shell: bash + run: | + set -euo pipefail + export PREFIX="$RUNNER_TEMP/willay-release" + export VERSION=0.1.0 + sh install.sh --dry-run + test ! -e "$PREFIX" + sh install.sh + test "$("$PREFIX/bin/willay" --version)" = 'willay 0.1.0' + if sh install.sh; then + echo 'existing install was overwritten' >&2 + exit 1 + fi + test "$("$PREFIX/bin/willay" --version)" = 'willay 0.1.0' + - name: Default command discovery without shell-profile changes + shell: bash + run: | + set -euo pipefail + export HOME="$RUNNER_TEMP/willay-default-home" + mkdir -p "$HOME" + unset PREFIX + export VERSION=0.1.0 + sh install.sh --dry-run + test ! -e "$HOME/.local" + sh install.sh + test -L "$HOME/.local/bin/willay" + test "$("$HOME/.local/bin/willay" --version)" = 'willay 0.1.0' + test ! -e "$HOME/.profile" diff --git a/README.md b/README.md index 1b3e505..f29c040 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,31 @@ and can publish it to an already-private podcast feed. Willay does not call a language model. The agent writing the script already is one. +## Install + +With Python 3.12+ and [uv](https://docs.astral.sh/uv/) already installed: + +```sh +curl -fsSL https://raw.githubusercontent.com/intigraph/willay/main/install.sh | sh +``` + +The installer selects the latest stable release, verifies its wheel against the +release's SHA256SUMS, and installs it with uv into a new private version directory +under `~/.local/share/willay`, then links the verified command at +`~/.local/bin/willay`. Add `~/.local/bin` to PATH if needed. It refuses an existing +destination or command, including a dangling command symlink; `VERSION=0.1.0` pins a release, `PREFIX` or `--prefix` +chooses a new destination, and `--dry-run` checks metadata without installing or +verifying payload bytes. To pass options through a pipe use `sh -s -- --dry-run`; +set VERSION/PREFIX on the `sh` process, not on curl. + +The release checksum trusts the publisher and HTTPS; it is not an independent +signature. Runtime dependencies are resolved from PyPI by uv and are not covered +by the release wheel checksum. No Python runtime is downloaded. No API key, +rendering, feed, service or shell profile is configured. To remove this install, +remove only its printed version directory and its command symlink. A custom +prefix keeps the command in that prefix’s `bin` directory without creating the +default command link. + ## See it in action ```sh diff --git a/docs/demos/installer/README.md b/docs/demos/installer/README.md new file mode 100644 index 0000000..24b8714 --- /dev/null +++ b/docs/demos/installer/README.md @@ -0,0 +1,9 @@ +The recording runs the actual embedded installer through the retained fixture +controls. It demonstrates successful private installation, retained-destination +refusal, checksum refusal before destination creation, and wrong-version cleanup. +HTTP and uv are mocked. It does not prove a real wheel installs on any OS. + +Reproduce with `python3 docs/demos/installer/demo.py`. Replay the recorded terminal +with `scriptreplay --log-timing docs/demos/installer/timing.log --log-out +docs/demos/installer/output.log`. The hosted installer workflow separately tests +the real published v0.1.0 wheel; those results remain pending. diff --git a/docs/demos/installer/demo.py b/docs/demos/installer/demo.py new file mode 100644 index 0000000..eb884e5 --- /dev/null +++ b/docs/demos/installer/demo.py @@ -0,0 +1,24 @@ +"""Exercise the actual installer through the retained HTTP/uv fixture controls.""" +import contextlib +import importlib.util +import io +from pathlib import Path + +root = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location('installer_controls', root / 'tests/test_installer.py') +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +for method, caption in [ + ('test_install_and_refuse_overwrite', 'Private install succeeds; existing destination refuses overwrite.'), + ('test_corrupt_payload_refuses_before_destination', 'Corrupt wheel refuses before creating the destination.'), + ('test_wrong_installed_version_cleanup', 'Wrong installed version refuses and cleans the new directory.'), +]: + control = module.InstallerTests(method) + control.setUp() + try: + with contextlib.redirect_stdout(io.StringIO()): + getattr(control, method)() + print('PASS:', caption) + finally: + control.doCleanups() +print('HTTP and uv are mocked; installer code is real. No real release or platform acceptance claimed.') diff --git a/docs/demos/installer/output.log b/docs/demos/installer/output.log new file mode 100644 index 0000000..5990d39 --- /dev/null +++ b/docs/demos/installer/output.log @@ -0,0 +1,7 @@ +Script started on 2026-11-04 17:45:32-05:00 [COMMAND="python3 docs/demos/installer/demo.py" ] +PASS: Private install succeeds; existing destination refuses overwrite. +PASS: Corrupt wheel refuses before creating the destination. +PASS: Wrong installed version refuses and cleans the new directory. +HTTP and uv are mocked; installer code is real. No real release or platform acceptance claimed. + +Script done on 2026-11-04 17:45:32-05:00 [COMMAND_EXIT_CODE="0"] diff --git a/docs/demos/installer/timing.log b/docs/demos/installer/timing.log new file mode 100644 index 0000000..5467712 --- /dev/null +++ b/docs/demos/installer/timing.log @@ -0,0 +1,4 @@ +0.052855 73 +0.001400 62 +0.000882 69 +0.000023 97 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..46f38c7 --- /dev/null +++ b/install.sh @@ -0,0 +1,113 @@ +#!/bin/sh +# Install a published wheel with an existing uv and Python 3.12+. +set -eu +exec python3 - "$@" <<'PY' +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import urllib.request + + +def download(url, limit): + with urllib.request.urlopen(url, timeout=30) as response: + data = response.read(limit + 1) + if len(data) > limit: + raise ValueError('download exceeds size limit') + return data + + +def install(argv=None): + parser = argparse.ArgumentParser(description='Install a checksummed Willay release wheel') + parser.add_argument('--prefix', default=os.environ.get('PREFIX')) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args(argv) + if sys.version_info < (3, 12): + raise ValueError('Python 3.12+ is required; no interpreter will be downloaded') + version = os.environ.get('VERSION', '') + if version and not re.fullmatch(r'\d+\.\d+\.\d+', version): + raise ValueError('VERSION must be a stable version such as 0.1.0') + api = 'https://api.github.com/repos/intigraph/willay/releases/' + release = json.loads(download(api + ('tags/v' + version if version else 'latest'), 2**20)) + tag = release['tag_name'] + if release.get('draft') or release.get('prerelease') or not re.fullmatch( + r'v\d+\.\d+\.\d+', tag + ) or (version and tag != 'v' + version): + raise ValueError('release is not the requested stable version') + version = tag[1:] + wheel = f'willay-{version}-py3-none-any.whl' + base = f'https://github.com/intigraph/willay/releases/download/{tag}/' + assets = {asset['name']: asset['browser_download_url'] for asset in release['assets']} + for name in ('SHA256SUMS', wheel): + if assets.get(name) != base + name: + raise ValueError('missing or noncanonical release asset: ' + name) + prefix = Path(args.prefix).expanduser().absolute() if args.prefix else ( + Path.home() / '.local/share/willay' / tag + ) + if prefix.exists() or prefix.is_symlink(): + raise ValueError('destination already exists; retained unchanged: ' + str(prefix)) + entrypoint = None if args.prefix else Path.home() / '.local/bin/willay' + if entrypoint is not None and (entrypoint.exists() or entrypoint.is_symlink()): + raise ValueError('command already exists; retained unchanged: ' + str(entrypoint)) + uv = shutil.which('uv') + if not uv: + raise ValueError('uv is required; install it from https://docs.astral.sh/uv/') + if args.dry_run: + print(f'Would install Willay {version} into {prefix}; metadata only, payload not verified') + return + sums = download(assets['SHA256SUMS'], 65536).decode('ascii') + matches = [line.split()[0] for line in sums.splitlines() + if len(line.split()) == 2 and line.split()[1].lstrip('*') == wheel] + if len(matches) != 1 or not re.fullmatch(r'[a-fA-F0-9]{64}', matches[0]): + raise ValueError('missing or ambiguous wheel checksum') + data = download(assets[wheel], 16 * 2**20) + if hashlib.sha256(data).hexdigest() != matches[0].lower(): + raise ValueError('wheel checksum mismatch; nothing installed') + # uv environments contain absolute paths: create at the final private location. + # Refuse existing destinations and remove only the new directory on failure. + prefix.mkdir(parents=True, exist_ok=False) + try: + with tempfile.TemporaryDirectory(prefix='willay-wheel-') as directory: + artifact = Path(directory) / wheel + artifact.write_bytes(data) + environment = {key: os.environ[key] for key in ('HOME', 'PATH', 'TMPDIR') + if key in os.environ} + environment.update(UV_TOOL_DIR=str(prefix / 'tools'), + UV_TOOL_BIN_DIR=str(prefix / 'bin'), + UV_PYTHON_DOWNLOADS='never', UV_NO_CONFIG='1', + UV_NO_CACHE='1') + subprocess.run([uv, 'tool', 'install', '--python', sys.executable, + '--no-managed-python', '--index-url', 'https://pypi.org/simple', + str(artifact)], env=environment, check=True, timeout=240) + result = subprocess.run([str(prefix / 'bin/willay'), '--version'], + env=environment, check=True, capture_output=True, + text=True, timeout=30) + if result.stdout.strip() != 'willay ' + version: + raise ValueError('installed command version does not match the release') + if entrypoint is not None: + entrypoint.parent.mkdir(parents=True, exist_ok=True) + entrypoint.symlink_to(prefix / 'bin/willay') + except BaseException: + shutil.rmtree(prefix) + raise + print(f'Installed Willay {version}: {prefix}/bin/willay') + if entrypoint is not None: + print(f'Command: {entrypoint}; add {entrypoint.parent} to PATH if needed.') + else: + print(f'Add {prefix}/bin to PATH.') + print('No credentials, feed, service or shell profile were configured.') + + +if __name__ == '__main__': + try: + install() + except Exception as error: + print('Willay install refused: ' + str(error), file=sys.stderr) + raise SystemExit(1) +PY diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..41a178c --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,167 @@ +"""Actual embedded installer controls; no package installs or network calls.""" + +import hashlib +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SOURCE = Path(__file__).parents[1] / "install.sh" +PAYLOAD = SOURCE.read_text().split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] +INSTALLER = {"__name__": "installer_fixture"} +exec(compile(PAYLOAD, str(SOURCE), "exec"), INSTALLER) + + +class InstallerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="willay-installer-control-") + self.addCleanup(self.temporary.cleanup) + self.prefix = Path(self.temporary.name) / "release" + self.wheel = "willay-0.1.0-py3-none-any.whl" + self.data = b"fixture wheel; never executed" + base = "https://github.com/intigraph/willay/releases/download/v0.1.0/" + self.release = { + "tag_name": "v0.1.0", + "assets": [ + {"name": name, "browser_download_url": base + name} + for name in ("SHA256SUMS", self.wheel) + ], + } + self.calls = [] + + def download(self, url, _limit): + if "api.github.com" in url: + return json.dumps(self.release).encode() + if url.endswith("SHA256SUMS"): + return (hashlib.sha256(self.data).hexdigest() + " " + self.wheel).encode() + return self.data + + def child(self, command, **kwargs): + self.calls.append((command, kwargs)) + if command[-1] == "--version": + return subprocess.CompletedProcess(command, 0, "willay 0.1.0\n", "") + return subprocess.CompletedProcess(command, 0) + + def invoke(self, *args, child=None): + with ( + patch.dict(INSTALLER, download=self.download), + patch("shutil.which", return_value="/fixture/uv"), + patch("subprocess.run", side_effect=child or self.child), + patch.dict(os.environ, {"VERSION": "0.1.0", "SECRET_CONTROL": "not-forwarded"}), + ): + INSTALLER["install"](["--prefix", str(self.prefix), *args]) + + def invoke_default(self, *args): + def install_child(command, **kwargs): + if command[1:3] == ["tool", "install"]: + binary = Path(kwargs["env"]["UV_TOOL_BIN_DIR"]) / "willay" + binary.parent.mkdir(parents=True) + binary.write_text("fixture command") + return self.child(command, **kwargs) + + with ( + patch.dict(INSTALLER, download=self.download), + patch("shutil.which", return_value="/fixture/uv"), + patch("subprocess.run", side_effect=install_child), + patch.dict(os.environ, {"VERSION": "0.1.0", "PREFIX": "", "HOME": self.temporary.name}), + ): + INSTALLER["install"](list(args)) + + def test_default_install_exposes_command_without_profile_changes(self): + self.invoke_default() + home = Path(self.temporary.name) + binary = home / ".local/bin/willay" + self.assertTrue(binary.is_symlink()) + self.assertEqual(binary.resolve(), home / ".local/share/willay/v0.1.0/bin/willay") + self.assertEqual(binary.read_text(), "fixture command") + self.assertFalse((home / ".profile").exists()) + + def test_default_install_preserves_existing_command(self): + binary = Path(self.temporary.name) / ".local/bin/willay" + binary.parent.mkdir(parents=True) + binary.write_text("previous owner") + with self.assertRaisesRegex(ValueError, "command already exists"): + self.invoke_default() + self.assertEqual(binary.read_text(), "previous owner") + self.assertEqual(self.calls, []) + self.assertFalse((Path(self.temporary.name) / ".local/share/willay/v0.1.0").exists()) + + def test_default_install_preserves_dangling_command_symlink(self): + binary = Path(self.temporary.name) / ".local/bin/willay" + binary.parent.mkdir(parents=True) + target = Path(self.temporary.name) / "other-owner" + binary.symlink_to(target) + with self.assertRaisesRegex(ValueError, "command already exists"): + self.invoke_default() + self.assertTrue(binary.is_symlink()) + self.assertEqual(binary.readlink(), target) + self.assertEqual(self.calls, []) + + def test_default_dry_run_leaves_no_command_or_destination(self): + self.invoke_default("--dry-run") + self.assertFalse((Path(self.temporary.name) / ".local").exists()) + self.assertEqual(self.calls, []) + + def test_install_and_refuse_overwrite(self): + self.invoke() + self.assertTrue(self.prefix.is_dir()) + self.assertEqual(len(self.calls), 2) + self.assertNotIn("SECRET_CONTROL", self.calls[0][1]["env"]) + self.assertEqual(self.calls[0][1]["env"]["UV_PYTHON_DOWNLOADS"], "never") + with self.assertRaisesRegex(ValueError, "already exists"): + self.invoke() + self.assertTrue(self.prefix.is_dir()) + + def test_dry_run_writes_nothing(self): + self.invoke("--dry-run") + self.assertFalse(self.prefix.exists()) + self.assertEqual(self.calls, []) + + def test_corrupt_payload_refuses_before_destination(self): + original = self.download + self.download = lambda url, limit: ( + b"corrupt" if url.endswith(".whl") else original(url, limit) + ) + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + self.invoke() + self.assertFalse(self.prefix.exists()) + + def test_foreign_asset_refused(self): + self.release["assets"][1]["browser_download_url"] = "https://example.test/wheel" + with self.assertRaisesRegex(ValueError, "noncanonical"): + self.invoke() + self.assertFalse(self.prefix.exists()) + + def test_wrong_version_and_prerelease_refused(self): + self.release["tag_name"] = "v0.2.0" + with self.assertRaisesRegex(ValueError, "requested stable"): + self.invoke() + self.release["tag_name"] = "v0.1.0" + self.release["prerelease"] = True + with self.assertRaisesRegex(ValueError, "requested stable"): + self.invoke() + + def test_failed_child_cleanup(self): + def failed(command, **_kwargs): + raise subprocess.CalledProcessError(2, command) + + with self.assertRaises(subprocess.CalledProcessError): + self.invoke(child=failed) + self.assertFalse(self.prefix.exists()) + + def test_wrong_installed_version_cleanup(self): + def wrong(command, **kwargs): + if command[-1] == "--version": + return subprocess.CompletedProcess(command, 0, "willay 9.9.9\n", "") + return self.child(command, **kwargs) + + with self.assertRaisesRegex(ValueError, "does not match"): + self.invoke(child=wrong) + self.assertFalse(self.prefix.exists()) + + +if __name__ == "__main__": + unittest.main() From 9c9530f79bf599f8c764ed1d95ca091473185ad0 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 6 Nov 2026 11:16:07 -0500 Subject: [PATCH 2/2] fix(test): compare physical installer paths through home aliases --- tests/test_installer.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index 41a178c..e89f6f6 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -54,7 +54,7 @@ def invoke(self, *args, child=None): ): INSTALLER["install"](["--prefix", str(self.prefix), *args]) - def invoke_default(self, *args): + def invoke_default(self, *args, home=None): def install_child(command, **kwargs): if command[1:3] == ["tool", "install"]: binary = Path(kwargs["env"]["UV_TOOL_BIN_DIR"]) / "willay" @@ -66,16 +66,25 @@ def install_child(command, **kwargs): patch.dict(INSTALLER, download=self.download), patch("shutil.which", return_value="/fixture/uv"), patch("subprocess.run", side_effect=install_child), - patch.dict(os.environ, {"VERSION": "0.1.0", "PREFIX": "", "HOME": self.temporary.name}), + patch.dict( + os.environ, + {"VERSION": "0.1.0", "PREFIX": "", "HOME": str(home or self.temporary.name)}, + ), ): INSTALLER["install"](list(args)) def test_default_install_exposes_command_without_profile_changes(self): - self.invoke_default() - home = Path(self.temporary.name) + root = Path(self.temporary.name) + real_home = root / "physical-home" + real_home.mkdir() + home = root / "aliased-home" + home.symlink_to(real_home, target_is_directory=True) + self.invoke_default(home=home) binary = home / ".local/bin/willay" self.assertTrue(binary.is_symlink()) - self.assertEqual(binary.resolve(), home / ".local/share/willay/v0.1.0/bin/willay") + self.assertEqual( + binary.resolve(), (home / ".local/share/willay/v0.1.0/bin/willay").resolve() + ) self.assertEqual(binary.read_text(), "fixture command") self.assertFalse((home / ".profile").exists())