diff --git a/.github/workflows/distro-test-matrix.yml b/.github/workflows/distro-test-matrix.yml index 0ef5b2a..efa8ff1 100644 --- a/.github/workflows/distro-test-matrix.yml +++ b/.github/workflows/distro-test-matrix.yml @@ -90,3 +90,32 @@ jobs: -v "$PWD:$PWD:ro" -e REPO="$PWD" \ "${{ matrix.container }}" \ bash "$PWD/scripts/install-test.sh" + + # The install matrix gates the local path: it hands install.sh a checkout. + # A curl|bash user takes a different one — bootstrap outside a checkout, tag + # selection, clone, handoff to the tagged installer — which scripts/ + # install-test.sh's own header admits it does not cover (#701). One container: + # the remote-only half (bootstrap, tag, handoff) is distro-agnostic, and the + # tagged installer's per-distro work is already covered by the matrix above. + install-remote: + name: remote install.sh on debian:12 + runs-on: ubuntu-latest + needs: syntax + # Two sequential remote installs (fresh clone, then fetch+reset) plus a + # real package install on each. + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The gate publishes HEAD as a tag in its mirror; it needs the + # commit's objects, not history. + fetch-depth: 1 + + - name: Run the remote install path in debian:12 + run: | + set -euo pipefail + docker run --rm \ + -v "$PWD:$PWD:ro" -e REPO="$PWD" \ + debian:12 \ + bash "$PWD/scripts/install-remote-test.sh" diff --git a/AGENTS.md b/AGENTS.md index 1ce1bb9..e3048a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,7 @@ just native-packages # build the .deb and .rpm in the pinned base image (needs just native-smoke fedora:42 # install the built package in a distro container just aur-gate # build the AUR PKGBUILD on current Arch (needs docker) just install-gate debian:12 # run install.sh unattended in a distro container +just remote-install-gate debian:12 # remote curl|bash path end to end against HEAD (needs docker) just verify-release # check a published release as published (needs gh) just pre-commit # pre-commit run --all-files just run-debug # vocalinux --debug diff --git a/install.sh b/install.sh index a048c07..e24009a 100755 --- a/install.sh +++ b/install.sh @@ -486,7 +486,9 @@ resolve_install_tag() { resolve_install_tag # Check if running from within the vocalinux repo or remotely (via curl) -REPO_URL="https://github.com/VocaHQ/vocalinux.git" +# VOCALINUX_REPO_URL overrides the clone source so the remote-install gate can +# serve a tag from a local mirror; public installs use the repository below. +REPO_URL="${VOCALINUX_REPO_URL:-https://github.com/VocaHQ/vocalinux.git}" INSTALL_DIR="" CLEANUP_ON_EXIT="${VOCALINUX_REMOTE_INSTALL:-no}" diff --git a/justfile b/justfile index dd8edc7..3c68954 100644 --- a/justfile +++ b/justfile @@ -151,6 +151,23 @@ install-gate distro="debian:12": fi docker run "${ARGS[@]}" {{distro}} bash "$PWD/scripts/install-test.sh" +# Run install.sh's remote path end to end in a distro container, as the CI gate +# does: bootstrap outside a checkout, tag selection, clone/fetch of a mirror +# published from HEAD, handoff to the tagged installer. Answers "does the +# public curl|bash path install this commit" (needs docker). Same worktree +# handling as install-gate above. +# +# Usage: `just remote-install-gate` for debian:12, or `just remote-install-gate fedora:42` +remote-install-gate distro="debian:12": + #!/usr/bin/env bash + set -euo pipefail + COMMON="$(cd "$(git rev-parse --git-common-dir)" && pwd)" + ARGS=(--rm -v "$PWD:$PWD:ro" -e REPO="$PWD") + if [ "$COMMON" != "$PWD/.git" ]; then + ARGS+=(-v "$COMMON:$COMMON:ro") + fi + docker run "${ARGS[@]}" {{distro}} bash "$PWD/scripts/install-remote-test.sh" + # Check that a published release verifies as published: manifest, provenance, # notes and PyPI digests. Needs gh, downloads nothing. # Usage: `just verify-release` for the latest, or `just verify-release v0.17.0` diff --git a/scripts/install-remote-test.sh b/scripts/install-remote-test.sh new file mode 100755 index 0000000..9ed4195 --- /dev/null +++ b/scripts/install-remote-test.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# Run install.sh's remote path end to end in a distro container and check what +# it installed. +# +# Usage (a read-only mount is enough; the tag is published from git, not the +# tree): +# docker run --rm -v "$PWD:/repo:ro" debian:12 bash /repo/scripts/install-remote-test.sh +# or: just remote-install-gate debian:12 +# +# The path under test is the one a `curl | bash` user takes and +# scripts/install-test.sh never reaches: bootstrap outside a checkout, --tag +# selection, clone of the selected tag, handoff to the tagged installer with +# the original arguments plus the remote venv path, then a second run's +# fetch+reset over the existing clone. +# +# The remote is a local mirror so the gate answers for *this commit*: the tag +# is published from HEAD's tree and VOCALINUX_REPO_URL points the bootstrap at +# it. A gate that cloned the public repo would install the last release, not +# the checkout under test. The tag's own install.sh is the bootstrap: the #701 +# guardrail allows the bootstrap to come from main, and this commit's copy is +# what a merged PR would serve anyway. +# +# Flags the installer sees: --auto (no TTY), --skip-models (a model is 40-75 MB +# per run and the download path is checksum-verified elsewhere), --tag (the +# selection under test). Not --skip-system-deps: the per-distro package +# installation is the point. +# +# Fail closed: after the install, the clone's tree must equal HEAD's tree — +# the #701 guardrail that installer, sourced modules and requirement exports +# all come from the selected tag is only as strong as that equality. +set -euo pipefail + +REPO="${REPO:-/repo}" +INSTALL_USER="${INSTALL_USER:-installer}" +INSTALL_HOME="/home/$INSTALL_USER" +# Opaque on purpose: a name the public repository cannot already carry, so a +# silent fallback to GitHub fails loudly instead of installing the wrong ref. +GATE_TAG="${GATE_TAG:-v0.0.0-remote-gate}" +MIRROR="/srv/vocalinux-remote.git" +BOOTSTRAP="/tmp/vl.sh" +CLONE_DIR="$INSTALL_HOME/.local/share/vocalinux-install" +REMOTE_VENV="$INSTALL_HOME/.local/share/vocalinux/venv" + +fail() { + echo "FAIL: $*" >&2 + dump_install_log + exit 1 +} + +# install.sh writes a full transcript under ~/.local/state/vocalinux/. It is the +# only place the per-distro package manager output survives. +dump_install_log() { + local log + log="$(ls -1t "$INSTALL_HOME"/.local/state/vocalinux/install-*.log 2>/dev/null | head -n1 || true)" + if [ -n "$log" ]; then + echo "== last 200 lines of $log ==" >&2 + tail -n 200 "$log" >&2 + else + echo "== no install log was written ==" >&2 + fi +} + +# A mirror going slow mid-transaction leaves a half-populated cache, so +# re-running the same transaction resumes rather than restarts. +retry() { + local attempt + for attempt in 1 2 3; do + "$@" && return 0 + echo " '$1' failed (attempt $attempt); retrying" >&2 + sleep 5 + done + return 1 +} + +[ -f "$REPO/install.sh" ] || fail "$REPO/install.sh not found; mount the checkout at \$REPO" + +. /etc/os-release +echo "== ${NAME:-unknown} ${VERSION_ID:-} ==" + +echo "== Bootstrap: sudo, git and an unprivileged user ==" +# The minimum for install.sh to start, and nothing it should install itself. +# git is for the mirror below; the remote path ensures it again for real users. +# Same arms as scripts/install-test.sh so `just remote-install-gate ` +# works everywhere the local gate does. +case "$ID" in + ubuntu | debian) + export DEBIAN_FRONTEND=noninteractive + retry apt-get update -qq + retry apt-get install -y -qq sudo git ca-certificates >/dev/null + ;; + fedora | rhel | centos) + retry dnf install -y -q sudo git shadow-utils >/dev/null + ;; + arch) + # -Syu, never -Sy: archlinux:latest lags the mirrors, and a partial upgrade + # would be this script's breakage rather than the installer's. + retry pacman -Syu --noconfirm --needed sudo git >/dev/null + ;; + opensuse-tumbleweed | opensuse-leap | sles) + retry zypper --non-interactive --gpg-auto-import-keys refresh >/dev/null + retry zypper --non-interactive install -y sudo git >/dev/null + ;; + *) + fail "no bootstrap arm for ID='$ID'; add one when adding the container" + ;; +esac + +id -u "$INSTALL_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$INSTALL_USER" +echo "$INSTALL_USER ALL=(ALL) NOPASSWD: ALL" >"/etc/sudoers.d/$INSTALL_USER" +chmod 0440 "/etc/sudoers.d/$INSTALL_USER" + +echo "== Publish HEAD's tree as tag $GATE_TAG in a local mirror ==" +# safe.directory: the mounted checkout and, for a linked worktree, its mounted +# common gitdir are owned by another uid. +git config --global --add safe.directory '*' +# CI checks out with fetch-depth 1, and a shallow boundary commit can neither +# be pushed nor fetched into a ref ("shallow update not allowed"). The tag +# therefore carries an orphan commit built from HEAD's tree: a root commit is +# not a shallow root, and tree equality below still binds the gate to this +# commit's exact contents. +SOURCE_TREE="$(git -C "$REPO" rev-parse 'HEAD^{tree}')" \ + || fail "$REPO is not a git checkout; the gate publishes the tag from git" +git clone --quiet --depth 1 "file://$REPO" /tmp/gate-source +# commit-tree needs an identity the bare container does not carry. +EXPECTED_SHA="$(GIT_AUTHOR_NAME=gate GIT_AUTHOR_EMAIL=gate@local \ + GIT_COMMITTER_NAME=gate GIT_COMMITTER_EMAIL=gate@local \ + git -C /tmp/gate-source commit-tree "$SOURCE_TREE" -m "remote-install gate")" +git init --bare "$MIRROR" >/dev/null +git -C /tmp/gate-source push "$MIRROR" "$EXPECTED_SHA:refs/tags/$GATE_TAG" >/dev/null +[ "$(git -C "$MIRROR" rev-parse "$GATE_TAG^{tree}")" = "$SOURCE_TREE" ] \ + || fail "the mirror's $GATE_TAG does not carry HEAD's tree" +# The installer clones as $INSTALL_USER, and git refuses a root-owned remote +# for anyone else as "dubious ownership". +chown -R "$INSTALL_USER:$INSTALL_USER" "$MIRROR" + +# A remote user downloads install.sh over HTTPS; the gate takes the same file +# out of the tag instead. The tagged copy is this commit's bootstrap, and the +# handoff target is identical either way. +git -C "$MIRROR" show "$GATE_TAG:install.sh" >"$BOOTSTRAP" +chmod 0644 "$BOOTSTRAP" + +run_remote_install() { + # cd to $INSTALL_HOME so the bootstrap sees no checkout: local mode is chosen + # by a pyproject.toml naming vocalinux in the working directory, and the + # remote path is the one under test. + su - "$INSTALL_USER" -c "cd '$INSTALL_HOME' && VOCALINUX_REPO_URL='file://$MIRROR' bash '$BOOTSTRAP' --auto --skip-models '--tag=$GATE_TAG'" +} + +assert_remote_install() { + echo "== Assert: the clone is exactly the selected tag ==" + [ -d "$CLONE_DIR/.git" ] || fail "remote install left no clone at $CLONE_DIR" + local cloned_sha cloned_tree + cloned_sha="$(git -C "$CLONE_DIR" rev-parse HEAD)" \ + || fail "the clone at $CLONE_DIR is not a git checkout" + [ "$cloned_sha" = "$EXPECTED_SHA" ] || fail \ + "installed from the wrong revision: clone HEAD is $cloned_sha, $GATE_TAG is $EXPECTED_SHA" + [ "$(git -C "$CLONE_DIR" rev-parse "$GATE_TAG^{commit}" 2>/dev/null || true)" = "$EXPECTED_SHA" ] \ + || fail "the clone does not carry the selected tag ref $GATE_TAG" + # The tag's commit is an orphan; tree equality is the check that installer, + # modules and exports are this commit's files and not a neighbour revision's. + cloned_tree="$(git -C "$CLONE_DIR" rev-parse 'HEAD^{tree}')" + [ "$cloned_tree" = "$SOURCE_TREE" ] || fail \ + "the clone's tree $cloned_tree is not HEAD's tree $SOURCE_TREE" + + # The artifacts the #701 guardrail names: the tagged installer, its sourced + # modules, and the exports pip installs with --require-hashes. + [ -f "$CLONE_DIR/install.d/system_dependencies.sh" ] \ + || fail "the clone ships no install.d/system_dependencies.sh for the tagged installer to source" + [ -f "$CLONE_DIR/requirements/runtime.txt" ] \ + || fail "the clone ships no requirements/runtime.txt for the tagged installer to install" + # Same shape as the local gate's #736 check, now reachable through the remote + # path: --skip-models means the run itself never reads the manifest. + [ -f "$CLONE_DIR/src/vocalinux/utils/model_checksums.txt" ] \ + || fail "the tag ships no src/vocalinux/utils/model_checksums.txt; install.sh pins model downloads against it" + + echo "== Assert: the handoff carried the original arguments ==" + # handoff_to_tagged_installer appends --venv-dir; a venv anywhere else means + # the appended argument was dropped. + [ -x "$REMOTE_VENV/bin/python" ] \ + || fail "no remote venv at $REMOTE_VENV; the handoff's --venv-dir did not reach the tagged installer" + # The remote marker relocates the activation helper to ~/.local/bin only when + # VOCALINUX_REMOTE_INSTALL survives the exec. + [ -f "$INSTALL_HOME/.local/bin/activate-vocalinux.sh" ] \ + || fail "no ~/.local/bin/activate-vocalinux.sh; the remote marker did not survive the handoff" + # --skip-models forwarded: a model in the data dir means the tagged installer + # re-parsed arguments the handoff did not deliver. + if [ -d "$INSTALL_HOME/.local/share/vocalinux/models" ]; then + [ -z "$(find "$INSTALL_HOME/.local/share/vocalinux/models" -type f -print -quit)" ] \ + || fail "models were downloaded; --skip-models did not survive the handoff" + fi + + echo "== Assert: the installed app is there and answers ==" + [ -x "$INSTALL_HOME/.local/bin/vocalinux" ] || fail "no wrapper at ~/.local/bin/vocalinux" + [ -x "$INSTALL_HOME/.local/bin/vocalinux-gui" ] || fail "no wrapper at ~/.local/bin/vocalinux-gui" + [ -f "$INSTALL_HOME/.local/share/applications/vocalinux.desktop" ] \ + || fail "no desktop entry; install_desktop_entry did not run" + + # The version the wrapper reports must be the tag's own, not an assertion + # about a version string composed locally. + local expected_version version_output + expected_version="$(grep -oP '__version__\s*=\s*"\K[^"]+' "$CLONE_DIR/src/vocalinux/version.py")" \ + || fail "cannot read __version__ from the clone's version.py" + version_output="$(su - "$INSTALL_USER" -c "'$INSTALL_HOME/.local/bin/vocalinux' --version")" \ + || fail "the installed wrapper cannot report a version" + case "$version_output" in + *"$expected_version"*) ;; + *) fail "wrapper reports '$version_output', expected the tag's $expected_version" ;; + esac + echo " vocalinux --version: $version_output" + + # Same blind spots as the local gate: the injection binaries are reached + # through subprocess, and the IBus runtime only where the package list + # promises it. + for tool in xclip xsel wl-copy; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is not on PATH; install.sh lists it for every distro" + done + case "$ID" in + arch | opensuse-tumbleweed | opensuse-leap | sles) + command -v ibus-daemon >/dev/null 2>&1 \ + || fail "no ibus-daemon: the package list ships the IBus typelib without the runtime ibus_engine.py spawns" + ;; + esac + + su - "$INSTALL_USER" -c "'$REMOTE_VENV/bin/python' - " <<'PY' || fail "the installation does not import" +import vocalinux.main +import vocalinux.ui.tray_indicator +import vocalinux.speech_recognition.recognition_manager +import vocalinux.text_injection.text_injector +from vocalinux.ui.keyboard_backends import EVDEV_AVAILABLE, PYNPUT_AVAILABLE +import gi + +# keyboard_backends imports each backend under `except ImportError`, so the +# import above stays clean when evdev and pynput both failed to build. Assert +# the flags rather than the import. +assert EVDEV_AVAILABLE or PYNPUT_AVAILABLE, ( + "no keyboard backend is importable: evdev and pynput both failed to install" +) + +# The typelibs the installer is responsible for putting on the system. Both are +# loaded lazily at runtime, so importing the modules above proves nothing about +# them: a missing one would surface on a user's first dictation instead. +gi.require_version("Gtk", "3.0") +gi.require_version("IBus", "1.0") +from gi.repository import Gtk, IBus # noqa: F401 + +print( + " main, tray, recognition, injection, Gtk and IBus import;" + f" keyboard backends: evdev={EVDEV_AVAILABLE} pynput={PYNPUT_AVAILABLE}" +) +PY +} + +echo "== Run 1: remote install (fresh clone) as $INSTALL_USER ==" +START=$(date +%s) +run_remote_install || fail "the remote install exited non-zero on a fresh clone" +echo " install took $(( $(date +%s) - START ))s" +assert_remote_install + +echo "== Run 2: re-run over the existing clone (fetch + reset) ==" +# The second bootstrap sees $CLONE_DIR/.git and updates it instead of cloning: +# the arm a user re-running curl|bash takes on every release after the first. +# Move the clone off the tag and drop its local tag ref first: a run that only +# had to notice the right commit was already checked out would pass even if +# the fetch and reset were both no-ops. +AWAY_SHA="$(su - "$INSTALL_USER" -c "cd '$CLONE_DIR' && \ + GIT_AUTHOR_NAME=gate GIT_AUTHOR_EMAIL=gate@local \ + GIT_COMMITTER_NAME=gate GIT_COMMITTER_EMAIL=gate@local \ + git commit-tree 'HEAD^{tree}' -p HEAD -m moved")" +su - "$INSTALL_USER" -c "git -C '$CLONE_DIR' reset --hard '$AWAY_SHA' \ + && git -C '$CLONE_DIR' update-ref -d 'refs/tags/$GATE_TAG'" \ + || fail "could not move the clone off $GATE_TAG for the re-run arm" +[ "$(git -C "$CLONE_DIR" rev-parse HEAD)" = "$AWAY_SHA" ] \ + || fail "the clone did not move off $GATE_TAG; the re-run proves nothing" +[ -z "$(git -C "$CLONE_DIR" tag -l "$GATE_TAG")" ] \ + || fail "the clone kept refs/tags/$GATE_TAG; the re-run proves nothing" +run_remote_install || fail "the remote install exited non-zero over the existing clone" +grep -l "Updating existing clone" "$INSTALL_HOME"/.local/state/vocalinux/install-*.log >/dev/null 2>&1 \ + || fail "no install log shows 'Updating existing clone'; the fetch arm did not run" +assert_remote_install + +echo "PASS: the remote install path (bootstrap -> tag -> clone/fetch -> handoff) installs ${NAME:-this distro}'s app" diff --git a/tests/test_installer_gate.py b/tests/test_installer_gate.py index 2e54df2..9dcdcd2 100644 --- a/tests/test_installer_gate.py +++ b/tests/test_installer_gate.py @@ -230,3 +230,156 @@ def test_the_smoke_checks_what_an_import_walk_cannot_see(): "the smoke imports keyboard_backends without asserting a backend is" " available, which is the failure the suse compiler packages fix" ) + + +REMOTE_GATE_SH = REPO_ROOT / "scripts" / "install-remote-test.sh" + + +def _remote_gate_text() -> str: + return REMOTE_GATE_SH.read_text(encoding="utf-8") + + +def _remote_bootstrap_case() -> str: + """The remote gate's bootstrap `case "$ID"`, which is not the only case.""" + blocks = re.findall(r'^case "\$ID" in\n(.*?)^esac', _remote_gate_text(), re.M | re.S) + bootstrap = [block for block in blocks if "apt-get" in block] + assert len(bootstrap) == 1, 'no single `case "$ID"` installs the bootstrap packages' + return bootstrap[0] + + +def _remote_job_image() -> str: + """The container image the install-remote job runs the gate in.""" + block = _job_block(_matrix_text(), "install-remote") + run = re.search(r'^\s*(\S+)\s*\\\n\s*bash "\$PWD/scripts/install-remote-test\.sh"', block, re.M) + assert run, "the install-remote job has no docker image line above the gate call" + return run.group(1) + + +def test_the_workflow_runs_the_remote_gate(): + """The remote path (bootstrap outside a checkout -> tag -> clone/fetch -> + handoff) is the one a curl|bash user takes; a gate that only runs the local + mode leaves it unguarded.""" + block = _job_block(_matrix_text(), "install-remote") + assert re.search(r"^\s*docker run", block, re.M), "the remote job must run a container" + assert re.search( + r'^\s*bash "\$PWD/scripts/install-remote-test\.sh"$', block, re.M + ), "the remote job no longer runs the remote gate script" + + +def test_the_remote_gate_can_fail_a_build(): + """Same rule as the local matrix: advisory jobs are documentation.""" + block = _job_block(_matrix_text(), "install-remote") + assert not re.search(r"^\s*continue-on-error:\s*true", block, re.M), ( + "the remote install gate is advisory; a gate that cannot fail a build" + " is documentation, not a gate" + ) + + +def test_the_remote_gate_selects_a_tag(): + """Tag selection is half the remote contract; a run without --tag resolves + whatever the latest release API returns instead of a chosen ref.""" + gate = _without_comments(_remote_gate_text()) + assert re.search(r"--tag=", gate), ( + "the remote gate no longer passes --tag; it tests the latest-release" + " fallback rather than the selectable-tag path" + ) + + +def test_the_remote_gate_fails_closed_on_the_selected_tag(): + """Guardrail: installer, sourced modules and requirement exports must all + come from the selected tag. The only evidence is the clone's HEAD equalling + the tag commit the gate published.""" + gate = _without_comments(_remote_gate_text()) + assert re.search(r"rev-parse HEAD", gate) + assert re.search(r'"\$cloned_sha" = "\$EXPECTED_SHA"', gate), ( + "the remote gate no longer compares the clone's HEAD to the tag commit;" + " installing a different revision would pass" + ) + assert re.search( + r'rev-parse "\$GATE_TAG\^\{commit\}"', gate + ), "the remote gate no longer checks the selected tag ref reached the clone" + + +def test_the_remote_gate_bootstraps_outside_a_checkout(): + """install.sh chooses local mode from a pyproject.toml in the working + directory; the remote path only runs when the bootstrap starts where no + checkout exists. Overriding the clone source is what keeps the run on this + commit instead of the public repo's last release.""" + gate = _without_comments(_remote_gate_text()) + assert "VOCALINUX_REPO_URL" in gate, ( + "the remote gate no longer redirects the clone source; it would" + " install the public repo's tag rather than this commit" + ) + installer = _without_comments(INSTALLER.read_text(encoding="utf-8")) + assert re.search(r"VOCALINUX_REPO_URL:-https://github\.com/VocaHQ/vocalinux\.git", installer), ( + "install.sh no longer honors VOCALINUX_REPO_URL; the gate's mirror" + " cannot reach it and the clone falls back to the public repo" + ) + + +def test_the_remote_gate_runs_as_an_unprivileged_user(): + """install.sh refuses to run as root and calls sudo directly. The remote + handoff re-execs as the same user, so a root run would test a path no + user takes twice over.""" + gate = _without_comments(_remote_gate_text()) + assert "useradd" in gate + assert re.search(r'su - "\$INSTALL_USER"', gate) + assert "NOPASSWD" in gate + + +def test_the_remote_gate_covers_the_fetch_arm(): + """The remote path has two ways in: `git clone` on a fresh machine and + `git fetch origin tag` + `git reset --hard` over an existing clone, which + is what every re-run hits. Asserting the update message in an install log + proves the second arm ran.""" + gate = _without_comments(_remote_gate_text()) + assert re.search( + r'grep -l "Updating existing clone"', gate + ), "the remote gate no longer checks the fetch+reset arm ran" + assert gate.count("run_remote_install") >= 3, "the remote gate no longer runs the install twice" + + +def test_the_remote_gate_checks_handoff_artifacts(): + """handoff_to_tagged_installer appends --venv-dir and re-execs with the + remote marker; the remote venv and the relocated activation helper are the + only observable proof both arrived.""" + gate = _without_comments(_remote_gate_text()) + for construct in ( + r'\[ -x "\$REMOTE_VENV/bin/python" \]', + r'\[ -f "\$INSTALL_HOME/\.local/bin/activate-vocalinux\.sh" \]', + ): + assert re.search(construct, gate), f"the remote gate no longer asserts {construct}" + + +def test_the_remote_gate_smoke_tests_the_installed_app(): + """Same standard as the local gate: the app binary answers --version + through the wrapper the user's PATH carries.""" + gate = _without_comments(_remote_gate_text()) + assert re.search(r"'\$INSTALL_HOME/\.local/bin/vocalinux' --version", gate) + assert re.search( + r'\[ -x "\$INSTALL_HOME/\.local/bin/vocalinux" \]', gate + ), "the remote gate no longer asserts the user-facing wrapper exists" + + +def test_the_remote_gate_has_a_bootstrap_arm_for_its_distro(): + """The job is pinned to one container; the gate's bootstrap arms are per + /etc/os-release ID. A pinned distro with no arm fails at the case + fallthrough 40 minutes into CI rather than here.""" + image = _remote_job_image() + assert image in CONTAINER_OS_IDS, ( + f"{image} is the remote gate's container but not in CONTAINER_OS_IDS;" + " add its /etc/os-release ID so this guard can check the gate handles it" + ) + os_id = CONTAINER_OS_IDS[image] + bootstrap = _remote_bootstrap_case() + assert re.search(rf"^ [\w |-]*\b{re.escape(os_id)}\b[\w |-]*\)", bootstrap, re.M), ( + f"{image} reports ID={os_id}, which scripts/install-remote-test.sh has" + " no bootstrap arm for" + ) + + +def test_the_remote_gate_is_runnable_locally(): + """CI and the local entry point must run the same script.""" + justfile = JUSTFILE.read_text(encoding="utf-8") + assert re.search(r"^remote-install-gate distro=", justfile, re.M) + assert "scripts/install-remote-test.sh" in justfile