Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 13 additions & 2 deletions .github/workflows/macos-setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
12 changes: 10 additions & 2 deletions .github/workflows/ubuntu-setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
12 changes: 10 additions & 2 deletions .github/workflows/wsl-setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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`.
11 changes: 11 additions & 0 deletions bin/init/bootstrap-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions bin/init/bootstrap-ubuntu.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions bin/init/bootstrap-wsl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions bin/init/common.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions bin/init/links.sh
Original file line number Diff line number Diff line change
@@ -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).
#
Expand Down
6 changes: 3 additions & 3 deletions bin/install_minimum_vim.sh
Original file line number Diff line number Diff line change
@@ -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"
75 changes: 75 additions & 0 deletions bin/lint-shell
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 28 additions & 1 deletion root/.claude/hooks/curl-guard.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading