ci(install): gate the remote install path end to end (#906)
* ci(install): gate the remote install path end to end A curl|bash user takes a different path than the local install gate covers: bootstrap outside a checkout, tag selection, clone/fetch of the selected tag, then handoff to the tagged installer with the original arguments. Add scripts/install-remote-test.sh, which publishes HEAD as a tag in a local mirror, runs install.sh against it twice (fresh clone, then fetch+reset of the existing clone), and fails closed when the clone's HEAD is not the selected tag commit. install.sh gains a VOCALINUX_REPO_URL override so the mirror can stand in for the public remote. Wire a single-distro job into the distro matrix and a just remote-install-gate recipe. Refs #701 * ci(install): publish the gate tag as an orphan from HEAD's tree CI checks out with fetch-depth 1, and git refuses to push or fetch a shallow boundary commit into a ref. Build the tag on an orphan commit created from HEAD's tree instead, and make tree equality the fail-closed check that installer, modules and exports are this commit's files. * test(install): force the re-run arm to prove fetch and reset Review feedback on the remote-install gate: the second run reused a clone already on the tag, so a broken fetch or reset would still pass. Move the clone onto a descendant commit and delete its local tag ref before the re-run, so only a real fetch + reset restores them. Also list the new recipe in AGENTS.md's command guide. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
6a6cc93919
commit
ff57244dce
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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}"
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Executable
+281
@@ -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 <distro>`
|
||||
# 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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user