diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml new file mode 100644 index 0000000..77ebe24 --- /dev/null +++ b/.github/workflows/lint-and-test.yml @@ -0,0 +1,34 @@ +--- +name: Lint and test + +on: [push] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # shellcheck ships with the runner image; zsh does not, and the zsh -n + # pass silently covers nothing without it. + - name: Install zsh + run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh + + - name: Lint shell scripts + run: bin/lint-shell + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # curl-guard.sh decides whether a curl invocation is auto-approved, so a + # regression here widens a permission boundary rather than breaking a + # feature — worth gating every push. + # + # root/.codex/rules/default.rules.test.sh is deliberately NOT run here: it + # needs the codex CLI, which is not on the runner, and would exit 0 with + # "skipping" — a permanently green check that proves nothing. Run it + # locally after editing default.rules, as CLAUDE.md describes. + - name: curl-guard hook + run: bash root/.claude/hooks/curl-guard.test.sh diff --git a/.github/workflows/macos-setup.yml b/.github/workflows/macos-setup.yml index eefc503..e01d8bf 100644 --- a/.github/workflows/macos-setup.yml +++ b/.github/workflows/macos-setup.yml @@ -7,6 +7,17 @@ jobs: build: runs-on: macos-latest steps: + - uses: actions/checkout@v4 + + # The bootstrap resolves everything under $HOME/dotfiles, so point that at + # this checkout. Previously CI curled the script from the main branch and + # let it clone the default branch, so every job validated main no matter + # which branch was pushed — a green check that could not fail on unmerged + # code. + - name: Place the checkout at ~/dotfiles + run: ln -s "$GITHUB_WORKSPACE" "$HOME/dotfiles" + - name: Run install script - run: | - curl -fsSL https://raw.githubusercontent.com/ebkn/dotfiles/main/bin/init/bootstrap-macos.sh | zsh + run: zsh bin/init/bootstrap-macos.sh + env: + DOTFILES_SKIP_UPDATE: '1' diff --git a/.github/workflows/ubuntu-setup.yml b/.github/workflows/ubuntu-setup.yml index 34f0f32..0f09af8 100644 --- a/.github/workflows/ubuntu-setup.yml +++ b/.github/workflows/ubuntu-setup.yml @@ -7,6 +7,14 @@ jobs: build: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + + # See macos-setup.yml for why the checkout is linked into place instead of + # letting the bootstrap clone the default branch. + - name: Place the checkout at ~/dotfiles + run: ln -s "$GITHUB_WORKSPACE" "$HOME/dotfiles" + - name: Run install script - run: | - curl -fsSL https://raw.githubusercontent.com/ebkn/dotfiles/main/bin/init/bootstrap-ubuntu.sh | bash + run: bash bin/init/bootstrap-ubuntu.sh + env: + DOTFILES_SKIP_UPDATE: '1' diff --git a/.github/workflows/wsl-setup.yml b/.github/workflows/wsl-setup.yml index f8966e2..ea5dd7e 100644 --- a/.github/workflows/wsl-setup.yml +++ b/.github/workflows/wsl-setup.yml @@ -7,6 +7,14 @@ jobs: build: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + + # See macos-setup.yml for why the checkout is linked into place instead of + # letting the bootstrap clone the default branch. + - name: Place the checkout at ~/dotfiles + run: ln -s "$GITHUB_WORKSPACE" "$HOME/dotfiles" + - name: Run install script - run: | - curl -fsSL https://raw.githubusercontent.com/ebkn/dotfiles/main/bin/init/bootstrap-wsl.sh | bash + run: bash bin/init/bootstrap-wsl.sh + env: + DOTFILES_SKIP_UPDATE: '1' diff --git a/CLAUDE.md b/CLAUDE.md index 1729b34..649fe9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ Personal dotfiles repository managing shell, editor, terminal, and development t │ ├── relink # Re-sync symlinks from link_dotfiles() (drift check + prompt; called by update-all) │ ├── fzf-files # List git-changed files first for fzf (symlinked to ~/.local/bin) │ ├── git-generated # Locally hide linguist-generated files from diffs via .git/info/attributes +│ ├── lint-shell # shellcheck + zsh -n over every shell script (same command CI runs) │ └── install_minimum_vim.sh ├── brewfiles/ # Homebrew dependency lists by category │ ├── Brewfile-shell # Shell tools (tmux, fzf, ripgrep, etc.) @@ -88,6 +89,9 @@ Personal dotfiles repository managing shell, editor, terminal, and development t ## Testing -- GitHub Actions CI runs `bin/init/macos.sh` (`.github/workflows/macos-setup.yml`) and `bin/init/ubuntu.sh` (`.github/workflows/ubuntu-setup.yml`) to verify setup scripts. +- **`bin/lint-shell`** static-checks every shell script: `shellcheck -x -P SCRIPTDIR` over the sh/bash ones, `zsh -n` over the zsh ones (shellcheck cannot parse zsh — SC1071). Run it before pushing; `.github/workflows/lint-and-test.yml` runs the same command, so local and CI cannot disagree. Targets are discovered by shebang rather than listed, so a new script is covered automatically. The two exceptions are named in the script: sourced fragments (`bin/init/common.sh`, `links.sh` — no shebang, so they carry a `# shellcheck shell=bash` directive) and the `zsh/` modules. +- GitHub Actions CI runs the platform setup scripts (`.github/workflows/{macos,ubuntu,wsl,windows}-setup.yml`) plus lint and the `curl-guard.sh` hook test (`lint-and-test.yml`). +- **Setup workflows must test the pushed branch.** They `actions/checkout`, symlink the checkout to `~/dotfiles`, and run the bootstrap with `DOTFILES_SKIP_UPDATE=1` so it does not self-update over the branch under test. The earlier form — `curl .../main/bin/init/bootstrap-*.sh | sh`, letting the bootstrap `git clone` the default branch — meant every job validated `main` regardless of the pushed ref, so the checks could not fail on unmerged code. Do not reintroduce a hardcoded `/main/` raw URL or drop the skip flag. +- `root/.codex/rules/default.rules.test.sh` is intentionally **not** in CI: it requires the `codex` CLI and exits 0 with "skipping" when absent, which would be a permanently green check proving nothing. Run it locally after editing `default.rules`. - After changing shell config, verify with a new shell session or `source ~/.zshrc`. - Zsh startup profiling can be enabled by uncommenting `zprof` lines in `.zshenv` and `.zshrc`. diff --git a/bin/init/bootstrap-macos.sh b/bin/init/bootstrap-macos.sh index 4bdcb67..e448ea5 100755 --- a/bin/init/bootstrap-macos.sh +++ b/bin/init/bootstrap-macos.sh @@ -28,9 +28,20 @@ if [ "$(uname -m)" = "arm64" ] && ! /usr/bin/pgrep -q oahd; then fi # Clone or update dotfiles so macos.sh always runs the latest code. +# +# DOTFILES_SKIP_UPDATE=1 leaves an existing checkout untouched, for callers that +# decide the revision themselves: CI validating a pushed branch, or a local +# re-run from a work-in-progress clone. Without it the fetch/merge silently +# swaps in the default branch, so the run validates code nobody asked for. if [ ! -d "$DOTFILES_DIR" ]; then + if [ "${DOTFILES_SKIP_UPDATE:-}" = "1" ]; then + printf "error: DOTFILES_SKIP_UPDATE=1 but %s does not exist\n" "$DOTFILES_DIR" >&2 + exit 1 + fi printf "Cloning dotfiles...\n" git clone https://github.com/ebkn/dotfiles "$DOTFILES_DIR" +elif [ "${DOTFILES_SKIP_UPDATE:-}" = "1" ]; then + printf "Using existing checkout at %s (DOTFILES_SKIP_UPDATE=1)\n" "$DOTFILES_DIR" else printf "Updating dotfiles...\n" git -C "$DOTFILES_DIR" fetch --all --prune diff --git a/bin/init/bootstrap-ubuntu.sh b/bin/init/bootstrap-ubuntu.sh index 5378f2b..89c6c46 100755 --- a/bin/init/bootstrap-ubuntu.sh +++ b/bin/init/bootstrap-ubuntu.sh @@ -19,9 +19,20 @@ if ! command -v git >/dev/null 2>&1; then fi # Clone or update dotfiles so ubuntu.sh always runs the latest code. +# +# DOTFILES_SKIP_UPDATE=1 leaves an existing checkout untouched, for callers that +# decide the revision themselves: CI validating a pushed branch, or a local +# re-run from a work-in-progress clone. Without it the fetch/merge silently +# swaps in the default branch, so the run validates code nobody asked for. if [ ! -d "$DOTFILES_DIR" ]; then + if [ "${DOTFILES_SKIP_UPDATE:-}" = "1" ]; then + printf "error: DOTFILES_SKIP_UPDATE=1 but %s does not exist\n" "$DOTFILES_DIR" >&2 + exit 1 + fi printf "Cloning dotfiles...\n" git clone https://github.com/ebkn/dotfiles "$DOTFILES_DIR" +elif [ "${DOTFILES_SKIP_UPDATE:-}" = "1" ]; then + printf "Using existing checkout at %s (DOTFILES_SKIP_UPDATE=1)\n" "$DOTFILES_DIR" else printf "Updating dotfiles...\n" git -C "$DOTFILES_DIR" fetch --all --prune diff --git a/bin/init/bootstrap-wsl.sh b/bin/init/bootstrap-wsl.sh index 6a6592e..6ef4fb4 100755 --- a/bin/init/bootstrap-wsl.sh +++ b/bin/init/bootstrap-wsl.sh @@ -19,9 +19,20 @@ if ! command -v git >/dev/null 2>&1; then fi # Clone or update dotfiles so wsl.sh always runs the latest code. +# +# DOTFILES_SKIP_UPDATE=1 leaves an existing checkout untouched, for callers that +# decide the revision themselves: CI validating a pushed branch, or a local +# re-run from a work-in-progress clone. Without it the fetch/merge silently +# swaps in the default branch, so the run validates code nobody asked for. if [ ! -d "$DOTFILES_DIR" ]; then + if [ "${DOTFILES_SKIP_UPDATE:-}" = "1" ]; then + printf "error: DOTFILES_SKIP_UPDATE=1 but %s does not exist\n" "$DOTFILES_DIR" >&2 + exit 1 + fi printf "Cloning dotfiles...\n" git clone https://github.com/ebkn/dotfiles "$DOTFILES_DIR" +elif [ "${DOTFILES_SKIP_UPDATE:-}" = "1" ]; then + printf "Using existing checkout at %s (DOTFILES_SKIP_UPDATE=1)\n" "$DOTFILES_DIR" else printf "Updating dotfiles...\n" git -C "$DOTFILES_DIR" fetch --all --prune diff --git a/bin/init/common.sh b/bin/init/common.sh index 3c73e40..cc7f171 100644 --- a/bin/init/common.sh +++ b/bin/init/common.sh @@ -1,3 +1,8 @@ +# Sourced by both zsh (macos.sh) and bash (ubuntu.sh/wsl.sh/relink), so it has +# no shebang. Declare the dialect explicitly or shellcheck refuses the file +# (SC2148) and CI silently checks nothing. +# shellcheck shell=bash + # ZERR (zsh) / ERR (bash) — report the failing line before set -e exits. if [ -n "${ZSH_VERSION:-}" ]; then trap 'printf "error: %s failed at line %d (exit %d)\n" "${0}" ${LINENO} $? >&2' ZERR diff --git a/bin/init/links.sh b/bin/init/links.sh index f0d6e0b..45ba0dd 100644 --- a/bin/init/links.sh +++ b/bin/init/links.sh @@ -1,3 +1,7 @@ +# Sourced, not executed — see the note in common.sh on why this directive is +# required for shellcheck to accept the file at all. +# shellcheck shell=bash + # link_dotfiles — create the platform-common $HOME symlinks that are safe to # re-run at any time (order-independent, no side effects beyond the link). # diff --git a/bin/install_minimum_vim.sh b/bin/install_minimum_vim.sh index d5083c9..8126c4d 100644 --- a/bin/install_minimum_vim.sh +++ b/bin/install_minimum_vim.sh @@ -1,5 +1,5 @@ #!/bin/bash -git clone https://github.com/ebkn/dotfiles $HOME/dotfiles -cat $HOME/dotfiles/.minvimrc >> $HOME/.vimrc -ln -s $HOME/dotfiles/vim/.vim $HOME/.vim +git clone https://github.com/ebkn/dotfiles "${HOME}/dotfiles" +cat "${HOME}/dotfiles/.minvimrc" >> "${HOME}/.vimrc" +ln -s "${HOME}/dotfiles/vim/.vim" "${HOME}/.vim" diff --git a/bin/lint-shell b/bin/lint-shell new file mode 100755 index 0000000..6093c74 --- /dev/null +++ b/bin/lint-shell @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# lint-shell — static-check every shell script in the repo. +# +# Runs shellcheck over the sh/bash scripts and `zsh -n` (parse-only) over the +# zsh ones, which shellcheck cannot read at all (SC1071). +# +# Lives in the repo rather than inline in the CI workflow so the check is +# reproducible locally — in a dotfiles repo you rarely watch CI, and a lint gate +# you can only trigger by pushing gets ignored. +# +# Targets are discovered by shebang, not from a hardcoded path list: a list rots +# silently, and a script that falls out of it stops being checked without anyone +# noticing. Two categories cannot be discovered that way and are named below: +# sourced fragments (no shebang) and zsh config modules (sourced from .zshrc). +# +# Usage: bin/lint-shell (exit 0 = clean; run from anywhere in the repo) +set -uo pipefail + +cd "$(dirname "$(readlink -f "$0")")/.." || exit 1 + +status=0 + +# `\bsh\b` deliberately does not match `zsh` — there is no word boundary between +# `z` and `s` — so zsh scripts fall through to the zsh pass below. +sh_targets=() +while IFS= read -r f; do + [ -f "$f" ] || continue + if head -n 1 "$f" | grep -qE '^#!.*\b(bash|sh|dash)\b'; then + sh_targets+=("$f") + fi +done < <(git ls-files) + +# Sourced by both zsh and bash, so they carry no shebang; each declares +# `# shellcheck shell=bash` instead. +sh_targets+=(bin/init/common.sh bin/init/links.sh) + +# -x follows `. "${SCRIPT_DIR}/common.sh"` into the sourced fragment instead of +# reporting SC1091, so the init scripts are checked against the definitions they +# actually get at runtime. -P SCRIPTDIR is what makes that resolve: the source +# paths are built from runtime variables shellcheck cannot evaluate, so it needs +# to be told to look next to the script being checked. +printf -- '--- shellcheck (%d files) ---\n' "${#sh_targets[@]}" +if shellcheck -x -P SCRIPTDIR "${sh_targets[@]}"; then + printf 'ok\n' +else + status=1 +fi + +# The entrypoints have no shebang and live outside zsh/, so name them; the +# `zsh/*.zsh` case below picks up the modules (including .p10k.zsh — a `case` +# pattern has no dotfile exclusion, unlike pathname expansion). +zsh_targets=(.zshrc .zshenv) +while IFS= read -r f; do + [ -f "$f" ] || continue + case "$f" in + zsh/*.zsh) zsh_targets+=("$f") ;; + *) head -n 1 "$f" | grep -qE '^#!.*\bzsh\b' && zsh_targets+=("$f") ;; + esac +done < <(git ls-files) + +printf -- '\n--- zsh -n (%d files) ---\n' "${#zsh_targets[@]}" +zsh_status=0 +for f in "${zsh_targets[@]}"; do + if ! zsh -n "$f"; then + printf 'FAIL %s\n' "$f" + zsh_status=1 + fi +done +if [ "$zsh_status" -eq 0 ]; then + printf 'ok\n' +else + status=1 +fi + +exit "$status" diff --git a/root/.claude/hooks/curl-guard.test.sh b/root/.claude/hooks/curl-guard.test.sh index ae29324..200cad9 100755 --- a/root/.claude/hooks/curl-guard.test.sh +++ b/root/.claude/hooks/curl-guard.test.sh @@ -5,13 +5,40 @@ set -uo pipefail HOOK="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/curl-guard.sh" + +# The hook reads its allow-list from $HOME/.claude/settings.json. Point HOME at +# this repo's root/ so it reads the settings.json *in the tree under test*, not +# whatever happens to be installed on the machine. Without this the test is not +# hermetic: on a provisioned Mac ~/.claude/settings.json is symlinked here and +# everything passes, but anywhere else (CI) the file is absent, the hook exits +# early, and every case defers -- which fails the 8 ALLOW cases and silently +# turns all 33 DEFER cases into vacuous passes. +REPO_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SETTINGS="${REPO_HOME}/.claude/settings.json" + +if [[ ! -f "$SETTINGS" ]]; then + printf 'FAIL: %s not found\n' "$SETTINGS" + exit 1 +fi + +# Guard against the vacuous-pass mode directly: if the allow-list were empty or +# lost these entries, the DEFER half of the suite would still report green. +for host in github.com api.github.com docs.claude.com support.claude.com \ + developers.google.com docs.perplexity.ai; do + if ! jq -e --arg h "WebFetch(domain:${host})" \ + '.permissions.allow | index($h)' "$SETTINGS" >/dev/null; then + printf 'FAIL: settings.json has no WebFetch(domain:%s); ALLOW cases depend on it\n' "$host" + exit 1 + fi +done + pass=0 fail=0 check() { local expect=$1 cmd=$2 out got out=$(printf '%s' "$cmd" | jq -Rn --arg c "$cmd" \ - '{tool_name:"Bash", tool_input:{command:$c}}' | "$HOOK" 2>/dev/null) + '{tool_name:"Bash", tool_input:{command:$c}}' | HOME="$REPO_HOME" "$HOOK" 2>/dev/null) if printf '%s' "$out" | grep -q '"permissionDecision": *"allow"'; then got=ALLOW else