diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 003cb47..0aa0483 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -245,10 +245,15 @@ jobs: steps: - name: Bootstrap prerequisites (as root - mirrors a shared host) + # tmux is deliberately NOT pre-installed. It used to be, and that is + # exactly why the no-sudo gap stayed invisible across 15 cells: `has tmux` + # was always true, so the missing installer never mattered while the repo + # still shipped tmux config and cloned tmux plugins. The environment was + # supplying the thing under test. install.sh must provide it now. run: | export DEBIAN_FRONTEND=noninteractive apt-get -yq update - apt-get -yq install apt-utils git curl wget ca-certificates zsh tmux python3 + apt-get -yq install apt-utils git curl wget ca-certificates zsh python3 - name: Install sudo binary (forced + nonsudoer) if: matrix.variant == 'forced' || matrix.variant == 'nonsudoer' @@ -345,7 +350,8 @@ jobs: # --allowerasing: EL9+ images ship curl-minimal, which conflicts with # the full curl package - allow dnf to swap it out. run: | - dnf -yq install --allowerasing git curl wget ca-certificates zsh tmux python3 \ + # tmux deliberately absent here too - see the Ubuntu no-sudo job. + dnf -yq install --allowerasing git curl wget ca-certificates zsh python3 \ tar gzip xz findutils shadow-utils glibc-langpack-en - name: Install sudo binary (forced + nonsudoer) diff --git a/AGENTS.md b/AGENTS.md index 994b6e1..e050231 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,12 @@ fallback and the unapplied-pins path - 28 cells total. list with a per-package retry, keep an optional tool's apt steps non-fatal (`|| { log_warn ...; return; }`), and let the `_install_*` GitHub fallbacks cover whatever apt cannot supply. +- **Never let a test environment supply the thing under test.** The no-sudo CI jobs + and `Dockerfile.nosudo` pre-installed tmux as a root prerequisite, so `has tmux` + was true before install.sh ran. A non-sudoer therefore got tmux CONFIG and tmux + PLUGINS but no tmux BINARY, and 15 green cells said nothing about it for months - + found on a real host, not in CI. When adding a prerequisite to a test image, ask + whether the install is supposed to provide it; if it is, leave it out. - Never construct GitHub release asset URLs manually - use `_gh_release_info` or `_gh_latest_release` from `lib/utils.sh`; asset names change between releases. Exception: `releases/latest/download/` for an asset whose name carries no diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6543c..d4d09ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.0] - 2026-09-19 + +### Added +- tmux is now installed without sudo, as a static AppImage, with an extract fallback for hosts without FUSE. The repo shipped tmux config and cloned tmux plugins while a non-sudoer could end up with no tmux binary at all. + +### Changed +- The no-sudo CI jobs and `Dockerfile.nosudo` no longer pre-install tmux. Doing so made `has tmux` true before install.sh ran, which is why the gap stayed invisible across 15 cells. + ## [1.11.9] - 2026-09-18 ### Fixed diff --git a/Dockerfile.nosudo b/Dockerfile.nosudo index 24313d3..183fb03 100644 --- a/Dockerfile.nosudo +++ b/Dockerfile.nosudo @@ -72,16 +72,20 @@ ARG NOSUDO_INSTALL= # RHEL family). The dnf list adds what Ubuntu images already carry: tar/gzip/xz # (binary extraction), findutils (_download_tar_bin uses find), shadow-utils # (useradd below), glibc-langpack-en (en_US.UTF-8 locale data). +# tmux is deliberately NOT here. Pre-installing it made `has tmux` true before +# install.sh ran, which is why a no-sudo host getting tmux CONFIG but no tmux +# BINARY went unnoticed - the image supplied the thing under test. install.sh +# must provide it (_install_tmux in modules/base.sh). RUN if command -v apt-get >/dev/null 2>&1; then \ apt-get -yq update && \ apt-get -yq install --no-install-recommends \ - apt-utils git curl wget ca-certificates zsh tmux python3 && \ + apt-utils git curl wget ca-certificates zsh python3 && \ rm -rf /var/lib/apt/lists/*; \ else \ # --allowerasing: EL9+ images ship curl-minimal, which conflicts with # the full curl package - allow dnf to swap it out. dnf -yq install --allowerasing \ - git curl wget ca-certificates zsh tmux python3 \ + git curl wget ca-certificates zsh python3 \ tar gzip xz findutils shadow-utils glibc-langpack-en && \ dnf clean all; \ fi diff --git a/VERSION b/VERSION index 361ffc5..0eed1a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.9 +1.12.0 diff --git a/modules/base.sh b/modules/base.sh index ca11f0f..d6d6389 100755 --- a/modules/base.sh +++ b/modules/base.sh @@ -60,13 +60,17 @@ install_base() { else log_warn "No sudo - skipping system packages; fetching tools as local binaries" fi + # git, zsh and python3 have no practical single-binary fallback, so they + # stay a hint. tmux does (see _install_tmux), and this repo ships tmux + # config and clones tmux plugins, so it is fetched rather than mentioned. local _hint tool; _hint=$(_pkg_install_hint) - for tool in git zsh tmux python3; do + for tool in git zsh python3; do has "$tool" || log_warn "$tool not found - install it: ${_hint} $tool" done _install_ripgrep _install_fd _install_jq + _install_tmux fi _install_fzf @@ -118,13 +122,17 @@ install_base_docker() { _install_jq else log_warn "No apt - skipping system packages; fetching tools as local binaries" + # git, zsh and python3 have no practical single-binary fallback, so they + # stay a hint. tmux does (see _install_tmux), and this repo ships tmux + # config and clones tmux plugins, so it is fetched rather than mentioned. local _hint tool; _hint=$(_pkg_install_hint) - for tool in git zsh tmux python3; do + for tool in git zsh python3; do has "$tool" || log_warn "$tool not found - install it: ${_hint} $tool" done _install_ripgrep _install_fd _install_jq + _install_tmux fi _install_fzf @@ -453,6 +461,56 @@ _install_ripgrep() { fi } +# tmux: only reachable here as a static AppImage. +# +# This repo ships .tmux.conf, .tmux.conf.local and clones tmux plugins, so a host +# that gets the configs but no tmux is incoherent - and that is exactly what a +# non-sudoer on a box without tmux got, measured 2026-09-19 on a fleet host. +# Upstream tmux publishes SOURCE ONLY (checked: the latest release carries just +# tmux-.tar.gz), and building it needs libevent and ncurses built locally +# too, so a prebuilt static binary is the only one-command option. +# +# The build is THIRD PARTY (nelsonenzo/tmux-appimage), which is why this verifies +# the binary runs before linking it and says plainly where it came from, rather +# than installing it silently. apt is always preferred when it is available. +_install_tmux() { + if has tmux; then + log_ok "tmux already installed - skipping" + return + fi + log_step "tmux (static AppImage - third-party build)" + local arch; arch=$(uname -m) + if [ "$arch" != x86_64 ]; then + log_warn "tmux: the AppImage is x86_64 only, this is $arch - install it with your package manager" + return + fi + local url="https://github.com/nelsonenzo/tmux-appimage/releases/latest/download/tmux.appimage" + local optdir="$HOME/.local/opt" + mkdir -p "$optdir" ~/.local/bin + log_info "tmux: fetching the static AppImage from nelsonenzo/tmux-appimage" + if has curl; then + curl -sfLo "$optdir/tmux.appimage" "$url" || { log_warn "tmux: download failed - skipping"; return; } + else + wget -qO "$optdir/tmux.appimage" "$url" || { log_warn "tmux: download failed - skipping"; return; } + fi + chmod +x "$optdir/tmux.appimage" + + # An AppImage mounts itself through FUSE, which many containers and hardened + # hosts do not have - measured on a fleet host, where the direct run failed and + # this fallback is what actually installed it. --appimage-extract needs no FUSE. + if "$optdir/tmux.appimage" -V >/dev/null 2>&1; then + ln -sf "$optdir/tmux.appimage" ~/.local/bin/tmux + log_ok "tmux installed → ~/.local/bin/tmux ($("$optdir/tmux.appimage" -V 2>/dev/null)) [AppImage]" + elif ( cd "$optdir" && "$optdir/tmux.appimage" --appimage-extract >/dev/null 2>&1 ) \ + && [ -x "$optdir/squashfs-root/usr/bin/tmux" ]; then + ln -sf "$optdir/squashfs-root/usr/bin/tmux" ~/.local/bin/tmux + log_ok "tmux installed → ~/.local/bin/tmux ($("$optdir/squashfs-root/usr/bin/tmux" -V 2>/dev/null)) [extracted, no FUSE]" + else + log_warn "tmux: the AppImage neither ran nor extracted - skipping" + return + fi +} + # fd: GitHub tarball - binary is 'fd' _install_fd() { if has fd || has fdfind; then