free-claude-code/scripts/ci.sh
2026-06-18 18:17:31 -07:00

211 lines
4.5 KiB
Bash
Executable file

#!/bin/sh
set -eu
CHECK_ORDER="suppressions ruff-format ruff-check ty pytest"
dry_run=0
only_checks=""
skip_checks=""
show_usage() {
cat <<'USAGE'
Usage: ci.sh [options]
Runs the local sequence for the same check IDs enforced by GitHub CI.
Requires uv on PATH when running ruff, ty, or pytest checks.
Local ruff checks repair formatting and autofixable lint before later checks.
Checks (in order):
suppressions Ban # type: ignore / # ty: ignore suppressions
ruff-format uv run ruff format
ruff-check uv run ruff check --fix
ty uv run ty check
pytest uv run pytest -v --tb=short
Options:
--only ID Run only the given check (repeatable)
--skip ID Skip the given check (repeatable)
--dry-run Print commands without running them.
--help Show this help text.
USAGE
}
fail() {
printf 'error: %s\n' "$*" >&2
exit 1
}
step() {
printf '\n==> %s\n' "$1"
}
quote_arg() {
case "$1" in
*[!A-Za-z0-9_./:@%+=,-]*|"")
escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf '"%s"' "$escaped"
;;
*)
printf '%s' "$1"
;;
esac
}
print_command() {
printf '+'
for arg in "$@"; do
printf ' '
quote_arg "$arg"
done
printf '\n'
}
run() {
print_command "$@"
if [ "$dry_run" -eq 0 ]; then
"$@"
fi
}
valid_check_id() {
case "$1" in
suppressions | ruff-format | ruff-check | ty | pytest) return 0 ;;
*) return 1 ;;
esac
}
validate_check_id() {
if ! valid_check_id "$1"; then
fail "unknown check id: $1 (expected one of: $CHECK_ORDER)"
fi
}
contains_check_id() {
list=$1
id=$2
case " $list " in
*" $id "*) return 0 ;;
*) return 1 ;;
esac
}
should_run_check() {
check_id=$1
if [ -n "$only_checks" ] && ! contains_check_id "$only_checks" "$check_id"; then
return 1
fi
if contains_check_id "$skip_checks" "$check_id"; then
return 1
fi
return 0
}
assert_uv_available() {
if ! command -v uv >/dev/null 2>&1; then
fail "uv is required but was not found on PATH. Install uv first (see README or scripts/install.sh)."
fi
}
selected_checks_need_uv() {
if [ "$dry_run" -ne 0 ]; then
return 1
fi
for check_id in $CHECK_ORDER; do
if should_run_check "$check_id" && [ "$check_id" != "suppressions" ]; then
return 0
fi
done
return 1
}
run_suppressions() {
step "Ban type ignore suppressions"
print_command grep -rE '# type: ignore|# ty: ignore' --include='*.py' . \
--exclude-dir=.venv --exclude-dir=.git
if [ "$dry_run" -eq 0 ]; then
if grep -rE '# type: ignore|# ty: ignore' --include='*.py' . \
--exclude-dir=.venv --exclude-dir=.git; then
fail "type: ignore / ty: ignore comments are not allowed. Fix the underlying type errors instead."
fi
fi
}
run_ruff_format() {
step "ruff format"
run uv run ruff format
}
run_ruff_check() {
step "ruff check --fix"
run uv run ruff check --fix
}
run_ty() {
step "ty check"
run uv run ty check
}
run_pytest() {
step "pytest"
run uv run pytest -v --tb=short
}
run_check() {
case "$1" in
suppressions) run_suppressions ;;
ruff-format) run_ruff_format ;;
ruff-check) run_ruff_check ;;
ty) run_ty ;;
pytest) run_pytest ;;
*) fail "unknown check id: $1" ;;
esac
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--only)
shift
[ "$#" -gt 0 ] || fail "--only requires a check id."
validate_check_id "$1"
only_checks="${only_checks} $1"
;;
--skip)
shift
[ "$#" -gt 0 ] || fail "--skip requires a check id."
validate_check_id "$1"
skip_checks="${skip_checks} $1"
;;
--dry-run)
dry_run=1
;;
--help|-h)
show_usage
exit 0
;;
*)
show_usage >&2
fail "unknown option: $1"
;;
esac
shift
done
}
parse_args "$@"
if selected_checks_need_uv; then
assert_uv_available
fi
for check_id in $CHECK_ORDER; do
if should_run_check "$check_id"; then
run_check "$check_id"
fi
done
printf '\nAll selected CI checks passed.\n'