From 5cdacc4db6374c9d7c0c68706ea3713374b61bbc Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 22 Aug 2026 09:21:10 -0400 Subject: [PATCH 1/2] feat(installer): the curl|bash no-warning path, auditable line by line (#479) A terminal installer that bootstraps the release wheels into a per-user venv at ~/.keel/.venv -- nothing is downloaded as an application, so no OS trust dialog is ever involved (that path stays the .dmg/.zip with docs/desktop-install.md; signing stays #438). Auditable by construction: set -euo pipefail, every step prints what it is about to do before running, every download URL is echoed, no sudo, no credentials, no nested pipe-to-shell. Python >= 3.11 is checked via the interpreter's own version_info and the failure names the floor. The five production wheels are selected by exact allowlisted name (equal to PRODUCTION_WHEEL_PREFIXES in keel/commands/update.py) and installed BY PATH -- never by name from an index (the PyPI 'keel' is an unrelated project). Wheel checksums are not published by the release; the script says so honestly and records each wheel's sha256 locally instead. config.yaml and keel*.db under ~/.keel are never overwritten. The venv is named .venv deliberately so 'keel update' serves what this script built (verified: it reports 'already at the latest release' on a fresh install), and the printed next steps compose with #439 option A. tests/test_install_script.py pins these properties over the script text, the house pattern from tests/test_desktop_packaging.py. --- README.md | 5 + docs/desktop-install.md | 21 ++-- scripts/install.sh | 236 +++++++++++++++++++++++++++++++++++ tests/test_install_script.py | 207 ++++++++++++++++++++++++++++++ 4 files changed, 461 insertions(+), 8 deletions(-) create mode 100755 scripts/install.sh create mode 100644 tests/test_install_script.py diff --git a/README.md b/README.md index 4c2c47f..718800f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ disclaimers below. ## Try it in five minutes +No terminal? The macOS/Windows app is on the [releases page](https://github.com/CodeGateSoftware/keel/releases) +(with [`docs/desktop-install.md`](docs/desktop-install.md) for the first launch). Terminal +person, no cloning? The installer does this from the published wheels instead: +`curl -fsSL https://raw.githubusercontent.com/CodeGateSoftware/keel/main/scripts/install.sh | bash`. + Everything here is read-only and paper-side: no funds, and nothing in this path can place an order. Verified on a clean clone. You need [uv](https://docs.astral.sh/uv/) and a **free, read-only Coinbase Developer Platform (CDP) API key** — candle history is fetched through the diff --git a/docs/desktop-install.md b/docs/desktop-install.md index a645b05..7c80409 100644 --- a/docs/desktop-install.md +++ b/docs/desktop-install.md @@ -5,18 +5,23 @@ broken. This page explains exactly what happened, why, and what to do about it. ## Would you rather not deal with this at all? -There is a path with no warning on any platform, because nothing is downloaded as an application: -the install-from-source route in the README's **"Try it in five minutes"**. `pip` and `uv` fetch -the published wheels directly, and no operating system objects to that. +There is a path with no warning on any platform, because nothing is downloaded as an +application: the terminal installer. It fetches the published wheels directly into a per-user +venv at `~/.keel`, and no operating system objects to that. ``` -pip install --find-links . ./keel_trader--py3-none-any.whl -keel versions +curl -fsSL https://raw.githubusercontent.com/CodeGateSoftware/keel/main/scripts/install.sh | bash ``` -It needs a terminal and Python 3.11 or later — which is exactly the friction the desktop app -exists to remove, so this is not the recommendation for everyone. But if you already have both, it -is the shorter road and the rest of this page does not apply to you. +You do not have to trust that line blind: the script is +[`scripts/install.sh`](../scripts/install.sh) in this repository, written to be read — every step +prints what it is about to do before it does it, it runs no privileged commands, and it verifies +itself with `keel versions` before claiming success. It needs a terminal and Python 3.11 or +later. To build from a source checkout instead, see the README's **"Try it in five minutes"**. + +Both are exactly the friction the desktop app exists to remove, so this is not the recommendation +for everyone. But if you already have a terminal, either is the shorter road and the rest of this +page does not apply to you. ## The short version diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..59fe7cc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# +# keel's terminal installer -- the no-warning path (issue #479). +# +# WHAT THIS IS: installs keel on macOS or Linux from the latest GitHub release's Python +# wheels, into a per-user venv at ~/.keel/.venv. Nothing is downloaded as an +# *application*, so no OS trust dialog ("developer cannot be verified", SmartScreen) is +# ever involved -- that path stays the .dmg/.zip walkthrough in docs/desktop-install.md, +# and code signing stays #438 until a certificate is affordable. +# +# HOW TO READ IT: every step prints what it is about to do, and why, BEFORE it runs. +# `set -euo pipefail` (below) stops the script on the first failing command or pipe, so +# a broken step can never be followed by a success message. The script runs no +# privileged commands, writes nothing outside the invoking user's home, and fetches +# exactly the five production wheels by allowlisted name -- the release also carries +# dev-only and stub venue wheels that a deployment must not have, and selection here is +# by exact name, never `*.whl`, for the same reason as `keel update`'s selector +# (keel/commands/update.py, PRODUCTION_WHEEL_PREFIXES). +# +# UPDATES compose with this installer (issue #439, option A: per-release download, no +# self-update): re-running this script moves ~/.keel to the latest release, and +# `keel update` -- run from ~/.keel -- also handles the venv deployment this creates. +# An existing config.yaml or database under ~/.keel is never touched. +set -euo pipefail + +[ -n "${BASH_VERSION:-}" ] || { printf 'installer: FAIL: run me under bash\n' >&2; exit 1; } + +# -- constants: what, where, and the one allowlist ------------------------------------------------ + +#: The repository we install from, and its public unauthenticated endpoints. No auth and +#: no tokens: a bootstrap script must never grow a credential. +REPO="CodeGateSoftware/keel" +LATEST_API="https://api.github.com/repos/${REPO}/releases/latest" + +#: The five PRODUCTION wheel name prefixes -- the same allowlist, in the same order, as +#: keel's own updater (keel/commands/update.py, PRODUCTION_WHEEL_PREFIXES). A release +#: also ships other venue wheels a deployment must not have; selection below is by exact +#: `--` name, so nothing outside this line can ride along. +WHEEL_PREFIXES="keel_core keel_broker_api keel_broker_coinbase keel_broker_alpaca keel_trader" + +#: Where the deployment lives: one per-user folder holding the venv, config.yaml and -- +#: once keel runs -- the database and .env. A folder you can look inside is keel's +#: deployment model. The venv is named `.venv` deliberately: that is the layout `keel +#: update` recognises, so the updater can serve what this script built. +KEEL_DIR="${HOME}/.keel" +VENV_DIR="${KEEL_DIR}/.venv" + +say() { printf '==> %s\n' "$*"; } +die() { printf 'installer: FAIL: %s\n' "$*" >&2; exit 1; } + +# -- step 1/7: the platform ---------------------------------------------------------------------- + +say "step 1/7: checking the platform" +case "$(uname -s)" in + Darwin | Linux) say " ok: $(uname -s)" ;; + *) + die "unsupported platform '$(uname -s)': this installer is for macOS (Darwin) and Linux. + Windows users: download the release .zip and follow docs/desktop-install.md." + ;; +esac +command -v curl >/dev/null 2>&1 || die "curl is required but was not found on PATH" + +# -- step 2/7: Python, with the floor stated ------------------------------------------------------ + +# keel requires Python 3.11+ (tests/test_python_floor.py); the check is the interpreter's +# own version_info, not a parsed string, and the failure names the floor and what to do. +say "step 2/7: finding Python >= 3.11" +PY="" +for candidate in python3 python3.14 python3.13 python3.12 python3.11; do + command -v "$candidate" >/dev/null 2>&1 || continue + if "$candidate" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then + PY="$(command -v "$candidate")" + break + fi +done +[ -n "$PY" ] || die "no Python >= 3.11 found on PATH -- keel requires 3.11 or later. Check + 'python3 --version', install a newer Python, and re-run this script." +say " ok: ${PY} ($("$PY" -c 'import platform; print(platform.python_version())'))" + +# -- step 3/7: the latest release ---------------------------------------------------------------- + +say "step 3/7: resolving the latest release from the GitHub API" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/keel-install.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT +RELEASE_JSON="${TMP_DIR}/release.json" +printf ' GET %s\n' "$LATEST_API" +curl -fsSL "$LATEST_API" -o "$RELEASE_JSON" || die "could not reach the GitHub releases API" +MANIFEST="${TMP_DIR}/manifest.tsv" + +# Parsing uses the Python we just found: macOS does not ship jq, and we already require +# Python, so we add no dependency. This mirrors keel's own selector -- exact +# `--...whl` names, one asset per prefix, and a loud refusal naming +# every prefix the release does not carry (a release missing a wheel cannot be deployed). +TAG="$("$PY" -c ' +import json, sys +try: + release = json.loads(open(sys.argv[1]).read()) +except ValueError as exc: + sys.stderr.write("release payload is not JSON: %s\n" % exc) + sys.exit(1) +tag = release.get("tag_name") or "" +version = tag[1:] if tag.startswith("v") else tag +if not version: + sys.stderr.write("release payload has no tag_name\n") + sys.exit(1) +assets = {a["name"]: a["browser_download_url"] for a in release.get("assets", [])} +lines, missing = [], [] +for prefix in sys.argv[3].split(): + matches = sorted( + name for name in assets + if name.startswith("%s-%s-" % (prefix, version)) and name.endswith(".whl") + ) + if len(matches) == 1: + lines.append(matches[0] + " " + assets[matches[0]]) + else: + missing.append(prefix) +if missing: + sys.stderr.write("release %s does not carry every production wheel (missing: %s)\n" + % (tag, ", ".join(missing))) + sys.exit(1) +open(sys.argv[2], "w").write("\n".join(lines) + "\n") +print(tag) +' "$RELEASE_JSON" "$MANIFEST" "$WHEEL_PREFIXES")" || die "the latest release is not installable (see above)" +say " latest release: ${TAG}" + +# -- step 4/7: download, printing every URL ------------------------------------------------------- + +say "step 4/7: downloading the five production wheels and config.yaml to ${TMP_DIR}" +WHEEL_PATHS=() +while read -r name url; do + printf ' GET %s\n' "$url" + curl -fsSL "$url" -o "${TMP_DIR}/${name}" || die "could not download ${name}" + WHEEL_PATHS+=("${TMP_DIR}/${name}") +done < "$MANIFEST" +CONFIG_URL="https://github.com/${REPO}/releases/download/${TAG}/config.yaml" +printf ' GET %s\n' "$CONFIG_URL" +curl -fsSL "$CONFIG_URL" -o "${TMP_DIR}/config.yaml" || die "could not download config.yaml" + +# -- step 5/7: checksums, stated honestly --------------------------------------------------------- + +# The release publishes SHA256SUMS files for the DESKTOP artifacts only; there are no +# published checksums for the wheels, and this script does not pretend to verify what was +# never published. What it does instead is print each wheel's sha256 as computed LOCALLY, +# so the run leaves an auditable record of exactly what was installed. +say "step 5/7: recording the sha256 of each wheel as downloaded (no published wheel checksums exist to compare against)" +for path in "${WHEEL_PATHS[@]}"; do + printf ' %s %s\n' \ + "$("$PY" -c 'import hashlib, sys +print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$path")" \ + "$(basename "$path")" +done + +# -- step 6/7: venv, install by exact path, config guard ------------------------------------------ + +say "step 6/7: installing into ${KEEL_DIR} (per-user; no elevated commands)" +mkdir -p "$KEEL_DIR" +# Discovery asks uv to ANSWER ITS OWN VERSION, not merely to be on PATH: a broken or +# half-installed uv shim must not abort the install -- a uv that cannot run is honestly +# treated as absent, and the pip path below needs no uv. +HAVE_UV=0 +if command -v uv >/dev/null 2>&1 && uv --version >/dev/null 2>&1; then HAVE_UV=1; fi +if [ ! -x "${VENV_DIR}/bin/python" ]; then + if [ "$HAVE_UV" -eq 1 ]; then + say " uv found: creating the venv with 'uv venv --python '" + uv venv --python "$PY" "$VENV_DIR" || die "uv venv failed" + else + say " uv not found: creating the venv with '${PY} -m venv'" + "$PY" -m venv "$VENV_DIR" || die "python -m venv failed (on Debian/Ubuntu the python3-venv + package is the usual missing piece -- install it and re-run)" + fi +else + say " ${VENV_DIR} already exists: reusing it (re-running this script upgrades in place)" +fi +VENV_PY="${VENV_DIR}/bin/python" + +# Installation is BY EXACT WHEEL PATH, the same form `keel update` uses -- never by +# package name, never from an index for the keel distributions themselves. The +# `--find-links` directory lets the wheels' pinned keel dependencies resolve from the +# downloaded release rather than a public index; third-party dependencies (click, numpy, +# ...) come from PyPI as usual. There is an unrelated "keel" project on PyPI; installing +# keel by name from an index is the one mistake this section exists to make impossible. +if [ "$HAVE_UV" -eq 1 ]; then + say " running: uv pip install --python --find-links " + uv pip install --python "$VENV_PY" --find-links "$TMP_DIR" "${WHEEL_PATHS[@]}" \ + || die "uv pip install failed" +else + # A venv without pip gets one honest bootstrap attempt via ensurepip, then a clear + # failure -- never a silent skip or an install pretending to have run. + if ! "$VENV_PY" -m pip --version >/dev/null 2>&1; then + say " pip is not bootstrapped in the new venv: running 'ensurepip --upgrade'" + "$VENV_PY" -m ensurepip --upgrade || die "ensurepip failed: this venv has no pip and it + could not be bootstrapped" + fi + say " running: ${VENV_PY} -m pip install --no-input --find-links " + "$VENV_PY" -m pip install --no-input --find-links "$TMP_DIR" "${WHEEL_PATHS[@]}" \ + || die "pip install failed" +fi + +# config.yaml is the user's once installed: NEVER overwritten -- it may hold their edits. +# The release's copy lands beside the venv only when no config is there yet, and an +# existing deployment's database is likewise never touched: upgrading code must not mean +# touching data. +if [ -e "${KEEL_DIR}/config.yaml" ]; then + say " ${KEEL_DIR}/config.yaml already exists: keeping it (not overwritten)" +else + say " installing the release's default config.yaml beside the venv (the paper profile)" + cp "${TMP_DIR}/config.yaml" "${KEEL_DIR}/config.yaml" +fi +for db in "${KEEL_DIR}"/keel*.db; do + if [ -e "$db" ]; then say " existing database $(basename "$db"): not touched"; fi +done + +# -- step 7/7: verify BEFORE declaring success ---------------------------------------------------- + +# `keel versions` is the one check that can actually fail -- it reports every keel +# distribution the venv resolves and whether they agree on the release version. It runs +# from the deployment folder so keel resolves its state there, and a failure fails this +# script: success is never declared unverified. +say "step 7/7: verifying the install with 'keel versions' (run from ${KEEL_DIR})" +( cd "$KEEL_DIR" && "${VENV_DIR}/bin/keel" versions ) \ + || die "'keel versions' failed: the install is NOT complete -- see its output above" + +# -- success + next steps (only reachable past the verify) ---------------------------------------- + +say "installed keel ${TAG} (verified)" +printf '\nNext steps:\n' +printf ' run it: cd ~/.keel && ./.venv/bin/keel versions\n' +printf ' (or: source ~/.keel/.venv/bin/activate, then: keel versions)\n' +printf ' paper: config.yaml beside the venv is the default paper profile -- nothing in\n' +printf ' it can place a live order. To fetch candles you will want a free,\n' +printf ' read-only Coinbase Developer Platform (CDP) API key in ~/.keel/.env.\n' +printf ' guide: https://keeltrading.com\n' +printf ' updates: re-run this installer to move to a later release (#439, option A), or\n' +printf ' run: keel update (from ~/.keel -- it serves this venv layout).\n' +printf ' Desktop app bundles update by re-downloading (docs/desktop-install.md).\n' +printf ' this folder: %s holds the venv, config.yaml and (once keel runs) the database.\n' "$KEEL_DIR" diff --git a/tests/test_install_script.py b/tests/test_install_script.py new file mode 100644 index 0000000..a8dc894 --- /dev/null +++ b/tests/test_install_script.py @@ -0,0 +1,207 @@ +"""The terminal installer: what the script must always be, pinned over its text. + +`scripts/install.sh` is the one file users are asked to pipe straight into bash (#479), +which makes every line of it a security surface. It cannot be executed here -- it needs +the network and a fresh machine -- so what is pinned is the same discipline +`tests/test_desktop_packaging.py` applies to the shell and workflow artifacts it guards: +the set of properties that would be expensive to discover were false. Each of these is a +way the script could be quietly made unauditable by a refactor: a dropped `pipefail`, a +helpful `sudo`, an install that names a package instead of a downloaded file (there is an +unrelated "keel" on PyPI, which is why the by-path rule exists), or a success banner that +survived the removal of the verification that earned it. +""" + +from __future__ import annotations + +import re +import stat +from pathlib import Path + +import pytest + +from keel.commands.update import PRODUCTION_WHEEL_PREFIXES + +_ROOT = Path(__file__).resolve().parents[1] +_SCRIPT = _ROOT / "scripts" / "install.sh" + + +@pytest.fixture(scope="module") +def script() -> str: + return _SCRIPT.read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def code(script: str) -> list[str]: + """The commands only: non-blank, non-comment lines. + + A comment may EXPLAIN a forbidden thing (why there is no signing, why wheel checksums + are absent); only code can DO one -- so the absence tests below run against code + lines, the same split `tests/test_desktop_packaging.py` makes for the macOS packer. + """ + return [ + line + for line in script.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +# -- the floor the whole script stands on ---------------------------------------------------------- + + +def test_fail_fast_is_on(script: str) -> None: + """`set -euo pipefail`: any failing command or pipe stops the script, so a broken + step can never be followed by a success message.""" + assert "set -euo pipefail" in script + + +def test_the_script_is_executable() -> None: + assert _SCRIPT.exists() + assert _SCRIPT.stat().st_mode & stat.S_IXUSR + + +def test_the_platform_is_checked_and_refuses_the_unsupported(script: str) -> None: + """macOS and Linux are the supported pair (the CI smoke runs exactly those); any + other `uname` must be refused loudly, with the Windows route named.""" + assert "uname" in script + assert "Darwin" in script and "Linux" in script + assert "desktop-install.md" in script # where the refused platforms are sent + + +def test_every_curl_is_strict(code: list[str]) -> None: + """`-fsSL`: fail on HTTP errors, no progress noise, follow the release redirect to + the asset host. A curl that tolerates a 404 body would install whatever HTML came + back. Only actual invocations count -- `command -v curl` is a PATH probe, not a + download.""" + curls = [line for line in code if re.search(r"\bcurl\s+-", line)] + assert curls, "the installer never curls anything -- this test proves nothing" + for line in curls: + assert "-fsSL" in line, f"a curl without -fsSL: {line}" + + +# -- no privilege, no secrets, no nested pipe-to-shell --------------------------------------------- + + +def test_no_privileged_commands(code: list[str]) -> None: + """A per-user install under $HOME needs none; the moment `sudo` appears, the + no-warning path becomes a system modification the user was not promised.""" + offenders = [line for line in code if "sudo" in line] + assert not offenders, f"sudo is invoked: {offenders}" + + +def test_no_shell_piping_of_downloaded_code(script: str) -> None: + """The user may pipe THIS script into bash; the script itself must never pipe + anything it fetched into a shell, and must not `eval` -- those are the two ways a + bootstrap script turns into a loader for arbitrary code.""" + assert not re.search(r"\|\s*(ba|z|da|k)?sh\b", script), "a pipe into a shell" + offenders = [line for line in script.splitlines() if re.search(r"\beval\b", line)] + assert not offenders, f"eval is used: {offenders}" + + +def test_no_credentials_or_auth(script: str) -> None: + """The public releases API is read unauthenticated, and a bootstrap script must + never grow a credential -- nothing to leak, nothing to phish for.""" + for secret in ("Authorization", "TOKEN", "PASSWORD", "SECRET"): + assert secret not in script, f"{secret} appears in the installer" + + +# -- Python: the floor is checked, and stated when it fails ---------------------------------------- + + +def test_the_python_floor_is_checked_and_stated(script: str) -> None: + """The interpreter's own `version_info` must be compared against (3, 11) -- the + floor every distribution declares (tests/test_python_floor.py) -- and the failure + must NAME the floor, because a bare 'wrong python' sends the user nowhere.""" + assert re.search(r"sys\.version_info >= \(3, ?11\)", script), ( + "the >= 3.11 check via sys.version_info is gone -- the installer would happily " + "build a venv keel refuses to run in" + ) + assert "3.11" in script, "the failure message must state the floor" + + +# -- the wheels: exactly the five, never by name from an index ----------------------------------- + + +def test_the_allowlist_is_keel_owns_production_prefixes(script: str) -> None: + """The script's allowlist must EQUAL `PRODUCTION_WHEEL_PREFIXES` -- the same five + distributions, in the same order, as the updater's selector. Selection is by exact + `--` name, so a drift here (a sixth wheel, a renamed one) is the + difference between a deployment and a different machine.""" + match = re.search(r'^WHEEL_PREFIXES="([^"]+)"', script, re.MULTILINE) + assert match, "the WHEEL_PREFIXES allowlist line is gone from scripts/install.sh" + assert tuple(match.group(1).split()) == PRODUCTION_WHEEL_PREFIXES + + +def test_the_venue_wheels_a_deployment_must_not_have_are_absent(script: str) -> None: + """Not merely unselected -- absent. The release carries a dev-only fake venue, an + optional venue and a stub venue that a deployment must never ride; if their names + appear anywhere in the script (even as a comment saying 'not this one'), the + allowlist above is no longer the thing that keeps them out.""" + for banned in ("fake", "robinhood", "kraken"): + assert banned not in script, f"{banned!r} appears in the installer" + + +def test_installs_by_exact_wheel_path_never_by_name(script: str, code: list[str]) -> None: + """keel is installed BY PATH from the downloaded release wheels -- the hard repo + rule, because `pip install keel` would fetch an UNRELATED PyPI project. Every + install invocation must carry `--find-links` (so the keel wheels' pinned keel + dependencies resolve from the download, not an index) and the wheel-path array; a + bare package name must not appear anywhere.""" + assert not re.search(r"pip install keel\b", script), ( + "an install names a keel package rather than a downloaded wheel path" + ) + joined = "\n".join(code) + assert '-m pip install --no-input --find-links "$TMP_DIR" "${WHEEL_PATHS[@]}"' in joined, ( + "the pip path's exact wheel-path install form is gone" + ) + assert 'uv pip install --python "$VENV_PY" --find-links "$TMP_DIR" "${WHEEL_PATHS[@]}"' in ( + joined + ), "the uv path's exact wheel-path install form is gone" + + +def test_the_pipless_venv_gets_one_honest_bootstrap(script: str) -> None: + """`python -m venv` can produce a venv without pip; the fallback must attempt + `ensurepip` and then fail clearly -- never skip the install silently.""" + assert "ensurepip" in script + assert "--no-input" in script + + +# -- the user's data: guarded ---------------------------------------------------------------------- + + +def test_an_existing_config_is_never_overwritten(script: str, code: list[str]) -> None: + """config.yaml is the user's the moment it lands: it may hold their edits. The copy + from the release must sit behind an existence check that SAYS it kept the existing + one -- a re-run that upgrades keel must not reset configuration.""" + assert '[ -e "${KEEL_DIR}/config.yaml" ]' in "\n".join(code) + assert "not overwritten" in script + assert 'cp "${TMP_DIR}/config.yaml" "${KEEL_DIR}/config.yaml"' in script + + +def test_an_existing_database_is_never_touched(script: str) -> None: + """Upgrading code must not mean touching data; a `keel*.db` under the install + directory is acknowledged and left alone.""" + assert "keel*.db" in script + assert "not touched" in script + + +def test_destructive_cleanup_targets_only_the_temp_dir(code: list[str]) -> None: + for line in code: + if "rm -rf" in line: + assert "TMP_DIR" in line, f"rm -rf outside the temp dir: {line}" + + +# -- verified before success ---------------------------------------------------------------------- + + +def test_versions_runs_before_any_success_message(code: list[str]) -> None: + """`keel versions` -- the one check that can actually fail, because it reports every + keel distribution the venv resolves and whether they agree -- must run BEFORE the + success banner. A success message that survives the removal of its verification is + the desktop milestone's silent-failure lesson applied to the installer.""" + verify = [i for i, line in enumerate(code) if 'bin/keel" versions' in line] + assert verify, "the installer no longer runs 'keel versions' from the venv" + success = [i for i, line in enumerate(code) if "installed keel" in line] + assert success, "no success line found -- this test proves nothing" + assert min(verify) < min(success), ( + "the success banner is printed before 'keel versions' verified the install" + ) From b5bd096fb550dcdc9985da3ac4dbe079ad3bd95e Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 22 Aug 2026 09:21:16 -0400 Subject: [PATCH 2/2] ci(installer): smoke the script on ubuntu and macos The acceptance evidence for #479's 'works on macOS and Linux': run scripts/install.sh FOR REAL on both runners (public repo, network, the actual release wheels) into an isolated HOME, then assert the installed venv's own 'keel versions' exits 0. shellcheck runs on the Linux leg only (ubuntu images ship it, macOS images do not; the bytes are the same on both). pull_request is filtered to the script's path so an unchanged installer costs no runners; the text-pinning suite in tests/test_install_script.py runs in CI on every PR regardless. --- .github/workflows/install-smoke.yml | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/install-smoke.yml diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml new file mode 100644 index 0000000..28c578e --- /dev/null +++ b/.github/workflows/install-smoke.yml @@ -0,0 +1,54 @@ +name: Installer smoke + +# The terminal installer's acceptance evidence (#479): "works on macOS and Linux". +# +# This workflow RUNS scripts/install.sh for real -- public repo, real network, real +# release wheels -- on both platforms it claims, then asserts the installed venv's own +# `keel versions` exits 0. The installer already runs that verification internally +# before printing success; the explicit second run is the CI-visible assertion, so a +# red leg names the installer and not just a step inside it. +# +# Triggers: `workflow_dispatch` for on-demand runs, and `pull_request` filtered to the +# script's path ONLY -- a paths filter any broader would spend two runners per PR on a +# script that did not change, while no filter at all would spend them on every push to +# every file. The full suite (which pins the script's properties as text, in +# tests/test_install_script.py) runs in CI on every PR regardless. +on: + workflow_dispatch: + pull_request: + paths: ["scripts/install.sh"] + +concurrency: + group: installer-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + # shellcheck is preinstalled on ubuntu runners and NOT on macOS runners; rather + # than add a setup action, the Linux leg carries the lint for both -- the script + # is the same bytes on every platform it runs on. + - name: Shellcheck + if: runner.os == 'Linux' + run: shellcheck scripts/install.sh + + # A throwaway HOME keeps the runner's real home clean and proves the script needs + # nothing outside it: the install lands wholly under $HOME/.keel. + - name: Run the installer for real, into an isolated HOME + run: | + export HOME="$RUNNER_TEMP/keel-home" + mkdir -p "$HOME" + bash scripts/install.sh + + - name: Assert the installed keel verifies + run: | + export HOME="$RUNNER_TEMP/keel-home" + cd "$HOME/.keel" + ./.venv/bin/keel versions