From be8f8f783264861a73e83a8707fbfbb57e5480f5 Mon Sep 17 00:00:00 2001 From: Alexandre Velia Date: Thu, 3 Sep 2026 16:04:56 +0400 Subject: [PATCH 1/4] fix: use Go arch names for the glab release asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit glab publishes its tarballs as glab__linux_amd64 / _arm64, but 10-packages.sh translated dpkg's amd64 to x86_64 before building the URL. The download therefore 404'd on every 64-bit Intel machine — only arm64 happened to line up. curl exited 22 and, under `set -e`, took the whole bootstrap with it. lazygit and jira-cli further down do use x86_64, hence the (wrong) symmetry; a comment now records the difference. Co-Authored-By: Claude Opus 5 (1M context) --- install/10-packages.sh | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/install/10-packages.sh b/install/10-packages.sh index 739c50f..2039178 100755 --- a/install/10-packages.sh +++ b/install/10-packages.sh @@ -47,13 +47,12 @@ fi if ! have glab; then log "Installing glab (GitLab CLI)" GLAB_VER="1.50.0" - case "$ARCH" in - amd64) GLAB_ARCH="x86_64" ;; - arm64) GLAB_ARCH="arm64" ;; - *) GLAB_ARCH="$ARCH" ;; - esac + # glab names its assets with Go arch strings (linux_amd64 / linux_arm64), + # which already match dpkg's — unlike lazygit and jira-cli below, which use + # x86_64. Translating amd64 -> x86_64 here yields a 404 on every 64-bit Intel + # box; only arm64 happened to work. tmp="$(mktemp -d)" - run bash -c "curl -fsSL 'https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VER}/downloads/glab_${GLAB_VER}_linux_${GLAB_ARCH}.tar.gz' -o '$tmp/glab.tar.gz'" + run bash -c "curl -fsSL 'https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VER}/downloads/glab_${GLAB_VER}_linux_${ARCH}.tar.gz' -o '$tmp/glab.tar.gz'" run tar -xzf "$tmp/glab.tar.gz" -C "$tmp" run install -m 0755 "$tmp/bin/glab" "$HOME/.local/bin/glab" rm -rf "$tmp" From f59bbdb57a8a14038128d87cb3150fb2c1e55d3c Mon Sep 17 00:00:00 2001 From: Alexandre Velia Date: Thu, 3 Sep 2026 16:05:07 +0400 Subject: [PATCH 2/4] fix: never back up a file reached through a folded stow symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stow folds trees: after a first run, ~/.zsh is a single symlink to dotfiles/zsh/.zsh. backup_conflicts() then walked the package files and tested ~/.zsh/rc.d/10-aliases.zsh, which resolves *through* that symlink to the repo's own file — it exists, and it is not itself a symlink, so backup_path() moved it. Every re-run of bootstrap.sh renamed a large part of dotfiles/ to *.forge-backup. inside the repo, and the shell config then failed to load (~/.zshrc: no matches found: ~/.zsh/rc.d/*.zsh). Skip any target that already resolves into dotfiles/ — it is stow's own work, not a user file to preserve. Co-Authored-By: Claude Opus 5 (1M context) --- install/50-stow.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/install/50-stow.sh b/install/50-stow.sh index a43d833..9c8280e 100755 --- a/install/50-stow.sh +++ b/install/50-stow.sh @@ -10,6 +10,8 @@ require_cmd stow "run 00-preflight.sh." DOTFILES="$FORGE_DIR/dotfiles" [[ -d "$DOTFILES" ]] || die "missing $DOTFILES" +# Canonical form, to compare against resolved symlink targets below. +dotfiles_real="$(readlink -f "$DOTFILES")" # Desktop packages only stow when a GUI is present. PACKAGES=(zsh starship git tmux kitty aerc jira) @@ -28,6 +30,13 @@ backup_conflicts() { rel="${f#"$pkgdir"/}" target="$HOME/$rel" if [[ -e "$target" && ! -L "$target" ]]; then + # stow folds trees: after a first run ~/.zsh is a symlink to + # $DOTFILES/zsh/.zsh, so $target resolves to the repo's *own* file — + # real, and not a symlink. Backing it up would rename the source out of + # the repo (it did: every re-run mangled dotfiles/ until CI caught it). + if [[ "$(readlink -f "$target")" == "$dotfiles_real"/* ]]; then + continue + fi backup_path "$target" fi done < <(find "$pkgdir" -type f -print0) From 93a9b7b3ba3174a15c270643fafc66be86eeca56 Mon Sep 17 00:00:00 2001 From: Alexandre Velia Date: Thu, 3 Sep 2026 16:05:17 +0400 Subject: [PATCH 3/4] ci: pre-install zsh in smoke, add a real bootstrap job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke job only ever ran bootstrap.sh --dry-run, which installs nothing. Anything that depends on a binary actually being present was therefore untested, and broke in CI twice (require_cmd zsh, then command -v zsh under set -e). Two changes: - smoke now pre-installs zsh, so 20-shell.sh traverses its real branches ([dry-run] sudo chsh -s /usr/bin/zsh) instead of bailing out early. - bootstrap-real runs the thing for real in ubuntu:24.04, asserts the post-conditions (ci/assert-bootstrap.sh: tools on PATH, omz plugins and tpm checked out, dotfiles resolving into the repo, `zsh -i` starting), then re-runs bootstrap and re-asserts — the repo claims to be safe to re-run, so CI checks it. It is slow and depends on third-party endpoints, but it is the only job that proves the install works rather than that the scripts merely parse: it found both bugs fixed in the two preceding commits on its first run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 41 ++++++++++++++++- ci/assert-bootstrap.sh | 96 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100755 ci/assert-bootstrap.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eacd62..0b2af94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: jobs: shellcheck: @@ -18,6 +19,9 @@ jobs: -not -path './dotfiles/*' -print0 \ | xargs -0 shellcheck -S warning -x + # Fast feedback: does bootstrap.sh survive an end-to-end --dry-run? + # It installs nothing, so it can only catch scripting errors — see + # bootstrap-real for the job that checks the install actually works. smoke: runs-on: ubuntu-latest container: ubuntu:24.04 @@ -26,7 +30,10 @@ jobs: - name: Install prerequisites run: | apt-get update - apt-get install -y sudo curl wget gnupg lsb-release git stow ca-certificates + # zsh is pre-installed on purpose: --dry-run never installs anything, + # so without it every `command -v zsh` / require_cmd zsh branch in + # 20-shell.sh stays untested here (it broke CI twice that way). + apt-get install -y sudo curl wget gnupg lsb-release git stow ca-certificates zsh # create a non-root user (bootstrap refuses to run as root) useradd -m -s /bin/bash tester echo 'tester ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tester @@ -38,3 +45,35 @@ jobs: - name: Validate stow packages link cleanly (zsh/git/tmux) run: | su tester -c 'cd ~/forge && stow --no --target=$HOME --dir dotfiles zsh git tmux 2>&1 | tee /tmp/stow.log; ! grep -i conflict /tmp/stow.log' + + # The real thing: no --dry-run. Slow (apt + several upstream downloads) and + # dependent on third-party endpoints, but it is the only job that proves the + # install works rather than merely that the scripts parse. + bootstrap-real: + runs-on: ubuntu-latest + container: ubuntu:24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Create the unprivileged user + run: | + apt-get update + # Bare minimum only: everything else must come from 00-preflight.sh, + # which is part of what we are testing. + apt-get install -y sudo ca-certificates curl git + useradd -m -s /bin/bash tester + echo 'tester ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tester + cp -r . /home/tester/forge + chown -R tester:tester /home/tester/forge + - name: Run bootstrap for real (headless, no security) + run: | + su tester -c 'cd ~/forge && ./bootstrap.sh --yes --no-desktop --no-security' + - name: Assert the install actually landed + run: | + su tester -c 'cd ~/forge && bash ci/assert-bootstrap.sh' + - name: Re-run bootstrap (must be idempotent) + run: | + su tester -c 'cd ~/forge && ./bootstrap.sh --yes --no-desktop --no-security' + - name: Assert post-conditions still hold after the second run + run: | + su tester -c 'cd ~/forge && bash ci/assert-bootstrap.sh' diff --git a/ci/assert-bootstrap.sh b/ci/assert-bootstrap.sh new file mode 100755 index 0000000..515aee5 --- /dev/null +++ b/ci/assert-bootstrap.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# ci/assert-bootstrap.sh — post-conditions for a REAL bootstrap run. +# +# Run inside the CI container right after: +# ./bootstrap.sh --yes --no-desktop --no-security +# +# bootstrap.sh only proves it ran without erroring; this proves it *landed*: +# every tool the install steps promise is on PATH, the zsh plugin checkouts +# exist, the dotfiles are live symlinks into the repo (not silently backed up), +# and the resulting .zshrc actually loads. +# +# Deliberately no `set -e`: we want the full list of what's missing in one CI +# run, not just the first failure. +set -uo pipefail + +FORGE_DIR="${FORGE_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck source=../lib/common.sh +source "$FORGE_DIR/lib/common.sh" + +# Same PATH the stowed .zshrc sets up: forge drops binaries in ~/.local/bin. +export PATH="$HOME/bin:$HOME/.local/bin:$PATH" + +failures=0 +fail() { err "$*"; failures=$((failures + 1)); } + +check_cmd() { + if have "$1"; then + info "cmd $1 → $(command -v "$1")" + else + fail "missing command: $1" + fi +} + +check_dir() { + if [[ -d "$1" ]]; then + info "dir $1" + else + fail "missing directory: $1" + fi +} + +# A dotfile must resolve back into the repo. Anything else means stow skipped +# it (conflict) or backed it up, leaving a dead copy: the config is not live. +# +# We test the resolved path, not `-L` on the leaf: stow folds trees, so a whole +# package can be linked at a parent (~/.zsh -> ../forge/dotfiles/zsh/.zsh) and +# the files inside it are then plain files reached through that symlink. +check_link() { + local target="$1" dest + if [[ ! -e "$target" ]]; then + fail "missing (stow did not link it): $target" + return + fi + dest="$(readlink -f "$target")" + case "$dest" in + "$FORGE_DIR"/dotfiles/*) info "link $target → $dest" ;; + *) fail "$target is not linked into the repo (resolves to: $dest)" ;; + esac +} + +section "assert: commands on PATH" +# Only tools the install steps install unconditionally. Optional ones +# (aerc, tealdeer, jira-cli) are skipped: their absence is tolerated by design. +for cmd in zsh tmux nvim rg fd bat jq tree htop fzf zoxide direnv \ + eza gh glab lazygit starship mise stow git; do + check_cmd "$cmd" +done + +section "assert: shell environment" +check_dir "$HOME/.oh-my-zsh" +check_dir "$HOME/.oh-my-zsh/custom/plugins/zsh-autosuggestions" +check_dir "$HOME/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting" +check_dir "$HOME/.config/tmux/plugins/tpm" + +section "assert: dotfiles are stowed" +check_link "$HOME/.zshrc" +check_link "$HOME/.zsh/rc.d/10-aliases.zsh" +check_link "$HOME/.zsh/functions/20-git.zsh" +check_link "$HOME/.gitconfig" +check_link "$HOME/.config/starship.toml" +check_link "$HOME/.config/tmux/tmux.conf" + +section "assert: the zsh config loads" +# -i so oh-my-zsh, the rc.d/ split, compinit, zoxide/direnv/mise/starship hooks +# all get sourced. timeout guards against any prompt sneaking in. +if timeout 60 zsh -i -c 'exit 0' 0 )); then + die "$failures assertion(s) failed" +fi +ok "bootstrap post-conditions verified" From 0b3760642c4cd5f123a373c5898dfbbe08a1c048 Mon Sep 17 00:00:00 2001 From: Alexandre Velia Date: Thu, 3 Sep 2026 16:23:28 +0400 Subject: [PATCH 4/4] ci: keep /usr/share/doc in the container, assert a silent zsh startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap-real job reported, on every run: fzf_setup_using_debian:source:40: no such file or directory: /usr/share/doc/fzf/examples/key-bindings.zsh Not a dotfiles bug. The ubuntu image ships /etc/dpkg/dpkg.cfg.d/excludes with path-exclude=/usr/share/doc/*, so dpkg drops fzf's shell integration at install time while leaving the examples/ directory behind — enough for oh-my-zsh's fzf plugin to pass its `-d` check and then source a file that was never unpacked. A real Ubuntu desktop has it. The container was lying, so drop the exclusion in both container jobs. While here, make that class of defect fail the build instead of scrolling past: the assertion now starts the shell under a pty (script -qec, so fzf's key-bindings.zsh does not warn `can't change option: zle`) and requires startup to print *nothing*. Checking the exit status alone was not enough — a failed `source` inside a plugin leaves it at 0, which is exactly why this went unnoticed. Verified both ways in a local amd64 container: with the exclusion left in place the assertion captures the 107-byte fzf warning and fails; with it removed, startup is silent. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 7 +++++++ ci/assert-bootstrap.sh | 20 ++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b2af94..facf6f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,10 @@ jobs: - uses: actions/checkout@v4 - name: Install prerequisites run: | + # The ubuntu image ships a dpkg config that drops /usr/share/doc/*, + # so packages install without the shell integration a real desktop + # gets (fzf's key-bindings.zsh, for one). Keep the container honest. + rm -f /etc/dpkg/dpkg.cfg.d/excludes apt-get update # zsh is pre-installed on purpose: --dry-run never installs anything, # so without it every `command -v zsh` / require_cmd zsh branch in @@ -57,6 +61,9 @@ jobs: - uses: actions/checkout@v4 - name: Create the unprivileged user run: | + # See the smoke job: without this, /usr/share/doc/* never lands and + # the container stops resembling the machine we are provisioning. + rm -f /etc/dpkg/dpkg.cfg.d/excludes apt-get update # Bare minimum only: everything else must come from 00-preflight.sh, # which is part of what we are testing. diff --git a/ci/assert-bootstrap.sh b/ci/assert-bootstrap.sh index 515aee5..7388a43 100755 --- a/ci/assert-bootstrap.sh +++ b/ci/assert-bootstrap.sh @@ -82,12 +82,24 @@ check_link "$HOME/.config/tmux/tmux.conf" section "assert: the zsh config loads" # -i so oh-my-zsh, the rc.d/ split, compinit, zoxide/direnv/mise/starship hooks -# all get sourced. timeout guards against any prompt sneaking in. -if timeout 60 zsh -i -c 'exit 0' "$startup_out" 2>&1; then + fail "interactive zsh exited non-zero" +fi +if [[ -s "$startup_out" ]]; then + fail "interactive zsh printed this at startup:" + cat "$startup_out" >&2 else - fail "zsh -i failed to start (exit $?) — see output above" + ok "zsh -i starts silently" fi +rm -f "$startup_out" section "assert: summary" if (( failures > 0 )); then