| #!/bin/sh |
| set -eu |
|
|
| CHECK_ORDER="suppressions ruff-format ruff-check ty frontend 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 ignores and legacy future annotations |
| ruff-format uv run ruff format |
| ruff-check uv run ruff check --fix |
| ty uv run ty check |
| frontend Build the admin UI and verify committed assets are current |
| 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 | frontend | 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 suppressions and legacy annotations" |
| pattern='# type: ignore|# ty: ignore|from __future__ import annotations' |
| print_command grep -rE "$pattern" --include='*.py' . \ |
| --exclude-dir=.venv --exclude-dir=.git |
| if [ "$dry_run" -eq 0 ]; then |
| if grep -rE "$pattern" --include='*.py' . \ |
| --exclude-dir=.venv --exclude-dir=.git; then |
| fail "type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue 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_frontend() { |
| step "frontend (admin UI build)" |
| if ! command -v npm >/dev/null 2>&1; then |
| fail "npm is required for the frontend check but was not found on PATH." |
| fi |
| run npm --prefix frontend ci |
| run npm --prefix frontend run build |
| step "frontend (verify committed admin assets are current)" |
| run git diff --exit-code -- src/free_claude_code/api/admin_static/ |
| } |
|
|
| 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 ;; |
| frontend) run_frontend ;; |
| 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' |
|
|